go中用semaphore实现请求级并发限制器更合适,因其支持上下文取消的acquire,可在handler前阻塞或拒绝请求;而waitgroup仅计数等待,无法控制协程启动。

Go 中用 semaphore 实现请求级并发限制器为什么比 sync.WaitGroup 更合适
因为 sync.WaitGroup 只能做计数等待,无法阻塞新协程启动;而请求级限流必须在进入 handler 前就决定“放行还是排队/拒绝”。golang.org/x/sync/semaphore 提供带上下文取消的 Acquire,天然适配 HTTP 请求生命周期。
常见错误是直接在 handler 里用 time.Sleep 模拟耗时,却忘了释放信号量——导致后续所有请求永久卡住。正确做法是用 defer 确保 Release 执行:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := sem.Acquire(ctx, 1); err != nil {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
defer sem.Release(1) // 必须放在这里,不是函数末尾随便写
// 处理业务逻辑...
}
- 每个请求独占 1 个 permit,
sem初始化时设最大并发数(如 100) - 若请求超时或客户端断开,
ctx会自动取消,Acquire返回context.Canceled或context.DeadlineExceeded,不会泄漏 permit - 不要用
int64类型 permit 数做动态调整——semaphore.Weighted不支持运行时扩容,需重建实例
全局级限流用 rate.Limiter 还是自定义原子计数器
golang.org/x/time/rate.Limiter 适合「单位时间请求数」场景(如 100 QPS),但它是漏桶模型,不保证瞬时并发数;而全局并发限制器要控的是「此刻最多多少 goroutine 在跑」,必须用许可计数 + CAS。
典型误用是把 rate.NewLimiter(100, 1) 当作并发控制器——它允许短时突发 100 个请求同时执行,完全失去并发压制效果。
推荐方案:用 atomic.Int64 + CompareAndSwap 构建无锁计数器:
type GlobalConcurrencyLimiter struct {
limit int64
cur atomic.Int64
}
func (l *GlobalConcurrencyLimiter) TryAcquire() bool {
for {
cur := l.cur.Load()
if cur >= l.limit {
return false
}
if l.cur.CompareAndSwap(cur, cur+1) {
return true
}
}
}
func (l *GlobalConcurrencyLimiter) Release() {
l.cur.Add(-1)
}
- 初始化时
limit设为期望的最大并发数(如 50) -
TryAcquire非阻塞,适合快速失败;若需排队,得配合sync.Cond或 channel 实现,但会增加调度开销 - 注意:该计数器不感知 goroutine 生命周期,必须确保每次
TryAcquire成功后,无论是否 panic,都调用Release(建议用defer包裹)
gin 框架中如何注入两种限流器且避免中间件顺序踩坑
在 gin 中,请求级限流器(基于 semaphore)必须放在最外层中间件,否则可能被其他中间件提前 abort 导致 permit 泄漏;而全局计数器(atomic 版)可放稍内层,但绝不能放在日志或 recover 中间件之后——panic 会导致 Release 被跳过。
错误示例:把 sem.Acquire 放在 gin.Recovery() 之后,一旦 handler panic,defer sem.Release 不会执行。
- 正确顺序:
semaphore middleware → auth → global atomic limiter → business handler - 全局计数器中间件里,
TryAcquire失败应直接c.AbortWithStatusJSON(http.StatusTooManyRequests, ...),不能继续调用c.Next() - gin 的
c.Copy()或子路由分组不影响限流作用域——限流器实例是共享的,按变量作用域决定生效范围
限流器混用时 context 超时与 permit 归还的竞态问题
当一个请求同时受 semaphore.Acquire(带 ctx)和全局 atomic 计数器约束时,如果 sem.Acquire 因超时返回错误,但 atomic.TryAcquire 已成功,就会出现 permit 多扣一次的情况。
根本原因是两个限流器没有事务语义。解决方式不是加锁(破坏性能),而是统一用一个上下文协调:
func combinedLimiterMiddleware(sem *semaphore.Weighted, global *GlobalConcurrencyLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// 先走全局计数器(轻量)
if !global.TryAcquire() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, nil)
return
}
defer global.Release()
// 再走 semaphore(可能阻塞)
if err := sem.Acquire(ctx, 1); err != nil {
global.Release() // 补偿:sem 失败,必须回退 global
c.AbortWithStatusJSON(http.StatusTooManyRequests, nil)
return
}
defer sem.Release(1)
}
}
- 顺序不能颠倒:必须先
global.TryAcquire,再sem.Acquire,否则sem超时后无法安全回退global - 任何一步失败,都要显式补偿已成功的步骤;
defer只负责成功路径的清理 - 这种组合在高并发下仍有极小概率因调度延迟导致短暂超限,但比单一层更可控——真正难处理的是跨服务、跨进程的分布式并发控制,那已经超出本机限流器能力范围
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











