Go context 入门:从取消、超时到请求链路传值

很多 Go 新手第一次看到 context.Context 时,会觉得它有点抽象:

复制代码
func QueryUser(ctx context.Context, userID int64) error

这个 ctx 到底是干嘛的?为什么很多函数都把它放在第一个参数?为什么创建了 cancel,调用的时候又不用传参?

一句话先说清楚:

context 是 Go 里用来控制一次任务生命周期的工具。

它通常用来做三件事:

  1. 取消任务
  2. 控制超时或截止时间
  3. 在请求链路中携带少量请求级别的数据

官方文档对 context 的定义也很直接:它可以跨 API 边界、跨进程传递截止时间、取消信号和请求级别的值。参考 Go 官方文档:Package context、Go 官方博客:Go Concurrency Patterns: Context

一、为什么需要 context

假设你写了一个接口:

复制代码
前端请求 /api/order/detail
    -> Go 服务接收请求
        -> 查询 MySQL
        -> 查询 Redis
        -> 调用库存服务
        -> 返回结果

如果用户点了一下页面,然后立刻关闭浏览器,这个请求其实已经没人要结果了。

但是如果后端不知道这件事,它可能还在继续:

复制代码
继续查数据库
继续调远程接口
继续占用 goroutine
继续占用连接资源

这就浪费资源。

context 的作用就是把这种信号传下去:

复制代码
这个请求已经取消了,后面的活不用干了。

再比如,一个数据库查询最多只能等 2 秒。超过 2 秒就不要继续卡在那里。

这时也可以用 context

复制代码
这个任务最多执行 2 秒,超时就取消。

二、context.Context 是什么

context.Context 本质上是一个接口:

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

四个方法分别表示:

方法 作用
Deadline() 返回任务截止时间
Done() 返回一个 channel,取消或超时时会关闭
Err() 返回取消原因
Value() 获取 context 携带的值

先不用急着全部记住。新手阶段最常用的是:

复制代码
ctx.Done()
ctx.Err()

它们通常这样配合:

复制代码
select {
case <-ctx.Done():
	return ctx.Err()
}

意思是:

复制代码
如果 ctx 被取消或超时,就退出当前任务,并返回取消原因。

三、根 context:Background 是起点

最常见的写法是:

复制代码
ctx := context.Background()

Background() 返回的是一个最基础、最干净的 context。

它有几个特点:

复制代码
不会被取消
没有超时时间
没有携带任何值

所以它通常作为整条 context 链的起点,也就是"根 context"。

你可以把 context 想成一棵树:

复制代码
context.Background()
    |
    |-- context.WithCancel(...)
            |
            |-- context.WithTimeout(...)
                    |
                    |-- context.WithValue(...)

父 context 被取消后,子 context 也会被取消。

比如:

复制代码
root := context.Background()

ctx1, cancel1 := context.WithCancel(root)
ctx2, cancel2 := context.WithTimeout(ctx1, 2*time.Second)
ctx3 := context.WithValue(ctx2, requestIDKey, "req-001")

defer cancel1()
defer cancel2()

_ = ctx3

这里的关系是:

复制代码
root -> ctx1 -> ctx2 -> ctx3

如果调用:

复制代码
cancel1()

那么 ctx1ctx2ctx3 都会被取消。

除了 Background(),还有一个:

复制代码
context.TODO()

它也是一个空 context,但语义不同。

函数 语义
context.Background() 我明确知道这里是根 context
context.TODO() 我暂时不知道该传什么,先占位

所以正式的入口处常用:

复制代码
context.Background()

临时改老代码、不知道该怎么传时,可以先用:

复制代码
context.TODO()

四、WithCancel:手动取消任务

先看一个完整例子:

复制代码
package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker 收到取消信号:", ctx.Err())
			return
		default:
			fmt.Println("worker 正在工作")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go worker(ctx)

	time.Sleep(2 * time.Second)

	cancel()

	time.Sleep(1 * time.Second)
}

核心是:

复制代码
ctx, cancel := context.WithCancel(context.Background())

这句会创建一个可以取消的 context,同时返回一个取消函数。

你可能会疑惑:

复制代码
cancel()

这里没有传参数,它怎么知道要取消哪个 context?

因为 cancel 是和 ctx 绑定在一起返回的。你可以把它想成:

复制代码
func WithCancel(parent context.Context) (context.Context, func()) {
	ctx := 创建一个新的可取消 context

	cancel := func() {
		取消这个 ctx
	}

	return ctx, cancel
}

所以:

复制代码
ctx1, cancel1 := context.WithCancel(context.Background())
ctx2, cancel2 := context.WithCancel(context.Background())

关系是:

复制代码
cancel1 只取消 ctx1
cancel2 只取消 ctx2

cancel() 不需要传参,是因为它创建时已经记住自己要取消谁了。

五、WithTimeout:超时自动取消

WithTimeout 表示:

复制代码
这个任务最多执行多久,超时就取消。

例子:

复制代码
package main

import (
	"context"
	"fmt"
	"time"
)

func query(ctx context.Context) error {
	select {
	case <-time.After(3 * time.Second):
		fmt.Println("查询成功")
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	err := query(ctx)
	if err != nil {
		fmt.Println("查询失败:", err)
	}
}

输出大概是:

复制代码
查询失败: context deadline exceeded

因为:

复制代码
查询模拟需要 3 秒
context 只允许 2 秒
所以 2 秒后 ctx.Done() 先触发

这里要注意一个重要点:

复制代码
select {
case <-time.After(3 * time.Second):
case <-ctx.Done():
}

select 不是从上到下顺序执行。

它的规则是:

复制代码
同时等待多个 case,哪个先准备好,就执行哪个。

如果 ctx.Done() 先触发,就走取消逻辑。

如果 time.After 先到时间,就走成功逻辑。

六、真实数据库查询不要这样写

上面的 time.After 只是模拟"查询完成这个事件"。

真实数据库查询不是 sleep,它一般是一个函数调用:

复制代码
rows, err := db.Query("select * from users")

如果你这样写:

复制代码
select {
case <-time.After(1 * time.Second):
	rows, err := db.Query("select * from users")
	_ = rows
	_ = err
case <-ctx.Done():
	return ctx.Err()
}

这就有问题。

因为一旦进入第一个 caseselect 就结束了。如果 db.Query 在里面卡住,它不会再跳到第二个 case

正确写法是用支持 context 的数据库方法:

复制代码
func queryUser(ctx context.Context, db *sql.DB, userID int64) error {
	rows, err := db.QueryContext(
		ctx,
		"select id, name from users where id = ?",
		userID,
	)
	if err != nil {
		return err
	}
	defer rows.Close()

	for rows.Next() {
		var id int64
		var name string

		if err := rows.Scan(&id, &name); err != nil {
			return err
		}

		fmt.Println(id, name)
	}

	return rows.Err()
}

外层这样控制超时:

复制代码
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := queryUser(ctx, db, 1001)
if err != nil {
	fmt.Println("查询失败:", err)
}

重点是:

复制代码
db.QueryContext(ctx, ...)

它会把 ctx 传进 database/sql 和数据库驱动。这样当 ctx 超时时,驱动就有机会取消底层查询。

这比自己用 goroutine 包一层更稳。官方 database/sql 也提供了 QueryContextExecContextQueryRowContext 等方法。参考:database/sql QueryContext

七、为什么一定要 defer cancel

你经常会看到:

复制代码
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

问题来了:

复制代码
不是 1 秒后自动超时吗?为什么还要手动 cancel?

原因是:WithTimeout 内部会创建定时器等资源。

如果任务提前完成了,比如 100 毫秒就完成了,而你没有调用 cancel(),这个定时器可能还要等到 1 秒后才释放。

所以写:

复制代码
defer cancel()

表示:

复制代码
函数结束时,不管成功还是失败,都及时释放 context 相关资源。

它还有一个好处:通知所有使用这个 context 的子任务退出。

所以推荐把它当成固定模板:

复制代码
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()

err := doSomething(ctx)
if err != nil {
	return err
}

官方文档也提醒,调用 CancelFunc 会取消子 context、停止相关定时器并释放资源;不调用可能导致资源泄漏。

八、WithDeadline:指定截止时间

WithTimeout 是:

复制代码
从现在开始,最多执行多久。

WithDeadline 是:

复制代码
到某个具体时间点为止。

例如:

复制代码
deadline := time.Now().Add(2 * time.Second)

ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

select {
case <-time.After(3 * time.Second):
	fmt.Println("任务完成")
case <-ctx.Done():
	fmt.Println("任务取消:", ctx.Err())
}

这和下面写法效果接近:

复制代码
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

区别只是表达方式不同。

一般业务代码里 WithTimeout 更常用。

九、WithValue:传请求级别的数据

WithValue 可以在 context 里放值。

常见场景是传:

复制代码
request_id
trace_id
用户认证信息中的少量元数据

例子:

复制代码
package main

import (
	"context"
	"fmt"
)

type contextKey string

const requestIDKey contextKey = "request_id"

func logInfo(ctx context.Context, msg string) {
	requestID := ctx.Value(requestIDKey)
	fmt.Println("request_id:", requestID, "msg:", msg)
}

func main() {
	ctx := context.Background()

	ctx = context.WithValue(ctx, requestIDKey, "req-abc-123")

	logInfo(ctx, "用户登录成功")
}

输出:

复制代码
request_id: req-abc-123 msg: 用户登录成功

注意,key 不建议直接用字符串:

复制代码
ctx = context.WithValue(ctx, "request_id", "req-abc-123")

更推荐自定义 key 类型:

复制代码
type contextKey string

const requestIDKey contextKey = "request_id"

这样可以减少和其他包里的 key 冲突。

也要注意:不要把 context.Value 当成万能参数传递工具。

不推荐:

复制代码
ctx = context.WithValue(ctx, "page", 1)
ctx = context.WithValue(ctx, "page_size", 20)

更推荐:

复制代码
func ListUsers(ctx context.Context, page int, pageSize int) error {
	return nil
}

官方文档也强调,context 的 value 应该只用于跨 API、跨进程的请求级别数据,不应用来传普通可选参数。

十、HTTP 请求中的 context

在 Web 开发里,每个请求都会带 context:

复制代码
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	select {
	case <-time.After(3 * time.Second):
		_, _ = w.Write([]byte("处理完成"))
	case <-ctx.Done():
		fmt.Println("请求取消:", ctx.Err())
	}
}

如果客户端断开连接,r.Context() 可能会被取消。

真实项目里你通常会把它继续传下去:

复制代码
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	err := service.QueryOrder(ctx, 1001)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	_, _ = w.Write([]byte("ok"))
}

然后 service 层继续传给 dao 层:

复制代码
func (s *OrderService) QueryOrder(ctx context.Context, orderID int64) error {
	return s.dao.QueryOrder(ctx, orderID)
}

dao 层再传给数据库:

复制代码
func (d *OrderDAO) QueryOrder(ctx context.Context, orderID int64) error {
	row := d.db.QueryRowContext(
		ctx,
		"select id from orders where id = ?",
		orderID,
	)

	var id int64
	return row.Scan(&id)
}

这样就形成了一条链路:

复制代码
HTTP 请求 ctx
    -> service
        -> dao
            -> db.QueryContext

任何上游取消,下面都有机会停下来。net/httpRequest.Context 也属于标准库能力。参考:net/http Request.Context

十一、context 和 goroutine

context 经常用来防止 goroutine 泄漏。

比如有一个生产数字的 goroutine:

复制代码
func gen(ctx context.Context) <-chan int {
	ch := make(chan int)

	go func() {
		defer close(ch)

		n := 1
		for {
			select {
			case <-ctx.Done():
				return
			case ch <- n:
				n++
			}
		}
	}()

	return ch
}

使用:

复制代码
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	for n := range gen(ctx) {
		fmt.Println(n)
		if n == 5 {
			cancel()
			break
		}
	}
}

如果没有:

复制代码
case <-ctx.Done():
	return

那么调用方不再读取 channel 后,后台 goroutine 可能还会一直卡住,造成泄漏。

十二、不要用关闭 channel 代替 context

有些人会想:

复制代码
超时了我把 channel close 掉不就行了?

这很危险。

Go 里通常遵守一个规则:

复制代码
谁发送,谁关闭。

如果接收方超时后直接关闭 channel:

复制代码
close(ch)

后台 goroutine 后面又发送:

复制代码
ch <- result

就会 panic:

复制代码
panic: send on closed channel

关闭 channel 的含义是:

复制代码
告诉接收方:以后没有数据了。

而取消任务的含义是:

复制代码
告诉正在做事的人:可以停下来了。

这两个概念不一样。

取消任务应该用:

复制代码
context

十三、常见最佳实践

第一,context.Context 放在函数第一个参数:

复制代码
func DoSomething(ctx context.Context, id int64) error {
	return nil
}

第二,不要把 context 存进结构体:

复制代码
type Service struct {
	ctx context.Context // 不推荐
}

更推荐调用方法时传入:

复制代码
func (s *Service) Do(ctx context.Context) error {
	return nil
}

第三,不要传 nil:

复制代码
DoSomething(nil) // 不推荐

不知道传什么时:

复制代码
DoSomething(context.TODO())

第四,创建了带 cancel 的 context,要记得调用 cancel:

复制代码
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

第五,不要滥用 WithValue

普通业务参数直接作为函数参数传。

第六,真实数据库、HTTP、RPC 调用优先使用支持 context 的 API:

复制代码
db.QueryContext(ctx, ...)
http.NewRequestWithContext(ctx, ...)

十四、常用函数总结

函数 作用
context.Background() 创建根 context
context.TODO() 临时占位 context
context.WithCancel(parent) 创建可手动取消的 context
context.WithTimeout(parent, d) 创建超时自动取消的 context
context.WithDeadline(parent, t) 创建到指定时间取消的 context
context.WithValue(parent, key, val) 创建携带值的 context

常见错误原因:

错误值 含义
context.Canceled 被手动取消
context.DeadlineExceeded 超时或超过截止时间

十五、最后总结

context 不是用来保存一堆业务参数的,也不是用来强行杀死 goroutine 的。

它更像一次任务的控制信号:

复制代码
这次任务什么时候结束?
是否已经被取消?
有没有超时?
请求链路里有没有 request_id、trace_id 这种信息?

新手先记住三个核心用法就够了:

复制代码
context.WithCancel()  // 手动取消
context.WithTimeout() // 超时取消
context.WithValue()   // 携带请求级别的数据

再记住一个最重要的习惯:

复制代码
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()

当你看到这种函数签名:

复制代码
func QueryOrder(ctx context.Context, orderID int64) error

你就可以理解成:

复制代码
这个函数支持取消、支持超时,也可能会读取请求链路中的少量上下文信息。

这就是 Go 里 context 的核心价值:让一次请求或任务,从入口到最底层调用,都能被统一地控制生命周期。