Go context 使用
在 Go 项目中,你经常会看到这样的函数签名:
func QueryUser(ctx context.Context, userID int64) (*User, error)
这里的 ctx 就是 context.Context,通常简称为 ctx。
context 是 Go 标准库中非常重要的一个包,主要用于在多个 goroutine、函数调用链、网络请求、数据库操作之间传递:
- 取消信号
- 超时时间
- 截止时间
- 请求级别的数据
它常见于 Web 服务、RPC、数据库查询、微服务调用、后台任务等场景。
一、为什么需要 context?
假设你有一个 HTTP 接口,请求进来后会:
- 查询数据库
- 调用第三方接口
- 启动一些 goroutine 做并发处理
如果客户端中途断开连接,或者请求超过了 3 秒还没处理完,我们应该停止后续操作,否则会浪费资源。
没有 context 时,子函数和 goroutine 很难知道:
- 请求是否已经取消
- 是否已经超时
- 是否应该停止当前任务
context 就是用来解决这类问题的。
二、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,当 context 被取消或超时时会关闭 |
Err() | 返回 context 被取消的原因 |
Value() | 获取 context 中携带的值 |
日常开发中,我们通常不会自己实现这个接口,而是使用标准库提供的方法创建和派生 context。
三、创建根 Context
最常用的两个根 context 是:
context.Background()
context.TODO()
context.Background()
通常用于程序入口、主函数、初始化逻辑中。
ctx := context.Background()
context.TODO()
当你还不确定应该使用哪个 context,或者代码暂时还没改造完成时,可以使用:
ctx := context.TODO()
一般来说,正式业务代码中更推荐使用 context.Background() 或从上层传入的 ctx。
四、使用 context.WithCancel
WithCancel 可以创建一个可手动取消的 context。
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
完整示例:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker stopped:", ctx.Err())
return
default:
fmt.Println("working...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
time.Sleep(2 * time.Second)
cancel()
time.Sleep(time.Second)
}
输出类似:
working...
working...
working...
working...
worker stopped: context canceled
这里的关键点是:
case <-ctx.Done():
当调用 cancel() 后,ctx.Done() 会被关闭,goroutine 就能收到退出信号。
五、使用 context.WithTimeout
WithTimeout 用于设置超时时间。
例如,限制某个操作最多执行 2 秒:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
完整示例:
package main
import (
"context"
"fmt"
"time"
)
func slowOperation(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
fmt.Println("operation finished")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := slowOperation(ctx)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("success")
}
输出:
error: context deadline exceeded
因为 slowOperation 需要 3 秒,但 context 只允许执行 2 秒。
六、使用 context.WithDeadline
WithDeadline 和 WithTimeout 类似,不过它指定的是一个具体的截止时间。
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
WithTimeout 更常用,因为大多数情况下我们关心的是“从现在开始最多执行多久”。
七、Context 的父子关系
context 是可以一层一层派生的。
parent := context.Background()
ctx1, cancel1 := context.WithCancel(parent)
ctx2, cancel2 := context.WithTimeout(ctx1, 3*time.Second)
它们之间有父子关系:
parent
└── ctx1
└── ctx2
如果父 context 被取消,所有子 context 都会被取消。
例如:
cancel1()
那么 ctx1 和 ctx2 都会被取消。
但是如果只取消 ctx2:
cancel2()
不会影响 parent 和 ctx1。
八、在函数中传递 Context
Go 中约定俗成的写法是:如果函数需要接收 context,应该把 ctx 作为第一个参数。
推荐:
func GetUser(ctx context.Context, userID int64) (*User, error) {
// ...
}
不推荐:
func GetUser(userID int64, ctx context.Context) (*User, error) {
// ...
}
调用链一般是这样的:
func Handler(ctx context.Context) error {
user, err := GetUser(ctx, 1001)
if err != nil {
return err
}
return SendMessage(ctx, user)
}
这样上层的取消信号、超时时间就可以自然传递到下层。
九、HTTP 服务中的 Context
在 Go 的 net/http 中,每个请求都自带一个 context:
ctx := r.Context()
当客户端断开连接、请求超时或服务端取消请求时,这个 context 会被取消。
示例:
package main
import (
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-time.After(3 * time.Second):
fmt.Fprintln(w, "request finished")
case <-ctx.Done():
fmt.Println("request canceled:", ctx.Err())
}
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}
如果客户端在 3 秒内断开连接,服务端可以通过 ctx.Done() 感知到取消信号。
十、HTTP 客户端请求中使用 Context
当我们调用外部接口时,也应该绑定 context,避免请求长时间卡住。
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
if err != nil {
panic(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("request error:", err)
return
}
defer resp.Body.Close()
fmt.Println("status:", resp.Status)
}
这样,如果请求超过 2 秒还没有完成,就会自动取消。
十一、数据库操作中使用 Context
Go 的 database/sql 包支持带 context 的方法,例如:
db.QueryContext()
db.QueryRowContext()
db.ExecContext()
示例:
func GetUserName(ctx context.Context, db *sql.DB, userID int64) (string, error) {
var name string
err := db.QueryRowContext(
ctx,
"SELECT name FROM users WHERE id = ?",
userID,
).Scan(&name)
if err != nil {
return "", err
}
return name, nil
}
调用时可以设置超时:
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
name, err := GetUserName(ctx, db, 1001)
如果数据库查询超过 500 毫秒,context 会触发超时取消。
十二、使用 context.WithValue
WithValue 可以在 context 中存储请求级别的数据。
例如,在 Web 服务中传递 trace_id、request_id、用户身份信息等。
ctx := context.WithValue(context.Background(), "request_id", "abc-123")
然后可以这样取出:
requestID := ctx.Value("request_id")
不过,这种写法不推荐直接使用字符串作为 key,因为容易冲突。
更推荐定义自己的 key 类型:
type contextKey string
const requestIDKey contextKey = "request_id"
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
func GetRequestID(ctx context.Context) string {
value := ctx.Value(requestIDKey)
if value == nil {
return ""
}
requestID, ok := value.(string)
if !ok {
return ""
}
return requestID
}
使用:
ctx := WithRequestID(context.Background(), "abc-123")
fmt.Println(GetRequestID(ctx))
十三、WithValue 的使用边界
虽然 WithValue 很方便,但不要滥用。
适合放入 context 的数据:
- request id
- trace id
- 用户认证信息
- 链路追踪信息
- 日志字段
不适合放入 context 的数据:
- 函数可选参数
- 业务参数
- 数据库连接
- 配置对象
- 大型结构体
- 本应该显式传递的依赖
不推荐:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "limit", 10)
ctx = context.WithValue(ctx, "userID", userID)
更推荐:
func ListOrders(ctx context.Context, userID int64, limit int) ([]Order, error) {
// ...
}
业务参数应该通过函数参数显式传递,而不是塞进 context。
十四、配合 goroutine 使用 Context
当启动 goroutine 时,最好把 context 传进去,让 goroutine 可以及时退出。
func startWorker(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("worker exit:", ctx.Err())
return
default:
doSomething()
time.Sleep(time.Second)
}
}
}()
}
也可以写成定时任务形式:
func startTicker(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("ticker stopped")
return
case <-ticker.C:
fmt.Println("tick")
}
}
}
注意:
defer ticker.Stop()
不要忘记释放 ticker 资源。
十五、优雅关闭中的 Context
在服务关闭时,可以使用 context 控制超时时间。
例如 HTTP 服务优雅关闭:
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{
Addr: ":8080",
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("server shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Println("server shutdown error:", err)
return
}
fmt.Println("server stopped")
}
这里的含义是:
- 收到退出信号
- 给服务最多 5 秒时间处理已有请求
- 超过 5 秒后强制结束
十六、context.Cause 和取消原因
在较新的 Go 版本中,可以使用 context.WithCancelCause 给取消操作附加具体原因。
示例:
package main
import (
"context"
"errors"
"fmt"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("user permission changed"))
fmt.Println(ctx.Err())
fmt.Println(context.Cause(ctx))
}
输出类似:
context canceled
user permission changed
区别是:
ctx.Err()
只会告诉你 context 是被取消了,还是超时了。
而:
context.Cause(ctx)
可以拿到更具体的取消原因。
这在复杂系统中排查问题很有用。
十七、常见错误用法
1. 忘记调用 cancel
不推荐:
ctx, _ := context.WithTimeout(context.Background(), time.Second)
推荐:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
即使 context 最终会超时,也建议调用 cancel(),这样可以及时释放相关资源。
2. 传递 nil context
不推荐:
DoSomething(nil)
推荐:
DoSomething(context.Background())
如果你暂时不知道传什么,可以使用:
context.TODO()
3. 把 context 存到结构体中
不推荐:
type Service struct {
ctx context.Context
}
推荐:
type Service struct {
repo UserRepository
}
func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
return s.repo.GetUser(ctx, id)
}
通常情况下,context 应该作为函数参数传递,而不是存储在结构体中。
4. 用 context 传业务参数
不推荐:
ctx = context.WithValue(ctx, "page", 1)
ctx = context.WithValue(ctx, "size", 20)
推荐:
func ListUsers(ctx context.Context, page int, size int) ([]User, error) {
// ...
}
5. goroutine 不监听 ctx.Done()
不推荐:
go func() {
for {
doSomething()
}
}()
推荐:
go func() {
for {
select {
case <-ctx.Done():
return
default:
doSomething()
}
}
}()
否则 goroutine 可能会泄漏。
十八、推荐的函数签名风格
如果函数可能涉及以下操作,就建议接收 context.Context:
- 网络请求
- 数据库查询
- 文件或对象存储访问
- RPC 调用
- goroutine 协作
- 长时间运行任务
- 可能需要取消的操作
推荐写法:
func DoSomething(ctx context.Context, arg string) error {
// ...
}
如果函数只是简单的纯计算,一般不需要 context:
func Add(a, b int) int {
return a + b
}
十九、实战示例:带超时的用户查询
下面是一个比较完整的例子。
package main
import (
"context"
"errors"
"fmt"
"time"
)
type User struct {
ID int64
Name string
}
func QueryUser(ctx context.Context, userID int64) (*User, error) {
select {
case <-time.After(2 * time.Second):
return &User{
ID: userID,
Name: "Tom",
}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func GetUserProfile(ctx context.Context, userID int64) (*User, error) {
user, err := QueryUser(ctx, userID)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("query user timeout: %w", err)
}
if errors.Is(err, context.Canceled) {
return nil, fmt.Errorf("query user canceled: %w", err)
}
return nil, err
}
return user, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
user, err := GetUserProfile(ctx, 1001)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("user: %+v\n", user)
}
因为查询需要 2 秒,但超时时间只有 1 秒,所以输出类似:
error: query user timeout: context deadline exceeded
二十、最佳实践总结
日常开发中,可以遵循这些规则:
context.Context通常作为函数的第一个参数。- 不要传递
nilcontext。 - 不要把 context 存进结构体,优先通过函数参数传递。
- 使用
WithTimeout或WithDeadline时,记得调用cancel()。 - goroutine 中要监听
ctx.Done(),避免 goroutine 泄漏。 - 不要用
context.Value传递业务参数。 context.Value只适合存放请求级别的元信息。- 父 context 取消后,子 context 也会被取消。
- 数据库、HTTP、RPC 等 I/O 操作应优先使用支持 context 的 API。
- 如果需要排查取消原因,可以考虑使用
context.WithCancelCause。
结语
context 是 Go 并发编程和服务端开发中非常核心的工具。
它的核心作用不是“传参数”,而是控制调用链的生命周期。
你可以把它理解为一次请求或一次任务的“控制信号”,它会随着函数调用不断向下传递,让每一层代码都能知道:
- 当前任务是否已经取消
- 当前任务是否已经超时
- 是否应该尽快释放资源并退出
掌握 context 之后,你写出的 Go 服务会更加健壮,也更容易处理超时、取消、资源释放和 goroutine 生命周期管理等问题。