Go:并发常见问题,竞态、死锁、泄漏、超时和取消怎么排查

学 Go 学到并发时,最容易被几句很有诱惑力的话带偏:

  • Go 起 goroutine 很轻量
  • channel 很优雅
  • select 一加就高级了
  • context 一传就算支持取消了
  • 并发代码只要能跑通,就说明设计差不多没问题

这些话单独看都不算错,但放到真实项目里,很容易把人带进另一种混乱:

  • 吞吐量没上去,bug 先上去了
  • 偶发数据错乱,复现一次很难
  • 程序不是卡死,就是 goroutine 数量越跑越多
  • 上线后偶尔超时,但不知道到底是慢、堵、泄漏还是没取消
  • 日志很多,真正定位问题时却连哪条 goroutine 没退出都看不出来

并发最难的地方,从来不是会不会写 go func() {},而是下面这些边界能不能分清:

  • 哪些数据可以共享,哪些必须明确归属
  • 哪个 goroutine 负责生产,哪个负责消费,哪个负责关闭 channel
  • 任务结束后,所有 goroutine 是否真的都能退出
  • 超时是业务边界,还是排障补丁
  • 取消信号能不能沿整条调用链传到底
  • 出问题时,先查竞态、查阻塞、查泄漏,还是先查下游慢

这一篇不按零碎语法点来讲,而是围绕一个实际小场景来讲:做一个并发巡检器

这个巡检器要做这些事:

  1. 接收一批待巡检接口
  2. 用固定 worker 并发执行
  3. 支持整批取消
  4. 支持单任务超时
  5. 汇总成功、失败、超时数量
  6. 出问题时能看出是竞态、死锁还是 goroutine 泄漏

这个场景不大,但足够把 Go 并发里最常见、最容易互相缠住的问题一次讲清楚。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答下面这些问题:

  1. Go 里的并发问题,为什么很多不是“语法错”,而是边界错
  2. 数据竞态到底是什么,为什么有时会错,有时又像没事
  3. 死锁和 goroutine 泄漏的区别到底是什么
  4. context.WithTimeoutcontext.WithCancel 分别解决什么问题
  5. 为什么很多“支持超时”的代码仍然会泄漏 goroutine
  6. 并发代码最少应该补哪些可观测性
  7. 线上出现超时、卡住、goroutine 增长时,排查顺序应该怎么定

如果这些问题能独立说清楚,后面再写 worker pool、HTTP 服务、任务调度器、消息消费器,代码会稳很多。

二、先把这个并发小项目的场景说清楚

假设现在要写一个最小的测试平台巡检器。

它每天会拿到一批接口巡检任务:

  • 检查登录接口
  • 检查订单接口
  • 检查支付接口
  • 检查消息推送接口

每个任务都有:

  • 任务 ID
  • 接口名
  • 超时时间
  • 巡检函数

目标是让它具备这些能力:

  1. 不要串行慢慢跑,要固定数量 worker 并发跑
  2. 某个任务超时时,只影响当前任务,不把整个批次永远拖住
  3. 整批取消时,worker 能尽快退出
  4. 汇总统计不能出现并发写坏
  5. 出问题时,能快速判断是共享数据冲突、channel 设计错误,还是 goroutine 没退出

后面的所有概念,都围绕这个小项目来展开。

三、先看一个最小可运行示例

先不要一上来就把并发写复杂,先看最小骨架。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
"context"
"fmt"
"sync"
"time"
)

type Task struct {
ID string
Name string
Timeout time.Duration
}

func probe(ctx context.Context, task Task) error {
select {
case <-time.After(50 * time.Millisecond):
fmt.Println("done:", task.Name)
return nil
case <-ctx.Done():
return ctx.Err()
}
}

func main() {
tasks := []Task{
{ID: "t-1", Name: "login", Timeout: 100 * time.Millisecond},
{ID: "t-2", Name: "order", Timeout: 100 * time.Millisecond},
}

jobs := make(chan Task)
var wg sync.WaitGroup

for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range jobs {
ctx, cancel := context.WithTimeout(context.Background(), task.Timeout)
_ = probe(ctx, task)
cancel()
}
}()
}

for _, task := range tasks {
jobs <- task
}
close(jobs)
wg.Wait()
}

这段代码已经把几个核心点带出来了:

  • goroutine 用来并发执行 worker
  • channel 用来分发任务
  • context.WithTimeout 给单任务加超时
  • WaitGroup 用来等 worker 退出

但它还远远不够稳。

因为真实项目里你很快就会碰到:

  • 多个 worker 同时写共享 map
  • 某个结果 channel 没人接收,worker 卡住
  • 某个 goroutine 一直等不到退出信号
  • 任务超时了,但探测协程还在后台偷偷跑
  • 统计数字偶尔不对,但肉眼看代码又像没问题

所以接下来真正要学的,不是“怎么开 goroutine”,而是“怎么让这些 goroutine 有边界、有退出路径、能排障”。

四、先把一句话结论说清楚

Go 并发里最重要的不是“并发起来”,而是:

把数据归属、阻塞点、退出路径和观测点设计清楚。

很多并发 bug 本质上都能归到下面四类问题:

  1. 共享数据没有归属,导致竞态
  2. 阻塞关系没有闭环,导致死锁
  3. 退出路径不完整,导致 goroutine 泄漏
  4. 超时和取消只写了一半,导致表面返回了,后台还没停

如果一开始只盯着语法,后面通常会把并发问题修成更难排查的状态。

五、数据竞态到底是什么,为什么它这么难定位

第一次接触“竞态条件”时,很容易把它理解成“两个 goroutine 同时操作一个变量”。
这个理解不算错,但还不够工程化。

更准确一点的说法是:

多个 goroutine 在没有正确同步的前提下,同时读写或写写同一份共享数据,最终结果依赖执行时序。

这类 bug 难定位的原因是:

  • 有时会直接报错,例如 concurrent map writes
  • 有时完全不报错,只是结果偶尔错
  • 有时本地跑不出来,线上高并发才出现
  • 加日志以后时序变了,bug 又不见了

先看一个典型错误例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"sync"
)

func main() {
counter := map[string]int{}
var wg sync.WaitGroup

for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter["success"]++
}()
}

wg.Wait()
fmt.Println(counter["success"])
}

这段代码的问题不是“map 语法错”,而是:

  • counter 被多个 goroutine 同时写
  • 写入过程没有锁,也没有单线程所有权
  • 最终结果可能错,也可能直接 panic

这里要先建立一个很重要的判断标准:

并发里最该先问的不是“能不能访问这个变量”,而是“谁拥有它的写权限”。

如果这句话没想清楚,代码就算这次跑过了,后面也会出问题。

六、竞态最常见的三种来源

真实项目里,竞态不只发生在 map 上。
最常见的来源通常有三类。

1. 多个 goroutine 直接改共享 map、切片、结构体字段

例如:

  • 共享 map[string]int 做统计
  • 共享 []Result 做 append
  • 共享 TaskSummary 直接累加计数

这些写法看起来都很顺手,但只要多个 worker 同时碰,就会出事。

2. 以为“只读”就安全,结果底层对象其实还在被改

例如把一个切片、map、指针传给多个 goroutine,以为都只是读;
但某处偷偷改了一下底层内容,其他 goroutine 读到的就是变化中的状态。

3. 闭包捕获了持续变化的外部状态

例如循环里启动 goroutine,然后直接引用外层变量、共享配置、共享结果对象。

这类问题的共同点不是“看起来很复杂”,而是:

  • 共享边界不清楚
  • 写权限分散
  • 谁负责同步不明确

七、竞态不是只有“加锁”这一种修法

一看到竞态,第一反应常常就是上锁。
锁当然是办法,但不是唯一办法,也不总是最好的办法。

更实用的修复思路通常有三种。

1. 用单 goroutine 收口写入权

例如 worker 只产出结果,汇总 goroutine 单独消费并更新统计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Summary struct {
Success int
Failed int
}

func collect(results <-chan error) Summary {
var s Summary
for err := range results {
if err == nil {
s.Success++
continue
}
s.Failed++
}
return s
}

这种方式最适合:

  • 汇总类数据
  • 事件流处理
  • 状态天然可以顺序更新的场景

优点是:

  • 不需要多个 goroutine 同时写同一个对象
  • 逻辑更容易推演
  • 出错点更集中

2. 用 sync.Mutex 明确保护共享状态

例如缓存、注册表、连接池状态这类对象,确实需要被多个 goroutine 共享写入。

1
2
3
4
5
6
7
8
9
10
type SafeCounter struct {
mu sync.Mutex
m map[string]int
}

func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key]++
}

锁适合:

  • 有明确共享状态
  • 更新逻辑不适合拆成事件流
  • 临界区可以保持足够小

3. 用 atomic 处理非常简单的数值计数

例如活跃 worker 数、成功次数、失败次数。

1
2
3
4
var active int64

atomic.AddInt64(&active, 1)
defer atomic.AddInt64(&active, -1)

但这里要非常克制。
atomic 适合简单数值,不适合把复杂状态全塞进去硬控。

一个很实用的工程判断是:

  • 共享的是流转中的事件,用 channel 收口
  • 共享的是可变对象,用 mutex
  • 共享的是极简单的计数,用 atomic

八、怎么用 go test -race 看竞态,而不是靠猜

Go 的 race detector 不是万能,但它是排查竞态时的第一优先级工具之一。

最常用的方式就是:

1
go test -race ./...

它解决的不是“让你更懂并发”,而是帮助你回答一个更具体的问题:

这段代码里,是否真的发生了未同步的并发访问。

你应该把它优先用在这些场景:

  • 汇总统计偶尔不对
  • slice append 偶发丢数据
  • map 偶发 panic 或结果怪异
  • 某个测试在 CI 才偶发失败

但也要知道它的边界:

  • 跑不到的路径,它看不到
  • 时序极端复杂时,不一定每次都能撞到
  • 它能告诉你“这里有竞争”,但不会替你设计所有权边界

所以 race detector 最适合做的是:

  1. 先把怀疑点变成可执行测试
  2. 再用 -race 验证是不是未同步访问
  3. 最后从“谁拥有写权限”这个角度回头改设计

九、死锁到底是什么,别和“程序很慢”混为一谈

程序一旦卡住,常会被统称成死锁。
这不够准确。

更工程化一点的说法是:

一组 goroutine 互相等待,导致系统无法继续推进。

它和“程序很慢”不一样:

  • 很慢,说明还能推进,只是推进得慢
  • 死锁,说明推进条件已经互相卡死了

Go 里最典型的死锁报错你很可能见过:

1
fatal error: all goroutines are asleep - deadlock!

先看一个最小错误例子:

1
2
3
4
5
6
package main

func main() {
ch := make(chan int)
ch <- 1
}

这里的问题是:

  • 无缓冲 channel 发送必须等接收方
  • 当前 goroutine 自己在发送
  • 没有任何 goroutine 会来接收
  • 所以它会永远卡住

这个例子很小,但真实项目里的死锁,本质上还是同一类问题:
某个阻塞动作永远等不到配对条件。

十、真实项目里最常见的死锁来源

死锁最常见的来源通常不是语法陌生,而是协作边界混乱。

1. channel 没有人消费,发送方一直堵住

例如 worker 往 results 写,但汇总 goroutine 根本没启动,或者过早退出了。

2. channel 没有人关闭,接收方一直 range 不完

例如:

1
2
3
for result := range results {
...
}

如果 results 永远不 close,这个循环就永远出不去。

3. WaitGroup 等待和 channel 关闭顺序写反

这在 worker pool 里非常常见。

错误写法通常像这样:

1
2
wg.Wait()
close(results)

但如果某个 worker 在等着往 results 发送,而主 goroutine 又在 wg.Wait(),就可能互相卡住:

  • worker 等结果被消费
  • 主 goroutine 等 worker 结束
  • 可真正负责消费结果的人并没有推进

4. 锁顺序不一致

例如 goroutine A 先拿 muA 再拿 muB,goroutine B 先拿 muB 再拿 muA
这会把死锁从 channel 设计问题,变成锁顺序问题。

十一、先看一个更接近现场的死锁错误例子

假设现在有一个巡检器,目标是让 worker 并发执行,然后由主 goroutine 收集结果。

错误版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import "sync"

func main() {
jobs := make(chan int)
results := make(chan int)

var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- job * 2
}
}()
}

for i := 0; i < 3; i++ {
jobs <- i
}
close(jobs)

wg.Wait()
close(results)

for range results {
}
}

这段代码的问题就在于顺序:

  • worker 往 results 发送
  • 但主 goroutine 还没开始接收 results
  • 主 goroutine 先去 wg.Wait()
  • worker 被 results <- ... 卡住,Done() 也就执行不到
  • wg.Wait() 永远等不回来

这就是典型的协作死锁。

更稳的写法通常是:

1
2
3
4
5
6
7
8
go func() {
wg.Wait()
close(results)
}()

for result := range results {
_ = result
}

这样谁负责关闭 results、谁负责消费 results,边界就清楚了。

十二、goroutine 泄漏是什么,和死锁有什么区别

goroutine 泄漏和死锁很容易被混成一件事。
它们经常一起出现,但不是一回事。

goroutine 泄漏更接近这种状态:

  • 程序整体可能还在跑
  • 但某些 goroutine 已经失去业务价值
  • 它们却没有退出,还在阻塞、等待、轮询或持有资源

所以死锁更像“系统推进不了”,而 goroutine 泄漏更像“系统还能推进,但后台一直积垃圾”。

goroutine 泄漏常见的后果包括:

  • goroutine 数量越来越多
  • 内存持续增长
  • 连接、timer、ticker、文件句柄没及时释放
  • 原本只是偶发超时,最后变成整体雪崩

十三、goroutine 泄漏最常见的四种来源

1. 一直阻塞在 channel 发送

例如:

1
2
3
func worker(results chan<- Result, result Result) {
results <- result
}

如果接收方提前退出,或者根本没人接收,这个 worker 就会一直卡住。

2. 一直阻塞在 channel 接收

例如:

1
2
3
for task := range jobs {
...
}

如果 jobs 永远不 close,而外层也没用 ctx.Done() 提前退出,worker 就会一直挂着。

3. 起了 ticker、timer、后台循环,却没有停止条件

例如:

1
2
3
4
5
6
func watch() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
// do work forever
}
}

如果这个 goroutine 只服务一批短任务,那这就是典型泄漏。
即使这个 goroutine 后面能退出,正常写法里也应该记得在合适位置 ticker.Stop()

4. 调用了支持取消的 API,却根本没把取消信号传下去

例如上层已经超时返回了,但底层 HTTP 请求、数据库查询、内部 goroutine 还在继续跑。

很多项目的“超时支持”其实只做到了这一半:

  • 外层函数返回了
  • 内层 goroutine 没停

这不叫真正支持超时,只叫“主调方先撤了”。

十四、为什么“加了超时”仍然可能泄漏 goroutine

这是 Go 并发里一个非常高频的误区。

先看一个错误示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func RunWithTimeout(task func() error, timeout time.Duration) error {
done := make(chan error)

go func() {
done <- task()
}()

select {
case err := <-done:
return err
case <-time.After(timeout):
return context.DeadlineExceeded
}
}

看到这里很容易以为:

  • 超时返回了
  • 问题解决了

其实不一定。

如果 task() 卡住了,或者它执行完后尝试 done <- task() 时外层已经超时返回、没人再接这个 done,那个 goroutine 仍然可能挂住。

这段代码至少有两个问题:

  1. 没有把取消信号传给 task
  2. done 的发送和接收关系没有完整退出策略

更稳的方向通常是:

  • context 把取消信号传到内部
  • worker 在发送结果时同时监听 ctx.Done()
  • 下游操作也必须真正支持取消

所以超时不是“函数早点 return”这么简单。
真正的目标是:超时发生后,所有相关 goroutine 都要有机会退出。

十五、context.WithCancelcontext.WithTimeout 分别解决什么问题

context 很容易被机械地一路传下去,但代码里常常说不清它到底在传什么。

最重要的两个动作是:

1. context.WithCancel

它解决的是:
我需要主动广播“这一批任务别做了”。

例如:

  • 某个关键依赖已经失败,整批没有继续价值
  • 用户主动取消
  • 父任务结束,子任务要一起停

2. context.WithTimeout

它解决的是:
这段操作最多只能占用这么久。

例如:

  • 单个接口探测最多 200ms
  • 整批巡检最多 3s
  • 外部依赖响应超过阈值就算失败

你可以把它们先粗略理解成:

  • WithCancel 更像手动刹车
  • WithTimeout 更像自动超时刹车

但要注意,context 真正有效的前提是:

  • 每一层都继续往下传
  • 阻塞点都监听 ctx.Done()
  • 下游 API 真的接受并遵守这个 ctx

否则它就只是一个一路传递却没人理的参数。

十六、在并发代码里,取消信号到底应该落到哪些地方

如果只是把 ctx 放到函数签名里,却不在阻塞点监听,它基本没有意义。

最关键的落点通常有四类:

1. worker 从 jobs 取任务时

1
2
3
4
5
6
select {
case <-ctx.Done():
return
case task, ok := <-jobs:
...
}

2. worker 往 results 发送结果时

1
2
3
4
5
select {
case <-ctx.Done():
return
case results <- result:
}

3. 调用外部依赖时

例如 HTTP、数据库、RPC,都应该把 ctx 继续传进去。

4. 后台定时循环里

1
2
3
4
5
6
7
8
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
...
}
}

如果这四类位置漏掉任何一个,取消通常都只能做到“部分有效”。

十七、先搭一个更完整的并发巡检器骨架

下面把竞态、超时、取消、结果收集和退出边界放进一个更像实际项目的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package checker

import (
"context"
"errors"
"fmt"
"sync"
"time"
)

type Task struct {
ID string
Name string
Timeout time.Duration
}

type Result struct {
TaskID string
Name string
Duration time.Duration
Err error
}

type Summary struct {
Success int
Timeout int
Failed int
}

type ProbeFunc func(context.Context, Task) error

func RunBatch(ctx context.Context, tasks []Task, workers int, probe ProbeFunc) ([]Result, Summary, error) {
if workers <= 0 {
return nil, Summary{}, fmt.Errorf("workers must be greater than zero")
}

jobs := make(chan Task)
results := make(chan Result)

var workerWG sync.WaitGroup

for i := 0; i < workers; i++ {
workerWG.Add(1)
go func() {
defer workerWG.Done()

for {
select {
case <-ctx.Done():
return
case task, ok := <-jobs:
if !ok {
return
}

taskCtx := ctx
cancel := func() {}
if task.Timeout > 0 {
taskCtx, cancel = context.WithTimeout(ctx, task.Timeout)
}

start := time.Now()
err := probe(taskCtx, task)
cancel()

result := Result{
TaskID: task.ID,
Name: task.Name,
Duration: time.Since(start),
Err: err,
}

select {
case <-ctx.Done():
return
case results <- result:
}
}
}
}()
}

go func() {
defer close(jobs)
for _, task := range tasks {
select {
case <-ctx.Done():
return
case jobs <- task:
}
}
}()

go func() {
workerWG.Wait()
close(results)
}()

var all []Result
var summary Summary

for result := range results {
all = append(all, result)

switch {
case result.Err == nil:
summary.Success++
case errors.Is(result.Err, context.DeadlineExceeded):
summary.Timeout++
default:
summary.Failed++
}
}

if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) {
return all, summary, err
}

return all, summary, nil
}

这个版本里有几件事很关键:

  1. worker 不直接改共享汇总对象,而是把结果发到 results
  2. 汇总只在主收集循环里做,所以没有多 goroutine 竞争写 summary
  3. 发任务时监听 ctx.Done(),整批取消时可以停
  4. worker 收任务和发结果时都监听 ctx.Done(),不会只退一半
  5. results 的关闭由等待所有 worker 的专门 goroutine 负责,关闭责任清晰
  6. 单任务 timeout 通过 task.Timeout 派生子 context

这已经是一个可以承载很多实际排障动作的最小骨架了。

十八、这个小项目里最容易写错的几个地方

1. 让 worker 直接并发更新 summary

错误示例:

1
2
3
4
5
go func() {
if err == nil {
summary.Success++
}
}()

这会把原本单线程收口的统计,又变成并发写共享状态。

2. 在多个地方关闭同一个 channel

错误示例:

1
close(results)

如果多个 worker 或多个控制路径都可能关它,就会出现:

  • close of closed channel
  • 关闭责任不清楚
  • 时序一复杂就炸

更稳的原则是:

谁创建、谁聚合、谁最清楚什么时候不会再有发送,谁就负责关闭。

3. 忘记在派生 context.WithTimeout 后调用 cancel

这不仅是风格问题。
不及时 cancel() 会让关联资源释放延后,尤其是大量短任务时会积出额外负担。

4. 结果发送时不监听 ctx.Done()

错误示例:

1
results <- result

如果收集方已经退出,这里就会把 worker 卡死。

十九、并发代码最少该补哪些可观测性

很多并发 bug 之所以难查,不是因为“太高级”,而是因为你根本看不到系统在等什么。

并发代码最少应该补下面这些观测点。

1. 带任务 ID 的结构化日志

至少要知道:

  • 哪个任务开始执行
  • 哪个任务结束
  • 是成功、失败还是超时
  • 取消来自整批取消,还是单任务超时

如果日志里只有一句 probe failed,几乎没有排障价值。

2. 批次维度和 worker 维度的计数

例如:

  • 已提交任务数
  • 已完成任务数
  • 超时数
  • 活跃 worker 数
  • 当前 goroutine 数

3. 关键阻塞点的耗时

例如:

  • 任务排队多久才开始执行
  • 单任务 probe 花了多久
  • 汇总是否被慢消费者拖住

4. goroutine 数量趋势

最简单的最小版本至少可以先打:

1
runtime.NumGoroutine()

如果批次结束后 goroutine 数量长期回不去,这就是很强的泄漏信号。

5. 必要时接入 pprof

当你已经怀疑:

  • 某些 goroutine 堵住了
  • 某些锁争用严重
  • 某些调用栈长期不退出

就应该上 pprof 看 goroutine stack、block profile、mutex profile,而不是继续凭感觉猜。

二十、一个更实用的最小观测骨架

先看一个很小但实用的状态对象:

1
2
3
4
5
6
7
type Stats struct {
ActiveWorkers int64
Submitted int64
Completed int64
Timeout int64
Failed int64
}

worker 执行时:

1
2
atomic.AddInt64(&stats.ActiveWorkers, 1)
defer atomic.AddInt64(&stats.ActiveWorkers, -1)

提交任务时:

1
atomic.AddInt64(&stats.Submitted, 1)

结果归档时:

1
atomic.AddInt64(&stats.Completed, 1)

再配合周期性日志:

1
2
3
4
5
6
7
log.Printf(
"submitted=%d completed=%d active_workers=%d goroutines=%d",
atomic.LoadInt64(&stats.Submitted),
atomic.LoadInt64(&stats.Completed),
atomic.LoadInt64(&stats.ActiveWorkers),
runtime.NumGoroutine(),
)

这套东西看起来很朴素,但已经足够帮你回答几个关键问题:

  • 任务是否在持续推进
  • worker 是否卡住不下降
  • goroutine 是否在批次结束后回收
  • 问题更像下游慢,还是更像退出路径没收干净

二十一、超时、取消和泄漏这三件事,最容易混淆在哪里

很多排障动作失败,是因为一开始就把三个不同问题混在一起了。

1. 超时

它关注的是:
这件事多久还没完成,就该按失败处理。

2. 取消

它关注的是:
上游已经决定不需要这件事继续了。

3. 泄漏

它关注的是:
这件事虽然已经没有业务价值,但执行体还没退出。

这三件事的关系通常是:

  • 超时和取消应该推动 goroutine 退出
  • 如果退出路径没写完整,就会演化成泄漏

所以当系统已经声称“支持超时”时,真正要再追问一句的是:

超时以后,相关 goroutine 和底层操作真的都停了吗?

二十二、给这个小项目补几个最常见的错误示例

错误示例一:共享切片并发 append

1
2
3
4
5
var results []Result

go func() {
results = append(results, result)
}()

这会造成:

  • 数据竞态
  • 底层数组扩容时更危险
  • 结果顺序和内容都不可信

错误示例二:结果通道没人消费,worker 全堵住

1
2
3
4
5
for _, task := range tasks {
jobs <- task
}

wg.Wait()

如果 worker 在处理时要往 results 写,而接收方没启动,就会卡住。

错误示例三:只在外层返回超时,不在内部监听取消

1
2
3
4
func probe(ctx context.Context, task Task) error {
time.Sleep(10 * time.Second)
return nil
}

这段代码签名看起来支持 ctx,但实现里完全没用。
所以外层就算超时返回,内部还是照睡 10 秒。

错误示例四:后台 watcher 没有退出条件

1
2
3
4
5
6
go func() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
refresh()
}
}()

如果这只是服务某一批巡检任务,它就是在悄悄泄漏。

二十三、怎么给并发问题补最小测试,而不是只靠手跑

并发问题如果只靠手工执行,通常很难稳定复现。
至少应该补下面几类测试。

1. 基本成功路径测试

验证:

  • 所有任务都能被处理
  • 结果数量正确
  • 汇总统计正确

2. 取消测试

验证:

  • context 取消后,RunBatch 能及时返回
  • worker 不会一直挂住

3. 超时测试

验证:

  • 单任务卡住时能按时返回超时
  • 超时结果会被正确计入汇总

4. 竞态测试

go test -race 跑,验证:

  • 结果归档
  • 汇总统计
  • 共享状态读写

下面给一个取消测试的最小示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package checker

import (
"context"
"errors"
"testing"
"time"
)

func TestRunBatchCancel(t *testing.T) {
tasks := []Task{
{ID: "t-1", Name: "slow-task", Timeout: time.Second},
}

probe := func(ctx context.Context, task Task) error {
<-ctx.Done()
return ctx.Err()
}

ctx, cancel := context.WithCancel(context.Background())

done := make(chan error, 1)
go func() {
_, _, err := RunBatch(ctx, tasks, 1, probe)
done <- err
}()

time.Sleep(20 * time.Millisecond)
cancel()

select {
case err := <-done:
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("unexpected error: %v", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("RunBatch did not return after cancel")
}
}

这个测试的重点不是写得多复杂,而是它在验证一件真实的工程边界:

上游取消后,批处理是否真的能退出。

再看一个超时测试的最小示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func TestRunBatchTimeout(t *testing.T) {
tasks := []Task{
{ID: "t-1", Name: "timeout-task", Timeout: 30 * time.Millisecond},
}

probe := func(ctx context.Context, task Task) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}

results, summary, err := RunBatch(context.Background(), tasks, 1, probe)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if !errors.Is(results[0].Err, context.DeadlineExceeded) {
t.Fatalf("expected deadline exceeded, got %v", results[0].Err)
}
if summary.Timeout != 1 {
t.Fatalf("expected timeout count 1, got %d", summary.Timeout)
}
}

二十四、并发问题出现时,更实用的排查顺序是什么

这一节很重要。
并发问题一出现,排查顺序很容易乱掉:

  • 先怀疑 Go 本身
  • 再怀疑机器性能
  • 然后拼命加日志
  • 最后连问题类别都没分清

更实用的顺序通常是下面这样。

1. 先判断症状属于哪一类

先问自己:

  • 是结果错了,还是程序卡了
  • 是偶发错,还是稳定卡
  • 是整体慢,还是批次结束后 goroutine 不回收
  • 是超时多,还是 CPU 很高,还是内存持续涨

这一步的目标不是立刻定位,而是先分型:

  • 结果错,优先查竞态
  • 稳定卡,优先查死锁和阻塞关系
  • 跑完不回收,优先查 goroutine 泄漏
  • 超时很多,先分清下游慢还是取消没传透

2. 先给系统加总超时,避免排查时无限挂住

如果连整体超时都没有,排查过程会很痛苦。
先让批处理有一个外层 deadline,保证问题至少能被收敛在可观察时间窗里。

3. 用 go test -race 或最小复现先排除竞态

只要结果有“偶尔错”“偶尔少”“偶尔 panic”,race detector 应该优先上。

4. 看 goroutine 数和关键日志,判断有没有退出问题

重点看:

  • 批次结束后 goroutine 是否回落
  • active worker 是否持续不归零
  • 某些任务是否只见开始不见结束

5. 画出 channel 和关闭责任图

哪几个 channel:

  • 谁发送
  • 谁接收
  • 谁关闭
  • 哪些地方可能提前返回

很多死锁和泄漏,画出来就已经暴露了。

6. 再去看更细的阻塞栈和 profile

如果上面还不够,就该看:

  • goroutine stack
  • block profile
  • mutex profile

而不是继续盲猜“是不是 goroutine 太多了”。

7. 最后再优化并发度,而不是一开始就乱调 worker 数

并发度当然重要,但它通常不是第一根因。
先把退出边界、阻塞关系、共享数据所有权理顺,再谈调参。

二十五、怎么从日志和现象初步判断问题类型

你不一定每次都能立刻上 profile。
这时先靠现象分型会更快。

更像竞态的信号

  • 统计值偶发不对
  • 本地一次正常,CI 偶发失败
  • 加日志以后问题消失或变形
  • -race 有告警

更像死锁的信号

  • 程序稳定卡在某一步
  • 没有新的完成日志
  • worker 不再推进
  • goroutine stack 大量停在 channel send/receive 或 lock wait

更像 goroutine 泄漏的信号

  • 请求都结束了,goroutine 数量还在涨
  • 周期性任务越来越多
  • 每批执行结束后仍然有老 goroutine 不退出
  • 内存、连接数、句柄数逐渐上升

更像取消没传透的信号

  • 上游已经超时返回
  • 下游过一会儿还在继续打印执行日志
  • 整批结束后后台探测还在跑

这种按现象分型的方法,不会直接给你答案,但会帮你少走很多弯路。

二十六、工程里什么时候该用 channel,什么时候该用 mutex

这也是并发设计里非常容易走偏的地方。

只记住一句“不要通过共享内存来通信,要通过通信来共享内存”后,代码就很容易把所有东西都改成 channel。
这会产生另一种复杂度。

更务实的判断通常是:

更适合 channel 的场景

  • 任务分发
  • 结果汇聚
  • 事件流传递
  • 明确的一进一出或生产者消费者模型

更适合 mutex 的场景

  • 共享缓存
  • 注册表
  • 连接池状态
  • 需要随机读写的共享对象

不要把这件事理解成“哪种更高级”。
它本质上是:

  • channel 更适合表达协作流转
  • mutex 更适合保护共享状态

如果你用错了,不一定编不过,但后面的维护成本会明显上升。

二十七、什么时候根本不该上并发

这一点也要说清楚。
不是所有任务都值得并发。

下面这些情况就要很克制:

1. 任务量很小,串行已经足够

例如只有两三个本地内存操作,并发只会让代码更难懂。

2. 下游本身不支持并发放大

例如数据库连接池很小、第三方接口限流很严。
这时盲目加 worker 只会更快把自己打死。

3. 数据依赖强,顺序本身就是业务语义

如果 B 明确依赖 A 的结果,那就别为了“更 Go”硬拆成并发。

4. 团队现在连退出路径和排障手段都没准备好

这不是保守,而是现实。
并发放大吞吐,也会放大系统复杂度。

二十八、这类并发代码的使用边界和常见误用

最后把边界再收紧一点。

适合这套模式的场景

  • 固定 worker 数的批处理
  • 接口巡检
  • 任务执行器
  • 轻量级并发探测

不适合直接照抄的场景

  • 强实时流式处理
  • 需要优先级调度的复杂队列
  • 跨进程分布式调度
  • 需要背压、重试、熔断、限流一起协同的大系统

常见误用

  • 以为 context 只要传了就算支持取消
  • 以为 buffered channel 就能解决所有阻塞
  • 以为 go test -race 过了就一定没有并发 bug
  • 以为 goroutine 泄漏只是“多几个协程没关系”

真正稳的工程判断通常是:

  • 先把最小边界写对
  • 再补可观测性
  • 最后才谈并发度和性能优化

二十九、练习题

如果你想确认自己真的掌握了这篇文章,可以试着独立做下面几题:

  1. 把文中的 RunBatch 改成支持失败阈值,例如失败超过 3 个就整批取消。
  2. RunBatch 增加队列等待时间统计,区分“排队超时”和“执行超时”。
  3. 故意把 summary 改成由 worker 直接更新,然后用 go test -race 观察告警。
  4. 故意删掉 results 发送时的 select <-ctx.Done() 分支,构造一个接收方提前退出的测试,看 worker 是否泄漏。
  5. 给巡检器增加一个周期性 watcher,然后正确地让它在批次结束时退出。

这些练习的重点不是把代码写长,而是训练你把并发问题拆回:

  • 数据归属
  • 阻塞关系
  • 退出路径
  • 观测点

三十、结语

Go 并发真正难的地方,从来不是 goroutine、channel、select 这些语法本身,而是你能不能把四件事同时做对:

  1. 共享数据的所有权
  2. 阻塞关系的闭环
  3. 超时和取消的传递
  4. 问题发生后的可观测性

如果这四件事没建立起来,并发代码就会进入一种很典型的失控状态:

  • 平时像能跑
  • 一上压力就出问题
  • 问题一来就很难复现
  • 修一次又引出另一种卡住

所以学 Go 并发,不要只记“怎么开 goroutine”,而要优先记下面这套更实用的判断顺序:

  1. 先明确谁拥有数据写权限
  2. 再明确谁发送、谁接收、谁关闭 channel
  3. 再保证每条路径都有退出条件
  4. 再把 context 真的传到阻塞点
  5. 最后补上 race、日志、goroutine 数和 profile 这些排障抓手

当你能按这套顺序去写和排查,并发问题就会从“玄学 bug”,慢慢变成能拆、能测、能定位的工程问题。