需要预先了解:


1. goroutine 与并发初探

Go 的并发能力从语言层面就开始了:一个 go 关键字就能启动一个 goroutine。这篇先讲清楚"并发"与"并行"的区别、goroutine 的启动与开销,再引出 sync.WaitGroup、sync.Once 这两个基础同步工具,最后给出 GMP 调度模型的概览,为后面几篇铺路。


1.1 并发与并行

先区分两个概念:

  • 并发(concurrency):多个任务在重叠的时间段内推进,宏观上"同时"发生,微观上可能是交替执行;
  • 并行(parallelism):多个任务在同一个时刻真正同时执行,需要多核支持。

并发是一种程序结构,并行是一种执行状态。一个程序可以并发但不并行(比如只给一个 CPU 核心)。

几个系统层面的执行单元:

概念 说明
进程 拥有独立地址空间的执行单元,进程之间默认不共享内存
线程 进程内的执行单元,共享进程的地址空间,由操作系统调度
协程 用户态的轻量执行单元,由语言运行时或库调度,切换成本低于线程

goroutine 就是 Go 的协程实现:它的创建、调度、栈管理全部由 Go 运行时负责,不直接对应一个内核线程。


1.2 启动 goroutine

在函数调用前加 go 关键字,就把它放到一个新的 goroutine 里执行:

go func() {
	time.Sleep(100 * time.Millisecond)
	fmt.Println("子 goroutine 输出")
}()
fmt.Println("main 结束")

连续运行三次,输出都是:

main 结束

子 goroutine 的打印一次都没有出现。原因是:main 函数返回时,程序立即退出,所有其他 goroutine 都被强行终止,不管它们是否执行完。

这不是 bug,而是设计:main 的返回意味着整个程序结束。想让子 goroutine 有机会完成,必须让 main 等待——用 WaitGroup、channel,或者干脆 time.Sleep(只适合演示)。


1.3 goroutine 的开销

goroutine 的栈是按需增长的,初始很小(最小 2KB,按 8KB 的页粒度分配),不像内核线程那样一上来就占用 MB 级内存。

var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(500 * time.Millisecond)
	}()
}
time.Sleep(200 * time.Millisecond)
memStats("启动 10 万个 goroutine 后")

输出(数字随机器和版本略有差异):

启动时: 堆内存 0.43 MB, 栈内存 0.10 MB, goroutine 数 1
启动 10 万个 goroutine 后: 堆内存 64.06 MB, 栈内存 819.49 MB, goroutine 数 100001
全部退出并 GC 后: 堆内存 51.47 MB, 栈内存 6.39 MB, goroutine 数 1

10 万个 goroutine 大约占用 800MB 栈内存(每个约 8KB),退出后栈内存被回收,降到 6MB 左右。单个 goroutine 的开销远小于一个内核线程,但也不是零——数量级上仍然要控制,几十万个常驻 goroutine 会明显吃内存。


1.4 sync.WaitGroup

WaitGroup 用来等待一组 goroutine 结束:

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		time.Sleep(time.Duration(rand.Intn(30)) * time.Millisecond)
		fmt.Println("worker", id, "完成")
	}(i)
}
wg.Wait()
fmt.Println("全部完成")

三个方法的职责:

  • Add(n):计数器加 n,必须在启动 goroutine 之前调用;
  • Done():计数器减 1,通常用 defer wg.Done();
  • Wait():阻塞直到计数器归零。

WaitGroup 的零值可以直接使用,不需要初始化。

Add 的位置很关键

把 Add(1) 放进 goroutine 内部是常见错误:

for i := 0; i < 5; i++ {
	go func(id int) {
		wg.Add(1) // 错误
		defer wg.Done()
		time.Sleep(50 * time.Millisecond)
		fmt.Println("worker", id, "完成")
	}(i)
}
wg.Wait()
fmt.Println("Wait 返回")

某次运行的结果:

Wait 返回
worker 3 完成
worker 2 完成
worker 1 完成
worker 4 完成
worker 0 完成

Wait 在计数器还是 0 的时候就返回了,因为 goroutine 还没来得及执行 Add。把 Add 放到循环里、go 语句之前,就能保证 Wait 开始时计数已经正确。

注:Go 1.25 起 WaitGroup 提供了 Go(f func()) 方法,把 Add(1)、go f()、Done() 三步合并,可以从根源上避免这个问题。


1.5 GOMAXPROCS

GOMAXPROCS 决定同时执行用户代码的 P 的数量(见下一节的 GMP 模型),默认等于机器的逻辑 CPU 数:

fmt.Println(runtime.GOMAXPROCS(0), runtime.NumCPU()) // 24 24

runtime.GOMAXPROCS(0) 是查询而不修改,传大于 0 的值才会修改。把它设为 1 并不等于"程序只有一个线程"——网络轮询、GC、系统调用仍然会在其他线程上发生,只是同时运行用户代码的处理器只有一个。


1.6 GMP 模型概览

Go 运行时的调度器由三个核心结构组成:

  G   G   G   G            G = goroutine,待执行的任务
  |   |   |   |
  v   v   v   v
+---------------+          P = 逻辑处理器,持有本地运行队列
| P    runq     |
+---------------+
        |
        v
+---------------+          M = 内核线程,真正执行指令
| M             |
+---------------+
  • G:一个 goroutine,保存栈、状态和调度信息;
  • M:一个内核线程,是真正执行代码的载体;
  • P:逻辑处理器,是 M 执行 G 所需的"资源与配额",持有本地运行队列。

三者关系可以概括成:M 必须绑定一个 P 才能执行 G,P 的本地队列里放着一批等待执行的 G。当某个 P 的队列空了,它会去其他 P 那里"偷"一半任务(work stealing);当 M 因为系统调用阻塞时,它会把 P 让给其他 M(hand-off)。

这些机制的细节(本地/全局队列、抢占、netpoller、sysmon)放在 调度器:GMP 模型深入 里展开。


1.7 goroutine 泄漏初见

goroutine 泄漏指的是:goroutine 因为永远等不到某个事件而无法退出,它占用的栈和引用到的对象都无法被回收。

func leak() {
	ch := make(chan int)
	go func() {
		ch <- 1 // 永远没有接收者
	}()
}
fmt.Println("初始 goroutine 数:", runtime.NumGoroutine())
for i := 0; i < 5; i++ {
	leak()
}
time.Sleep(100 * time.Millisecond)
fmt.Println("泄漏后 goroutine 数:", runtime.NumGoroutine())
初始 goroutine 数: 1
泄漏后 goroutine 数: 6

每次调用 leak 都留下一个永久阻塞的 goroutine。排查手段是 runtime.NumGoroutine() 和 pprof 的 goroutine profile,完整的排查思路见 并发设计模式与陷阱总结 与 性能分析与调优。

常见的泄漏原因:

  • 向没有接收者的 channel 发送;
  • 从没有发送者的 channel 接收;
  • 忘记关闭 ticker;
  • 请求被取消后,工作 goroutine 仍然阻塞在 IO 上。

1.8 sync.Once

sync.Once 保证某个函数在整个程序生命周期内只执行一次,适合惰性初始化全局资源:

var once sync.Once
for i := 0; i < 3; i++ {
	once.Do(func() {
		fmt.Println("once.Do 里的代码只执行一次")
	})
}
once.Do 里的代码只执行一次

Once 的零值即可使用。即使多个 goroutine 同时调用 Do,也只有一个会执行,其他调用会阻塞到它完成。初始化失败需要重试时不要用 Once(它没有"重置"接口),改用 sync.Mutex 加一个标志位。


1.9 为什么还需要 channel

WaitGroup 只能回答"都做完了吗",Once 只能回答"做过没有"。goroutine 之间要传递数据、要通知取消、要限制并发,就需要一个通信机制——这就是下一篇的 channel。


1.10 练习

  1. 启动 10 个 goroutine,每个把 1 到 100 累加到一个局部变量,用 WaitGroup 等待全部完成,然后打印每个 goroutine 的结果。
  2. 把上题中累加的结果写进一个共享变量,观察是否出现丢失更新,并解释原因(提示:竞态,详见 sync 包与原子操作)。
  3. 写一个函数,用 sync.Once 实现"只初始化一次的配置对象",在多个 goroutine 中并发调用,验证初始化只发生一次。