4. Go 并发实战:模式、限流与排查
4. Go 并发实战:模式、限流与排查
掌握 goroutine、channel、select、sync 之后,还需要把它们组合成稳定的工程模式。Go 并发实战的关键是:限制并发、传播取消、收集错误、保证收尾。
Worker Pool:固定数量处理任务
worker pool 用固定数量的 goroutine 消费任务,避免”每个任务一个 goroutine”导致资源失控。
type Job struct {
ID int
}
type Result struct {
JobID int
Err error
}
func workerPool(ctx context.Context, jobs <-chan Job, workerCount int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
results <- handle(ctx, workerID, job)
}
}
}(i)
}
go func() {
wg.Wait()
close(results)
}()
return results
}
这个版本还有一个隐藏风险:results <- ... 如果下游不再接收,worker 会阻塞。更完整的写法应该让发送结果也监听取消:
result := handle(ctx, workerID, job)
select {
case results <- result:
case <-ctx.Done():
return
}
worker pool 的检查清单:
- worker 数量是否有上限?
- jobs 由谁关闭?
- results 由谁关闭?
- 下游提前退出时,worker 能否退出?
- 任务处理函数是否也接收 context?
Semaphore:限制并发数量
如果不想维护完整 worker pool,只想限制同时执行的任务数量,可以用带 buffer 的 channel 做 semaphore:
func runAll(ctx context.Context, items []Item, limit int) error {
sem := make(chan struct{}, limit)
errCh := make(chan error, len(items))
var wg sync.WaitGroup
for _, item := range items {
select {
case sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
wg.Add(1)
go func(item Item) {
defer wg.Done()
defer func() { <-sem }()
if err := process(ctx, item); err != nil {
errCh <- err
}
}(item)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
sem <- struct{}{} 表示获取许可,<-sem 表示释放许可。buffer 大小就是最大并发数。
这种模式适合批量请求外部 API、批量处理文件、批量生成缩略图等任务。
Fan-out / Fan-in:并行处理再合并
Fan-out 是把一条输入流分给多个 worker,Fan-in 是把多个 worker 的输出合并。
func fanOut(ctx context.Context, in <-chan int, workers int) []<-chan int {
outs := make([]<-chan int, 0, workers)
for i := 0; i < workers; i++ {
out := make(chan int)
outs = append(outs, out)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
}
return outs
}
func fanIn(ctx context.Context, inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, input := range inputs {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for v := range ch {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}(input)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
两个要点:
- 多个 goroutine 从同一个 input channel 读,天然会分摊任务
- 合并输出时,必须等所有输入都结束后再关闭 out
Pipeline:分阶段处理
pipeline 把任务拆成多个阶段,每个阶段只关心自己的输入和输出:
read files → parse → validate → write database
每个阶段的标准形状:
func stage(ctx context.Context, in <-chan Item) <-chan Item {
out := make(chan Item)
go func() {
defer close(out)
for item := range in {
result := transform(item)
select {
case out <- result:
case <-ctx.Done():
return
}
}
}()
return out
}
pipeline 最常见的问题是下游提前退出,上游还在发送,导致 goroutine 泄漏。所以每个发送点都应该带上取消分支:
select {
case out <- result:
case <-ctx.Done():
return
}
如果某个阶段发现错误,应当取消整个 pipeline,而不是只让自己退出。
errgroup:错误收集与取消
并发任务需要”一个失败,全部取消”时,errgroup 比手写 WaitGroup 更合适:
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("%s: %s", url, resp.Status)
}
return nil
})
}
return g.Wait()
}
errgroup.WithContext 的语义是:任意一个 goroutine 返回非 nil error,派生出的 ctx 会被取消。其他任务如果正确监听 ctx,就会尽快退出。
如果项目不想引入 golang.org/x/sync/errgroup,也可以继续用 WaitGroup + error channel,只是样板代码会更多。
常见问题一:死锁
典型死锁:没有 goroutine 能继续推进。
func main() {
ch := make(chan int)
ch <- 1 // 没有接收者,main 阻塞
}
运行时可能报:
fatal error: all goroutines are asleep - deadlock!
常见原因:
- 无缓冲 channel 只有发送没有接收
range ch等待关闭,但发送方忘记 close- WaitGroup 计数没有归零
- 锁没有释放
- 多把锁顺序不一致
排查时先看 goroutine dump,找每个 goroutine 卡在哪里。
常见问题二:goroutine 泄漏
泄漏是 goroutine 没有退出,但程序还在运行:
func search(query string) <-chan Result {
ch := make(chan Result)
go func() {
ch <- doSearch(query)
}()
return ch
}
func handler(ctx context.Context) {
ch := search("go")
select {
case result := <-ch:
_ = result
case <-ctx.Done():
return // search goroutine 可能还在发送,泄漏
}
}
修复方式一:给结果 channel 加 buffer,保证发送方能结束:
ch := make(chan Result, 1)
修复方式二:让发送方也监听 context:
func search(ctx context.Context, query string) <-chan Result {
ch := make(chan Result, 1)
go func() {
defer close(ch)
result := doSearch(query)
select {
case ch <- result:
case <-ctx.Done():
return
}
}()
return ch
}
常见问题三:过度并发
下面的代码在数据量小时没问题,数据量大时会把服务打爆:
for _, id := range ids {
go fetchUser(id)
}
过度并发的症状:
- CPU 飙高
- 内存上涨
- goroutine 数量持续增长
- 外部 API 返回限流错误
- 数据库连接池耗尽
修复方向:
- 给并发数设置上限
- 给每个请求设置超时
- 使用连接池和队列
- 做批处理而不是无限 fan-out
常见问题四:忽略 backpressure
如果生产速度长期大于消费速度,buffer 只会延迟问题,不会解决问题:
jobs := make(chan Job, 100000)
大 buffer 可能让生产者看起来很顺畅,但真实代价是内存堆积和处理延迟。更好的方式是让 channel 小一些,让生产者在消费者跟不上时自然阻塞,形成 backpressure。
jobs := make(chan Job, workerCount*2)
队列长度应该是系统设计的一部分,而不是随手写一个很大的数字。
排查工具
race detector
检查数据竞争:
go test -race ./...
pprof
查看 goroutine 数量和阻塞位置:
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
常用地址:
http://localhost:6060/debug/pprof/goroutine?debug=2
http://localhost:6060/debug/pprof/profile
http://localhost:6060/debug/pprof/heap
runtime.NumGoroutine
简单监控 goroutine 数量:
log.Println("goroutines:", runtime.NumGoroutine())
如果某个接口压测后 goroutine 数量只涨不跌,大概率有泄漏。
trace
go test -trace 可以看到 goroutine 调度、阻塞、网络、系统调用等时间线:
go test -trace trace.out ./pkg/...
go tool trace trace.out
trace 适合排查”为什么并发了但不快”、“时间到底卡在哪里”这类问题。
并发设计检查表
写并发代码前,可以先问自己:
- 最大并发数是多少?
- 每个 goroutine 的退出条件是什么?
- 上游失败时,下游会不会退出?
- 下游提前退出时,上游会不会阻塞?
- 错误由谁收集?
- channel 由谁关闭?
- 共享状态是否有锁、atomic 或 channel 保护?
- 是否能用
go test -race覆盖关键路径?
小结
Go 并发实战不是把所有东西都丢进 goroutine,而是建立一套清楚的生命周期:
- 用 worker pool 或 semaphore 限制并发
- 用 context 传播取消
- 用 errgroup 或 WaitGroup 收尾
- 用 channel 表达数据流
- 用 mutex 或 atomic 保护共享状态
- 用 race、pprof、trace 排查问题
稳定的并发代码通常看起来并不花哨:入口有限流,中间有取消,出口能关闭,错误能返回。