goroutine、channel、context 与取消传播的关系
把执行单元、通信与生命周期控制放在一张图里:启动、交接、取消、关闭与等待如何分工而不互相替代。
[!info] related notes
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。
预算到期后请求取消;worker 在通信点响应,并由父侧等待退出。context 不是实时调度或强制中断机制,不能保证整条流水线在 50ms 内已经停下。
三角分工:
- goroutine:并发跑 worker / 投递方
- channel:交接任务与结果;
close(jobs)= 流结束 - context:超时取消整棵协作树
package main
import (
"context"
"fmt"
"sync"
"time"
)
// worker:缩略图工人(极简模型)。
// 业务意图:接收和发送都能响应取消;jobs 关闭则正常结束。
// 教学点:收 jobs、发 results 都 select 了 ctx.Done()——取消与通信并列。
func worker(ctx context.Context, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
// 取消分支被选中:退出;select 没有取消优先级。
return
case j, ok := <-jobs:
if !ok {
// 流结束:投递方 close(jobs),与 cancel 不同语义
return
}
// 模拟处理;真实项目是解码/缩放/写对象存储
select {
case results <- j * 2:
case <-ctx.Done():
// 结果无人收且已取消:不要阻塞在 results 上
return
}
}
}
}
func main() {
// 50ms 后请求取消,不是 goroutine 退出的硬时限。
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel() // 即使提前返回也释放定时器
jobs := make(chan int)
results := make(chan int)
var wg sync.WaitGroup
wg.Add(2) // worker 与投递方;在启动前登记
go func() {
defer wg.Done()
defer close(results) // 此例唯一发送者退出后关闭结果流
worker(ctx, jobs, results)
}()
// 投递方:最多塞 100 个任务;中途取消则停投并 close(流结束)
go func() {
defer wg.Done()
defer close(jobs)
for i := 1; i <= 100; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}()
// 主流程:消费结果,或因超时停止等待结果。
consume:
for {
select {
case <-ctx.Done():
fmt.Println("stop:", ctx.Err())
break consume
case r, ok := <-results:
if !ok {
// 流关闭不等于成功:也可能是 worker 响应取消退出。
if err := ctx.Err(); err != nil {
fmt.Println("stop:", err)
} else {
fmt.Println("input drained")
}
break consume
}
fmt.Println(r) // 可能打印若干偶数;数量随调度变化
}
}
cancel()
wg.Wait() // 确认两个 goroutine 已退出,再释放共享依赖
fmt.Println("all goroutines stopped")
}
建议运行:
go run .
期望观察点:
- 通常很快处理完 100 个任务,打印 2 到 200、input drained,然后 all goroutines stopped,无需等到超时。
- 若先观察到取消,可能只输出部分结果;最后仍等待 worker 和投递方退出。
- 真实图片处理也要有可取消/有界的 I/O 或计算检查点;本例乘法不模拟这部分。
结合场景再看关注点
- cancel ≠ close:cancel 是“请停下”;close 是“没有更多任务了”。
- 发送与接收两侧都要听 Done,否则一边停了另一边仍阻塞 → 泄漏。
- WaitGroup 等待 worker 与投递方。多 worker 共享 results 时,必须改由等全体发送者结束的协调者关闭结果流。
核心概念与准确模型
三角职责
| 机制 | 解决的问题 | 不解决的问题 |
|---|---|---|
| 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: finish input; close jobs
W->>W: drain jobs; close results
P->>P: wait for registered goroutines
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 返回自身的错误 |
| 进行中的 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)
通过 WithCancel/WithTimeout 等派生的子 context 会接收父取消;WithoutCancel 是显式脱离取消的例外。取消子 context 不会自动取消父 context;持有父 CancelFunc 的代码则有权触发父取消。值透传保持克制(go-context)。
模式中的位置
- worker pool:N 个 goroutine + jobs channel + ctx
- pipeline:多阶段 goroutine + 链式 channel + ctx 防泄漏
- 优雅停机:停止接入 → 按排空或取消策略处理在途 → 等待 → 释放依赖;不要先取消所有请求却仍期待自然排空
边界情况
- 只 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.WithContext 的派生 ctx 在首个非 nil 任务错误或 Wait 返回时取消;任务要使用该 ctx,Wait 只返回首个非 nil 任务错误,不聚合全部错误。
- 泄漏检查用可控完成信号、goroutine profile 或专门工具;单次 runtime.NumGoroutine 计数会受运行时和其他后台工作干扰。
- 文档化所有权:谁 close、谁 Wait、谁 cancel。
- 观测:取消原因计数、在途 goroutine、排队时长(go-observability)。
可验证实验
实验 1:无取消的提前返回
下游读一个结果就 return,上游无缓冲发送;观察 goroutine 泄漏;再加 ctx 对比。
实验 2:只 cancel 不 Wait
cancel 后立即检查共享资源是否仍被写(加日志)。
实验 3:close 与 Done 双路径
jobs 关闭与 timeout 同时可能发生,worker 两种出口都要干净。
实验 4:errgroup
用 errgroup.WithContext 注册任务并传递派生 ctx;让一个返回非 nil 错误,确认其他协作任务退出且 Wait 返回该错误。
实验 5:优雅停机草图
signal.NotifyContext → 停接入/Shutdown → 按策略排空或取消 → Wait;Shutdown 的清理预算用独立且未取消的 context。
本节总结
- 本质:执行 / 通信 / 取消信号 / 等待 的分工模型。
- 关键规则:协作式取消;发送方关闭;父 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 确认已登记任务结束。资源是否释放取决于任务自己的清理协议,内部另起而未登记的 goroutine 不在保证范围内。
- close 结束数据流;ctx 发出取消信号,需要代码在阻塞点响应。
- 下游可能不再消费,上游发送阻塞导致泄漏。
代码题:worker 可能永不看到取消,泄漏或死锁。
工程题:g, groupCtx := errgroup.WithContext(requestCtx),用 g.Go 登记三个任务并传 groupCtx。err := g.Wait() 返回首个非 nil 任务错误;若需要全部错误,另设同步的收集机制。不能在 Wait 后用 groupCtx.Err() 判断任务成败,因为成功 Wait 也会取消它。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Go Concurrency Patterns: Pipelines | 官方博客 | 取消与泄漏 |
| Go Concurrency Patterns: Context | 官方博客 | 上下文树 |
| Package context | 标准库 | API |
| Share Memory By Communicating | 博客 | channel 结构 |
| Memory Model | 规范 | 同步语义 |