天天看点

golang context的深度理解

作者:干饭人小羽
golang context的深度理解

1. 什么是 context

Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。

context 主要用来在 goroutine 之间传递上下文信息。

随着 context 包的引入,标准库中很多接口因此加上了 context 参数。context 几乎成为了并发控制和超时控制的标准做法。

context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。

2. 为什么有 context

Go 常用来写后台服务,通常只需要几行代码,就可以搭建一个 http server。

在 Go 的 server 里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据……

golang context的深度理解

这些 goroutine 需要共享这个请求的基本数据,例如登陆的 token,处理请求的最大超时时间等等。当请求被取消或是处理时间太长,这时所有正在为这个请求工作的 goroutine 需要快速退出。相关联的 goroutine 都退出后,系统就可以回收相关的资源。

Go 语言中的 server 实际上是一个“协程模型”,即一个协程处理一个请求。例如在业务高峰期,某个下游服务的响应变慢,而当前系统的请求又没有超时控制,或者超时时间设置地过大,那么等待下游服务返回数据的协程就会越来越多。而后果就是协程数激增,内存占用飙涨,甚至导致服务不可用。更严重的会导致雪崩效应,整个服务对外表现为不可用。

其实前面描述的事故,通过设置 允许下游最长处理时间 就可以避免。例如,给下游设置的 timeout 是 50 ms,如果超过这个值还没有接收到返回数据,就直接向客户端返回一个默认值或者错误。

context 包就是为了解决上面所说的这些问题而开发的:在 一组 goroutine 之间传递共享的值、取消信号、deadline……

golang context的深度理解

在Go 里,我们不能直接杀死协程,协程的关闭一般会用 channel+select 方式来控制。但是在某些场景下,例如处理一个请求衍生了很多协程,这些协程之间是相互关联的:需要共享一些全局变量、有共同的 deadline 等,而且可以同时被关闭。再用 channel+select 就会比较麻烦,这时就可以通过 context 来实现。

一句话:context 用来解决 goroutine 之间退出通知、元数据传递的功能。

3. context 底层实现原理

3.1 整体概览

类型 名称 作用
Context 接口 定义了 Context 接口的四个方法
emptyCtx 结构体 实现了 Context 接口,它其实是个空的 context
CancelFunc 函数 取消函数
canceler 接口 context 取消接口,定义了两个方法
cancelCtx 结构体 可以被取消
timerCtx 结构体 超时会被取消
valueCtx 结构体 可以存储 k-v 对
Background 函数 返回一个空的 context,常作为根 context
TODO 函数 返回一个空的 context,常用于重构时期,没有合适的 context 可用
WithCancel 函数 基于父 context,生成一个可以取消的 context
newCancelCtx 函数 创建一个可取消的 context
propagateCancel 函数 向下传递 context 节点间的取消关系
parentCancelCtx 函数 找到第一个可取消的父节点
removeChild 函数 去掉父节点的孩子节点
WithDeadline 函数 创建一个有 deadline 的 context
WithTimeout 函数 创建一个有 timeout 的 context
WithValue 函数 创建一个存储 k-v 对的 context

上面这张表展示了 context 的所有函数、接口、结构体,可以纵览全局,可以在读完文章后,再回头细看。

整体类图如下:

golang context的深度理解

3.2 接口

3.2.1 Context

scss复制代码type Context interface {
	// 当 context 被取消或者到了 deadline,返回一个被关闭的 channel
	Done() <-chan struct{}

	// 在 channel Done 关闭后,返回 context 取消原因
	Err() error

	// 返回 context 是否会被取消以及自动取消时间(即 deadline)
	Deadline() (deadline time.Time, ok bool)

	// 获取 key 对应的 value
	Value(key interface{}) interface{}
}
           

Context 是一个接口,定义了 4 个方法,它们都是幂等的。即连续多次调用同一个方法,得到的结果都是相同的。

Done() 返回一个 channel,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only 的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。

Err() 返回一个错误,表示 channel 被关闭的原因。例如是被取消,还是超时。

Deadline() 返回 context 的截止时间。

Value() 获取之前设置的 key 对应的 value。

3.2.2 canceler

go复制代码type canceler interface {
	cancel(removeFromParent bool, err error)
	Done() <-chan struct{}
}
           

实现上面定义的两个方法的 Context,表明该 Context 是可取消的。源码中有两个类型实现了 canceler 接口:*cancelCtx 和 *timerCtx。注意是加了 * 号的,是这两个结构体的指针实现了 canceler 接口。

Context 接口设计成这个样子的原因:

  • “取消”操作应该是建议性,而非强制性
  • “取消”操作应该可传递

    “取消”某个函数时,和它相关联的其他函数也应该“取消”。因此,Done() 方法返回一个只读的 channel,所有相关函数监听此 channel。一旦 channel 关闭,通过 channel 的“广播机制”,所有监听者都能收到。

3.3 结构体

3.3.1 emptyCtx

go复制代码type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
	return nil
}
           

一个空的 context,永远不会被 cancel,没有存储值,也没有 deadline。

它被包装成:

go复制代码var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)
           

通过下面两个导出的函数对外公开:

go复制代码func Background() Context {
	return background
}

func TODO() Context {
	return todo
}
           

background 通常用在 main 函数中,作为所有 context 的根节点。

todo 通常用在并不知道传递什么 context的情形。例如,调用一个需要传递 context 参数的函数,你手头并没有其他 context 可以传递,这时就可以传递 todo。这常常发生在重构进行中,给一些函数添加了一个 Context 参数,但不知道要传什么,就用 todo “占个位子”,最终要换成其他 context。

3.3.2 cancelCtx

go复制代码type cancelCtx struct {
	Context

	// 保护之后的字段
	mu       sync.Mutex
	done     chan struct{}
	children map[canceler]struct{}
	err      error
}
           

可取消的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样就可以被看成一个 Context。

Done() 方法的实现:

go复制代码func (c *cancelCtx) Done() <-chan struct{} {
	c.mu.Lock()
	if c.done == nil {
		c.done = make(chan struct{})
	}
	d := c.done
	c.mu.Unlock()
	return d
}
           

c.done 是“懒汉式”创建,只有调用了 Done() 方法的时候才会被创建。再次说明,函数返回的是一个只读的 channel,而且没有地方向这个 channel 里面写数据。所以,直接调用读这个 channel,协程会被 block 住。一般通过搭配 select 来使用。一旦关闭,就会立即读出零值。

Err() 和 String() 方法比较简单,不多说。

重点关注 cancel() 方法的实现:

go复制代码func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    // 必须要传 err
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // 已经被其他协程取消
	}
	// 给 err 字段赋值
	c.err = err
	// 关闭 channel,通知其他协程
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	
	// 遍历它的所有子节点
	for child := range c.children {
	    // 递归地取消所有子节点
		child.cancel(false, err)
	}
	// 将子节点置空
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
	    // 从父节点中移除自己 
		removeChild(c.Context, c)
	}
}
           

cancel() 的功能就是关闭 channel:c.done;递归地取消它的所有子节点;从父节点从删除自己。达到的效果是通过关闭 channel,将取消信号传递给了它的所有子节点。goroutine 接收到取消信号的方式就是 select 语句中的读 c.done 被选中。

创建一个可取消的 Context 的方法:

go复制代码func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}
           

这是一个暴露给用户的方法,传入一个父 Context(通常是 background,作为根节点),返回新建的 context,新 context 的 done channel 是新建的。

当 WithCancel 函数返回的 CancelFunc 被调用或者是父节点的 done channel 被关闭(父节点的 CancelFunc 被调用),此 context(子节点) 的 done channel 也会被关闭。

注意传给 c.cancel 方法的参数,前者是 true,也就是说取消的时候,需要将自己从父节点里删除。第二个参数则是一个固定的取消错误类型:

ini复制代码var Canceled = errors.New("context canceled")
           

还注意到一点,调用子节点 cancel 方法的时候,传入的第一个参数 removeFromParent 是 false。

两个问题需要回答:1. 什么时候会传 true?2. 为什么有时传 true,有时传 false?

当 removeFromParent 为 true 时,会将当前节点的 context 从父节点 context 中删除:

css复制代码func removeChild(parent Context, child canceler) {
	p, ok := parentCancelCtx(parent)
	if !ok {
		return
	}
	p.mu.Lock()
	if p.children != nil {
		delete(p.children, child)
	}
	p.mu.Unlock()
}
           

最关键的一行:

scss复制代码delete(p.children, child)
           

在取消函数内部,所有子节点都会因为c.children = nil 而化为灰烬。自然没必要再多做这一步(传true)。另外,如果遍历子节点时,调用 child.cancel 函数传了 true,还会造成同时遍历和删除一个 map 的境地,会有问题的。

golang context的深度理解

重点看 propagateCancel():

go复制代码func propagateCancel(parent Context, child canceler) {
	// 父节点是个空节点
	if parent.Done() == nil {
		return 
	}
	// 找到可以取消的父 context
	if p, ok := parentCancelCtx(parent); ok {
		p.mu.Lock()
		if p.err != nil {
			// 父节点已经被取消了,本节点(子节点)也要取消
			child.cancel(false, p.err)
		} else {
			// 父节点未取消
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			// "挂到"父节点上
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		// 如果没有找到可取消的父 context。新启动一个协程监控父节点或子节点取消信号
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}
           

这个方法的作用就是向上寻找可以“挂靠”的“可取消”的 context,并且“挂靠”上去。这样,调用上层 cancel 方法的时候,就可以层层传递,将那些挂靠的子 context 同时“取消”。

这里着重解释下为什么会有 else 描述的情况发生。else 是指当前节点 context 没有向上找到可以取消的父节点,那么就要再启动一个协程监控父节点或者子节点的取消动作。

这里就有疑问了,既然没找到可以取消的父节点,那 case <-parent.Done() 这个 case 就永远不会发生,所以可以忽略这个 case;而 case <-child.Done() 这个 case 又啥事不干。那这个 else 不就多余了吗?

其实不然。我们来看 parentCancelCtx 的代码:

go复制代码func parentCancelCtx(parent Context) (*cancelCtx, bool) {
	for {
		switch c := parent.(type) {
		case *cancelCtx:
			return c, true
		case *timerCtx:
			return &c.cancelCtx, true
		case *valueCtx:
			parent = c.Context
		default:
			return nil, false
		}
	}
}
           

这里只会识别三种 Context 类型:*cancelCtx,*timerCtx,*valueCtx。若是把 Context 内嵌到一个类型里,就识别不出来了。

验证上面的说法:

go复制代码type MyContext struct {
	Context
}

func main() {
	childCancel := true

	parentCtx, parentFunc := WithCancel(Background())
	mctx := MyContext{parentCtx}

	childCtx, childFun := WithCancel(mctx)

	fmt.Println(parentCtx)
	fmt.Println(mctx)
	fmt.Println(childCtx)
}
           

我们看下三个 context 的打印结果:

复制代码context.Background.WithCancel
{context.Background.WithCancel}
{context.Background.WithCancel}.WithCancel
           

果然,mctx,childCtx 和正常的 parentCtx 不一样,因为它是一个自定义的结构体类型。

else 这段代码(propagateCancel()函数里第二个else)说明,如果把 ctx 强行塞进一个结构体,并用它作为父节点,调用 WithCancel 函数构建子节点 context 的时候,Go 会新启动一个协程来监控取消信号。

再来说一下,select 语句里的两个 case 其实都不能删。

vbscript复制代码select {
	case <-parent.Done():
		child.cancel(false, parent.Err())
	case <-child.Done():
}
           

第一个 case 说明当父节点取消,则取消子节点。如果去掉这个 case,那么父节点取消的信号就不能传递到子节点。

第二个 case 是说如果子节点自己取消了,那就退出这个 select,父节点的取消信号就不用管了。如果去掉这个 case,那么很可能父节点一直不取消,这个 goroutine 就泄漏了。当然,如果父节点取消了,就会重复让子节点取消,不过,这也没什么影响嘛。

3.3.3 timerCtx

timerCtx 基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 context。

lua复制代码type timerCtx struct {
	cancelCtx
	timer *time.Timer 

	deadline time.Time
}
           

timerCtx 首先是一个 cancelCtx,所以它能取消。看下 cancel() 方法:

scss复制代码func (c *timerCtx) cancel(removeFromParent bool, err error) {
	// 直接调用 cancelCtx 的取消方法
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// 从父节点中删除子节点
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		// 关掉定时器,这样,在deadline 到来时,不会再次取消
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}
           

创建 timerCtx 的方法:

scss复制代码func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}
           

WithTimeout 函数直接调用了 WithDeadline,传入的 deadline 是当前时间加上 timeout 的时间,即从现在开始再经过 timeout 时间就算超时。WithDeadline 需要用的是绝对时间。重点来看它:

scss复制代码func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(deadline) {
		// 如果父节点 context 的 deadline 早于指定时间。直接构建一个可取消的 context。
		// 原因是一旦父节点超时,自动调用 cancel 函数,子节点也会随之取消。
		// 所以不用单独处理子节点的计时器时间到了之后,自动调用 cancel 函数
		return WithCancel(parent)
	}
	
	// 构建 timerCtx
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  deadline,
	}
	// 挂靠到父节点上
	propagateCancel(parent, c)
	
	// 计算当前距离 deadline 的时间
	d := time.Until(deadline)
	if d <= 0 {
		// 直接取消
		c.cancel(true, DeadlineExceeded)
		return c, func() { c.cancel(true, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		// d 时间后,timer 会自动调用 cancel 函数。自动取消
		c.timer = time.AfterFunc(d, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}
           

这个函数的最核心的一句是:

go复制代码c.timer = time.AfterFunc(d, func() {
	c.cancel(true, DeadlineExceeded)
})
           

c.timer 会在 d 时间间隔后,自动调用 cancel 函数,并且传入的错误就是 DeadlineExceeded:

go复制代码var DeadlineExceeded error = deadlineExceededError{}

type deadlineExceededError struct{}

func (deadlineExceededError) Error() string   { return "context deadline exceeded" }
           

也就是超时错误。

3.3.4 valueCtx

go复制代码type valueCtx struct {
	Context
	key, val interface{}
}
           

它实现了两个方法:

vbnet复制代码func (c *valueCtx) String() string {
	return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
           

创建 valueCtx 的函数:

go复制代码func WithValue(parent Context, key, val interface{}) Context {
	if key == nil {
		panic("nil key")
	}
	if !reflect.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}
           

对 key 的要求是可比较,因为之后需要通过 key 取出 context 中的值,可比较是必须的。

通过层层传递 context,最终形成这样一棵树:

golang context的深度理解

取值的过程,实际上是一个递归查找的过程:

vbnet复制代码func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}
           

它会顺着链路一直往上找,比较当前节点的 key 是否是要找的 key,是则返回 value。否则,一直顺着 context 往前,最终找到根节点,直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。

WithValue 创建 context 节点的过程实际上就是创建链表节点的过程。两个节点的 key 值是可以相等的,但它们是两个不同的 context 节点。查找的时候,会向上查找到最后一个挂载的 context 节点,即离得比较近的一个父 context。所以,用 WithValue 构造的其实是一个低效率的链表。

如果你接手过项目,肯定经历过这样的窘境:在一个处理过程中,有若干子函数、子协程。各种不同的地方会向 context 里塞入各种不同的 k-v 对,最后在某个地方使用。

你根本就不知道什么时候什么地方传了什么值?这些值会不会被“覆盖”(底层是两个不同的 context 节点,查找的时候,只会返回一个结果)?你肯定会崩溃的。

而这也是 context.Value 最受争议的地方。很多人建议尽量不要通过 context 传值。

4. 如何使用 context

context 使用起来非常方便。源码里对外提供了一个创建根节点 context 的函数:

go复制代码func Background() Context
           

background 是一个空的 context, 它不能被取消,没有值,也没有超时时间。

有了根节点 context,又提供了四个函数创建子节点 context:

scss复制代码func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
           

context 会在函数传递间传递。只需要在适当的时间调用 cancel 函数向 goroutines 发出取消信号或者调用 Value 函数取出 context 中的值。

在官方博客里,对于使用 context 提出了几点建议:

  1. 不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一参数,而且一般都命名为 ctx。
  2. 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库给你准备好了一个 context:todo。
  3. 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等。
  4. 同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。

4.1 取消 goroutine

我们先来设想一个场景:打开外卖的订单页,地图上显示外卖小哥的位置,而且是每秒更新 1 次。app 端向后台发起 websocket 连接请求后,后台启动一个协程,每隔 1 秒计算 1 次小哥的位置,并发送给客户端。如果用户退出此页面,则后台需要“取消”此过程,退出 goroutine,系统回收资源。

后端可能的实现如下:

scss复制代码func Perform() {
    for {
        calculatePos()
        sendResult()
        time.Sleep(time.Second)
    }
}
           

如果需要实现“取消”功能,并且在不了解 context 功能的前提下,可能会这样做:给函数增加一个指针型的 bool 变量,在 for 语句的开始处判断 bool 变量是发由 true 变为 false,如果改变,则退出循环。

上面给出的简单做法,可以实现想要的效果,没有问题,但是并不优雅,并且一旦协程数量多了之后,并且各种嵌套,就会很麻烦。优雅的做法,自然就要用到 context。

go复制代码func Perform(ctx context.Context) {
    for {
        calculatePos()
        sendResult()

        select {
        case <-ctx.Done():
            // 被取消,直接返回
            return
        case <-time.After(time.Second):
            // block 1 秒钟 
        }
    }
}
           

主流程可能是这样的:

css复制代码ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)

// ……
// app 端返回页面,调用cancel 函数
cancel()
           

注意一个细节,WithTimeOut 函数返回的 context 和 cancelFun 是分开的。context 本身并没有取消函数,这样做的原因是取消函数只能由外层函数调用,防止子节点 context 调用取消函数,从而严格控制信息的流向:由父节点 context 流向子节点 context。

4.2 防止 goroutine 泄漏

前面那个例子里,goroutine 还是会自己执行完,最后返回,只不过会多浪费一些系统资源。这里改编一个“如果不用 context 取消,goroutine 就会泄漏的例子。

go复制代码func gen() <-chan int {
	ch := make(chan int)
	go func() {
		var n int
		for {
			ch <- n
			n++
			time.Sleep(time.Second)
		}
	}()
	return ch
}
           

这是一个可以生成无限整数的协程,但如果我只需要它产生的前 5 个数,那么就会发生 goroutine 泄漏:

csharp复制代码func main() {
	for n := range gen() {
		fmt.Println(n)
		if n == 5 {
			break
		}
	}
	// ……
}
           

当 n == 5 的时候,直接 break 掉。那么 gen 函数的协程就会执行无限循环,永远不会停下来。发生了 goroutine 泄漏。

用 context 改进这个例子:

go复制代码func gen(ctx context.Context) <-chan int {
	ch := make(chan int)
	go func() {
		var n int
		for {
			select {
			case <-ctx.Done():
				return
			case ch <- n:
				n++
				time.Sleep(time.Second)
			}
		}
	}()
	return ch
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // 避免其他地方忘记 cancel,且重复调用不影响

	for n := range gen(ctx) {
		fmt.Println(n)
		if n == 5 {
			cancel()
			break
		}
	}
	// ……
}
           

增加一个 context,在 break 前调用 cancel 函数,取消 goroutine。gen 函数在接收到取消信号后,直接退出,系统回收资源。

5. context 真的这么好吗

读完全文,你一定有这种感觉:context 就是为 server 而设计的。说什么处理一个请求,需要启动多个 goroutine 并行地去处理,并且在这些 goroutine 之间还要传递一些共享的数据等等,这些都是写一个 server 要做的事。

Go 官方建议我们把 Context 作为函数的第一个参数,甚至连名字都准备好了。这造成一个后果:因为我们想控制所有的协程的取消动作,所以需要在几乎所有的函数里加上一个 Context 参数。很快,我们的代码里,context 将像病毒一样扩散的到处都是。

另外,像 WithCancel、WithDeadline、WithTimeout、WithValue 这些创建函数,实际上是创建了一个个的链表结点而已。我们知道,对链表的操作,通常都是 O(n) 复杂度的,效率不高。

那么,context 包到底解决了什么问题呢?答案是:cancelation。仅管它并不完美,但它确实很简洁地解决了问题。

总结

context 包是 Go 1.7 引入的标准库,主要用于在 goroutine 之间传递取消信号、超时时间、截止时间以及一些共享的值等。

使用上,先创建一个根节点的 context,之后根据库提供的四个函数创建相应功能的子节点 context。由于它是并发安全的,所以可以放心地传递。

当使用 context 作为函数参数时,直接把它放在第一个参数的位置,并且命名为 ctx。另外,不要把 context 嵌套在自定义的类型里。

链接:https://juejin.cn/post/7170520137433350151