golang中你应该知道的context 知识

Exploring Context Package in Go: A Comprehensive Guide
photo by Johannes Plenio on Unsplash

Commitment is an act, not a word.
— Jean-Paul Sartre

1.1 context 包简介

context package versions - context - Go Packages我们可以知道 Go 语言在
go1.7beta1 版本的标准库中提供了context包,它定义了Context类型。

它是 goroutine 的上下文,可以用来传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。

我们可以理解为它是一个能承载程序运行的上下文。当然这只是一些基本的了解,go 官方有一句比较关键的话如下:

The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

这句话我们初步可以看出context 和 gouroutinue 联系紧密,接下来让我们继续往下看。

1.2 为什么 context

众所周知,一个看似简单的业务请求在服务端是很复杂的。它的背后往往是多个goroutine 同时在工作,有的去redis获取元数据,有的去 s3 获取具体数据,有的去调用下游其他微服务接口等等。

这就要求请求之间有隶属关系,可以方便我们进行超时控制、回收资源等, 例如我们不希望当上游的请求停止,下游的gorooutine 还在执行任务,因为这会浪费资源、资源泄漏甚至是服务雪崩。

Go 语言中独特的设计 context 就是为了解决这种场景问题(在其他编程语言中我们很少见到类似的概念)。
image.png

通过context 和 goroutinue的配合,我们可以实现 Go Concurrency Patterns: Context - The Go Programming Language 该文中introduction 介绍的那样的效果。

When a request is canceled or times out, all the goroutines working on that request should exit quickly so the system can reclaim any resources they are using.

至于如何实现,我们可以在源码赏析中找到答案。

1.3 创建和使用 context

我们可以使用 context 包中的函数创建 context

1. context.Background() 和 context.TODO()

context.Background() 返回一个空的 context,这个 context 不包含任何数据,不支持取消操作,并且没有设置超时时间。在主函数、初始化以及测试代码中,我们通常会使用 context.Background()

而 context.TODO() 同样返回一个空的 context,但在语义上,我们会在不确定该使用哪种 context 时,选择使用 context.TODO()

1
2
ctx := context.Background()
ctx = context.TODO()

WithCancel Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
"context"
"fmt"
)

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
}
}
}

// run output
1
2
3
4
5

WithDeadline、WithTimeout、WithValue 等使用可以参考官方示例 Context Examples

1.4 context 源码赏析

这里重点以 go1.22.4 中的 WithCancel为例进行分析

1
2
3
4
5
6
7
8
9
10
11
12
13
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}

func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
c := &cancelCtx{}
c.propagateCancel(parent, c)
return c
}

我们按执行步骤梳理如下:

  • 创建一个cancelCtx对象,作为子context
  • 然后调用propagateCancel构建父子context之间的关联关系,这样当父context被取消时,子context也会被取消。
  • 返回子context对象和子树取消函数

cancelCtx类及 propagateCancel() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
type cancelCtx struct {
Context

mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
cause error // set to non-nil by the first cancel call
}

func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent

done := parent.Done()
if done == nil {
return // parent is never canceled
}

select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}

if p, ok := parentCancelCtx(parent); ok {
// parent is a *cancelCtx, or derives from one.
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err, p.cause)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
return
}

if a, ok := parent.(afterFuncer); ok {
// parent implements an AfterFunc method.
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}

goroutines.Add(1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
}

上述函数有与父上下文相关的三种不同的情况:

  1. 当 parent.Done() == nil,也就是 parent 不会触发取消事件时,当前函数会直接返回;
  2. 当 child 的继承链包含可以取消的上下文时,会判断 parent 是否已经触发了取消信号;
    • 如果已经被取消,child 会立刻被取消;
    • 如果没有被取消,child 会被加入 parent 的 children 列表中,等待 parent 释放取消信号;
  3. 当父上下文是开发者自定义的类型、实现了 context.Context接口并在 Done() 方法中返回了非空的管道时;
    1. 运行一个新的 Goroutine 同时监听 parent.Done() 和 child.Done() 两个 Channel;
    2. 在 parent.Done() 关闭时调用 child.cancel 取消子上下文;

context.propagateCancel 的作用是在 parent 和 child 之间同步取消和结束的信号,保证在 parent 被取消时,child 也会收到对应的信号,不会出现状态不一致的情况。

cancel() 方法

最重要的方法是 context.cancelCtx.cancel,该方法会关闭上下文中的 Channel 并向所有的子上下文同步取消信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
if cause == nil {
cause = err
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
c.cause = cause
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err, cause)
}
c.children = nil
c.mu.Unlock()

if removeFromParent {
removeChild(c.Context, c)
}
}

通过源码我们可以知道cancel方法可以被重复调用,是幂等的。综上,可以画个图简单表示cancel 的流程:
image.png

其他方法

 context.WithDeadline 和 context.WithTimeout 逻辑类似,但是增加了定时器的处理。
 它们在创建 context.timerCtx 的过程中判断了父上下文的截止日期与当前日期,并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel同步取消信号。

XXXCause 进一步延展,增加了 error 相关的流程,解决了一些令人诟病的问题:错误返回不够友好,无法自定义错误,出现难以排查的问题时不好排查。

1
2
3
4
ctx, cancel := context.WithCancelCause(parent)
cancel(myError)
ctx.Err() // returns context.Canceled
context.Cause(ctx) // returns myError

具体的例子有一篇文章已经给出:Context cancel cause in Go 1.20. Go 1.20 introduced two new functions in… | by Peter Gillich | Dev Genius

1.5 context 的最佳实践

这里有一些最佳实践可以参考:

  1. 不要把 context 存入结构体中: context 在设计时,就是为了方便在函数之间传递,而不是用来保存状态信息的。所以,尽量保持它的流动性,而不是将它存储在结构体中。

  2. 使用 context 的 Value 方法,应保持最小化: 尽管 context 的 Value 方法在一些场景下非常有用,但大部分情况下,如果滥用这个功能会导致代码更难维护,尤其是当 context 被大量使用的时候。

  3. 遵守 context 包的生命周期管理规则: 一般来说,context 应由发起请求的最外层函数创建,并通过调用链向下传递。具体地,如果你的函数需要在子进程中运行,并且可能需要被取消或超时,则应该考虑接收一个 context 参数。尽量不要在全局变量中存储和使用 context。

  4. 通过 defer 语句调用 cancel 函数: 当使用 WithCancelWithTimeout 或 WithDeadline 函数返回的 cancel 函数时,你应该调用它们来释放相关资源。最佳实践是,在你的函数返回之前的地方,通过 defer 语句来调用这个 cancel 函数。

  5. 尽量创建新的 context: 而不是复用已经存在的 context,这样可以让每个请求都有一个有明确生命周期的 context。

1.6 总结

Go 官方建议我们把 Context 作为函数的第一个参数,如果我们想控制所有的协程的取消动作,几乎所有的函数里都加上一个 Context 参数,context 将像病毒一样扩散的到处都是。

另外,像 WithCancel等创建函数,实际上是创建了一个个的链表结点而已。我们知道,对链表的操作,通常都是 O(n) 复杂度的,而子节点多了,效率显然会变低。

那么,context 包到底解决了什么问题呢?答案是:cancelation。它能帮助我们更好的做并发控制,能更好的管理goroutine滥用。仅管它并不完美,但它确实很简洁地解决了问题。

1.7 参考

  1. context package versions - context - Go Packages
  2. Go Concurrency Patterns: Context - The Go Programming Language
  3. Context cancel cause in Go 1.20. Go 1.20 introduced two new functions in… | by Peter Gillich | Dev Genius
  4. segmentfault.com/a/1190000040917752
  5. 深度解密Go语言之context - Stefno - 博客园
  6. Go 语言并发编程与 Context | Go 语言设计与实现

更多该系列文章,参考medium链接:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

English post: https://programmerscareer.com/golang-context/
作者:Wesley Wei – Twitter Wesley Wei – Medium
注意:原文在 2024-06-13 01:13 时发布于 https://programmerscareer.com/golang-context/. 本文为作者原创,转载请注明出处。

使用VSCode Profiles快速切换不同开发语言 golang中你应该知道的nil知识

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×