用带缓冲的通道作为信号量,配合 sync.WaitGroup,精确控制 goroutine 的并发数量。这是 Go 并发编程中最经典的模式之一。


完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup // WaitGroup:等待所有协程完成
sem := make(chan struct{}, 3) // 信号量:缓冲容量=3,最多 3 个协程并发

for i := 0; i < 10; i++ {
wg.Add(1) // 计数器 +1,新增一个待完成的协程
sem <- struct{}{} // 获取信号量:通道未满立即通过;满了则阻塞排队

go func(id int) { // 启动协程,把 i 作为参数传入(避免闭包共享)
defer func() {
<-sem // 释放信号量:腾出一个并发名额
wg.Done() // 计数器 -1,通知 WaitGroup 此协程完成
}()

fmt.Printf("任务 %d 执行\n", id) // 打印当前任务编号
time.Sleep(time.Second) // 模拟 1 秒耗时操作
}(i)
}

wg.Wait() // 阻塞等待:直到所有协程都调用了 Done
fmt.Println("全部任务已完成")
}

核心原理

用一个带缓冲的通道(容量 = 3)作为信号量,限制同时运行的协程数量:

  • sem <- struct{}{} — 向通道发送值 → 获取一个并发名额。通道已满时会阻塞,后面的循环排队等待
  • <-sem — 从通道接收值 → 释放一个并发名额。腾出空间后,排队的循环就可以继续了

为什么需要 WaitGroup?

如果只用信号量,主协程在 for 循环结束后就会直接退出 main,新启动的协程还没来得及执行。wg.Wait() 确保 main 等所有子协程完成后才退出。


执行流程

步骤 1 — wg.Add(1)

计数器 +1,告诉 WaitGroup 有一个新协程要等。循环 10 次后计数器变成 10。

步骤 2 — sem <- struct{}{}

向通道发送空值,申请并发名额。通道未满立即通过,满了则阻塞。

步骤 3 — go func(id)

启动协程执行任务。把循环变量 i 作为参数传入,避免闭包共享导致所有协程拿到同一个值。

步骤 4 — defer <-sem + wg.Done()

协程结束时自动执行:先释放信号量让下一个循环继续,再通知 WaitGroup 计数器 -1。


详细步骤拆解

步骤一:创建信号量

1
sem := make(chan struct{}, 3)
  • make 在内存中开辟了一块空间,最多存放 3 个 struct{}
  • 初始状态是空的,已占用 0,剩余容量 3
  • struct{} 是 Go 中占用 0 字节的类型,只用来”占位置”,不传递数据

类比: 建了一个停车场,里面有 3 个车位,当前全是空的。

步骤二:循环开始,计数器 +1

1
2
3
4
for i := 0; i < 10; i++ {
wg.Add(1)
...
}
  • wg 内部有一个计数器,Add(1) 让计数器 +1
  • 必须有对应次数的 wg.Done() 把计数器减回 0,wg.Wait() 才会放行

类比: 停车场门口有个登记本,每派一辆车出去就划一笔,等 10 笔都勾掉才说明活干完了。

步骤三:获取信号量(关键:可能阻塞)

1
sem <- struct{}{}

两种情况:

情况通道状态行为
A — 通道没满剩余容量 > 0发送瞬间完成,继续往下走启动协程
B — 通道满了已占用 = 3发送操作卡住(阻塞),代码停在这一行不动,直到有协程释放信号量

类比: 车开到停车场门口 — 有空位直接开进去,没空位在门口排队等。

步骤四:启动协程执行任务

1
go func(id int) { ... }(i)
  • go 关键字后面的函数会放到后台异步执行
  • 主循环不会等它,继续下一次循环
  • (i) 把当前 i 的值复制一份传给 id,每个协程拿到的是独立的值

类比: 车进了停车场后各自去干活,停车场管理员(主循环)继续放行下一辆车。

步骤五:协程内部 — 注册 defer 延迟执行

1
2
3
4
defer func() {
<-sem
wg.Done()
}()

不管任务执行成功还是中途崩溃,信号量必须被释放、WaitGroup 必须被通知,否则其他协程会永远阻塞、主协程永远等不到完成。

注意顺序: <-sem 必须在 wg.Done() 前面 — 先释放信号量让排队的循环继续,再通知 WaitGroup。

步骤六:执行任务本体

1
2
fmt.Printf("任务 %d 执行\n", id)
time.Sleep(time.Second)

打印任务编号,time.Sleep(1秒) 模拟耗时操作。这 1 秒内协程占着信号量的一个名额。

步骤七:协程结束,自动触发 defer

1
2
3
4
defer func() {
<-sem // 1. 从通道取出一个值 → 腾出空位
wg.Done() // 2. WaitGroup 计数器 -1
}()
  • <-sem — 通道已占用容量 -1。如果有循环正阻塞在 sem <- struct{}{} 上,那个发送操作会立即成功
  • wg.Done() — 计数器 -1,从 10 减到 0 时 wg.Wait() 放行

步骤八:主协程等待所有任务完成

1
wg.Wait()

主协程在这里阻塞,直到 WaitGroup 计数器归零。

如果不写这行: for 循环跑完后 main 函数直接结束,程序退出,已启动的协程还没执行完就被强制终止,你可能只能看到 2~3 个任务的输出。


执行时序图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
主协程                            子协程

├─ 循环 0: wg.Add(1)
├─ 循环 0: sem <- struct{}{} → 通道 [x, _, _] 成功
├─ 循环 0: go 协程0 ────────────────────────────────→ 任务 0 执行...

├─ 循环 1: wg.Add(1)
├─ 循环 1: sem <- struct{}{} → 通道 [x, x, _] 成功
├─ 循环 1: go 协程1 ────────────────────────────────→ 任务 1 执行...

├─ 循环 2: wg.Add(1)
├─ 循环 2: sem <- struct{}{} → 通道 [x, x, x] 成功
├─ 循环 2: go 协程2 ────────────────────────────────→ 任务 2 执行...

├─ 循环 3: wg.Add(1)
├─ 循环 3: sem <- struct{}{} → 通道满了!阻塞等待 ←← 卡在这里

│ 协程0 执行完毕
│ <-sem 取出值 → 通道 [_, x, x]
│ wg.Done() → 计数器 9

├─ 循环 3: 检测到空位,发送成功!→ 通道 [x, x, x]
├─ 循环 3: go 协程3 ────────────────────────────────→ 任务 3 执行...

│ ...以此类推,直到10个任务全部完成...

├─ wg.Wait() → 计数器从 10 减到 0 → 放行
└─ fmt.Println("全部任务已完成")

信号量通道图解

容量 = 3 的信号量通道

1
2
3
4
5
6
7
循环批次    并发任务          信号量通道 [_, _, _]
──────────────────────────────────────────────
第1批 任务0,1,2 [x, x, x] 满了,后续阻塞
任务0完成 任务3进入 [o, x, x] 释放一个,补一个
任务1完成 任务4进入 [o, o, x]
任务2完成 任务5进入 [o, o, o]
...以此类推,直到10个任务全部完成

常见问题

为什么要把 i 传给 func(id int)?直接用 i 不行吗?

在 Go 1.22 之前,for 循环变量在所有迭代间共享。如果直接写 go func() { fmt.Println(i) }(),所有协程可能打印同一个值。传参 (i) 可以把当前值复制一份传入,确保每个协程拿到独立的值。

Go 1.22+ 已经修复了这个问题,循环变量每次迭代都是新的,但传参写法仍然是好习惯。

信号量容量设多大合适?

取决于任务类型:

  • CPU 密集型 — 设为 runtime.NumCPU(),不超过 CPU 核心数
  • I/O 密集型(网络请求、数据库查询)— 可以设得大一些(如 50~100),因为 I/O 等待时不占 CPU
  • 不确定 — 从 3~10 开始,根据实际压测调整

defer 中 <-sem 和 wg.Done() 的顺序能换吗?

不建议换。先 <-sem 释放信号量让后续循环继续,再 wg.Done() 通知完成。如果反过来:

1
2
3
4
defer func() {
wg.Done() // 先通知完成
<-sem // 再释放信号量
}()

极端情况下,如果最后一个协程执行 wg.Done() 后还没来得及 <-sem,主协程在 wg.Wait() 返回后继续执行后续代码,而信号量还占着没释放(虽然程序快退出了通常没问题,但逻辑上不严谨)。

可以用 sync.Semaphore 吗?

Go 标准库 golang.org/x/sync/semaphore 提供了 sync.Semaphore,但它需要配合 context 使用,且性能不如通道。通道作为信号量是 Go 社区推荐的做法,语义清晰且性能更好。


📖 系列下一篇: Worker Pool 协程池详解

信号量模式每个任务新建一个协程,只是用通道限制同时跑几个。如果 1000 个任务就 1000 个协程,创建和销毁开销大。下一篇的 Worker Pool 改为固定数量 worker 反复复用,解决这个问题。