goroutine、channel、context 与取消传播的关系

把执行单元、通信与生命周期控制放在一张图里:启动、交接、取消、关闭与等待如何分工而不互相替代。

#type / synthesis #status / growing #tech / dev #resource / go

[!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,若未来得及产出)。
  • 进程正常退出,而不是永久挂起。
  • 多次运行产出条数可能不同——超时与调度竞争,属预期。

结合场景再看关注点

  1. cancel ≠ close:cancel 是“请停下”;close 是“没有更多任务了”。
  2. 发送与接收两侧都要听 Done,否则一边停了另一边仍阻塞 → 泄漏。
  3. 本例未 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 → 退出进程

边界情况

  1. 只 cancel 不等待:资源竞赛式关闭。
  2. 只 close 不 cancel:进行中工作与阻塞发送残留。
  3. worker 忽略 ctx:取消无效。
  4. 把 ctx 塞进结构体长期存活:生命周期错位(惯例反对,例外需极清晰)。
  5. 用 channel 关闭模拟树形取消:难组合超时与多子树。
  6. 错误当取消:业务失败应返回 error,并按策略决定是否 cancel 兄弟任务。

常见误区

[!warning] 常见误区:三者可互相替代 错误:“有 channel 就不需要 context”。
正确:通信 ≠ 生命周期树。

[!warning] 常见误区:cancel 等于 kill 线程 错误:以为 cancel 后 goroutine 立刻蒸发。
正确:协作式;代码必须检查。

[!warning] 常见误区:接收方 close 共享 jobs 错误:消费者关发送通道。
正确:发送所有权方关闭。

[!warning] 常见误区:main 直接退出 错误:cancel 后立刻 os.Exit 或返回而不 Wait。
正确:有界等待 + 超时降级日志。

工程实践

  1. 函数首参 ctx context.Context,向下传。
  2. 创建 cancel 的位置 defer cancel()
  3. 所有阻塞点考虑:channel、I/O、锁;尽量可取消。
  4. errgroup 绑定“失败即取消兄弟 + Wait”。
  5. 泄漏检测:测试末 runtime.NumGoroutine 或 pprof goroutine。
  6. 文档化所有权:谁 close、谁 Wait、谁 cancel。
  7. 观测:取消原因计数、在途 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-contextgo-pipeline-patterngo-graceful-shutdown

自测题

概念题

  1. 为什么 cancel 不能替代 WaitGroup?
  2. close(ch) 与 ctx 取消如何分工?
  3. 为何 pipeline 必须设计 cancellation?

代码推理题

worker 在 results <- v 阻塞,此时 cancel 但发送未包在 select 里。会怎样?

工程思考题

HTTP 请求取消时,已启动的 3 个下游 fan-out 如何统一停并汇总错误?

参考答案

展开
  1. cancel 只发信号;Wait 确认退出与资源释放。
  2. close 结束数据流;ctx 停止工作与阻塞点。
  3. 下游可能不再消费,上游发送阻塞导致泄漏。
    代码题: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规范同步语义

笔记元信息

  • 建议文件名:go-goroutine-channel-context-and-cancellation-relationship.md
  • 类型:关系/综合(synthesis)
  • 建议下一篇:优雅停机pipeline
  • 本篇状态:已深化
创建于 2026/6/20 更新于 2026/7/15