在高并发系统中,goroutine 的创建成本虽然低,但并非没有代价。每个 goroutine 需要初始栈空间,运行时调度和垃圾回收都会受到活跃 goroutine 数量的影响。如果没有限制,一个恶意或异常的请求洪峰就可能让服务内存飙升甚至崩溃。因此限制并发请求数量是 Go 服务端开发的基本功。

一、带缓冲 channel 实现信号量
在 Go 中,channel 本身具备阻塞和唤醒能力,非常适合充当信号量来限制并发。创建一个容量为 N 的带缓冲 channel,在启动 goroutine 前向 channel 发送一个值,如果 channel 已满则发送操作阻塞,直到有空位。处理完成后从 channel 接收一个值释放位置。这种做法的代码非常简洁:
sem := make(chan struct{}, 10)
for _, req := range requests {
sem <- struct{}{}
go func(r Request) {
defer func() { <-sem }()
handle(r)
}(req)
}
需要说明的是,向 channel 发送空结构体 struct{}{} 不占用额外内存,仅作为令牌使用。如果要在请求超时或取消时避免 goroutine 泄漏,可以使用 select 配合 context 或 timeout,在等待信号量的同时监听退出信号。例如:
select {
case sem <- struct{}{}:
// 获取到名额
case <-ctx.Done():
// 上下文取消,直接返回
return
}
这种方案优点是零第三方依赖、实现简单、容易理解。缺点是当并发上限较大时,channel 本身占用的内存非常有限,但当大量 goroutine 阻塞在发送操作上时,调度器仍需要维护这些 goroutine 的栈和状态,可能带来一定的开销。此外,它只能限制同时执行的数量,无法实现更复杂的限流策略,比如速率控制或突发控制。如果并发限制数值经常变化,还需要重新创建 channel 或采用其他同步原语,灵活性一般。
二、使用 golang.org/x/sync/semaphore 包
golang.org/x/sync/semaphore 包提供了加权信号量,可以更精细地控制并发资源。与简单的 channel 信号量不同,它允许每个请求占用不同数量的权重,适合限制总资源消耗而非仅请求数量。例如一个请求可能占用多个数据库连接,权重可以反映资源占用。使用时先通过 semaphore.NewWeighted 创建一个带权重的信号量,然后在处理前调用 Acquire 方法获取资源,处理完成后调用 Release 释放。
import "golang.org/x/sync/semaphore"
var sem = semaphore.NewWeighted(20)
func handle(ctx context.Context, req Request) error {
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
return doWork(req)
}
该包的另一个优势是支持 TryAcquire,可以在不阻塞的情况下尝试获取资源,如果失败则立即返回,适合有快速失败需求的场景。它的内部实现基于互斥锁和条件变量,比纯 channel 更高效,尤其是并发数很大时。Acquire 方法支持 context,如果 context 被取消,等待中的 goroutine 会立即返回错误,避免无限阻塞,这对于处理客户端断开连接或服务降级非常关键。
不过 x/sync 包属于扩展库,需要额外引入模块。在工程中如果允许依赖,它是最推荐的方案之一,因为 API 语义清晰,且维护者会持续优化底层实现。相比手写 channel 信号量,它避免了容易被忽视的边界问题,例如 channel 关闭后的 panic 或死锁。如果需要在并发控制中引入权重概念,或者希望非阻塞获取失败时快速返回,semaphore 包是首选。
三、Worker Pool 模式
Worker Pool 是另一种经典限流思路:不限制启动的 goroutine 总数,而是预先创建固定数量的 worker goroutine,从任务队列中消费任务。任务队列可以用 channel 承载,请求处理逻辑作为任务放入队列,由 worker 并发执行。这种模式将并发数完全由 worker 数量决定,任务队列长度可以独立配置,从而支持一定程度的排队。任务队列满时,可以丢弃请求或返回 503 状态,实现背压机制。
jobs := make(chan Request, 100)
for i := 0; i < 5; i++ {
go func() {
for req := range jobs {
handle(req)
}
}()
}
// 请求入口
select {
case jobs <- req:
// 已入队
case <-time.After(time.Second):
// 队列满,拒绝请求
}
Worker Pool 的优点是执行模型清晰,worker 数量固定,资源占用可预测。它特别适合处理耗时任务,比如批量调用外部 API 或执行 CPU 密集计算。任务队列还能隔离请求接收与处理,让服务在高峰期不至于瞬间耗尽资源。缺点是任务队列的长度和 worker 数量需要根据业务负载调整,配置不当可能导致队列积压或 worker 空闲。另外,当任务需要动态扩展并发数时,调整 worker 数量需要安全地启停 goroutine,实现起来比信号量稍复杂。
在实现 Worker Pool 时,往往还会配合 sync.WaitGroup 等待所有 worker 退出,或者使用 close(jobs) 来通知 worker 停止接收新任务。这样可以确保服务关闭时没有任务被丢失。需要注意的是,如果任务执行时间很长,而队列又已经满了,请求入口可能会阻塞在发送操作上,此时最好用 select 加超时来避免请求长时间挂起。
四、使用令牌桶限流器控制速率
前面几种方案主要限制同时执行的并发数,但有时还需要限制请求速率,比如每秒最多处理 100 个请求。golang.org/x/time/rate 包实现了令牌桶算法,可以同时控制平均速率和突发容量。rate.Limiter 提供了 Wait、Allow、Reserve 等方法。Wait 方法会阻塞直到有令牌可用或 context 被取消,适合在 HTTP 中间件中使用。
import "golang.org/x/time/rate"
limiter := rate.NewLimiter(rate.Limit(100), 200)
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := limiter.Wait(r.Context()); err != nil {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
令牌桶和并发数限制并不冲突,可以组合使用:先通过令牌桶控制入口速率,再用信号量限制在途请求数量,防止突发流量打垮下游系统。这种组合在网关和限流中间件中非常常见。需要留意的是,rate.Limiter 的 Wait 方法在令牌不足时会精确计算等待时间,如果等待时间过长,可能累积大量阻塞 goroutine。因此在高流量入口处,建议设置合理的突发容量和等待超时,避免请求无限排队。
五、方案选型与工程建议
选择哪种方案取决于业务特点。如果只是简单限制并发请求数,不希望引入第三方依赖,带缓冲 channel 足够;如果希望更优雅地管理资源权重和取消,优先使用 semaphore 包;如果任务天然适合队列化处理,worker pool 能提供更稳定的资源模型;如果需要同时控制请求速率,令牌桶是标准选择。在实际工程中,通常会组合这些技术。例如在 API 网关中,先使用 rate.Limiter 做粗粒度限流,再结合 semaphore 限制后端连接数,最后通过 worker pool 执行耗时操作。这样每一层职责清晰,便于监控和调优。
此外,无论采用哪种方案,都应关注 goroutine 泄漏问题。当请求被取消或超时时,确保等待信号量的 goroutine 能通过 context 退出,Worker Pool 的任务队列在服务关闭时能正确关闭 channel 并等待 worker 退出。加一些指标监控,如当前并发数、排队长度、拒绝次数等,有助于及时发现限流策略是否合理。限流不是越严格越好,过严会牺牲吞吐量,过松则失去保护作用,需要在压测中不断调整参数,找到服务稳定性和资源利用率之间的平衡点。