Golang并发编程-context包详解

1 关于context包

在 Go 服务器中,每个传入请求都在其自己的 goroutine 中处理。请求处理程序通常会启动其他 goroutine 来访问后端,例如数据库和 RPC 服务。处理请求的 goroutine 集通常需要访问特定于请求的值,例如最终用户的身份、授权令牌和请求的截止时间。当请求被取消或超时时,处理该请求的所有 goroutine 都应快速退出,以便系统可以回收它们正在使用的任何资源。

而context包,它可以轻松地将请求范围的值、取消信号和截止期限跨 API 边界传递给处理请求所涉及的所有 goroutine。

其实总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期。

目前我们常用的一些库都是支持context的,例如gin、database/sql等库都是支持context的,这样更方便我们做并发控制了,只要在服务器入口创建一个context上下文,不断透传下去即可。

2 创建Context

Go提供了两个方法来创建Context实例:

  • context.Background()
    创建默认的上下文,所有其他的上下文都应该从它衍生(Derived)出来。
  • context.TODO()
    当暂时不知道使用哪一种上下文时,使用该方法创建上下文来占位。

从后文的源码解析可以知道,这两个方法创建的context是一模一样的,只是官方对其定义不同。

这两种方式创建的都是空的根Context,不具备任何功能。要实现具体功能时,还需要根据需求,从根Context派生具体的Context。(关于Context的派生关系,后文会描述)。

3 使用Context传递数据

3.1 WithValue

使用context.WithValue方法,可以根据一个Context派生出一个新的子Context,这个新的Context会携带指定的数据。

func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

使用context的Value(key any) any方法,可以从context中获取指定key的数据。

func (c *valueCtx) Value(key any) any {
	if c.key == key {
		return c.val
	}
	return value(c.Context, key)
}
  • 如果当前context中找到了key,就返回对应值。 如果当前context中没有找到key,就从父context中找,以此类推。 如果在context及其所有父context中都没有找到key,返回nil。

3.2 注意事项

  • Context应该只用于跨API或进程传递请求数据,而不应该用于传递可选参数。(可以但不建议)
  • key必须是可比较的类型。
  • key不应该是字符串或任何其他内置类型,以避免不同包之间在使用上下文时发生键的冲突。
  • Context 中存储值的函数通常会在全局变量中分配一个键,然后使用该键作为 context.WithValue 和 Context.Value 的参数。键可以是任何支持相等的类型;包应该将键定义为未导出的类型以避免冲突。
  • 定义 Context 键的包应该为使用该键存储的值提供类型安全的访问器。

可以参考go blog - Go Concurrency Patterns: Context

3.3 官方推荐示例

// User 要存储到context中的值的类型
type User struct {
	UserID   string
	UserName string
}

// key 定义一个未导出的key类型
// 这可以防止与其他包中定义的key发生冲突。
type key int

// userKey context中存储用户信息的key.
// 它也是未导出的
// 客户端使用提供的安全访问器user.NewContext and user.FromContext来访问,而不是直接使用它
var userKey key

// NewContext 返回一个携带用户信息的新context
func NewContext(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, userKey, u)
}

// FromContext 返回存储在context中的用户信息
func FromContext(ctx context.Context) (*User, bool) {
	u, ok := ctx.Value(userKey).(*User)
	return u, ok
}

func main() {
	c := context.Background()

	// 存储用户信息
	c = NewContext(c, &User{
		UserID:   "001",
		UserName: "admin",
	})

	// 读取用户信息
	user, exist := FromContext(c)
	if exist {
		log.Printf("%+v", user)
		return
	}
	log.Println("user not found.")
}

3.4 笔者示例

type requestKeyType string  // 请求Key类型
type businessKeyType string // 业务Key类型

const (
	traceIDRequestKey  requestKeyType = "traceID" // 请求跟踪号
	userNameRequestKey requestKeyType = "admin"   // 请求用户

	traceIDBusinessKey  businessKeyType = "traceID" // 业务跟踪号
	userNameBusinessKey businessKeyType = "admin"   // 业务用户
)

func main() {
	// 创建根Context
	c := context.Background()
	// 携带请求数据
	c = context.WithValue(c, traceIDRequestKey, "das454asd564a6s1da165d4a1a3s1d6a413")
	c = context.WithValue(c, userNameRequestKey, "admin")
	// 携带业务数据
	c = context.WithValue(c, traceIDBusinessKey, "das556da1d35de31as1d65a1da4ew15q46q")
	c = context.WithValue(c, userNameBusinessKey, "genAdmin")

	// 请求处理
	DoRequest(c)
	// 业务处理
	DoBusiness(c)
}

func DoRequest(c context.Context) {
	fmt.Println(c.Value(traceIDRequestKey))  // das454asd564a6s1da165d4a1a3s1d6a413
	fmt.Println(c.Value(userNameRequestKey)) // admin
}

func DoBusiness(c context.Context) {
	fmt.Println(c.Value(traceIDBusinessKey))  // das556da1d35de31as1d65a1da4ew15q46q
	fmt.Println(c.Value(userNameBusinessKey)) // genAdmin
}

示例中,我们遵循官方的建议,自定义了key的类型,避免了key的冲突。在上述的场景中,如果我们不自定义key类型,而是直接使用string,就会因为key冲突从而导致程序允许不正确。

4 使用Context实现超时控制

4.1 WithDeadline

WithDeadline返回一个父context的副本,这个副本的deadline被调整到参数d之前(如果父context的deadline在d之前,则取父context的deadline。否则取参数d)。

返回的子context的Done通道将在以下任一场景关闭(谁先发生谁触发):

  • 时间到达deadline
  • 返回的cancel方法被调用
  • 父context的Done通道被关闭。
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	return WithDeadlineCause(parent, d, nil)
}

4.2 WithDeadline使用示例

执行一个比较耗时的函数,并设置超时时间为5秒。如果5秒内执行完成,就打印执行结果,否则打印执行超时错误。

func main() {
	// 设置超时时间为5秒钟
	ctx, cancelFunc := context.WithDeadline(context.Background(), time.Now().Add(time.Second*5))
	defer cancelFunc()

	// resultChan 结果通道
	resultChan := make(chan int,1)

	// 异步执行计算方法
	go func() {
		resultChan <- cal(5)
	}()

	// 等待
	select {
	case <-ctx.Done():
		// 打印超时信息
		fmt.Println(ctx.Err()) // 最终输出了:context deadline exceeded
		fmt.Println(context.Cause(ctx)) // 最终输出了:context deadline exceeded
	case n := <-resultChan:
		// 打印结果信息
		fmt.Println(n)
	}
}

// cal 方法睡眠6秒后返回
func cal(n int) int {
	time.Sleep(time.Second * 6)
	return n * n
}

4.3 WithDeadlineCause

再看一眼WithDeadline方法的源码:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	return WithDeadlineCause(parent, d, nil)
}

可以看到,WithDeadline方法实际上就是调用WithDeadlineCause方法,只是没有设置最后一个cause参数而已。

func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	c := &timerCtx{
		deadline: d,
	}
	c.cancelCtx.propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded, cause) // deadline has already passed
		return c, func() { c.cancel(false, Canceled, nil) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded, cause)
		})
	}
	return c, func() { c.cancel(true, Canceled, nil) }
}

这个cause参数,实际上是用于指定【原因】。调用context的Cause方法,可以获取到context被取消的原因。

cause参数
现在假定一个带超时时间或者截止时间的context(加入变量名为ctx),因为到达指定的时间,被自动超时取消了:

  • 如果创建时指定了cause参数为timeout
    • 调用ctx.Err()方法,将返回context deadline exceeded
    • 调用context.Cause(ctx)方法,将返回timeout
  • 如果创建时没有指定cause
    • 调用ctx.Err()方法,将返回context deadline exceeded
    • 调用context.Cause(ctx)方法,将返回context deadline exceeded

一句话来说:WithDeadline和WithDeadlineCause的区别在于有无cause,cause用于指定超时后context.Cause()方法返回的值。这看起来好像作用不大~

4.4 WithDeadlineCause使用示例

本示例就在WithDeadline示例的基础上进行少量改造:

func main() {
	// 设置超时时间为5秒钟
	// 设置超时原因为timeout
	ctx, cancelFunc := context.WithDeadlineCause(context.Background(), time.Now().Add(time.Second*5), errors.New("timeout"))
	defer cancelFunc()

	// resultChan 结果通道
	resultChan := make(chan int, 1)

	// 异步执行计算方法
	go func() {
		resultChan <- cal(5)
	}()

	// 等待
	select {
	case <-ctx.Done():
		// 打印超时信息
		fmt.Println(ctx.Err())          // 最终输出了:context deadline exceeded
		fmt.Println(context.Cause(ctx)) // 最终输出了:timeout
	case n := <-resultChan:
		// 打印结果信息
		fmt.Println(n)
	}
}

// cal 方法睡眠6秒后返回
func cal(n int) int {
	time.Sleep(time.Second * 6)
	return n * n
}

4.5 WithTimeout & WithTimeoutCause

源码:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

func WithTimeoutCause(parent Context, timeout time.Duration, cause error) (Context, CancelFunc) {
	return WithDeadlineCause(parent, time.Now().Add(timeout), cause)
}

可以看到,WithTimeout、WithTimeoutCause实际上分别是WithDeadline、WithDeadlineCause方法的简单变形。对于调用者来说,只是传参方式不一样而已,其他地方完全一致。因此这里不再赘述。

5 使用Context取消goroutine执行

5.1 WithCancel

有时候为了完成一个复杂的需求会开多个gouroutine去做一些事情,这就导致我们会在一次请求中开了多个goroutine却无法控制他们,这时我们就可以使用withCancel来衍生一个context传递到不同的goroutine中,当我想让这些goroutine停止运行,就可以调用cancel来进行取消。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := withCancel(parent)
	return c, func() { c.cancel(true, Canceled, nil) }
}

WithCancel 返回带有新 Done 通道的父级副本。当调用返回的取消函数或关闭父context的 Done 通道(以先发生者为准)时,返回的context的 Done 通道将关闭。

5.2 WithCancel使用示例

在下面的示例中,我们将启动一个协程去不断生成随机数。主协程负责读取生成的随机数,并在生成的随机数等于0时,取消随机数的生成。

func main() {
	// 创建取消上下文
	ctx, cancelFunc := context.WithCancel(context.Background())

	// 启动一个协程去生成随机数
	c := genRandomNum(ctx)

	// 读取生成的随机数
	for {
		i := <-c
		log.Printf("receive %d\n", i)
		
		// 如果生成的随机数等于0,就取消随机数生成
		if i == 0 {
			cancelFunc()
			break
		}
	}

	log.Println("end")
	
	// 这是为了等待被取消的协程打印日志,正常是不需要的
	time.Sleep(time.Second)
}

// genRandomNum 用于不断生成随机数,知道上下文取消
func genRandomNum(ctx context.Context) <-chan int {
	res := make(chan int)
	go func() {
		for {
			select {
			case <-ctx.Done():
				log.Println(ctx.Err()) // 将打印:context canceled
				return
			case res <- rand.Intn(100):
			}
		}
	}()
	return res
}

5.3 WithCancelCause

func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc) {
	c := withCancel(parent)
	return c, func(cause error) { c.cancel(true, Canceled, cause) }
}

WithCancelCause方法与WithCancel方法唯一的不同在于:WithCancelCause方法返回的取消方法可以指定取消的原因。

使用context.Cause方法可以获取取消原因。

5.4 WithCancelCause使用示例

在下面的示例中,我们将启动一个协程去不断生成随机数。主协程负责读取生成的随机数,并在生成的随机数等于0,或者生成的随机数数量达到100时,取消随机数的生成,同时设置取消的原因。

func main() {
	// 创建取消上下文
	ctx, cancelFunc := context.WithCancelCause(context.Background())

	// 启动一个协程去生成随机数
	c := genRandomNum(ctx)

	count := 0

	// 读取生成的随机数
	for {
		i := <-c
		log.Printf("receive %d\n", i)

		// 如果生成的随机数等于0,就取消随机数生成
		if i == 0 {
			cancelFunc(errors.New("generated num is zero"))
			break
		}

		// 如果生成的随机数数量达到100个,就取消随机数生成
		count++
		if count == 100 {
			cancelFunc(errors.New("the number of generated numbers equals 100"))
			break
		}

	}

	log.Println("end")

	// 这是为了等待被取消的协程打印日志,正常是不需要的
	time.Sleep(time.Second)
}

// genRandomNum 用于不断生成随机数,知道上下文取消
func genRandomNum(ctx context.Context) <-chan int {
	res := make(chan int)
	go func() {
		for {
			select {
			case <-ctx.Done():
				log.Println(ctx.Err()) // 总会打印:context canceled
				// 可能打印:generated num is zero
				// 可能打印:the number of generated numbers equals 100
				log.Println(context.Cause(ctx))
				return
			case res <- rand.Intn(100):
			}
		}
	}()
	return res
}

6 源码阅读

6.1 Context核心接口

contex包的核心是Context类型:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Deadline 返回此 Context 将被取消的时间(如果有),也就是截至时间。
    • 如果没有设置截至时间,返回的ok=false
  • Done 返回一个通道,该通道充当代表 Context 运行的函数的取消信号,该通道在Context被取消或者超时时关闭。
    • 如果Context永远不会被取消/超时,该方法将返回一个nil通道
    • WithCancel会使Done通道在被取消时关闭;WithDeadline会使Done通道在到达截至时间时关闭;WithTimeout会使Done通道在超时时关闭。
  • Err方法返回Done通道关闭的原因
    • 如果Done通道没有关闭,返回nil
    • 如果Done通道关闭了,Err返回一个非空的error来解释原因
      • 如果是因为Context被取消了,返回Canceled
      • 如果是因为到达截止时间,返回DeadlineExceeded
      • 当Err返回一个非空的报错后,后续都返回相同的报错。
  • Value 返回与 key 关联的值,如果没有则返回 nil。
    • Value 允许 Context 携带请求范围的数据。该数据对于多个 goroutine 同时使用必须是安全的。
    • Context的Value应该用于跨API和进程边界传递请求范围的值,而不应该用于在方法中传递可选参数。
    • key标识Context中的特定值。在 Context 中存储值的函数通常会在全局变量中分配一个键,然后使用该键作为 context.WithValue 和 Context.Value 的参数。键可以是任何支持相等的类型;包应该将键定义为未导出的类型以避免冲突。
    • 定义 Context 键的包应该为使用该键存储的值提供类型安全的访问器。

6.2 Context的派生

对于一个Context实例,可以使用go语言提供的With系列的方法进行派生,即根据传入的父节点Context,派生一个子节点Context。每个父节点Context可以派生出任意数量的子节点Context,这样就形成了一个Context树:
在这里插入图片描述

6.3 根Context

Go源码中提供了两个根Context:

  • backgroundContext
    • 是一个非零、空的Context,永远不会被取消,也没有Values,也没有超时时间。它通常在main函数、初始化和测试时使用。
    • 通常会将它作为顶级上下文。
  • todoContext
    • todoContext实际上跟background是一模一样的,从后面的源码中也可以看出来。
    • 当不清楚要使用哪个 Context 或它尚不可用时(因为周围的函数尚未扩展为接受 Context 参数),代码应使用 context.TODO。

emptyCtx

emptyCtx 提供了Context接口的空实现:永远不会被取消,也没有Values,也没有超时时间

type emptyCtx struct{}

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 any) any {
	return nil
}

backgroundCtx

从源码可以看到,backgroundCtx 实际上就是一个空Context,只不过提供了一个Background方法来创建实例。

type backgroundCtx struct{ emptyCtx }

func (backgroundCtx) String() string {
	return "context.Background"
}

func Background() Context {
	return backgroundCtx{}
}

todoCtx

从源码可以看到,todoCtx和backgroundCtx是一模一样的。【唯一的不同也就是官方对他们两个使用场景的定义了吧】

type todoCtx struct{ emptyCtx }

func (todoCtx) String() string {
	return "context.TODO"
}

func TODO() Context {
	return todoCtx{}
}

6.4 派生方法

WithCancel

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := withCancel(parent)
	return c, func() { c.cancel(true, Canceled, nil) }
}

WithCancel方法返回两个值,第一个是基于父Context派生的cancelCtx,第二个是取消方法CancelFunc。

  • 返回的cancelCtx是一个带有新 Done 通道的父级Context副本。当调用返回的CancelFunc,或者父级Context的Done通道被关闭时,它的Done通道也会被关闭。
  • 取消Context会释放其关联的资源,因此代码应该在Context中的操作完成时,立即调用取消方法。
  • 取消一个Context时,其实现了canceler接口的所有子节点Context也会被取消。

WithCancelCause

func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc) {
	c := withCancel(parent)
	return c, func(cause error) { c.cancel(true, Canceled, cause) }
}

WithCancelCause 的行为类似于 WithCancel,但返回 CancelCauseFunc而不是 CancelFunc。CancelCauseFunc 的行为类似于 CancelFunc,但还设置取消原因。可以通过在已取消的上下文或其任何派生上下文上调用 Cause来检索此原因。

Cause 返回一个非零错误,解释 被取消的原因。 Context或其父级之一的第一次取消确定了原因。如果取消是通过调用 CancelCauseFunc(err) 发生的,则 [Cause] 返回 err。否则 Cause© 返回与 c.Err() 相同的值。如果 c 尚未取消,Cause 返回 nil。

func Cause(c Context) error {
	if cc, ok := c.Value(&cancelCtxKey).(*cancelCtx); ok {
		cc.mu.Lock()
		defer cc.mu.Unlock()
		return cc.cause
	}
	return c.Err()
}

WithoutCancel

WithCancel 返回带有新 Done 通道的父级副本。当调用返回的取消函数或关闭父上下文的 Done 通道(以先发生者为准)时,返回的上下文的 Done 通道将关闭。

取消此上下文会释放与其关联的资源,因此在此上下文中运行的操作完成后,代码应立即调用取消。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := withCancel(parent)
	return c, func() { c.cancel(true, Canceled, nil) }
}

WithDeadline

ithDeadline 返回父上下文的副本,并将截止日期调整为不晚于 d。如果父级的截止日期已经早于 d,则 WithDeadline(parent, d) 在语义上等同于parent。当截止时间到期、调用返回的取消函数时或当父上下文的 Done 通道关闭时(以先发生者为准),返回的 [Context.Done] 通道将关闭。

取消此上下文会释放与其关联的资源,因此在此 [Context] 中运行的操作完成后,代码应立即调用取消。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	return WithDeadlineCause(parent, d, nil)
}

WithDeadlineCause

WithDeadlineCause 的行为类似于 [WithDeadline],但还设置超出截止日期时返回 Context 的原因。返回的[CancelFunc]没有设置原因。

func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	c := &timerCtx{
		deadline: d,
	}
	c.cancelCtx.propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded, cause) // deadline has already passed
		return c, func() { c.cancel(false, Canceled, nil) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded, cause)
		})
	}
	return c, func() { c.cancel(true, Canceled, nil) }
}

WithTimeout

WithTimeout 返回 WithDeadline(parent, time.Now().Add(timeout))。

取消此上下文会释放与其关联的资源,因此在此 [Context] 中运行的操作完成后,代码应立即调用取消

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

WithTimeoutCause

WithTimeoutCause 的行为类似于 [WithTimeout],但也设置超时到期时返回 Context 的原因。返回的[CancelFunc]没有设置原因。

func WithTimeoutCause(parent Context, timeout time.Duration, cause error) (Context, CancelFunc) {
	return WithDeadlineCause(parent, time.Now().Add(timeout), cause)
}

WithValue

WithValue 返回父级的副本,其中与 key 关联的值为 val。仅将上下文值用于传输进程和 API 的请求范围数据,而不是用于将可选参数传递给函数。提供的键必须是可比较的,并且不应是字符串类型或任何其他内置类型,以避免使用上下文的包之间发生冲突。 WithValue 的用户应该定义自己的键类型。为了避免在分配给 interface{} 时进行分配,上下文键通常具有具体类型 struct{}。或者,导出的上下文键变量的静态类型应该是指针或接口。

func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

7 参考文档

  • 10
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值