详解go中context使用
并发是go语言最迷人也是最容易踩坑的部分,Goroutine 的轻量级设计让我们可以轻松地开启成百上千个并发任务,但如何优雅地管理它们的生命周期?这时,context
包就登场了。它是 Go 标准库中最重要的并发控制机制之一, 提供了取消信号、超时机制、以及请求范围内数据传递等功能。随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如database/sql包。context 几乎成为了并发控制和超时控制的标准做法。
本文记录了我学习 context
的过程。首先就是看官方文档,其次是参考这篇博客。这篇文章主要是将使用层面,想探究底层的话可以参考上面提到的这篇博客。
上下文包定义了上下文类型,这个类型用于在API边界和不同的协程(或进程)之间传递信息,比如截止时间,取消信号以及其他与请求相关的上下文数据。简单来说就是控制goroutine
之间的生命周期与取消信号传递,核心思想主要是:在并发程序中,允许上游控制下游的执行。
比如,一个请求往往会触发多个goroutine(数据库查询、HTTP 请求、缓存读取等),context
提供了一个优雅的方式来在整个调用链上传递:取消信号(如超时或手动取消),截止时间(deadline),携带请求范围内的元数据(如 trace ID、用户信息等)。这样,当上层逻辑需要取消或超时时,所有关联的goroutine都能感知到,安全地退出。
也就是说,传入服务器的请求应该创建一个 Context
,而发往其他服务器的请求应该接收一个 Context
。在这两者之间的整个函数调用链中,必须传递这个 Context
,并且在需要时,可以使用 WithCancel
、WithDeadline
、WithTimeout
或 WithValue
创建一个派生(derived)Context来替换原有的 Context。
一个 Context
可以被取消(canceled),用来表示与该 Context
相关的工作应当停止执行。带有截止时间(deadline)的 Context
会在截止时间到达后自动被取消。当一个 Context
被取消时,所有从它派生的子 Context 也会同时被取消。
WithCancel
、WithDeadline
和 WithTimeout
这三个函数都会:接收一个父级 Context
,返回一个派生的子级 Context
和一个CancelFunc
。当直接调用 CancelFunc
时,子 Context 以及它的所有子孙 Context 都会被取消,父 Context 对该子 Context 的引用会被移除,相关的定时器(如果有)也会停止。如果忘记调用 CancelFunc
,那么该子 Context 及其子 Context 会一直存在,直到父 Context 被取消,从而造成资源泄漏。
同时还有WithCancelCause
、WithDeadlineCause
和 WithTimeoutCause
这三个函数,它们返回一个 CancelCauseFunc
, 这个CancelCauseFunc
接受一个 error
参数,并将其记录为取消的原因。当调用 Cause
函数并传入已被取消的 Context时,就可以取回这个取消原因。如果没有指定原因,那么 Cause(ctx)
返回的结果与 ctx.Err()
相同。
调用示例如下,只是介绍常见用法:
go
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("database connection lost"))
case <-ctx.Done():
fmt.Println("Worker canceled:", context.Cause(ctx))
官方文档还说,我们使用context
的程序应该遵循一些原则,以保持不同包之间接口的一致性,并使静态分析工具能够正确检查 Context 的传播:
- 不要在结构体中保存
Context
,应该将Context
显式地作为参数传递给每个需要它的函数。 - 不要传入
nil
的 Context,如果你暂时不确定使用哪个 Context,请传入context.TODO
。 - 仅将 Context 的 Value 用于"请求范围内的数据",即那些需要在进程与 API 之间传递的数据,不要使用 Context 的 Value 来传递函数的可选参数。
- 同一个 Context 可以安全地被多个 goroutine 同时使用。Context 的实现是并发安全的,可以在不同的 goroutine 间共享。
下面我们来逐步解释每一点。对于第一点,我们要从Context
的设计理念说明,Context
的目的是传递请求的生命周期,一个 Context
通常只对应一次请求,它的生命周期通常是短暂的,当请求结束时,它应当被取消并释放相关资源。但是结构体往往表示长生命周期的组件(如 service、repository、handler),而且可能在多个请求之间复用,如果你把 Context
存到结构体里,就把短生命周期的对象放进了长生命周期的容器里,很容易导致资源泄漏和错误的取消传播(结构体被多次使用)。
而且如果把Context
放入结构体中,如下:
go
type Service struct {
ctx context.Context
}
如果 Service
被多个请求并发使用,那么不同的goroutine就共享同一个 s.ctx
,当其中一个请求取消时,所有请求都被取消,导致并发出现问题,行为不可预测。
对于第二点,首先是因为 ctx.Done()
、ctx.Err()
等方法都是接口调用,当ctx==nil时会立刻导致空指针访问崩溃。其次传入nil破坏了Context
的链式传递,如果在某一层传入了nil,那么下面的层级就会断掉,取消信号或超时信号无法继续传递下去。整个请求的控制流就中断了。
context.TODO()
是 Go 提供的一个占位用 Context:其实和context.Background()
一模一样,都是一个初始化为空的Context
,但是TODO
表示我还没决定应该用哪个 Context(Background 还是上层传入的),暂时放一个占位符。
第三点说的是Context.Value
应该存放与请求整体相关的信息和在整个调用链中全局共享的数据,不应该存放与函数逻辑紧密绑定的"普通参数"。因为Context
传输的实际上是控制流而不是数据流,所以Value只应该传递控制信息。
对于第四点,给出一段代码参考:
go
func handleRequest(ctx context.Context) {
go doA(ctx)
go doB(ctx)
}
这段代码表示在一个请求的生命周期里启动多个goroutine来做并发任务,这里的 ctx
是同一个对象,两个goroutine并发访问它(例如监听取消信号、读取值),这完全是安全的。这是 Go 官方设计 Context
时的一个明确目标:一个 Context 可以被任意数量的 goroutine 同时使用(读取 / 监听),但不能被修改。
因为一个请求往往需要多个 goroutine 协作,比如一个 HTTP 请求可能同时查询数据库、调用外部 API、写日志,它们都需要知道"请求是否超时"或"是否被取消",所以必须共享同一个 Context。而且Context
是不可变的,context.WithCancel
, context.WithDeadline
, context.WithValue
等函数返回新的派生 Context,原 Context 永远不会被修改,而且Context
接口中的函数都是只读接口,用户不能改变任何状态,所以Context 可能被多个 goroutine 共享。
下面给出一些Context的使用示例。
首先是context.WithValue
的使用,它创建了一个带有键值对的 context,可以调用ctx.Value(key)
从 context 中读取对应 key 的值。
go
package main
import (
"context"
"fmt"
)
func main() {
type favContextKey string
f := func(ctx context.Context, k favContextKey) {
if v := ctx.Value(k); v != nil {
fmt.Println("found value:", v)
return
}
fmt.Println("key not found:", k)
}
k := favContextKey("language")
ctx := context.WithValue(context.Background(), k, "Go")
f(ctx, k)
f(ctx, favContextKey("color"))
}
定义了一个匿名函数 f
,它接收一个 context.Context
和一个 key,然后调用 ctx.Value(k)
从 context 里取值,如果找到了,就打印:found value: ...
,否则打印:key not found: ...
。之后通过context.Background()
和context.WithValue
基于一个空的ctx创建了一个新的 context,其中包含一个键值对。之后调用函数f去取值。
值得一提的是第二次调用key 是 favContextKey("color")
,即使底层类型一样(string),但键不同,所以找不到对应Value。
接下来是Cancel context
。
go
func main() {
// gen generates integers in a separate goroutine and
// sends them to the returned channel.
// The callers of gen need to cancel the context once
// they are done consuming generated integers not to leak
// the internal goroutine started by gen.
gen := func(ctx context.Context) <-chan int {
dst := make(chan int)
n := 1
go func() {
for {
select {
case <-ctx.Done():
return // returning not to leak the goroutine
case dst <- n:
n++
}
}
}()
return dst
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // cancel when we are finished consuming integers
for n := range gen(ctx) {
fmt.Println(n)
if n == 5 {
break
}
}
}
这段go代码用 context 来通知 goroutine 停止工作,防止因为通道未关闭或循环未终止而导致 goroutine 泄漏,程序启动一个 goroutine 不断生成整数 1, 2, 3, ...
并发送到一个 channel,主函数从 channel 中接收数字并打印,当数字等于 5
时停止读取,此时调用 cancel()
通知 goroutine 停止运行(防止 goroutine 泄漏)。
gen
函数就是在内部开启一个 goroutine,不断把整数 1, 2, 3, ...
发送到 channel dst
,同时监听 ctx.Done()
,一旦收到取消信号(cancel()
被调用),就退出循环,结束 goroutine。注意这里dst
是一个无缓冲的channel,如果接收方不再从通道取数据,而 goroutine 继续发送,就会阻塞,所以我们必须提供一种机制让 goroutine 自己知道该退出,就是监听 ctx.Done()
。
接着在主程序中创建了一个可取消的上下文,其中cancel
是一个函数,调用它可以广播取消信号,使用 defer cancel()
保证无论函数如何结束,最终都会调用 cancel()。
之后的循环使用 range
从 channel 接收数据,打印每个数字,当收到第 5 个数字时,break
跳出循环。break
跳出循环后,main()函数到结尾,执行 defer cancel(),cancel()会关闭 ctx.Done()
,goroutine 中的 select
会检测到 <-ctx.Done()
可读,然后执行 return
,退出 goroutine,避免资源泄漏。
假设我们不监听 ctx.Done()
,只是一味地向无缓冲区channel发送数据,那么当主循环 break
后,没人再从 dst
取数据,goroutine 在发送 dst <- n
时会永久阻塞,从而导致goroutine泄漏。
最后就是Deadline Context
。
go
// ref: https://pkg.go.dev/context
d := time.Now().Add(shortDuration)
ctx, cancel := context.WithDeadline(context.Background(), d)
// Even though ctx will be expired, it is good practice to call its
// cancellation function in any case. Failure to do so may keep the
// context and its parent alive longer than necessary.
defer cancel()
select {
case <-neverReady:
fmt.Println("ready")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
前面的代码是创建一个新的上下文对象 ctx
,它会在指定的时间点 d
自动过期(被取消),cancel
是与之配套的取消函数。这表示到了时间 d
,ctx.Done()
会被关闭,ctx.Err()
将返回 context.DeadlineExceeded
。那后面为什么还要defer cancel()
呢?
官方文档中的注释也说明了原因,即使 ctx 会自动过期(expired),仍然应该手动调用 cancel()。否则可能会让 context 及其父级context存活得比需要的时间更长,造成内存泄漏。WithDeadline
在内部启动了一个定时器(timer),用于监控超时,如果你不显式调用 cancel()
,这个 timer 只有等超时时间到了才会被自动清理,若你提前结束函数而不调用 cancel()
,定时器会继续存活,从而让整个 ctx
树在 GC 时被延迟清理,浪费资源。
之后就是select语句走不同分支,其中走<-ctx.Done()
就表示context被取消或超时。
接下来是一些关于context使用时的知识点。
如果 cancel context 延伸出其他 child context,当 cancel function 呼叫时,其 child context 和 child context 的 child 都会收到关闭通知。因为在go的Context
体系中,context 是一个树状结构,每个子 context 都会持有对父 context 的引用。只要父 context 被取消,整个子树全部终止。
Context
中Value主要用于存放与请求相关的参数,例如HTTP 请求中的 header、uri、scheme 等,在 HTTP server 中,每次请求 (http.Request
) 都会自带一个 Context
,这使得你可以将与该请求相关的数据存放在 Context 中并在整个调用链传递。这些值与请求生命周期绑定,请求结束时,context 自动取消,被称为"request-scoped value"。
在实际开发中,如果不希望某个 goroutine 受到 parent 的管理,可以利用 WithoutCancel 产生一个不会被关闭的 context。它基于一个已有的 context 创建出不受取消信号影响的 context。如下:
go
ctx, cancel := context.WithCancel(context.Background())
safeCtx := context.WithoutCancel(ctx)
go func() {
<-ctx.Done() // 会被关闭
<-safeCtx.Done() // 永远不会被关闭
}()
用途是当你有某个后台任务需要脱离当前请求生命周期继续执行时(例如写日志、上传统计数据),就可以使用 WithoutCancel
:go doBackgroundWork(context.WithoutCancel(ctx))
。
如果有自行处理 OS signal 的需求,可以在 main 建立一个 goroutine 处理这件事,当 goroutine 收到 os signal,便可以呼叫 cancel function 关闭所有 goroutine(s)。写法如下:
go
func main() {
ctx, cancel := context.WithCancel(context.Background())
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
fmt.Println("received stop signal")
cancel()
}()
runServer(ctx)
}
当用户发出信号时,信号捕获 goroutine 调用 cancel()
,所有监听 <-ctx.Done()
的任务立刻退出,main 等待子任务退出后再终止。
最后就是main goroutine 应该要是最后一个结束的,所以在使用 cancel context 管理 goroutine(s) 可以搭配sync.WaitGroup 避免 main 在收到 signal 后马上结束。因为 cancel()
发出信号只是通知,并不等待子 goroutine 真正退出,所以我们通常这样写:
go
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(ctx, id)
}(i)
}
<-ctx.Done()
wg.Wait()
fmt.Println("all workers done, exit.")
这样就可以确保信号触发保证cancel()
广播,子 goroutine 收到 <-ctx.Done()
→ 清理退出,wg.Wait()
等待所有清理完之后,main才退出。