goroutine、channel、context 与取消传播的关系
把执行单元、通信与生命周期控制放在一张图里:启动、交接、取消、关闭与等待如何分工而不互相替代。
[!info] 关联笔记
goroutine、channel、context 与取消传播的关系
这个概念为什么会出现
分开学三个 API 时,各自都简单:
go f()并发跑ch <- v/<-ch传值ctx.Done()该停了
合到真实服务里却同时出现泄漏、死锁、关掉 channel 当取消、cancel 了却不等待等问题。需要一张关系图:谁负责执行、谁负责通信、谁负责生命周期信号、谁负责等待收尾。这是综合笔记(type/synthesis),不重复各原子篇的全部细节,而钉死协作契约。
[!abstract] 一句话理解 goroutine 是执行;channel 是进程内交接与同步;context 是沿调用树传播的取消/截止信号。关闭 channel 表示“流结束”,cancel 表示“请停下”,Wait 表示“确认已停”——三者缺一不可混用替代。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:图片缩略图流水线,50ms 超时必须停
上传服务把待处理图片 ID 丢进 jobs 队列,worker 做“缩略图”(这里用 *2 代替重计算),结果写回 results。
上游 SLA 要求:整条流水线 50ms 内必须停下,不能让 worker 在无人接收时永久阻塞,也不能把 close(jobs) 误当成唯一的取消手段。
三角分工:
- goroutine:并发跑 worker / 投递方
- channel:交接任务与结果;
close(jobs)= 流结束 - context:超时取消整棵协作树
package main
import (
"context"
"fmt"
"time"
)
// worker:缩略图工人(极简模型)。
// 业务意图:有任务就处理;超时或 jobs 关闭就退出,绝不在发送结果时卡死。
// 教学点:收 jobs、发 results 都 select 了 ctx.Done()——取消与通信并列。
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
// 超时/取消:停止接新活
return
case j, ok := <-jobs:
if !ok {
// 流结束:投递方 close(jobs),与 cancel 不同语义
return
}
// 模拟处理;真实项目是解码/缩放/写对象存储
select {
case results <- j * 2:
case <-ctx.Done():
// 结果无人收且已取消:不要阻塞在 results 上
return
}
}
}
}
func main() {
// 业务:整条流水线 50ms 硬超时
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel() // 即使提前返回也释放定时器
jobs := make(chan int)
results := make(chan int)
go worker(ctx, jobs, results)
// 投递方:最多塞 100 个任务;中途取消则停投并 close(流结束)
go func() {
defer close(jobs)
for i := 1; i <= 100; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}()
// 主流程:消费结果,或因超时退出
for {
select {
case <-ctx.Done():
fmt.Println("stop:", ctx.Err()) // 期望:stop: context deadline exceeded
return
case r, ok := <-results:
if !ok {
return
}
fmt.Println(r) // 可能打印若干偶数;数量随调度变化
}
}
}
建议运行:
go run .
期望观察点:
- 打印若干处理结果(偶数)后出现
stop: context deadline exceeded(或仅 stop,若未来得及产出)。 - 进程正常退出,而不是永久挂起。
- 多次运行产出条数可能不同——超时与调度竞争,属预期。
结合场景再看关注点
- cancel ≠ close:cancel 是“请停下”;close 是“没有更多任务了”。
- 发送与接收两侧都要听 Done,否则一边停了另一边仍阻塞 → 泄漏。
- 本例未 Wait worker 退出;生产优雅停机还要
WaitGroup/errgroup(见相关笔记)。
核心概念与准确模型
三角职责
| 机制 | 解决的问题 | 不解决的问题 |
|---|---|---|
| goroutine | 并发执行 | 自动停止、自动回收逻辑资源 |
| channel | 传值、同步、流结束(close) | 跨进程持久化;广播取消整棵调用树 |
| context | 取消/超时/截止 + 少量请求元数据 | 等待 goroutine 退出;替代业务错误值 |
典型生命周期
sequenceDiagram
participant P as Parent
participant W as Worker
P->>P: ctx, cancel := WithCancel / WithTimeout
P->>W: go worker(ctx, jobs, results)
P->>W: jobs <- task
alt 正常完成
W-->>P: results <- value
P->>P: close owned channels when done
else 超时或上游取消
P->>P: cancel()
W->>W: select <-ctx.Done(); return
P->>P: wait (WG/errgroup)
else worker 失败
W-->>P: error via result/errgroup
P->>P: cancel siblings
P->>P: wait
end
关闭 vs 取消
close(ch) | cancel() / 超时 | |
|---|---|---|
| 信号含义 | 不会再有新元素 | 工作应停止 |
| 谁发出 | 发送所有权方 | context 拥有者 |
| 接收方式 | range / ok | <-ctx.Done() / API 返回 ctx.Err() |
| 进行中的 RPC | 不自动中断 | 需下游支持 ctx |
pipeline 博文强调:仅 close 不足以让阻塞在发送上的上游退出,必须把 cancellation 设计进去。Pipelines · Context。
等待:被遗忘的第四极
cancel 只广播“请停”。父侧还要:
WaitGroup.Wait/errgroup.Wait- 或接收完结果 channel
否则主流程以为结束了,子 goroutine 仍持有锁、连接或向半关闭系统写入。
与 select 的粘合
select 是把 channel 通信 与 Done 绑在同一调度点的语法装置。没有 select,就容易写出不可取消的阻塞发送/接收。见 go-select。
请求树与派生
Background / TODO
└── WithCancel / WithTimeout(请求入口)
├── handler
├── worker goroutine(传同一 ctx 或派生)
└── 下游 HTTP/DB(ctx)
父 cancel ⇒ 子 Done 关闭。子不应 cancel 父。值透传保持克制(go-context)。
模式中的位置
- worker pool:N 个 goroutine + jobs channel + ctx
- pipeline:多阶段 goroutine + 链式 channel + ctx 防泄漏
- 优雅停机:信号 → cancel root → 停接入 → Wait → 退出进程
边界情况
- 只 cancel 不等待:资源竞赛式关闭。
- 只 close 不 cancel:进行中工作与阻塞发送残留。
- worker 忽略 ctx:取消无效。
- 把 ctx 塞进结构体长期存活:生命周期错位(惯例反对,例外需极清晰)。
- 用 channel 关闭模拟树形取消:难组合超时与多子树。
- 错误当取消:业务失败应返回 error,并按策略决定是否 cancel 兄弟任务。
常见误区
[!warning] 常见误区:三者可互相替代 错误:“有 channel 就不需要 context”。
正确:通信 ≠ 生命周期树。
[!warning] 常见误区:cancel 等于 kill 线程 错误:以为 cancel 后 goroutine 立刻蒸发。
正确:协作式;代码必须检查。
[!warning] 常见误区:接收方 close 共享 jobs 错误:消费者关发送通道。
正确:发送所有权方关闭。
[!warning] 常见误区:main 直接退出 错误:cancel 后立刻
os.Exit或返回而不 Wait。
正确:有界等待 + 超时降级日志。
工程实践
- 函数首参
ctx context.Context,向下传。 - 创建 cancel 的位置
defer cancel()。 - 所有阻塞点考虑:channel、I/O、锁;尽量可取消。
- errgroup 绑定“失败即取消兄弟 + Wait”。
- 泄漏检测:测试末
runtime.NumGoroutine或 pprof goroutine。 - 文档化所有权:谁 close、谁 Wait、谁 cancel。
- 观测:取消原因计数、在途 goroutine、排队时长(go-observability)。
可验证实验
实验 1:无取消的提前返回
下游读一个结果就 return,上游无缓冲发送;观察 goroutine 泄漏;再加 ctx 对比。
实验 2:只 cancel 不 Wait
cancel 后立即检查共享资源是否仍被写(加日志)。
实验 3:close 与 Done 双路径
jobs 关闭与 timeout 同时可能发生,worker 两种出口都要干净。
实验 4:errgroup
一个子任务失败,确认其它任务收到取消。
实验 5:优雅停机草图
signal.Notify → cancel → Shutdown → Wait。
本节总结
- 本质:执行 / 通信 / 取消信号 / 等待 的分工模型。
- 关键规则:协作式取消;发送方关闭;父 Wait;select 粘合。
- 最易错:替代使用、泄漏、假停机。
- 下一步:深挖 go-context、go-pipeline-pattern、go-graceful-shutdown。
自测题
概念题
- 为什么 cancel 不能替代 WaitGroup?
- close(ch) 与 ctx 取消如何分工?
- 为何 pipeline 必须设计 cancellation?
代码推理题
worker 在 results <- v 阻塞,此时 cancel 但发送未包在 select 里。会怎样?
工程思考题
HTTP 请求取消时,已启动的 3 个下游 fan-out 如何统一停并汇总错误?
参考答案
展开
- cancel 只发信号;Wait 确认退出与资源释放。
- close 结束数据流;ctx 停止工作与阻塞点。
- 下游可能不再消费,上游发送阻塞导致泄漏。
代码题:worker 可能永不看到取消,泄漏或死锁。
工程题:请求 ctx 传入 errgroup;子任务用同一 ctx;Wait 收集错误;handler 返回 ctx.Err() 或聚合错误。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Go Concurrency Patterns: Pipelines | 官方博客 | 取消与泄漏 |
| Go Concurrency Patterns: Context | 官方博客 | 上下文树 |
| Package context | 标准库 | API |
| Share Memory By Communicating | 博客 | channel 结构 |
| Memory Model | 规范 | 同步语义 |