用 make(chan struct{}, N) 限制并发协程数最直接:缓冲容量 N 即最大并发数,发送 struct{}{} 获取许可,接收释放;误用无缓冲 channel 会导致串行化,struct{} 零开销且语义清晰。

用 make(chan struct{}, N) 限制并发协程数最直接
Go 中没有内置“线程池”概念,但用带缓冲的 chan struct{} 模拟信号量是最轻量、最符合 Go 风格的做法。缓冲容量 N 就是最大并发数,每次启动新协程前先向 channel 发送一个空结构体(struct{}{}),执行完再接收一次释放信号。
常见错误是误用无缓冲 channel(make(chan struct{}))——它会导致所有协程在发送时立刻阻塞,无法实现“控制并发数”,只实现“串行化”。也有人用 chan int 或 chan bool,没必要,struct{} 零内存占用,语义也最清晰。
示例关键片段:
sem := make(chan struct{}, 5) // 最多 5 个并发
for _, task := range tasks {
go func(t Task) {
sem <- struct{}{} // 获取许可
defer func() { <-sem }() // 释放许可(注意:必须用 defer + 匿名函数捕获,否则闭包会错)
doWork(t)
}(task)
}为什么不能在循环里直接用 defer
这是新手高频翻车点。如果写成:
立即学习“go语言免费学习笔记(深入)”;
for _, task := range tasks {
go func() {
sem <- struct{}{}
defer <-sem // ❌ 错!defer 在 goroutine 启动时就绑定,但此时 sem 还没发过值,会永久阻塞
doWork(task)
}()
}因为 defer 语句在 goroutine 创建时求值(不是执行),而此时 sem 是空的, 会一直等,导致整个 goroutine 卡死。正确做法是用 <code>defer func(){ 延迟到函数退出时执行,且必须用匿名函数包裹,否则闭包会共享同一个 <code>task 变量。
更安全的写法是把释放逻辑放在 defer 里,但确保它不提前求值:
- 用
defer func(){ (推荐) - 或显式写
defer func() { ,避免和外部变量混淆 - 不要依赖
recover来兜底——channel 阻塞不会 panic,只会卡住
用 sync.WaitGroup 配合 channel 更稳妥
单纯靠 channel 信号量能控并发,但没法等全部任务结束。实际场景中往往既要限流又要等待完成,这时必须加 sync.WaitGroup。
典型组合模式:
- 先
wg.Add(1),再启动 goroutine - goroutine 内部用
defer wg.Done()标记完成 -
sem只负责准入控制,wg负责生命周期同步 - 主 goroutine 调用
wg.Wait()等全部结束,而不是靠 sleep 或计数器
漏掉 wg.Add 或 wg.Done 会导致程序提前退出或永远 hang 住,这类 bug 很难复现,建议用 go vet 检查 WaitGroup 使用是否匹配。
别忽略 context 取消和超时场景
真实服务中,任务可能被取消或超时,此时仅靠 sem 释放不够——如果某个 doWork 卡死或慢查询,它占着信号量不放,后续任务全被堵住。
必须结合 context.WithTimeout 或 context.WithCancel:
- 在 goroutine 内部监听
ctx.Done(),及时退出 - 退出前仍要保证
执行,否则信号量泄漏,后续所有任务都会被 block - 可用
select+default避免阻塞释放:比如select { case 不安全,应始终确保释放,哪怕用 <code>close(sem)也不如正常接收可靠
最简健壮模式是:把 放进 <code>defer,并在主逻辑里用 select 监听 ctx.Done() 和工作完成,确保无论哪种路径退出,defer 都能执行。


















