← [ BACK_TO_NOTES ]

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

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

#synthesis #growing #dev #go

[!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 或计算检查点;本例乘法不模拟这部分。

结合场景再看关注点

  1. cancel ≠ close:cancel 是“请停下”;close 是“没有更多任务了”。
  2. 发送与接收两侧都要听 Done,否则一边停了另一边仍阻塞 → 泄漏。
  3. 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 防泄漏
  • 优雅停机:停止接入 → 按排空或取消策略处理在途 → 等待 → 释放依赖;不要先取消所有请求却仍期待自然排空

边界情况

  1. 只 cancel 不等待:资源竞赛式关闭。
  2. 若下游提前退出,仅关闭输入不足以解开上游的阻塞发送;正常排空则可按关闭协议完成,无需靠取消结束。
  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.WithContext 的派生 ctx 在首个非 nil 任务错误或 Wait 返回时取消;任务要使用该 ctx,Wait 只返回首个非 nil 任务错误,不聚合全部错误。
  5. 泄漏检查用可控完成信号、goroutine profile 或专门工具;单次 runtime.NumGoroutine 计数会受运行时和其他后台工作干扰。
  6. 文档化所有权:谁 close、谁 Wait、谁 cancel。
  7. 观测:取消原因计数、在途 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。

自测题

概念题

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

代码推理题

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

工程思考题

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

参考答案

展开
  1. cancel 只发信号;Wait 确认已登记任务结束。资源是否释放取决于任务自己的清理协议,内部另起而未登记的 goroutine 不在保证范围内。
  2. close 结束数据流;ctx 发出取消信号,需要代码在阻塞点响应。
  3. 下游可能不再消费,上游发送阻塞导致泄漏。
    代码题: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规范同步语义

笔记元信息

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

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本