引言
之前对context的了解比较浅薄,只知道它是用来传递上下文信息的对象;
对于Context本身的存储、类型认识比较少。
最近又正好在业务代码中发现一种用法:在每个协程中都会复制一份新的局部context对象,想探究下这种写法在性能上有没有弊端。
jobList := []func() error{s.task1,s.task2,s.task3,s.task4,}
if err := gconc.GConcurrency(jobList); err != nil {resource.LoggerService.Error(ctx, "exec concurrency job list error", logit.Error("error", err))
}func (s *Service) task1() (err error) {if !s.isLogin() {return nil}// 新局部变量,值来自全局的context对象ctx := s.ctxreturn nil
}
基本概念
介绍
golang.org/x/net/context,是golang中的一个标准库,主要作用就是创建一个上下文,实现对程序中创建的协程通过传递上下文信息来实现对协程的管理。
创建一个Context对象
在Go语言中,可以通过多种方式创建Context:
空Context对象
Background()和TODO():这两个函数分别用于创建空的Context,通常作为根节点使用
- Background()通常用于main函数、初始化以及测试;
- TODO()则用于尚未确定使用哪种Context的情况。
可取消的Contex对象
WithCancel(parent Context):创建一个可取消的Context,并返回一个取消函数
- 当调用取消函数时,会通知所有的子Context,使它们都取消执行。
带有截止时间的Context对象
WithDeadline(parent Context, deadline time.Time):创建一个带有截止时间的Context,并返回一个取消函数
- 当超过截止时间时,会自动通知所有的子Context,使它们都取消执行;
- 当超过截止时间时,会自动通知所有的子Context,使它们都取消执行。
带有超时控制的Contex对象
WithTimeout(parent Context, timeout time.Duration):创建一个带有超时控制的Context,它等同于WithDeadline(parent, time.Now().Add(timeout))。
带键值对的Context对象
WithValue(parent Context, key, val interface{}):创建一个带有键值对的Context,同时保留父级Context的所有数据。
- 需要注意Context主要用于传递请求范围的数据,而不是用于存储大量数据或传递业务逻辑中的参数。
分析Context对象
上面介绍了几种创建Context对象的方法,包括创建可取消的Context、带有截止时间的Context以及带有键值对的Context
Context接口
type Context interface {Deadline() (deadline time.Time, ok bool)Done() <-chan struct{}Err() errorValue(key any) any
}
Context接口定义了四个方法:
- Deadline():返回该context被取消的时间,当没有设置截止日期时,返回ok==false。
- Done():返回一个只读的channel。当context被取消或超时时,此channel会被关闭,通知goroutine不再继续执行。
- Err():如果Done尚未关闭,返回nil;如果context已被取消或超时,返回取消或超时的错误。
- Value(key interface{}) interface{}:从context中获取与key相关联的值。通常用于传递一些请求范围内的变量,如用户认证信息、跟踪请求ID等。但需要注意的是,不应滥用此功能传递业务逻辑中的参数。
不同类型的Context
通过分析Context接口,可以知道Context对象都是对Context接口的实现,如空Context对象就是emptyCtx,它不包含任何值
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
}
而带有超时控制的Context其实就是一个带有定时器并且实现了Context接口的对象
type timerCtx struct {cancelCtxtimer *time.Timer // Under cancelCtx.mu.deadline time.Time
}
Context的作用
控制子协程
对于存在若干个协程的程序,协程之前可能会存在如下的关系,这就需要在父协程关闭时,对子协程及时关闭;
否则协程可能会持续存在与内存中,造成内存泄漏。
Context对子协程的控制销毁就是基于协程创建的过程中,为每个子协程创建子context,以WithCancel()方法为例进行分析:
WithCancel()会返回一个新的子context和一个上下文取消方法,当执行cancel时,当前协程下的子context都会被销毁。
package mainimport ("context""fmt""time"
)// worker 是一个模拟工作的函数,它接受一个 context 并根据 context 的状态来决定是否继续工作。
func worker(ctx context.Context, id int) {for {select {case <-ctx.Done():// 当 context 被取消时,worker 会收到通知并退出循环。fmt.Printf("Worker %d: stopping\n", id)returndefault:// 继续执行模拟的工作。fmt.Printf("Worker %d: working\n", id)time.Sleep(1 * time.Second)}}
}func main() {// 创建一个带取消功能的 context。ctx, cancel := context.WithCancel(context.Background())// 启动多个 worker 协程。for i := 1; i <= 3; i++ {go worker(ctx, i)}// 让主协程等待一段时间,然后取消 context。time.Sleep(5 * time.Second)fmt.Println("Main: canceling context")cancel()// 等待一段时间以确保所有 worker 都有机会响应取消信号。// 在实际应用中,你可能需要一个更复杂的机制来等待所有 worker 退出。time.Sleep(2 * time.Second)fmt.Println("Main: exiting")
}
传递上下文信息
通常使用带键值对的Contex对象,即ValueContext传递信息。
package mainimport ("context""fmt""time"
)// requestIDKey 是一个用于在 context 中存储请求 ID 的键。
type requestIDKey struct{}// worker 是一个模拟工作的函数,它接受一个 context 并从中提取请求 ID。
func worker(ctx context.Context, taskName string) {// 从 context 中获取请求 ID。requestID := ctx.Value(requestIDKey{}).(string)fmt.Printf("%s: started, request ID: %s\n", taskName, requestID)// 模拟工作。time.Sleep(2 * time.Second)// 完成工作。fmt.Printf("%s: completed, request ID: %s\n", taskName, requestID)
}func main() {// 创建一个带有请求 ID 的 context。requestID := "12345"ctx := context.WithValue(context.Background(), requestIDKey{}, requestID)// 启动多个 worker 协程。tasks := []string{"Task A", "Task B", "Task C"}for _, task := range tasks {go worker(ctx, task)}// 等待一段时间以确保所有 worker 都有机会完成工作。// 在实际应用中,你可能需要一个更复杂的机制来等待所有 worker 退出。time.Sleep(6 * time.Second)fmt.Println("Main: all tasks completed or timed out")
}
额外补充:Context变量的复制
写一个示例代码,通过debug来分析
type UserInfo struct {UID intName stringAddress *Address
}
type Address struct {X intY int
}func TestValueContext(t *testing.T) {ctx := context.Background()address := &Address{X: 101,Y: 202,}withValue := context.WithValue(ctx, UserInfo{}, UserInfo{UID: 1,Name: "test",Address: address,})// 新变量 拷贝的context对象copyCtx := withValuefmt.Println(copyCtx)
}
通过debug可以看到,和普通的变量赋值一样,拷贝出的copyCtx对象就是ctx对象的值;
拷贝的过程是浅拷贝,当ctx中包含指针时,拷贝的是其地址。
最开始的问题-拷贝Context的意义?
通过上面的分析我们可以知道以下几点事实
- Context的拷贝是针对具体实现了Context接口的对象,因为接口无法拷贝;
- Context对象的拷贝是浅拷贝,和普通的变量一样;
- 需要基于一个Context实现 添加信息、并发控制、并发安全等功能,需要使用Context库提供的方法,普通的拷贝没有意义。
因此,问题代码中对ctx的拷贝,不考虑代码清晰度的情况下,并没有额外的意义,而且在被拷贝的Context对象很大时,会有额外的内存开销。
func (s *Service) task1() (err error) {if !s.isLogin() {return nil}// 新局部变量,值来自全局的context对象ctx := s.ctxreturn nil
}
参考
Go语言高并发系列三:context - 掘金