4. context 进阶特性

这一篇把视线拉到底层和边缘:context 接口的内部结构、生产环境最常见的优雅关机场景、Go 1.21 引入的两个新 API,以及怎么避免 goroutine 泄漏。

Context 接口的四个方法

context.Context 是一个极简接口:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

四个方法的语义:

方法返回说明
Deadline()时间 + boolok=false 表示没设置截止时间
Done()只读 channel取消时关闭,未取消时读阻塞
Err()errornil = 未取消;Canceled / DeadlineExceeded = 已取消
Value(key)anynil = 不存在

Background() 的四项返回值都是”空”:

bg := context.Background()
deadline, ok := bg.Deadline() // zero, false
done := bg.Done()             // nil(永远不关闭)
err := bg.Err()               // nil
val := bg.Value("any")        // nil

实际开发里几乎不会自己实现这个接口。标准库提供了 emptyCtxcancelCtxtimerCtxvalueCtx 四个实现,覆盖所有场景。理解它们的存在主要为了看懂源码和文档。

内部类型一览

emptyCtx   - Background 和 TODO 的类型,无 deadline / cancel / value
cancelCtx  - WithCancel 返回的类型,有 cancel + children 列表
timerCtx   - WithTimeout/WithDeadline 返回,内嵌 cancelCtx + timer
valueCtx   - WithValue 返回,存 key-value + 指向 parent

层级关系timerCtx 内嵌 cancelCtxcancelCtxvalueCtx 都内嵌 Context(父 context)。

取消传播的实现:每个 cancelCtx 维护一个 children 列表,记录自己派生的子 cancelCtx。cancel() 时遍历 children 列表,依次取消所有子。这就是”父取消 → 子也被取消”的底层机制。

parent, _ := context.WithCancel(context.Background())
child1, _ := context.WithCancel(parent)
child2, _ := context.WithCancel(parent)
// parent 内部的 children 列表里有 child1 和 child2
// parent cancel 时,会遍历 children,把 child1、child2 都取消

signal.NotifyContext:优雅关机

signal.NotifyContext(Go 1.16+)把系统信号变成 context 的取消信号。生产环境的标准用法

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

go runServer(ctx)

<-ctx.Done() // 阻塞,直到收到 Ctrl+C 或 kill

收到 SIGINT/SIGTERM 时,ctx.Done() 关闭,所有派生的 goroutine 自动收到取消信号。

完整的优雅关机模式

HTTP 服务器优雅关机的标准结构:

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{Addr: ":8080"}
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    <-ctx.Done() // 等待信号
    log.Println("收到信号,开始关机...")

    // 给关机本身一个超时,防止卡住
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("关机超时: %v", err)
    }
    log.Println("服务器已退出")
}

关键点:

  1. signal.NotifyContext 监听信号,自动取消 context
  2. 关机本身也用 WithTimeout 限制时间,防止某些请求无限拖延
  3. srv.Shutdown(ctx) 是优雅关机:停止接受新连接,等待已有连接处理完毕

AfterFunc:取消后的回调(Go 1.21+)

context.AfterFunc(ctx, fn) 注册一个回调,ctx 被取消后,fn 在新 goroutine 中执行一次

ctx, cancel := context.WithCancel(context.Background())

done := make(chan struct{})
context.AfterFunc(ctx, func() {
    fmt.Println("context 已取消,执行清理")
    close(done)
})

cancel()      // 触发回调
<-done        // 等待回调完成

取消注册

AfterFunc 返回一个 stop 函数,调用它可以取消注册,阻止回调执行:

stop := context.AfterFunc(ctx, func() {
    fmt.Println("不应该看到这行")
})

stop() // 取消注册
cancel()
// 回调不会被调用

相比传统写法

传统写法需要额外一个 goroutine 监听 Done()

// 旧写法:需要额外 goroutine
go func() {
    <-ctx.Done()
    // 清理逻辑
}()

// 新写法:AfterFunc 更轻量
context.AfterFunc(ctx, func() {
    // 清理逻辑
})

AfterFunc 的四个优势:

  1. 不需要显式启动 goroutine
  2. 返回 stop 可以取消注册
  3. 回调保证只执行一次
  4. 如果 ctx 已经取消,回调立即在新 goroutine 执行

WithoutCancel:脱离父的取消(Go 1.21+)

context.WithoutCancel(parent) 返回一个不会被父取消影响的子 context,但继承父的所有 Value

parent, cancel := context.WithCancel(context.Background())
child := context.WithoutCancel(parent)

cancel() // 取消父

fmt.Println(parent.Err()) // canceled
fmt.Println(child.Err())  // nil —— 不受影响

应用场景:请求后的后台任务

请求已经返回了,但需要做一些”发后即忘”的操作(比如埋点、异步发邮件、清理)。这些任务不应该因为请求 context 结束而被取消

func handler(ctx context.Context) {
    // 业务逻辑...

    // 启动后台任务,使用脱离请求的 context
    bgCtx := context.WithoutCancel(ctx)
    go func(ctx context.Context) {
        traceID := ctx.Value("trace_id") // 仍然能拿到请求的值
        log.Printf("异步任务开始, trace=%s", traceID)
        time.Sleep(5 * time.Second)
        log.Println("异步任务完成")
    }(bgCtx)

    // handler 返回后,原 ctx 会被取消,但后台任务继续
}

// 如果后台任务也需要超时,再包一层
bgCtxWithTimeout, cancel := context.WithTimeout(bgCtx, 30*time.Second)
defer cancel()

WithoutCancel 解决的是生命周期不匹配问题:请求 context 是秒级,后台任务是分钟级,强行用同一 context 会导致后台任务被提前掐断。

goroutine 泄漏:三种典型模式

goroutine 泄漏是 Go 并发里最隐蔽的 bug:goroutine 永远阻塞,无法退出,内存随时间持续上涨。几乎所有的泄漏都是因为缺了 select + ctx.Done()

模式一:不监听 ctx.Done()

// ❌ 泄漏:channel 没人发数据,goroutine 永久阻塞
go func() {
    val := <-ch // 永远等不到
}()

// ✅ 修复:select 加上取消分支
go func() {
    select {
    case val := <-ch:
        fmt.Println(val)
    case <-ctx.Done():
        return
    }
}()

模式二:忘记 cancel

// ❌ 泄漏:每次创建都分配内部资源,不调用 cancel 不释放
for _, item := range items {
    ctx, _ := context.WithCancel(parent) // 丢弃了 cancel!
    process(ctx, item)
}

// ✅ 修复:循环里显式 cancel,或抽成函数走 defer
for _, item := range items {
    func() {
        ctx, cancel := context.WithCancel(parent)
        defer cancel()
        process(ctx, item)
    }()
}

模式三:channel 发送无 select

// ❌ 泄漏:无人接收时发送永久阻塞
go func() {
    ch <- "data" // 无人接收,永久阻塞
}()

// ✅ 修复:select 加取消分支
go func() {
    select {
    case ch <- "data":
    case <-ctx.Done():
        return
    }
}()

正确的完整模式

写一个不会泄漏的并发 worker:

func doWork(ctx context.Context, id int) <-chan string {
    out := make(chan string, 1) // ✅ buffer 或保证有接收方
    go func() {
        defer close(out) // ✅ 始终关闭输出 channel
        select {
        case <-time.After(100 * time.Millisecond):
            out <- fmt.Sprintf("worker-%d 完成", id)
        case <-ctx.Done(): // ✅ 监听取消
            fmt.Printf("worker-%d: 被取消\n", id)
            return
        }
    }()
    return out
}

三条铁律:

  1. 每个 goroutine 都要有 select + ctx.Done() 的退出路径
  2. 每个 channel 写入都要 select,避免无接收方时永久阻塞
  3. defer cancel() + defer close(out) 是避免泄漏的两道保险

小结

这一篇的几个点都偏向生产环境:

  • signal.NotifyContext 是服务优雅关机的标准入口
  • AfterFunc 替代”goroutine + Done()“的传统监听写法,更轻量
  • WithoutCancel 解决请求 context 与后台任务的生命周期不匹配
  • goroutine 泄漏几乎都是缺 select + ctx.Done() 导致的