引言
之前对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.ctx
return 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() error
Value(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 {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
Context的作用
控制子协程
对于存在若干个协程的程序,协程之前可能会存在如下的关系,这就需要在父协程关闭时,对子协程及时关闭;
否则协程可能会持续存在与内存中,造成内存泄漏。
Context对子协程的控制销毁就是基于协程创建的过程中,为每个子协程创建子context,以WithCancel()方法为例进行分析:
WithCancel()会返回一个新的子context和一个上下文取消方法,当执行cancel时,当前协程下的子context都会被销毁。
package main
import (
"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)
return
default:
// 继续执行模拟的工作。
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 main
import (
"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 int
Name string
Address *Address
}
type Address struct {
X int
Y 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 := withValue
fmt.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.ctx
return nil
}
参考
Go语言高并发系列三:context - 掘金