Go context 使用

在 Go 项目中,你经常会看到这样的函数签名:

func QueryUser(ctx context.Context, userID int64) (*User, error)

这里的 ctx 就是 context.Context,通常简称为 ctx

context 是 Go 标准库中非常重要的一个包,主要用于在多个 goroutine、函数调用链、网络请求、数据库操作之间传递:

  • 取消信号
  • 超时时间
  • 截止时间
  • 请求级别的数据

它常见于 Web 服务、RPC、数据库查询、微服务调用、后台任务等场景。


一、为什么需要 context

假设你有一个 HTTP 接口,请求进来后会:

  1. 查询数据库
  2. 调用第三方接口
  3. 启动一些 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

WithDeadlineWithTimeout 类似,不过它指定的是一个具体的截止时间。

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()

那么 ctx1ctx2 都会被取消。

但是如果只取消 ctx2

cancel2()

不会影响 parentctx1


八、在函数中传递 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_idrequest_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

二十、最佳实践总结

日常开发中,可以遵循这些规则:

  1. context.Context 通常作为函数的第一个参数。
  2. 不要传递 nil context。
  3. 不要把 context 存进结构体,优先通过函数参数传递。
  4. 使用 WithTimeoutWithDeadline 时,记得调用 cancel()
  5. goroutine 中要监听 ctx.Done(),避免 goroutine 泄漏。
  6. 不要用 context.Value 传递业务参数。
  7. context.Value 只适合存放请求级别的元信息。
  8. 父 context 取消后,子 context 也会被取消。
  9. 数据库、HTTP、RPC 等 I/O 操作应优先使用支持 context 的 API。
  10. 如果需要排查取消原因,可以考虑使用 context.WithCancelCause

结语

context 是 Go 并发编程和服务端开发中非常核心的工具。

它的核心作用不是“传参数”,而是控制调用链的生命周期。

你可以把它理解为一次请求或一次任务的“控制信号”,它会随着函数调用不断向下传递,让每一层代码都能知道:

  • 当前任务是否已经取消
  • 当前任务是否已经超时
  • 是否应该尽快释放资源并退出

掌握 context 之后,你写出的 Go 服务会更加健壮,也更容易处理超时、取消、资源释放和 goroutine 生命周期管理等问题。

曼波