用 chan struct{} 实现信号量最稳妥:make(chan struct{}, N) 创建容量为 N 的缓冲通道作为许可池,任务前写入、完成后读取,避免用 chan int 或遗漏释放导致卡死。

用 semaphore 控制 Goroutine 并发数最稳妥
Go 标准库没有叫 semaphore 的类型,但用 sync.Mutex + sync.Cond 或更简单的 chan struct{} 就能实现信号量语义。直接上 chan struct{} 是多数人实际在用的方案,它轻量、无锁、语义清晰。
常见错误是把 chan int 当信号量,或者忘记 defer 释放——结果 goroutine 卡死或并发数失控。
-
make(chan struct{}, N)创建容量为N的缓冲通道,就是信号量的“许可池” - 每个任务启动前写入一个
struct{}(sem ),相当于申请许可 - 任务结束时读出一个(
<-sem),相当于归还许可 - 通道满时写操作会阻塞,天然限流;不用加锁,也不用担心竞态
为什么不用 sync.WaitGroup 或 context.WithTimeout 来限并发
sync.WaitGroup 只管等待完成,不管同时跑几个;context.WithTimeout 管超时,不控数量。两者混用容易误以为“等 10 个就等于并发 10 个”,其实它们完全不阻止你起第 11 个 goroutine。
典型翻车场景:循环里开 goroutine + wg.Add(1),但没做任何并发控制,瞬间炸出几百个 goroutine,内存和调度压力飙升。
立即学习“go语言免费学习笔记(深入)”;
- 限流必须发生在“启动前”,不是“等待后”
-
WaitGroup和context是协作工具,不是同步原语 - 真要组合用,也得先过信号量,再
wg.Add,再defer wg.Done()
sem 阻塞时怎么避免死锁
如果所有 goroutine 都卡在 sem <- struct{}{},说明信号量没被正确释放——大概率是某个 goroutine panic 了,或者提前 return 但没执行释放逻辑。
最简单防死锁方式:用 defer 包一层释放操作,但要注意 defer 在匿名函数里不能捕获外层变量,得显式传参。
func doWork(sem chan struct{}) {
sem <- struct{}{} // 获取许可
defer func() { <-sem }() // 保证释放,即使 panic 也生效
<pre class='brush:php;toolbar:false;'>// 实际工作
http.Get("https://example.com")}
- 别在 defer 里写
<-sem而不包成闭包,否则可能读空 channel 导致 panic - 如果工作函数本身有 error 返回,别因为 error 就跳过 defer —— 释放必须无条件发生
- 测试时可故意让某个 goroutine sleep 后 panic,验证是否真能释放
生产环境建议用 golang.org/x/sync/semaphore
虽然手写 chan struct{} 足够用,但官方 semaphore 包支持带权值(Weight)的信号量,比如某些任务消耗资源多,该占 2 个许可,而普通任务只占 1 个——这种需求手写容易出错。
它还提供 TryAcquire 非阻塞尝试,适合做快速失败逻辑;底层仍是 channel,但封装了上下文取消、超时、权重计算等细节。
- 引入:
go get golang.org/x/sync/semaphore - 初始化:
sem := semaphore.NewWeighted(int64(maxConcurrent)) - 获取:
err := sem.Acquire(ctx, 1)(返回 error,需检查) - 释放:
sem.Release(1)(注意 Release 不会 panic,但参数必须匹配 Acquire 的 weight)
真正复杂的地方不在怎么写,而在“哪些操作该进信号量、哪些不该”。比如日志打点、指标上报这类轻量旁路操作,如果也塞进信号量,反而会拖慢主流程。信号量保护的应是真实受限资源:数据库连接、HTTP 客户端、文件句柄、外部 API 配额——不是所有并发都需要被同一个信号量管住。










