Go:并发常见问题,竞态、死锁、泄漏、超时和取消怎么排查
学 Go 学到并发时,最容易被几句很有诱惑力的话带偏:
- Go 起 goroutine 很轻量
- channel 很优雅
select一加就高级了context一传就算支持取消了- 并发代码只要能跑通,就说明设计差不多没问题
这些话单独看都不算错,但放到真实项目里,很容易把人带进另一种混乱:
- 吞吐量没上去,bug 先上去了
- 偶发数据错乱,复现一次很难
- 程序不是卡死,就是 goroutine 数量越跑越多
- 上线后偶尔超时,但不知道到底是慢、堵、泄漏还是没取消
- 日志很多,真正定位问题时却连哪条 goroutine 没退出都看不出来
并发最难的地方,从来不是会不会写 go func() {},而是下面这些边界能不能分清:
- 哪些数据可以共享,哪些必须明确归属
- 哪个 goroutine 负责生产,哪个负责消费,哪个负责关闭 channel
- 任务结束后,所有 goroutine 是否真的都能退出
- 超时是业务边界,还是排障补丁
- 取消信号能不能沿整条调用链传到底
- 出问题时,先查竞态、查阻塞、查泄漏,还是先查下游慢
这一篇不按零碎语法点来讲,而是围绕一个实际小场景来讲:做一个并发巡检器。
这个巡检器要做这些事:
- 接收一批待巡检接口
- 用固定 worker 并发执行
- 支持整批取消
- 支持单任务超时
- 汇总成功、失败、超时数量
- 出问题时能看出是竞态、死锁还是 goroutine 泄漏
这个场景不大,但足够把 Go 并发里最常见、最容易互相缠住的问题一次讲清楚。
一、这篇文章要解决什么问题
读完这一篇,应该能独立回答下面这些问题:
- Go 里的并发问题,为什么很多不是“语法错”,而是边界错
- 数据竞态到底是什么,为什么有时会错,有时又像没事
- 死锁和 goroutine 泄漏的区别到底是什么
context.WithTimeout和context.WithCancel分别解决什么问题- 为什么很多“支持超时”的代码仍然会泄漏 goroutine
- 并发代码最少应该补哪些可观测性
- 线上出现超时、卡住、goroutine 增长时,排查顺序应该怎么定
如果这些问题能独立说清楚,后面再写 worker pool、HTTP 服务、任务调度器、消息消费器,代码会稳很多。
二、先把这个并发小项目的场景说清楚
假设现在要写一个最小的测试平台巡检器。
它每天会拿到一批接口巡检任务:
- 检查登录接口
- 检查订单接口
- 检查支付接口
- 检查消息推送接口
每个任务都有:
- 任务 ID
- 接口名
- 超时时间
- 巡检函数
目标是让它具备这些能力:
- 不要串行慢慢跑,要固定数量 worker 并发跑
- 某个任务超时时,只影响当前任务,不把整个批次永远拖住
- 整批取消时,worker 能尽快退出
- 汇总统计不能出现并发写坏
- 出问题时,能快速判断是共享数据冲突、channel 设计错误,还是 goroutine 没退出
后面的所有概念,都围绕这个小项目来展开。
三、先看一个最小可运行示例
先不要一上来就把并发写复杂,先看最小骨架。
1 | package main |
这段代码已经把几个核心点带出来了:
- goroutine 用来并发执行 worker
- channel 用来分发任务
context.WithTimeout给单任务加超时WaitGroup用来等 worker 退出
但它还远远不够稳。
因为真实项目里你很快就会碰到:
- 多个 worker 同时写共享
map - 某个结果 channel 没人接收,worker 卡住
- 某个 goroutine 一直等不到退出信号
- 任务超时了,但探测协程还在后台偷偷跑
- 统计数字偶尔不对,但肉眼看代码又像没问题
所以接下来真正要学的,不是“怎么开 goroutine”,而是“怎么让这些 goroutine 有边界、有退出路径、能排障”。
四、先把一句话结论说清楚
Go 并发里最重要的不是“并发起来”,而是:
把数据归属、阻塞点、退出路径和观测点设计清楚。
很多并发 bug 本质上都能归到下面四类问题:
- 共享数据没有归属,导致竞态
- 阻塞关系没有闭环,导致死锁
- 退出路径不完整,导致 goroutine 泄漏
- 超时和取消只写了一半,导致表面返回了,后台还没停
如果一开始只盯着语法,后面通常会把并发问题修成更难排查的状态。
五、数据竞态到底是什么,为什么它这么难定位
第一次接触“竞态条件”时,很容易把它理解成“两个 goroutine 同时操作一个变量”。
这个理解不算错,但还不够工程化。
更准确一点的说法是:
多个 goroutine 在没有正确同步的前提下,同时读写或写写同一份共享数据,最终结果依赖执行时序。
这类 bug 难定位的原因是:
- 有时会直接报错,例如
concurrent map writes - 有时完全不报错,只是结果偶尔错
- 有时本地跑不出来,线上高并发才出现
- 加日志以后时序变了,bug 又不见了
先看一个典型错误例子:
1 | package main |
这段代码的问题不是“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 | type Summary struct { |
这种方式最适合:
- 汇总类数据
- 事件流处理
- 状态天然可以顺序更新的场景
优点是:
- 不需要多个 goroutine 同时写同一个对象
- 逻辑更容易推演
- 出错点更集中
2. 用 sync.Mutex 明确保护共享状态
例如缓存、注册表、连接池状态这类对象,确实需要被多个 goroutine 共享写入。
1 | type SafeCounter struct { |
锁适合:
- 有明确共享状态
- 更新逻辑不适合拆成事件流
- 临界区可以保持足够小
3. 用 atomic 处理非常简单的数值计数
例如活跃 worker 数、成功次数、失败次数。
1 | var active int64 |
但这里要非常克制。atomic 适合简单数值,不适合把复杂状态全塞进去硬控。
一个很实用的工程判断是:
- 共享的是流转中的事件,用 channel 收口
- 共享的是可变对象,用 mutex
- 共享的是极简单的计数,用 atomic
八、怎么用 go test -race 看竞态,而不是靠猜
Go 的 race detector 不是万能,但它是排查竞态时的第一优先级工具之一。
最常用的方式就是:
1 | go test -race ./... |
它解决的不是“让你更懂并发”,而是帮助你回答一个更具体的问题:
这段代码里,是否真的发生了未同步的并发访问。
你应该把它优先用在这些场景:
- 汇总统计偶尔不对
- slice append 偶发丢数据
- map 偶发 panic 或结果怪异
- 某个测试在 CI 才偶发失败
但也要知道它的边界:
- 跑不到的路径,它看不到
- 时序极端复杂时,不一定每次都能撞到
- 它能告诉你“这里有竞争”,但不会替你设计所有权边界
所以 race detector 最适合做的是:
- 先把怀疑点变成可执行测试
- 再用
-race验证是不是未同步访问 - 最后从“谁拥有写权限”这个角度回头改设计
九、死锁到底是什么,别和“程序很慢”混为一谈
程序一旦卡住,常会被统称成死锁。
这不够准确。
更工程化一点的说法是:
一组 goroutine 互相等待,导致系统无法继续推进。
它和“程序很慢”不一样:
- 很慢,说明还能推进,只是推进得慢
- 死锁,说明推进条件已经互相卡死了
Go 里最典型的死锁报错你很可能见过:
1 | fatal error: all goroutines are asleep - deadlock! |
先看一个最小错误例子:
1 | package main |
这里的问题是:
- 无缓冲 channel 发送必须等接收方
- 当前 goroutine 自己在发送
- 没有任何 goroutine 会来接收
- 所以它会永远卡住
这个例子很小,但真实项目里的死锁,本质上还是同一类问题:
某个阻塞动作永远等不到配对条件。
十、真实项目里最常见的死锁来源
死锁最常见的来源通常不是语法陌生,而是协作边界混乱。
1. channel 没有人消费,发送方一直堵住
例如 worker 往 results 写,但汇总 goroutine 根本没启动,或者过早退出了。
2. channel 没有人关闭,接收方一直 range 不完
例如:
1 | for result := range results { |
如果 results 永远不 close,这个循环就永远出不去。
3. WaitGroup 等待和 channel 关闭顺序写反
这在 worker pool 里非常常见。
错误写法通常像这样:
1 | wg.Wait() |
但如果某个 worker 在等着往 results 发送,而主 goroutine 又在 wg.Wait(),就可能互相卡住:
- worker 等结果被消费
- 主 goroutine 等 worker 结束
- 可真正负责消费结果的人并没有推进
4. 锁顺序不一致
例如 goroutine A 先拿 muA 再拿 muB,goroutine B 先拿 muB 再拿 muA。
这会把死锁从 channel 设计问题,变成锁顺序问题。
十一、先看一个更接近现场的死锁错误例子
假设现在有一个巡检器,目标是让 worker 并发执行,然后由主 goroutine 收集结果。
错误版本:
1 | package main |
这段代码的问题就在于顺序:
- worker 往
results发送 - 但主 goroutine 还没开始接收
results - 主 goroutine 先去
wg.Wait() - worker 被
results <- ...卡住,Done()也就执行不到 wg.Wait()永远等不回来
这就是典型的协作死锁。
更稳的写法通常是:
1 | go func() { |
这样谁负责关闭 results、谁负责消费 results,边界就清楚了。
十二、goroutine 泄漏是什么,和死锁有什么区别
goroutine 泄漏和死锁很容易被混成一件事。
它们经常一起出现,但不是一回事。
goroutine 泄漏更接近这种状态:
- 程序整体可能还在跑
- 但某些 goroutine 已经失去业务价值
- 它们却没有退出,还在阻塞、等待、轮询或持有资源
所以死锁更像“系统推进不了”,而 goroutine 泄漏更像“系统还能推进,但后台一直积垃圾”。
goroutine 泄漏常见的后果包括:
- goroutine 数量越来越多
- 内存持续增长
- 连接、timer、ticker、文件句柄没及时释放
- 原本只是偶发超时,最后变成整体雪崩
十三、goroutine 泄漏最常见的四种来源
1. 一直阻塞在 channel 发送
例如:
1 | func worker(results chan<- Result, result Result) { |
如果接收方提前退出,或者根本没人接收,这个 worker 就会一直卡住。
2. 一直阻塞在 channel 接收
例如:
1 | for task := range jobs { |
如果 jobs 永远不 close,而外层也没用 ctx.Done() 提前退出,worker 就会一直挂着。
3. 起了 ticker、timer、后台循环,却没有停止条件
例如:
1 | func watch() { |
如果这个 goroutine 只服务一批短任务,那这就是典型泄漏。
即使这个 goroutine 后面能退出,正常写法里也应该记得在合适位置 ticker.Stop()。
4. 调用了支持取消的 API,却根本没把取消信号传下去
例如上层已经超时返回了,但底层 HTTP 请求、数据库查询、内部 goroutine 还在继续跑。
很多项目的“超时支持”其实只做到了这一半:
- 外层函数返回了
- 内层 goroutine 没停
这不叫真正支持超时,只叫“主调方先撤了”。
十四、为什么“加了超时”仍然可能泄漏 goroutine
这是 Go 并发里一个非常高频的误区。
先看一个错误示例:
1 | func RunWithTimeout(task func() error, timeout time.Duration) error { |
看到这里很容易以为:
- 超时返回了
- 问题解决了
其实不一定。
如果 task() 卡住了,或者它执行完后尝试 done <- task() 时外层已经超时返回、没人再接这个 done,那个 goroutine 仍然可能挂住。
这段代码至少有两个问题:
- 没有把取消信号传给
task done的发送和接收关系没有完整退出策略
更稳的方向通常是:
- 用
context把取消信号传到内部 - worker 在发送结果时同时监听
ctx.Done() - 下游操作也必须真正支持取消
所以超时不是“函数早点 return”这么简单。
真正的目标是:超时发生后,所有相关 goroutine 都要有机会退出。
十五、context.WithCancel 和 context.WithTimeout 分别解决什么问题
context 很容易被机械地一路传下去,但代码里常常说不清它到底在传什么。
最重要的两个动作是:
1. context.WithCancel
它解决的是:
我需要主动广播“这一批任务别做了”。
例如:
- 某个关键依赖已经失败,整批没有继续价值
- 用户主动取消
- 父任务结束,子任务要一起停
2. context.WithTimeout
它解决的是:
这段操作最多只能占用这么久。
例如:
- 单个接口探测最多 200ms
- 整批巡检最多 3s
- 外部依赖响应超过阈值就算失败
你可以把它们先粗略理解成:
WithCancel更像手动刹车WithTimeout更像自动超时刹车
但要注意,context 真正有效的前提是:
- 每一层都继续往下传
- 阻塞点都监听
ctx.Done() - 下游 API 真的接受并遵守这个
ctx
否则它就只是一个一路传递却没人理的参数。
十六、在并发代码里,取消信号到底应该落到哪些地方
如果只是把 ctx 放到函数签名里,却不在阻塞点监听,它基本没有意义。
最关键的落点通常有四类:
1. worker 从 jobs 取任务时
1 | select { |
2. worker 往 results 发送结果时
1 | select { |
3. 调用外部依赖时
例如 HTTP、数据库、RPC,都应该把 ctx 继续传进去。
4. 后台定时循环里
1 | for { |
如果这四类位置漏掉任何一个,取消通常都只能做到“部分有效”。
十七、先搭一个更完整的并发巡检器骨架
下面把竞态、超时、取消、结果收集和退出边界放进一个更像实际项目的版本。
1 | package checker |
这个版本里有几件事很关键:
- worker 不直接改共享汇总对象,而是把结果发到
results - 汇总只在主收集循环里做,所以没有多 goroutine 竞争写
summary - 发任务时监听
ctx.Done(),整批取消时可以停 - worker 收任务和发结果时都监听
ctx.Done(),不会只退一半 results的关闭由等待所有 worker 的专门 goroutine 负责,关闭责任清晰- 单任务 timeout 通过
task.Timeout派生子 context
这已经是一个可以承载很多实际排障动作的最小骨架了。
十八、这个小项目里最容易写错的几个地方
1. 让 worker 直接并发更新 summary
错误示例:
1 | go func() { |
这会把原本单线程收口的统计,又变成并发写共享状态。
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 | type Stats struct { |
worker 执行时:
1 | atomic.AddInt64(&stats.ActiveWorkers, 1) |
提交任务时:
1 | atomic.AddInt64(&stats.Submitted, 1) |
结果归档时:
1 | atomic.AddInt64(&stats.Completed, 1) |
再配合周期性日志:
1 | log.Printf( |
这套东西看起来很朴素,但已经足够帮你回答几个关键问题:
- 任务是否在持续推进
- worker 是否卡住不下降
- goroutine 是否在批次结束后回收
- 问题更像下游慢,还是更像退出路径没收干净
二十一、超时、取消和泄漏这三件事,最容易混淆在哪里
很多排障动作失败,是因为一开始就把三个不同问题混在一起了。
1. 超时
它关注的是:
这件事多久还没完成,就该按失败处理。
2. 取消
它关注的是:
上游已经决定不需要这件事继续了。
3. 泄漏
它关注的是:
这件事虽然已经没有业务价值,但执行体还没退出。
这三件事的关系通常是:
- 超时和取消应该推动 goroutine 退出
- 如果退出路径没写完整,就会演化成泄漏
所以当系统已经声称“支持超时”时,真正要再追问一句的是:
超时以后,相关 goroutine 和底层操作真的都停了吗?
二十二、给这个小项目补几个最常见的错误示例
错误示例一:共享切片并发 append
1 | var results []Result |
这会造成:
- 数据竞态
- 底层数组扩容时更危险
- 结果顺序和内容都不可信
错误示例二:结果通道没人消费,worker 全堵住
1 | for _, task := range tasks { |
如果 worker 在处理时要往 results 写,而接收方没启动,就会卡住。
错误示例三:只在外层返回超时,不在内部监听取消
1 | func probe(ctx context.Context, task Task) error { |
这段代码签名看起来支持 ctx,但实现里完全没用。
所以外层就算超时返回,内部还是照睡 10 秒。
错误示例四:后台 watcher 没有退出条件
1 | go func() { |
如果这只是服务某一批巡检任务,它就是在悄悄泄漏。
二十三、怎么给并发问题补最小测试,而不是只靠手跑
并发问题如果只靠手工执行,通常很难稳定复现。
至少应该补下面几类测试。
1. 基本成功路径测试
验证:
- 所有任务都能被处理
- 结果数量正确
- 汇总统计正确
2. 取消测试
验证:
- 父
context取消后,RunBatch能及时返回 - worker 不会一直挂住
3. 超时测试
验证:
- 单任务卡住时能按时返回超时
- 超时结果会被正确计入汇总
4. 竞态测试
用 go test -race 跑,验证:
- 结果归档
- 汇总统计
- 共享状态读写
下面给一个取消测试的最小示例:
1 | package checker |
这个测试的重点不是写得多复杂,而是它在验证一件真实的工程边界:
上游取消后,批处理是否真的能退出。
再看一个超时测试的最小示例:
1 | func TestRunBatchTimeout(t *testing.T) { |
二十四、并发问题出现时,更实用的排查顺序是什么
这一节很重要。
并发问题一出现,排查顺序很容易乱掉:
- 先怀疑 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 泄漏只是“多几个协程没关系”
真正稳的工程判断通常是:
- 先把最小边界写对
- 再补可观测性
- 最后才谈并发度和性能优化
二十九、练习题
如果你想确认自己真的掌握了这篇文章,可以试着独立做下面几题:
- 把文中的
RunBatch改成支持失败阈值,例如失败超过 3 个就整批取消。 - 给
RunBatch增加队列等待时间统计,区分“排队超时”和“执行超时”。 - 故意把
summary改成由 worker 直接更新,然后用go test -race观察告警。 - 故意删掉
results发送时的select <-ctx.Done()分支,构造一个接收方提前退出的测试,看 worker 是否泄漏。 - 给巡检器增加一个周期性 watcher,然后正确地让它在批次结束时退出。
这些练习的重点不是把代码写长,而是训练你把并发问题拆回:
- 数据归属
- 阻塞关系
- 退出路径
- 观测点
三十、结语
Go 并发真正难的地方,从来不是 goroutine、channel、select 这些语法本身,而是你能不能把四件事同时做对:
- 共享数据的所有权
- 阻塞关系的闭环
- 超时和取消的传递
- 问题发生后的可观测性
如果这四件事没建立起来,并发代码就会进入一种很典型的失控状态:
- 平时像能跑
- 一上压力就出问题
- 问题一来就很难复现
- 修一次又引出另一种卡住
所以学 Go 并发,不要只记“怎么开 goroutine”,而要优先记下面这套更实用的判断顺序:
- 先明确谁拥有数据写权限
- 再明确谁发送、谁接收、谁关闭 channel
- 再保证每条路径都有退出条件
- 再把
context真的传到阻塞点 - 最后补上 race、日志、goroutine 数和 profile 这些排障抓手
当你能按这套顺序去写和排查,并发问题就会从“玄学 bug”,慢慢变成能拆、能测、能定位的工程问题。