详解go中context使用

详解go中context使用

并发是go语言最迷人也是最容易踩坑的部分,Goroutine 的轻量级设计让我们可以轻松地开启成百上千个并发任务,但如何优雅地管理它们的生命周期?这时,context 包就登场了。它是 Go 标准库中最重要的并发控制机制之一, 提供了取消信号、超时机制、以及请求范围内数据传递等功能。随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如database/sql包。context 几乎成为了并发控制和超时控制的标准做法。

本文记录了我学习 context 的过程。首先就是看官方文档,其次是参考这篇博客。这篇文章主要是将使用层面,想探究底层的话可以参考上面提到的这篇博客。

上下文包定义了上下文类型,这个类型用于在API边界和不同的协程(或进程)之间传递信息,比如截止时间,取消信号以及其他与请求相关的上下文数据。简单来说就是控制goroutine之间的生命周期与取消信号传递,核心思想主要是:在并发程序中,允许上游控制下游的执行。

比如,一个请求往往会触发多个goroutine(数据库查询、HTTP 请求、缓存读取等),context 提供了一个优雅的方式来在整个调用链上传递:取消信号(如超时或手动取消),截止时间(deadline),携带请求范围内的元数据(如 trace ID、用户信息等)。这样,当上层逻辑需要取消或超时时,所有关联的goroutine都能感知到,安全地退出。

也就是说,传入服务器的请求应该创建一个 Context,而发往其他服务器的请求应该接收一个 Context。在这两者之间的整个函数调用链中,必须传递这个 Context,并且在需要时,可以使用 WithCancelWithDeadlineWithTimeoutWithValue创建一个派生(derived)Context来替换原有的 Context。

一个 Context 可以被取消(canceled),用来表示与该 Context 相关的工作应当停止执行。带有截止时间(deadline)的 Context 会在截止时间到达后自动被取消。当一个 Context 被取消时,所有从它派生的子 Context 也会同时被取消。

WithCancelWithDeadlineWithTimeout 这三个函数都会:接收一个父级 Context,返回一个派生的子级 Context和一个CancelFunc。当直接调用 CancelFunc 时,子 Context 以及它的所有子孙 Context 都会被取消,父 Context 对该子 Context 的引用会被移除,相关的定时器(如果有)也会停止。如果忘记调用 CancelFunc,那么该子 Context 及其子 Context 会一直存在,直到父 Context 被取消,从而造成资源泄漏。

同时还有WithCancelCauseWithDeadlineCauseWithTimeoutCause 这三个函数,它们返回一个 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 的传播:

  1. 不要在结构体中保存Context,应该将 Context 显式地作为参数传递给每个需要它的函数。
  2. 不要传入 nil 的 Context,如果你暂时不确定使用哪个 Context,请传入 context.TODO
  3. 仅将 Context 的 Value 用于"请求范围内的数据",即那些需要在进程与 API 之间传递的数据,不要使用 Context 的 Value 来传递函数的可选参数。
  4. 同一个 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 是与之配套的取消函数。这表示到了时间 dctx.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() // 永远不会被关闭
}()

用途是当你有某个后台任务需要脱离当前请求生命周期继续执行时(例如写日志、上传统计数据),就可以使用 WithoutCancelgo 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才退出。

相关推荐
用户904706683572 小时前
如何使用 Spring MVC 实现 RESTful API 接口
java·后端
Java微观世界2 小时前
告别重复数据烦恼!MySQL ON DUPLICATE KEY UPDATE 优雅解决存在更新/不存在插入难题
后端
程序员飞哥2 小时前
真正使用的超时关单策略是什么?
java·后端·面试
用户904706683572 小时前
SpringBoot 多环境配置与启动 banner 修改
java·后端
chenyuhao20243 小时前
《C++二叉引擎:STL风格搜索树实现与算法优化》
开发语言·数据结构·c++·后端·算法
小old弟3 小时前
后端三层架构
java·后端
花花鱼3 小时前
spring boot 2.x 与 spring boot 3.x 及对应Tomcat、Jetty、Undertow版本的选择(理论)
java·后端
小咕聊编程3 小时前
【含文档+PPT+源码】基于springboot的旅游路线推荐系统的设计与实现
spring boot·后端·旅游
道可到3 小时前
阿里面试原题 java面试直接过06 | 集合底层——HashMap、ConcurrentHashMap、CopyOnWriteArrayList
java·后端·面试