用带缓冲的通道作为信号量,配合
sync.WaitGroup,精确控制 goroutine 的并发数量。这是 Go 并发编程中最经典的模式之一。
完整代码
1 | |
核心原理
用一个带缓冲的通道(容量 = 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 | |
make在内存中开辟了一块空间,最多存放 3 个struct{}- 初始状态是空的,已占用 0,剩余容量 3
struct{}是 Go 中占用 0 字节的类型,只用来”占位置”,不传递数据
类比: 建了一个停车场,里面有 3 个车位,当前全是空的。
步骤二:循环开始,计数器 +1
1 | |
wg内部有一个计数器,Add(1)让计数器 +1- 必须有对应次数的
wg.Done()把计数器减回 0,wg.Wait()才会放行
类比: 停车场门口有个登记本,每派一辆车出去就划一笔,等 10 笔都勾掉才说明活干完了。
步骤三:获取信号量(关键:可能阻塞)
1 | |
两种情况:
| 情况 | 通道状态 | 行为 |
|---|---|---|
| A — 通道没满 | 剩余容量 > 0 | 发送瞬间完成,继续往下走启动协程 |
| B — 通道满了 | 已占用 = 3 | 发送操作卡住(阻塞),代码停在这一行不动,直到有协程释放信号量 |
类比: 车开到停车场门口 — 有空位直接开进去,没空位在门口排队等。
步骤四:启动协程执行任务
1 | |
go关键字后面的函数会放到后台异步执行- 主循环不会等它,继续下一次循环
(i)把当前i的值复制一份传给id,每个协程拿到的是独立的值
类比: 车进了停车场后各自去干活,停车场管理员(主循环)继续放行下一辆车。
步骤五:协程内部 — 注册 defer 延迟执行
1 | |
不管任务执行成功还是中途崩溃,信号量必须被释放、WaitGroup 必须被通知,否则其他协程会永远阻塞、主协程永远等不到完成。
注意顺序: <-sem 必须在 wg.Done() 前面 — 先释放信号量让排队的循环继续,再通知 WaitGroup。
步骤六:执行任务本体
1 | |
打印任务编号,time.Sleep(1秒) 模拟耗时操作。这 1 秒内协程占着信号量的一个名额。
步骤七:协程结束,自动触发 defer
1 | |
<-sem— 通道已占用容量 -1。如果有循环正阻塞在sem <- struct{}{}上,那个发送操作会立即成功wg.Done()— 计数器 -1,从 10 减到 0 时wg.Wait()放行
步骤八:主协程等待所有任务完成
1 | |
主协程在这里阻塞,直到 WaitGroup 计数器归零。
如果不写这行: for 循环跑完后 main 函数直接结束,程序退出,已启动的协程还没执行完就被强制终止,你可能只能看到 2~3 个任务的输出。
执行时序图
1 | |
信号量通道图解
容量 = 3 的信号量通道
1 | |
常见问题
为什么要把 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 | |
极端情况下,如果最后一个协程执行 wg.Done() 后还没来得及 <-sem,主协程在 wg.Wait() 返回后继续执行后续代码,而信号量还占着没释放(虽然程序快退出了通常没问题,但逻辑上不严谨)。
可以用 sync.Semaphore 吗?
Go 标准库 golang.org/x/sync/semaphore 提供了 sync.Semaphore,但它需要配合 context 使用,且性能不如通道。通道作为信号量是 Go 社区推荐的做法,语义清晰且性能更好。
📖 系列下一篇: Worker Pool 协程池详解
信号量模式每个任务新建一个协程,只是用通道限制同时跑几个。如果 1000 个任务就 1000 个协程,创建和销毁开销大。下一篇的 Worker Pool 改为固定数量 worker 反复复用,解决这个问题。