Go:并发编程入门,goroutine、channel、context 应该怎么配合
第一次学 Go 并发时,很快会先记住三个词:
- goroutine
- channel
- context
但真正开始写代码时,又经常会同时卡在几件事上:
- goroutine 什么时候该起,什么时候不该乱起
- channel 是拿来传数据,还是拿来做同步
- context 是不是“到处传一下就行”
- 为什么代码看起来能跑,结果一超时就收不住
- 为什么任务都执行完了,程序还挂着不退出
这类问题通常不是因为语法不会,而是因为三种能力之间的分工没建立起来。
Go 并发入门真正要先看清的是:
- goroutine 负责并发执行
- channel 负责通信和协同
- context 负责控制生命周期
如果把这三件事混在一起,代码很快就会变成:
- goroutine 一路乱开
- channel 既传数据又强行承载取消语义
- context 传了,但没人真正响应取消
这一篇就围绕一个实际小场景来讲:做一个批量任务分发器。
这个分发器要做几件事:
- 接收一批检查任务
- 并发执行这些任务
- 回收每个任务结果
- 整体超时后立即停止收口
- 某个关键失败时通知其它任务尽快退出
这个场景不大,但足够把 goroutine、channel、context 的配合关系一次讲清楚。
一、这篇文章要解决什么问题
读完这一篇,应该能独立回答这些问题:
- goroutine、channel、context 分别在解决什么问题
- 一个最小并发任务模型应该怎么搭
- 什么时候该用 channel 传结果,什么时候该用 context 取消
- 超时、取消和正常完成三种结束方式怎么区分
- 怎么给并发逻辑补最小验证
- 什么时候不该把“能并发”理解成“必须并发”
如果这些问题能答清,后面再学竞态、死锁、goroutine 泄漏和 HTTP 并发服务,理解会快很多。
二、先看这篇文章里的实际场景
假设现在要做一个最小任务分发器,输入一批探测任务:
check-order-apicheck-user-apicheck-billing-api
每个任务都要:
- 执行一次检查
- 返回成功或失败
- 记录耗时
同时还有两个约束:
- 整批任务 2 秒内必须收口
- 某个关键任务一旦失败,剩余任务要尽快停止
这时如果完全串行执行,最直接的问题是总耗时可能太长。
但如果只是看见 Go 有 goroutine,就一路并发开出去,后面又会立刻遇到:
- 结果怎么回收
- 谁负责关闭通道
- 超时后谁通知其它任务退出
- 子任务没退出时主流程为什么会卡住
所以这一篇不按“概念定义”展开,而是直接围绕这个分发器搭并发骨架。
三、先给一个最小可运行示例
先只做最小版本:三个任务并发执行,把结果从 channel 收回来。
1 | package main |
输出可能像这样:
1 | user 200.412ms |
这个例子先只说明一件事:
- goroutine 把任务并发跑起来
- channel 把结果带回来
但它还没解决超时、取消、错误传播这些更关键的问题。
四、先把一句话结论说清楚
在 Go 并发里,最容易写乱的不是语法,而是职责边界。
一个更稳的理解方式是:
- goroutine 负责“谁在并发执行”
- channel 负责“谁和谁之间交换结果或信号”
- context 负责“谁通知谁该结束了”
如果一个地方把这三件事全塞给同一个工具,就会开始失控。
例如:
- 用 channel 既传结果又表示取消
- 用 goroutine 随便包一层,却没人负责收尾
- 用 context 传下去,但底层函数从头到尾不检查
Done
五、goroutine 到底在解决什么问题
goroutine 解决的是:把一段执行流独立跑起来。
最小写法很简单:
1 | go runTask("order") |
但这里要先看清一个事实:
- 开 goroutine 不等于任务一定会完成
- 主 goroutine 退出时,其它 goroutine 也会直接结束
所以只会写 go xxx() 远远不够。
后面至少要回答:
- 结果怎么回来
- 结束怎么通知
- 异常怎么处理
如果这些没有配套,goroutine 只会把原本串行的混乱改成并发的混乱。
六、channel 到底在解决什么问题
channel 解决的是:goroutine 之间怎么交换数据或同步状态。
最常见的两种用途是:
- 传递结果
- 传递完成信号
例如传结果:
1 | resultCh := make(chan Result) |
例如传完成信号:
1 | done := make(chan struct{}) |
这里先要建立一个意识:
- channel 不是“并发万能容器”
- 它解决的是协作,不是所有共享问题
如果只是想保护共享内存,可能更该看 sync.Mutex。
如果是想表示整体任务结束,更可能应该搭配 context。
七、context 到底在解决什么问题
context 最核心的作用不是“顺手传一下”,而是:
把取消、超时和请求作用域沿调用链传下去。
最常见的入口:
1 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
这个 ctx 可以往下传给:
- 任务执行器
- HTTP 请求
- 数据读取逻辑
- 下游 worker
然后下游在合适的位置响应:
1 | select { |
这才叫“用了 context”。
如果只是函数参数里带个 ctx,但内部根本不看 Done,那它只是一个摆设。
八、先把三者串成一个最小分发器
先做一个有超时控制的版本。
1 | package dispatcher |
这时已经有了三层配合:
- goroutine 并发执行任务
- channel 回收结果
- context 控制任务是否继续
九、为什么这里要把循环变量显式传进去
写 goroutine 时,最常见的坑之一是:
1 | for _, task := range tasks { |
这类写法在不同 Go 版本和不同上下文里都容易制造误判。
更稳的写法是直接把当前值传进闭包参数:
1 | for _, task := range tasks { |
这里不是为了“写法好看”,而是为了把当前 goroutine 使用的对象边界写清楚。
十、为什么只会起 goroutine 还远远不够
先看一个常见的坏版本:
1 | func Dispatch(tasks []Task) { |
问题是:
- 没有结果回收
- 没有错误处理
- 没有结束条件
- 没有超时边界
这种代码最常见的后果是:
- 主流程提早结束
- 子 goroutine 状态不可见
- 出问题时根本不知道哪一个任务卡住了
所以一段并发代码至少要回答:
- 任务何时开始
- 结果往哪里回
- 何时停止等待
- 谁负责取消
十一、为什么取消信号更适合让 context 承担
有些人刚开始会想用一个 channel 表示取消:
1 | stopCh := make(chan struct{}) |
这当然不是完全不能做,但一旦调用链变长,就会开始暴露问题:
- 函数签名多出很多额外通道参数
- 超时和取消语义要自己封装
- 下游库函数通常不认识你的
stopCh
而 context 的好处是:
- 标准库普遍认识它
- 超时、取消、层层传递都已经有现成模型
- 调用链可以统一响应
Done
所以更常见的边界是:
- 用 channel 传业务结果
- 用 context 传生命周期控制
十二、一个更完整的版本:关键失败触发全局取消
现在把“关键任务失败就停止整批执行”也补进去。
1 | package dispatcher |
这里更接近实际工程的地方有两点:
- 子任务会响应
ctx.Done() - 某个关键失败能触发整体取消
十三、buffered channel 和 unbuffered channel 这里该怎么选
这个话题在并发入门里很容易被神化。
先不用背很多口诀,只看当前场景。
如果当前写法是:
1 | resultCh := make(chan Result, len(tasks)) |
它的直接好处是:
- 发送结果的 goroutine 不会因为主流程暂时没接收就立刻卡住
但这不意味着“有 buffer 就更高级”。
当前选择它,只是因为:
- 每个任务只发一次结果
- 结果数上限已知
- 发送端不需要和接收端强同步
如果你的模型需要强同步,或者要靠阻塞表达背压,unbuffered channel 反而更合适。
十四、为什么不能靠关闭 resultCh 来表示“所有任务结束”
这也是高频混乱点。
如果每个 worker 都想自己决定:
1 | close(resultCh) |
很容易直接触发:
1 | panic: close of closed channel |
更关键的是,在多 worker 场景下,谁拥有关闭通道的权力必须非常清楚。
一个常见原则是:
- 谁创建通道
- 谁明确知道“不会再有发送”
- 谁来关闭通道
在这篇文章的最小模型里,因为主流程按任务数固定接收,所以甚至不需要强行关闭 resultCh。
十五、一个更接近项目现场的小案例
假设现在有一组环境巡检任务:
- 数据库连通性
- 缓存连通性
- 用户接口健康检查
- 支付接口健康检查
要求是:
- 总巡检时间不能超过 3 秒
- 数据库检查是关键项,一旦失败立刻终止整批
- 非关键项超时可以记失败,但不阻断结果汇总
这时一套更合适的并发骨架通常是:
- 顶层创建
context.WithTimeout - 每个任务单独起 goroutine
- 所有任务结果统一写入
resultCh - 关键项失败时触发
cancel() - 主流程继续收口结果,生成最终摘要
最终摘要结构可能像这样:
1 | type Summary struct { |
主流程根据 result.Err 决定归类:
nil记成功context.DeadlineExceeded记超时context.Canceled记取消- 其它错误记失败
这样一来,并发就不是“快一点”这么简单,而是开始具备可控的工程边界。
十六、怎么给这类并发逻辑补最小验证
并发代码如果只靠手跑,很容易留下两个误判:
- 偶尔跑通就以为没问题
- 一次没卡死就以为模型正确
至少要验证这些点:
- 所有任务结果都能收回来
- 超时后任务能退出
- 关键失败时整批能取消
示例测试:
1 | package dispatcher |
这组测试很小,但已经能把并发模型最容易失控的两条边守住。
十七、怎么验证这段代码是不是真的响应了取消
很多代码表面上已经传了 ctx,但实际并没有响应取消。
一个最直接的检查方式是:
- 人为把任务延迟拉长
- 把
WithTimeout时间压短 - 看最终返回的是不是
context.DeadlineExceeded - 看程序会不会继续悬挂不退出
如果超时以后:
- 结果迟迟不回来
- 程序还挂着
- 某些 goroutine 继续睡眠很久
那就说明底层根本没有在合适位置检查 ctx.Done()。
十八、一个高频排错场景:为什么主流程已经结束了,程序却不退出
这类问题通常有几种高频原因:
- 某个 goroutine 还在阻塞发送
- 某个 goroutine 永远等不到 channel 数据
- 某个任务内部根本不响应 context 取消
排查顺序通常是:
- 先看哪个 channel 上有 send / receive 卡住
- 再看有没有 goroutine 没有退出条件
- 再看
ctx.Done()有没有真正被 select 到
在这类问题里,最常见的根因不是 Go 并发“太玄学”,而是:
- 通信边界没画清
- 生命周期控制没收住
十九、一个高频误区:把 channel 当共享状态表来用
例如有人会想把所有状态都塞进一个 channel 流里,然后在多个地方读写,希望它顺便承担:
- 任务队列
- 取消信号
- 结果汇总
- 当前状态同步
这通常会把模型越写越乱。
更常见有效的思路是:
- channel 只承担明确的一种协作语义
- 结果通道就是结果通道
- 取消交给 context
- 共享状态单独考虑是否需要锁或归并到单 goroutine 管理
二十、什么时候不该并发
这是并发入门里特别值得先建立的边界。
如果当前任务具备这些特征:
- 总量很小
- 完全没有 I/O 等待
- 顺序必须严格一致
- 并发后排障成本反而更高
那它可能根本不值得并发。
并发不是默认更高级,而是:
- 有等待可以重叠
- 有吞吐要提升
- 有生命周期需要统一控制
才值得引入。
二十一、一个实际练习
可以直接把这一篇改成一个完整练习。
练习目标:做一个最小环境巡检分发器。
要求:
- 同时执行 3 到 5 个任务
- 用
resultCh回收每个任务结果 - 用
context.WithTimeout控制整体时间 - 至少定义一个关键任务,失败时触发
cancel() - 补两组最小测试:正常返回和超时返回
- 故意写一个不响应
ctx.Done()的版本,再修回来
如果这个练习能独立做完,说明并发基础已经不只是“会开 goroutine”,而是开始有收口意识了。
二十二、这篇文章的边界在哪里
这一篇重点讲的是并发入门骨架:
- goroutine 怎么起
- channel 怎么收
- context 怎么控
先不展开这些更复杂的话题:
sync.WaitGroup、Mutex、Cond- worker pool 的限流和背压
- 竞态条件和死锁的系统排查
- 更复杂的 fan-out / fan-in 设计
这些更适合放到后面的并发问题和工程实践文章里继续展开。
二十三、这篇文章学完以后,下一步应该补什么
这一篇解决的是:
- goroutine、channel、context 的基本分工
- 一个最小并发任务模型怎么搭起来
接下来最适合继续补的是:
- 竞态、死锁、goroutine 泄漏、超时和取消怎么排查
io、os、net/http、json这些标准库能力怎么和并发配合- 项目继续长大后,目录、层次和入口怎么组织
因为到这一步,并发代码已经能跑起来了。
后面真正会卡住的,是它能不能长期稳定、能不能排查、能不能被工程化接住。
二十四、结语
Go 并发入门真正关键的,不是先记住多少术语,而是先把三件事分开:
- goroutine 负责执行
- channel 负责协作
- context 负责生命周期
只要这个分工先立住,后面无论是写批量任务、环境巡检,还是 HTTP 请求并发处理,代码都不会那么容易一上来就缠在一起。
并发从来不是“多写一个 go”这么简单。
真正让代码稳下来的,是启动、通信、取消和收口这四件事有没有同时被设计进去。