很多 Go 新手第一次看到 context.Context 时,会觉得它有点抽象:
func QueryUser(ctx context.Context, userID int64) error
这个 ctx 到底是干嘛的?为什么很多函数都把它放在第一个参数?为什么创建了 cancel,调用的时候又不用传参?
一句话先说清楚:
context是 Go 里用来控制一次任务生命周期的工具。
它通常用来做三件事:
- 取消任务
- 控制超时或截止时间
- 在请求链路中携带少量请求级别的数据
官方文档对 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()
那么 ctx1、ctx2、ctx3 都会被取消。
除了 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()
}
这就有问题。
因为一旦进入第一个 case,select 就结束了。如果 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 也提供了 QueryContext、ExecContext、QueryRowContext 等方法。参考: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/http 的 Request.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 的核心价值:让一次请求或任务,从入口到最底层调用,都能被统一地控制生命周期。