Go 语言用 chan struct{} 实现信号量是最轻量、最符合 Go 风格的并发控制方式:初始化带缓冲 channel(容量为最大并发数),发送空结构体获取许可,接收释放许可;需严格配对、避免 close、封装为 Semaphore 结构体提升安全性,常与 sync.WaitGroup 协同使用。

Go 语言原生没有 semaphore 类型,但用 chan struct{} 实现信号量控制并发数量,是最轻量、最符合 Go 风格的做法。
用 chan struct{} 模拟信号量控制 goroutine 并发数
核心思路:初始化一个带缓冲的 channel,容量即最大并发数;每个任务启动前先向 channel 发送一个空结构体(占位),执行完再接收一次(释放)。channel 满时发送操作会阻塞,自然限流。
常见错误是把 chan int 或带缓冲的 chan bool 当信号量——没必要,struct{} 零内存开销,语义也更清晰。
- 缓冲大小必须在
make(chan struct{}, N)中显式指定,N即最大并发数 - 发送和接收必须成对出现,否则 channel 会“卡死”,后续 goroutine 永远阻塞
- 不要在循环外提前 close channel,会导致 panic:
send on closed channel
sem := make(chan struct{}, 3) // 最多 3 个并发
for i := 0; i < 10; i++ {
go func(id int) {
sem <- struct{}{} // 获取许可
defer func() { <-sem }() // 释放许可(用 defer 确保执行)
// 执行实际任务(如 HTTP 请求、文件处理等)
fmt.Printf("task %d started\n", id)
time.Sleep(time.Second)
fmt.Printf("task %d done\n", id)
}(i)}
立即学习“go语言免费学习笔记(深入)”;
// 等待所有 goroutine 结束(示例中省略 sync.WaitGroup,实际需配合使用)
time.Sleep(4 * time.Second)
封装成可复用的 Semaphore 结构体
直接裸用 chan 容易漏掉 defer 或写错收发顺序。封装成结构体能提升可读性和安全性,尤其适合多个地方复用或需要统计/调试的场景。
注意:结构体方法不应对 chan 做额外同步(如加 mutex),因为 channel 本身已线程安全;但 Acquire 和 Release 必须严格配对,且不能重复 Release(会导致 panic:recv on closed channel)。
-
Acquire()是阻塞调用,适合“等到位再干活”的场景 - 如果需要超时控制,应改用
select+time.After,而不是给 channel 加 timeout - 不要暴露内部
chan,避免外部误操作
type Semaphore struct {
ch chan struct{}
}
func NewSemaphore(n int) *Semaphore {
return &Semaphore{ch: make(chan struct{}, n)}
}
func (s Semaphore) Acquire() { s.ch <- struct{}{} }
func (s Semaphore) Release() { <-s.ch }
对比 sync.WaitGroup 和 semaphore 的适用边界
sync.WaitGroup 只负责等待 goroutine 结束,不控制启动节奏;semaphore 控制的是“同一时刻最多几个在跑”。两者常一起用:WaitGroup 等全部完成,semaphore 控制并发密度。
典型误用:只用 WaitGroup.Add(1) 启一堆 goroutine,结果瞬间打爆 API 限频或数据库连接池——这时必须插一层 semaphore。
- API 调用密集型任务(如批量请求第三方服务)→ 必须用 semaphore 限流
- 纯 CPU 计算任务(无 I/O)→ 并发数通常设为
runtime.NumCPU(),用 semaphore 更可控 - 混合场景(如先查 DB 再发 HTTP)→ semaphore 应按瓶颈环节设(通常是下游服务 QPS)
小心 context.Context 取消与 semaphore 的交互
当任务可能被 context.WithTimeout 中断时,Acquire 成功后若上下文已取消,仍要确保 Release 被调用,否则信号量泄漏,后续所有 goroutine 都会永久阻塞。
正确做法是在 Acquire 后立即用 select 检查 context 是否已取消,并用 default 防止死锁;或者把 Acquire 放进 select 分支里统一处理取消逻辑。
- 不要在
Acquire外部做 cancel 判断,因为 acquire 本身可能阻塞,此时 context 已超时也无法响应 - 释放操作必须放在
defer或finally逻辑里,哪怕 context 取消也要归还许可 - 测试时务必覆盖 “acquire 成功 → context cancel → task 不执行 → release 仍发生” 这条路径
真正难的不是写对那几行 channel 操作,而是判断哪个环节才是瓶颈、该设多少并发数、以及 context 取消时如何不漏掉 release。这些没法靠封装自动解决,得结合压测和监控来看。










