Go Context 到底是什么?

文章来源声明: 原文作者:云浪; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687633622006185993; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把 Context 讲成调用链上的任务遥控器,形象易懂;适合 Go 开发者理解取消、超时和请求域传值的正确用法,尤其服务端与数据库调用场景。

Context 是 Go 中用来控制“一次任务生命周期”的工具。

可以用于取消任务、设置超时时间和在一次请求链路中传递一些上下文信息。

我们可以把 Context 想象成一个“遥控器”。

一个 HTTP 请求进来了:

用户请求
   │
   ▼
HTTP Handler
   │
   ├── 查询用户
   │
   ├── 查询订单
   │
   └── 调用 AI 服务

如果用户突然关闭页面,我们希望后面的工作也别干了,以免浪费系统资源。

这时候,Context 就可以按下取消按钮,停止后续的任务。

Context 的三个功能

学习 Context 时,记住这三个词:

Context
  │
  ├── Cancel      取消
  │
  ├── Deadline    截止时间
  │
  └── Value       上下文数据

这三个词对应 Context 的类型。

Cancel

告诉任务:

别干了。

Deadline / Timeout

告诉任务:

最多干这么久。

Value

告诉下游:

这是这次请求携带的一些上下文信息。

案例一:手动取消任务

我们来看一个取消任务的例子:

<span>package</span> main

<span>import</span> (
    <span>"context"</span>
    <span>"fmt"</span>
    <span>"time"</span>
)

<span><span>func</span> <span>work</span><span>(ctx context.Context)</span></span> {
    <span>for</span> {
        <span>select</span> {
        <span>case</span> <-ctx.Done():
            fmt.Println(<span>"任务被取消"</span>)
            <span>return</span>

        <span>default</span>:
            fmt.Println(<span>"正在工作..."</span>)
            time.Sleep(time.Second)
        }
    }
}

<span><span>func</span> <span>main</span><span>()</span></span> {
    ctx, cancel := context.WithCancel(context.Background())

    <span>go</span> work(ctx)

    time.Sleep(<span>3</span> * time.Second)

    cancel()

    time.Sleep(time.Second)
}

运行结果:

正在工作...
正在工作...
正在工作...
任务被取消

context.WithCancel() 创建一个带有取消功能的上下文。

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

ctx.Done() 会返回一个 Channel ,当 cancel() 被调用时,这个 Channel 就会被关闭,从而实现通知任务取消。

<span>select</span> {
<span>case</span> <-ctx.Done():
    <span>return</span>
}

案例二:设置任务超时时间

我们来看使用 Context 设置任务超时时间的例子

<span><span>func</span> <span>work</span><span>(ctx context.Context)</span></span> {
    <span>for</span> {
        <span>select</span> {
        <span>case</span> <-ctx.Done():
            fmt.Println(<span>"任务结束:"</span>, ctx.Err())
            <span>return</span>

        <span>default</span>:
            fmt.Println(<span>"正在工作..."</span>)
            time.Sleep(time.Second)
        }
    }
}

然后:

<span><span>func</span> <span>main</span><span>()</span></span> {
    ctx, cancel := context.WithTimeout(
        context.Background(),
        <span>3</span>*time.Second,
    )

    <span>defer</span> cancel()

    work(ctx)
}

大约 3 秒之后:

正在工作...
正在工作...
正在工作...
任务结束: context deadline exceeded

这里:

ctx.Err()

可以告诉我们 Context 为什么结束。

常见的结果包括:

context.Canceled

表示:

被主动取消了。

以及:

context.DeadlineExceeded

表示:

超时了。

案例三:传递上下文数据

我们来实现一个使用 Context 传递上下文数据的例子。

<span>package</span> main

<span>import</span> (
	<span>"context"</span>
	<span>"fmt"</span>
)

<span>type</span> ctxKey <span>string</span> <span>// 自定义 key 类型,避免和别人的 key 撞车</span>

<span>const</span> traceIDKey ctxKey = <span>"traceID"</span>

<span><span>func</span> <span>withTraceID</span><span>(ctx context.Context, id <span>string</span>)</span></span> context.Context {
	<span>return</span> context.WithValue(ctx, traceIDKey, id)
}

<span><span>func</span> <span>handle</span><span>(ctx context.Context)</span></span> {
	<span>if</span> id, ok := ctx.Value(traceIDKey).(<span>string</span>); ok {
		fmt.Println(<span>"traceID ="</span>, id)
	}
}

<span><span>func</span> <span>main</span><span>()</span></span> {
	ctx := withTraceID(context.Background(), <span>"req-42"</span>)
	handle(ctx) <span>// → traceID = req-42</span>
}


运行结果:

traceID = req-42

注意 Context.Value 不是什么都能放,比如业务参数 、大型对象和普通函数参数就不适合放在 Context.Value 中。

Context 的 Value 适合传递 trace ID 、鉴权相关的请求上下文等请求域等数据。

key 一定要用自定义类型,不要用裸 string

ctx = context.WithValue(ctx, <span>"traceID"</span>, id)   <span>// ✗ 别的包可能也用 "traceID"</span>
ctx = context.WithValue(ctx, traceIDKey, id)  <span>// ✓ 类型不导出,天然隔离</span>

取出来必须做类型断言,并且不要存指针指向可变对象——Context 是并发安全的,但它存的值本身不会帮你加锁:

v := ctx.Value(traceIDKey)
id, ok := v.(<span>string</span>) <span>// 一定要用带 ok 的形式,否则可能 panic</span>

Context 可以一层一层传递

Context 厉害的地方,是它可以沿着调用链一路传递。

例如一个 HTTP 请求:

HTTP Request
     │
     ▼
Handler
     │
     ▼
Service
     │
     ▼
Repository
     │
     ▼
Database

我们可以让 Context 一路传下去:

<span><span>func</span> <span>Handler</span><span>(ctx context.Context)</span></span> {
    user := GetUser(ctx)
}

<span><span>func</span> <span>GetUser</span><span>(ctx context.Context)</span></span> *User {
    <span>return</span> queryDatabase(ctx)
}

<span><span>func</span> <span>queryDatabase</span><span>(ctx context.Context)</span></span> *User {
    <span>// 使用 ctx 查询数据库</span>
}

于是:

Handler
   │
   │ ctx
   ▼
Service
   │
   │ ctx
   ▼
Repository
   │
   │ ctx
   ▼
Database

如果最上面的请求取消:

HTTP 请求取消
      │
      ▼
    ctx
      │
      ├── Handler
      │
      ├── Service
      │
      └── Database

下游都可以感知到,类似于“遥控器”。

Context 的“签名约定”

Go 社区强制约定:

<span><span>func</span> <span>DoSomething</span><span>(ctx context.Context, a <span>int</span>, b <span>string</span>)</span></span> <span>error</span> {
	<span>// ctx 永远是第一个参数,命名永远叫 ctx</span>
}

这样一眼就能看出来:

这个函数支持 Context 控制。

Context 最常见的场景:HTTP 服务

在写 Go Web 服务的时候,我们一般都会使用 Context。

例如:

<span><span>func</span> <span>handler</span><span>(w http.ResponseWriter, r *http.Request)</span></span> {
    ctx := r.Context()

    user, err := GetUser(ctx, <span>1001</span>)

    <span>if</span> err != <span>nil</span> {
        <span>return</span>
    }

    fmt.Fprintln(w, user)
}

这里:

r.Context()

就是当前 HTTP 请求对应的 Context。

你可以理解成:

浏览器
  │
  │ HTTP Request
  ▼
Go HTTP Server
  │
  ▼
Request Context
  │
  ├── Service
  ├── Database
  └── Third-party API

因此,在 Web 服务中:

HTTP 请求的 Context 通常是整条调用链的“总指挥”。

Context + 数据库

数据库操作同样支持 Context。

例如:

rows, err := db.QueryContext(
    ctx,
    <span>"SELECT * FROM users WHERE id = ?"</span>,
    userID,
)

这里:

QueryContext(...)

意味着:

这个数据库查询属于这个 Context。

如果 Context 被取消,数据库驱动就有机会终止这个查询。

所以实际项目中经常看到:

<span><span>func</span> <span>GetUser</span><span>(ctx context.Context, id <span>int</span>)</span></span> (*User, <span>error</span>) {
    <span>return</span> db.QueryContext(
        ctx,
        <span>"SELECT * FROM users WHERE id = ?"</span>,
        id,
    )
}

Context 是树结构

Context 还有一个非常重要的特点:

Context 是可以派生的。

例如:

root := context.Background()

ctx1, cancel1 := context.WithCancel(root)

ctx2, cancel2 := context.WithTimeout(
    ctx1,
    <span>5</span>*time.Second,
)

可以画成:

             root
              │
              ▼
            ctx1
              │
              ▼
            ctx2

如果:

cancel1()

那么:

ctx1 ❌
 │
 └── ctx2 ❌

父 Context 被取消:

子 Context 也会被取消。

但是反过来:

cancel2()

只会影响:

ctx2 ❌

不会影响:

ctx1 ✅

所以 Context 的关系可以理解为:

父 Context 控制子 Context 的生命周期。

这也是为什么 Context 特别适合请求链路,例如:

HTTP Request Context
        │
        ├── User Service
        │      │
        │      └── Database
        │
        ├── Order Service
        │      │
        │      └── Database
        │
        └── AI Service
               │
               └── HTTP API

整个请求取消:

HTTP Request ❌
      │
      ├── User Service ❌
      ├── Order Service ❌
      └── AI Service ❌

这就形成了一个完整的生命周期控制体系。

context.Background() 是什么?

你经常会看到:

context.Background()

它是一个最基础的 Context。

可以把它理解成:

Context 世界的根节点。——一个永远不取消、没有截止时间、不携带任何值的空 ctx,只该出现在 main、初始化、测试这类调用链的起点。

例如:

ctx := context.Background()

然后:

ctx, cancel := context.WithCancel(ctx)

再:

ctx, cancel := context.WithTimeout(ctx, <span>5</span>*time.Second)

context.TODO() 是什么?

context.TODO() 的行为和 context.Background() 完全一样(永不取消、无超时、无值),差别只在语义 ——它是一句"我知道这里该有一个真正的 ctx,但暂时还没想好传哪个"的待办标记。

例如开发过程中:

<span><span>func</span> <span>DoSomething</span><span>(ctx context.Context)</span></span> {
    <span>// ...</span>
}

但调用方暂时没有 Context:

DoSomething(context.TODO())

可以先这样写。

不过在明确知道应该使用哪个 Context 时,优先使用真实的 Context。

又假设我们正在重构一个老项目,原来的代码没有 Context:

<span><span>func</span> <span>GetUser</span><span>(userID <span>int</span>)</span></span> {
	fmt.Println(<span>"查询用户:"</span>, userID)
}

现在我们希望让它支持 Context:

<span><span>func</span> <span>GetUser</span><span>(ctx context.Context, userID <span>int</span>)</span></span> {
	fmt.Println(<span>"查询用户:"</span>, userID)
}

但是项目中还有很多地方调用:

GetUser(<span>1001</span>)

暂时还没来得及把真正的 Context 传下来。

这时候可以临时改成:

GetUser(context.TODO(), <span>1001</span>)

等以后把调用链改造完成,再替换成真正的 Context:

<span><span>func</span> <span>Handler</span><span>(w http.ResponseWriter, r *http.Request)</span></span> {
	ctx := r.Context()

	GetUser(ctx, <span>1001</span>)
}

defer cancel() 为什么到处都是?

我们经常会看到:

ctx, cancel := context.WithTimeout(
    context.Background(),
    <span>5</span>*time.Second,
)

<span>defer</span> cancel()

可能有人会问:

“不是 5 秒之后自动取消吗?为什么还要 cancel()?”

因为:

<span>defer</span> cancel()

可以确保:

函数提前结束时,及时释放 Context 相关资源。

所以这是一个非常常见的 Go 写法:

ctx, cancel := context.WithTimeout(ctx, <span>5</span>*time.Second)
<span>defer</span> cancel()

记住这个模式即可。

总结

如果用一句话解释 Context:

Context 是 Go 用来管理一次请求或任务生命周期的机制,可以让调用链中的函数共享取消信号、超时信息和少量请求范围的数据。

再简单一点:

Context 就是一个贯穿整个调用链的“任务遥控器”。

它主要负责三件事:

取消
超时
传递请求上下文

用一个接近生活的案例来类比:

假设你用手机在餐厅点了一份外卖。

手机
 │
 ▼
餐厅
 │
 ▼
厨师
 │
 ▼
配菜
 │
 ▼
外卖员

订单就是一次请求。

Context 就像订单上的一个“状态控制器”。

如果你在手机上取消订单:

订单取消
   │
   ├── 厨师:别做了
   ├── 配菜:别准备了
   └── 外卖员:别送了

这就是:

cancel()

如果订单超 30 分钟后自动取消订单,这就是:

context.WithTimeout(ctx, <span>30</span>*time.Minute)

如果订单上还有:

订单号:A10086
用户:张三
TraceID:abc123

这些属于这次订单的上下文信息,可以类比:

context.WithValue(...)

所以:

Context 不是用来“做事情”的,而是用来管理事情怎么结束、什么时候结束,以及在这条调用链上携带哪些上下文信息。