第一章:Context滥用引发goroutine泄漏的底层机理与危害全景
Go 语言中 context.Context 本为控制 goroutine 生命周期与传递取消信号而生,但不当使用会绕过其设计契约,导致 goroutine 永久阻塞、无法被回收——即典型的 goroutine 泄漏。根本原因在于:Context 的取消信号仅作用于显式监听 <-ctx.Done() 的 goroutine;若协程未检查 Done 通道、或在阻塞 I/O、无缓冲 channel 发送、死循环中忽略上下文状态,则 cancel 调用形同虚设。
Context取消机制的失效场景
- 启动 goroutine 后未将 ctx 传入,或传入但未监听
ctx.Done() - 在
select中遗漏case <-ctx.Done(): return分支 - 对
ctx.Err()的检查滞后于阻塞操作(如先http.Get()再检查 err) - 使用
context.WithTimeout但未对底层操作设置超时(如net.Dialer.Timeout未同步配置)
典型泄漏代码示例
func leakyHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// ❌ 错误:启动 goroutine 但未传递 ctx 或监听 Done
go func() {
time.Sleep(10 * time.Second) // 模拟长耗时任务
fmt.Fprintln(w, "done") // 此时 w 可能已关闭,且 goroutine 无法被取消
}()
}
上述代码中,HTTP handler 返回后连接关闭,但子 goroutine 仍运行 10 秒,且因无 ctx 监听,无法响应父请求终止信号。
危害全景表
| 维度 | 表现 |
|---|---|
| 资源消耗 | 每泄漏 goroutine 占用约 2KB 栈内存 + 调度开销,千级泄漏可致 OOM |
| 服务可用性 | 连接池耗尽、HTTP server maxOpenConns 触顶、健康检查失败 |
| 排查难度 | runtime.NumGoroutine() 持续增长,pprof 查看 goroutine profile 显示大量 sleep 状态 |
修复核心原则:每个长期运行的 goroutine 必须持有有效 context,并在 select 中优先响应 <-ctx.Done(),同时确保所有阻塞调用(如 net.Conn.Read, time.After, chan send/receive)均受 context 控制。
第二章:11种Context滥用模式的分类解构与复现验证
2.1 跨goroutine传递未取消的Context导致泄漏链式传播
当一个 context.Context 未被显式取消,却跨 goroutine 传播时,其携带的 deadline、value 和 cancel 函数将长期驻留内存,阻塞所有依赖它的子 context 生命周期。
场景复现
func leakyHandler(ctx context.Context) {
child := context.WithValue(ctx, "key", "val") // 无 cancel 函数
go func() {
time.Sleep(5 * time.Second)
_ = child.Value("key") // child 永远存活,ctx 无法释放
}()
}
该代码创建无取消能力的子 context,并在新 goroutine 中隐式持有引用,导致父 context 及其整个树无法被 GC 回收。
泄漏传播路径
| 源 goroutine | 子 goroutine | 影响范围 |
|---|---|---|
| HTTP handler | 数据库查询 | DB 连接池超时失效 |
| 定时任务 | 日志异步写入 | 日志缓冲区持续增长 |
根本机制
graph TD
A[main goroutine] -->|WithCancel| B[child ctx]
B -->|未调用cancel| C[worker goroutine]
C --> D[ctx.Value + timer]
D --> E[GC 不可达对象链]
正确做法:始终使用 context.WithCancel 或 WithTimeout,并在 goroutine 结束前显式调用 cancel。
2.2 defer中误用context.WithCancel未显式调用cancel的静默泄漏
defer 是 Go 中优雅清理资源的惯用方式,但与 context.WithCancel 结合时极易埋下泄漏隐患。
常见误用模式
func badHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel() // ✅ 表面正确 —— 但若 handler panic 或提前 return,cancel 可能永不执行?
// ... 启动 goroutine 并传入 ctx
go doWork(ctx)
}
⚠️ 问题:defer cancel() 在函数返回后才执行;若 doWork 持有 ctx 并长期运行(如监听 channel),而主 goroutine 因超时/panic 提前退出,cancel 仍被调用——看似安全。但若 defer 被包裹在 if 或嵌套作用域中,或 cancel 被意外覆盖,则彻底失效。
泄漏路径分析
| 场景 | 是否触发 cancel | 后果 |
|---|---|---|
| 正常返回 | ✅ | 安全 |
| panic 后 recover | ✅ | 安全(defer 仍执行) |
cancel 变量被重赋值 |
❌ | ctx 永不取消 → goroutine + timer + memory 全部泄漏 |
graph TD
A[HTTP Handler] --> B[WithCancel]
B --> C[启动子goroutine]
C --> D{ctx.Done() select?}
D -->|未收到信号| E[持续占用内存/CPU]
B --> F[defer cancel]
F -->|仅当本函数return时| G[可能永远不执行]
根本解法:在子 goroutine 内部监听父 ctx,并确保 cancel 显式、尽早调用。
2.3 HTTP Handler中无界context.WithTimeout嵌套引发的并发泄漏雪崩
问题根源:超时上下文的错误复用
当多个 goroutine 共享同一 context.WithTimeout(parent, d) 返回的 ctx,且 parent 是 context.Background() 或长期存活的上下文时,取消信号无法被正确传播或释放。
典型错误模式
// ❌ 危险:在 handler 外部提前创建并复用 timeout ctx
var sharedCtx = context.WithTimeout(context.Background(), 5*time.Second) // 错误!
func handler(w http.ResponseWriter, r *http.Request) {
// 所有请求共享同一个 ctx,cancel() 被忽略或延迟触发
dbQuery(sharedCtx, r.URL.Query().Get("id"))
}
逻辑分析:
sharedCtx的cancel函数未被调用,导致底层 timer goroutine 永不退出;每个新请求都继承该已过期但未清理的 ctx,堆积大量僵尸 goroutine。
雪崩效应对比表
| 场景 | Goroutine 泄漏速率 | Timer 持续数 | 可观测性 |
|---|---|---|---|
| 正确:每请求新建 ctx | 0 | ≤1/请求 | 无残留 |
| 错误:全局复用 timeout ctx | O(N) 线性增长 | 指数级累积 | pprof 显示 timerproc 占比飙升 |
正确实践
- ✅ 每次请求内独立创建
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) - ✅ 必须
defer cancel() - ❌ 禁止跨请求传递或缓存
WithTimeout返回的 ctx
graph TD
A[HTTP Request] --> B[New context.WithTimeout]
B --> C[DB Call]
C --> D{Success/Timeout?}
D -->|Timeout| E[Cancel called → timer freed]
D -->|Success| F[Cancel called → clean exit]
2.4 channel操作中Context超时未同步关闭接收端导致goroutine永久阻塞
数据同步机制
当 context.WithTimeout 创建的 Context 超时后,仅取消 Context 本身,不会自动关闭关联 channel。若接收端仍阻塞在 <-ch,且无额外退出信号,goroutine 将永久挂起。
典型错误模式
func badExample(ch <-chan int, ctx context.Context) {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
// 忘记 close(ch) 或通知发送端停止!
return
}
}
⚠️ 问题:ctx.Done() 触发后,ch 可能持续有数据(或发送端未感知取消),接收 goroutine 在后续 <-ch 中无路可退。
正确协同策略
- 发送端需监听
ctx.Done()并主动关闭 channel - 接收端应在
ctx.Done()后立即退出循环,避免二次读取 - 使用
sync.Once或原子标志确保 channel 仅关闭一次
| 场景 | 是否安全 | 原因 |
|---|---|---|
| Context 超时 + channel 未关闭 | ❌ | 接收端永久阻塞 |
| Context 超时 + 发送端 close(ch) | ✅ | channel 关闭触发 zero value 返回 |
Context 超时 + 接收端 default 非阻塞读 |
⚠️ | 可能漏数据,需业务权衡 |
graph TD
A[启动 goroutine] --> B{Context 超时?}
B -->|是| C[触发 ctx.Done()]
B -->|否| D[正常读 ch]
C --> E[需显式关闭 ch 或退出]
E --> F[否则接收端 <-ch 永久阻塞]
2.5 中间件链中Context重复封装且cancel函数被意外丢弃的隐式泄漏
问题根源:嵌套WithCancel导致cancel丢失
当多个中间件连续调用 context.WithCancel(parent),若未显式传递并调用上游 cancel 函数,将造成父 context 的 cancel 能力被覆盖:
func middlewareA(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context()) // ✅ 创建新cancel
defer cancel() // ⚠️ 仅取消本层,不传播至上游
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
cancel()仅终止当前层 context,而r.Context()原始 cancel 函数未被调用,导致上游 timeout 或 deadline 无法生效,形成 goroutine 泄漏。
典型泄漏路径
| 场景 | 是否保留上游 cancel | 后果 |
|---|---|---|
ctx, _ := context.WithCancel(r.Context()) |
❌ 丢弃返回值 | 父 cancel 不可触发 |
ctx := context.WithValue(...) |
✅ 无 cancel 操作 | 安全但无取消能力 |
修复模式:透传与组合
func safeMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
// 关键:defer 中同时调用本层 + 上游 cancel(需从 context.Value 提取)
defer func() {
cancel()
if parentCancel, ok := r.Context().Value("cancel").(func()); ok {
parentCancel()
}
}()
r = r.WithContext(context.WithValue(ctx, "cancel", cancel))
next.ServeHTTP(w, r)
})
}
此方案通过
context.WithValue显式携带 cancel 函数,确保链式调用中取消信号可逐层回溯。
第三章:pprof火焰图精读方法论与泄漏定位实战
3.1 runtime/pprof与net/http/pprof协同采样策略设计
为避免采样冲突与资源争用,需统一调度 runtime/pprof(CPU/heap/goroutine)与 net/http/pprof(HTTP暴露接口)的采集节奏。
数据同步机制
使用 sync.Once 初始化共享采样控制器,并通过 pprof.SetProfileRate() 动态调节 CPU 采样频率:
var once sync.Once
func initProfiler() {
once.Do(func() {
pprof.SetProfileRate(100) // 每秒约100次CPU采样(纳秒级精度)
http.DefaultServeMux.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index))
})
}
SetProfileRate(100)表示每 10ms 触发一次 CPU 栈采样;值为 0 则禁用,负值启用精确模式(仅调试用)。
协同调度策略
| 维度 | runtime/pprof | net/http/pprof |
|---|---|---|
| 启动时机 | 程序启动即注册 | 需显式注册 HTTP 路由 |
| 采样粒度 | 全局、持续 | 按请求触发(如 /debug/pprof/profile?seconds=30) |
| 数据一致性 | 依赖 runtime 底层 |
读取 runtime 快照 |
graph TD
A[HTTP /debug/pprof/profile] --> B{是否已启用 runtime 采样?}
B -->|否| C[自动调用 pprof.StartCPUProfile]
B -->|是| D[复用当前 runtime profile]
C --> E[30s 后写入 response body]
3.2 火焰图中goroutine生命周期异常模式识别(含stacktrace语义标注)
常见异常模式特征
- goroutine泄漏:火焰图底部持续存在未收敛的调用栈,
runtime.gopark后无对应runtime.goready - 阻塞膨胀:同一函数(如
net/http.(*conn).serve)在多层堆栈中重复展开,宽度异常增长 - 空转自旋:
runtime.futex或sync.runtime_SemacquireMutex长时间占据顶部,无业务逻辑下沉
stacktrace语义标注示例
// go tool pprof -http :8080 cpu.pprof 中截取的典型泄漏栈
main.startWorker
→ http.HandlerFunc.ServeHTTP // 标注:HTTP handler入口(语义标签:handler)
→ database.QueryContext // 标注:阻塞IO调用(语义标签:blocking-io)
→ runtime.gopark // 标注:goroutine挂起(语义标签:parked)
→ runtime.chanrecv1 // 标注:channel接收阻塞(语义标签:chan-block)
该栈表明goroutine在channel接收处永久挂起,未被唤醒——结合火焰图宽度持续不衰减,可判定为泄漏。
异常模式判定矩阵
| 模式类型 | 火焰图视觉特征 | 关键语义标签组合 |
|---|---|---|
| goroutine泄漏 | 底部宽幅稳定、无终止 | parked + chan-block + 无goready |
| Mutex争用 | 多路径汇聚于sync.(*Mutex).Lock |
mutex-lock + 高频重入深度 |
graph TD
A[火焰图采样] --> B{栈顶函数分析}
B -->|runtime.gopark| C[检查后续是否出现goready]
B -->|sync.Mutex.Lock| D[统计同一锁的并发深度]
C -->|缺失goready| E[标记为parked-leak]
D -->|深度>5| F[标记为mutex-contention]
3.3 基于goroutine profile与allocs profile交叉验证泄漏根因
当怀疑存在协程泄漏或内存持续增长时,单一 profile 往往难以定位根源。需协同分析 goroutine(运行中/阻塞态协程快照)与 allocs(累计堆分配记录)。
goroutine profile:识别“不死协程”
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2
该输出显示所有 goroutine 栈迹;重点关注 select 阻塞、chan receive 挂起、或无限 for {} 循环——这些是泄漏的典型信号。
allocs profile:追踪分配源头
go tool pprof -inuse_objects http://localhost:6060/debug/pprof/allocs
-inuse_objects 展示当前存活对象数,配合 top -cum 可定位高频分配且未释放的结构体(如 *http.Request、自定义 Session)。
交叉验证关键路径
| Profile | 关注指标 | 关联线索 |
|---|---|---|
| goroutine | runtime.gopark 栈 |
协程挂起在某 channel recv |
| allocs | new 调用位置 |
同一包内持续 new Session |
graph TD
A[goroutine profile] -->|发现100+阻塞在 userChan| B(定位 channel 消费端缺失)
C[allocs profile] -->|userChan 对应 Session 持续增长| B
B --> D[检查 consumer goroutine 是否 panic 后未 recover]
第四章:团队级Context治理落地实践与禁用清单执行指南
4.1 静态分析工具集成:go vet + custom linter检测Context misuse规则集
Go 生态中,context.Context 的误用(如传入 nil、重复取消、goroutine 泄漏)是高频隐蔽缺陷。仅依赖运行时日志难以捕获,需在编译前拦截。
检测规则覆盖场景
context.WithCancel(nil)或WithTimeout(nil, ...)select中未包含ctx.Done()分支- 函数参数含
context.Context但未在函数体内调用ctx.Err()或ctx.Value()
go vet 扩展能力
go vet -vettool=$(which staticcheck) ./...
staticcheck提供SA1012(nil context passed toWith*)、SA1019(deprecated context funcs)等内置检查;需配合-vettool启用自定义规则链。
自定义 linter 规则示例(golint + nolint)
//nolint:contextcheck // false positive in test setup
func badHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select { // ❌ 缺少 ctx.Done() case → 可能 goroutine 泄漏
case <-time.After(5 * time.Second):
w.Write([]byte("done"))
}
}
此代码触发自定义
contextcheck规则:select块中未监听ctx.Done(),静态分析器通过 AST 遍历ast.SelectStmt节点并校验case子句是否含ctx.Done()调用。
规则集成流水线
| 工具 | 检测能力 | 集成方式 |
|---|---|---|
go vet |
基础 nil context 传递 | go vet 原生支持 |
staticcheck |
上下文生命周期警告 | -vettool 插件模式 |
revive |
可扩展 Context misuse 规则集 | .revive.toml 自定义 |
graph TD
A[源码] --> B[go vet]
A --> C[staticcheck]
A --> D[revive + contextcheck]
B --> E[报告 nil context]
C --> F[报告过期 API]
D --> G[报告 select 缺失 Done]
E & F & G --> H[CI 拦截]
4.2 单元测试强制覆盖:Context cancel路径的覆盖率门禁与panic注入验证
覆盖率门禁配置
在 CI 流程中启用 go test -covermode=count -coverprofile=coverage.out,结合 gocov 与阈值校验脚本,对 context.CancelFunc 调用路径实施 ≥95% 分支覆盖硬性拦截。
panic 注入验证模式
通过 testhelper.WithPanicCapture() 拦截预期 panic,确保 cancel 后续操作(如 select 中 case <-ctx.Done():)不引发未处理 panic:
func TestHTTPHandler_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 触发 Done channel 关闭
go func() { time.Sleep(10 * time.Millisecond); cancel() }()
// 模拟 handler 中阻塞等待
done := make(chan error, 1)
go func() { done <- handleRequest(ctx) }()
select {
case err := <-done:
if errors.Is(err, context.Canceled) {
return // ✅ 预期路径
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout: context cancel not propagated")
}
}
逻辑分析:该测试显式触发
cancel()并并发执行 handler,验证ctx.Done()可被及时接收;errors.Is(err, context.Canceled)确保错误语义正确,而非底层 panic 泄漏。
关键覆盖维度对比
| 路径类型 | 是否必须覆盖 | 验证方式 |
|---|---|---|
| 正常 cancel 传播 | 是 | ctx.Err() == context.Canceled |
| cancel 后 defer 执行 | 是 | t.Cleanup() 断言日志 |
| panic 注入场景 | 是 | recover() 捕获 + 断言 |
graph TD
A[启动测试] --> B[创建 cancellable context]
B --> C[并发 goroutine 触发 cancel]
C --> D[主流程 select <-ctx.Done()]
D --> E{是否收到 context.Canceled?}
E -->|是| F[通过]
E -->|否| G[失败并超时]
4.3 CI/CD流水线中pprof自动化比对:泄漏回归检测阈值配置与告警机制
在CI/CD阶段集成pprof比对,需将基准Profile(如主干构建)与待测Profile(PR构建)自动拉取、标准化并量化差异。
阈值策略配置
heap_alloc_delta_ratio > 0.15:分配量增长超15%触发预警goroutine_count_delta > 50:协程数净增超50个即标记可疑profile_duration_sec < 30:采样时长不足30秒则跳过比对(防噪声)
自动化比对脚本核心逻辑
# pprof-diff.sh —— 流水线内嵌比对入口
pprof --proto "$BASE_HEAP" "$CANDIDATE_HEAP" | \
go tool pprof --text -diff_base "$BASE_HEAP" "$CANDIDATE_HEAP" | \
awk '$1 ~ /^heap/ && $3 > 0.15 { print "ALERT: alloc delta", $3 }'
该命令链完成:二进制Profile差分 → 提取heap指标 → 按相对增量过滤。-diff_base确保以基准为分母,$3对应delta/old列,保障阈值语义一致。
告警路由表
| 触发条件 | 通知渠道 | 升级策略 |
|---|---|---|
| 单次超标且Δ>0.25 | Slack #perf | @owner + 自动阻断合并 |
| 连续2次Δ>0.15 | 创建GitHub Issue |
graph TD
A[CI Job] --> B[Fetch base.pprof from latest main]
B --> C[Run pprof --http=:8080 candidate.pprof]
C --> D{Delta > threshold?}
D -->|Yes| E[Post to AlertManager]
D -->|No| F[Pass]
4.4 生产环境Context健康度监控:基于expvar暴露goroutine上下文状态指标
在高并发微服务中,Context泄漏常导致goroutine堆积与内存持续增长。expvar提供轻量级运行时指标导出能力,无需引入第三方依赖即可暴露关键上下文健康信号。
核心监控维度
context_active:当前活跃的context.WithCancel/Timeout/Deadline数量context_cancelled:已触发取消的Context总数(累计)context_deadline_exceeded:因超时被终止的Context计数
指标注册示例
import "expvar"
var (
activeCtx = expvar.NewInt("context_active")
cancelled = expvar.NewInt("context_cancelled")
)
// 在 context.WithCancel() 后递增 activeCtx
// 在 <-ctx.Done() 触发时递减 activeCtx 并递增 cancelled
该代码通过expvar.Int原子操作实现线程安全计数;activeCtx实时反映潜在泄漏风险,cancelled辅助判断超时策略合理性。
指标语义对照表
| 指标名 | 类型 | 业务含义 | 告警阈值建议 |
|---|---|---|---|
context_active |
int64 | 未完成的Context生命周期数 | > 1000(视QPS动态调整) |
context_deadline_exceeded |
int64 | 超时终止次数(每分钟) | > 5% 请求总量 |
graph TD
A[HTTP Handler] --> B[context.WithTimeout]
B --> C[启动goroutine]
C --> D{ctx.Done()?}
D -->|是| E[decr activeCtx, incr cancelled]
D -->|否| F[继续执行]
第五章:从Context泄漏到Go运行时可观测性体系的演进思考
Context泄漏的真实代价
2023年某支付网关服务在大促期间出现持续内存增长,pprof heap profile 显示 runtime.goroutineProfile 中堆积了超12万 goroutine,其中 87% 持有已超时的 context.WithTimeout 实例。深入追踪发现,一个被 http.HandlerFunc 启动的异步日志上报协程未正确监听 ctx.Done(),导致其携带的 *http.Request(含原始 body buffer)长期驻留堆中。修复后 GC pause 时间从平均 42ms 降至 3.1ms。
Go 1.21 运行时指标的生产级接入
Go 1.21 引入 runtime/metrics 包暴露 127 个细粒度指标,我们通过以下方式集成至 Prometheus 生态:
import "runtime/metrics"
func collectGoMetrics() {
samples := []metrics.Sample{
{Name: "/gc/heap/allocs:bytes"},
{Name: "/gc/heap/frees:bytes"},
{Name: "/sched/goroutines:goroutines"},
{Name: "/mem/heap/allocs:bytes"},
}
metrics.Read(samples)
// 推送至 OpenTelemetry SDK
}
关键指标采集间隔设为 5s,避免高频调用影响调度器性能。
运行时可观测性分层架构
| 层级 | 观测目标 | 工具链 | 数据采样率 |
|---|---|---|---|
| 应用层 | HTTP 延迟、错误码分布 | OpenTelemetry SDK | 100% trace, 1% span |
| 运行时层 | Goroutine 状态、GC 周期、P 数量 | runtime/metrics + pprof | 100% metrics, 1/60s pprof |
| 内核层 | 网络连接数、页错误、CPU steal | eBPF (bcc) | 1s 轮询 |
该架构在 32c64g 容器中实测 CPU 开销
从 pprof 到 trace 的诊断路径演进
过去排查慢请求需三步串联:net/http/pprof 抓取 CPU profile → go tool pprof 分析热点函数 → 手动关联日志时间戳。现采用统一 trace ID 注入机制:
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
span := otel.Tracer("api").Start(ctx, "http_handler")
defer span.End()
// 将 trace_id 注入 runtime.GC() 的 label 中(实验性)
runtime.SetFinalizer(&struct{ traceID string }{span.SpanContext().TraceID().String()},
func(_ *struct{ traceID string }) {})
next.ServeHTTP(w, r.WithContext(span.Context()))
})
}
可观测性数据驱动的 GC 调优案例
某实时风控服务在启用 -gcflags="-m=2" 编译后发现 sync.Pool 对象逃逸严重。结合 /gc/heap/allocs:bytes 指标与 GODEBUG=gctrace=1 日志,定位到 json.Unmarshal 创建临时 map 导致高频分配。改用预分配 map[string]interface{} 并复用 sync.Pool 后,每秒 GC 次数从 8.3 次降至 0.9 次,young gen 分配速率下降 64%。
运行时指标与业务 SLI 的耦合建模
我们将 /sched/goroutines:goroutines 与订单创建成功率建立回归模型:当 goroutine 数 > 8500 且持续 30s,预测成功率下降概率达 92.7%(基于 6 周线上数据训练)。该模型已嵌入自动扩缩容决策引擎,触发扩容延迟
Context 生命周期的可视化验证
使用 go tool trace 生成的 trace 文件中,通过自定义解析器提取所有 context.WithCancel 调用栈及对应 ctx.Done() 关闭事件,生成状态机图:
graph LR
A[WithCancel] --> B[Ctx active]
B --> C{Done called?}
C -->|Yes| D[Ctx closed]
C -->|No| E[Leaked]
D --> F[All children cancelled]
E --> G[Heap retained]
在线上集群扫描中,发现 3.2% 的 context 实例存活超 15 分钟且无 Done 监听,全部归因于第三方 SDK 的回调注册缺陷。
