第9章 context包
# 第9章 context包
上下文(Context)指的是某件事情发生的环境和背景。当我们讨论程序时,上下文指的就是程序的运行环境、配置等。对于服务器程序(比如响应客户端请求的HTTP服务器、响应函数调用的RPC服务器等),或者响应用户请求的程序(交互式程序、命令行应用程序等)而言,我们可以提及特定请求的上下文。特定请求的上下文在服务器或程序开始处理某个特定请求时创建,并在处理结束时终止。请求上下文包含诸如请求标识符等信息,这些信息有助于识别在处理请求过程中生成的日志消息,或者包含调用者的身份信息,以便确定调用者的访问权限。context
包的用途之一,就是对这样的请求上下文进行抽象,即创建一个用于保存特定请求数据的对象。
你可能还会关注请求的运行时间。通常,你会希望限制请求的处理时长,或者检测客户端是否不再关注请求的结果(例如WebSocket连接的对等方断开连接)。context
包也旨在处理这些情况。
context
包定义了context.Context
接口。它有两个主要用途:
- 为请求处理添加超时和 / 或取消功能
- 向下传递特定请求的元数据
context.Context
的使用并不局限于服务器程序。“请求处理” 这一术语应从广义上去理解:请求可以是通过TCP连接的网络请求、HTTP请求、从命令行读取的命令、使用特定标志运行的程序,等等。因此,context.Context
的用途更为广泛。
本章将介绍context
包的常见用法。在本章中,你将学习以下内容:
- 使用上下文传递请求作用域的数据
- 使用上下文进行取消操作和设置超时
# 使用上下文传递请求作用域的数据
请求作用域的对象是指在请求处理开始时创建,在请求处理结束时丢弃的对象。这些通常是轻量级的对象,例如请求标识符、用于识别调用者的身份验证信息或日志记录器。在本节中,你将了解如何使用上下文来传递这些对象。
# 如何操作...
向上下文添加数据值的惯用方法如下:
- 定义上下文键类型。这可以避免意外的名称冲突。通常会使用类似下面这样未导出的类型名称。这种模式将设置或获取特定类型上下文值的能力限制在当前包内:
type requestIDKeyType int
警告 你可能会想用 struct{} 来代替int 。毕竟,struct{} 不会占用任何额外内存。但在使用零大小的结构体时必须格外小心,因为Go语言规范并未对两个零大小结构体的等价性提供任何保证。也就是说,如果你创建多个零大小类型的变量,它们有时可能相等,有时可能不相等。简而言之,不要在这种情况下使用struct{} 。 |
---|
- 使用键类型定义键值。在下面的代码行中,
requestIDKey
被定义为requestIDKeyType
类型,其值为0(requestIDKey
在声明时被初始化为其零值):
var requestIDKey requestIDKeyType
- 使用
context.WithValue
将新值添加到上下文中。你可以定义几个辅助函数来设置和获取上下文中的值:
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx,requestIDKey, requestID)
}
func GetRequestID(ctx context.Context) string {
id,_: = ctx.Value(requestIDKey).(string)
return id
}
2
3
4
5
6
7
8
- 将新的上下文传递给从当前函数调用的函数:
newCtx: = WithRequestID(ctx, requestID)
handleRequest(newCtx)
2
# 它的工作原理...
你可能已经注意到,context.Context
并不完全像一个键值映射(它没有SetValue
方法;实际上,context.Context
是不可变的),尽管你可以用它来存储键值对。事实上,你不能向上下文添加键值,但你可以获取一个包含该键值的新上下文,同时保留旧上下文。上下文就像洋葱一样具有层次结构;每次向上下文添加内容时,都会创建一个新的上下文,该上下文与旧上下文相关联,但具有更多的特性:
// ctx: 一个空上下文
ctx := context.Background()
// ctx1: ctx + {key1:value1}
ctx1 := context.WithValue(ctx, "key1", "value1")
// ctx2: ctx1 + {key2:value2}
ctx2 := context.WithValue(ctx, "key2", "value2")
2
3
4
5
6
在前面的代码中,ctx
、ctx1
和ctx2
是三个不同的上下文。ctx
上下文为空。ctx1
包含ctx
以及键值对key1: value1
。ctx2
包含ctx1
以及键值对key2: value2
。假设你执行以下操作:
val1,_ := ctx2.Value("key1")
val2,_ := ctx2.Value("key2")
fmt.Println(val1, val2)
2
3
这将输出:
value1 value2
假设你对ctx1
执行相同操作:
val1,_ = ctx1.Value("key1")
val2,_ = ctx1.Value("key2")
fmt.Println(val1, val2)
2
3
这将输出:
value1 <nil>
对ctx
执行以下操作:
val1,_ = ctx.Value("key1")
val2,_ = ctx.Value("key2")
fmt.Println(val1, val2)
2
3
这将输出:
<nil> <nil>
提示
尽管你不能在上下文中设置值(即上下文是不可变的),但你可以设置一个指向结构体的指针,并在该结构体中设置值。
即:
type ctxData struct {
value int
}
...
ctx: = context.WithValue(context.Background(),dataKey, &ctxData{})
...
if data, exists: = ctx.Value(dataKey); exists {
data.(*ctxData).value = 1
}
2
3
4
5
6
7
8
9
10
11
标准库提供了几个预定义的上下文值:
context.Background()
返回一个没有值且无法取消的上下文。这通常是大多数操作的基础上下文。context.TODO()
与context.Background()
类似,其名称表明,无论在何处使用它,最终都应重构为接受真实的上下文。
# 更多内容...
上下文通常在多个goroutine之间共享。如果你在上下文中放入对象指针,就必须注意并发问题。看下面这个示例,它展示了一个HTTP服务的身份验证中间件:
type AuthInfo struct {
// 创建AuthInfo时设置
UserID string
// 延迟初始化
privileges map[string]Privilege
}
type authInfoKeyType int
var authInfoKey authInfoKeyType
// 设置权限(如果尚未初始化)。
// 不要这样做!!
func (auth *AuthInfo) GetPrivileges() map[string]Privilege {
if auth.privileges == nil {
// 可能有问题:如果多个调用AuthInfo.GetPrivileges的goroutine可能会尝试多次初始化映射,
// 导致相互覆盖。
auth.privileges = GetPrivileges(auth.UserID)
}
return auth.privileges
}
// 身份验证中间件
func AuthMiddleware(next http.Handler) func(http.Handler) http.Handler
{
return http.HandleFunc(func(w http.ResponseWriter, r *http.Request) {
// 对调用者进行身份验证
var authInfo *AuthInfo
var err error
authInfo, err = authenticate(r)
if err != nil {
http.Error(w,err.Error(),http.StatusUnauthorized)
return
}
// 使用身份验证信息创建一个新的上下文
newCtx: = context.WithValue(r.Context(), authInfoKey, authInfo)
// 将新上下文传递给下一个处理程序
next.ServeHTTP(w,r.WithContext(newCtx))
})
}
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
身份验证中间件创建一个*AuthInfo
实例,并使用包含身份验证信息的上下文调用链中的下一个处理程序。这段代码的问题在于,*AuthInfo
包含一个privileges
字段,该字段在调用AuthInfo.GetPrivileges
时初始化。由于上下文可以由处理程序传递给多个goroutine,这种延迟初始化方案容易引发数据竞争;多个调用AuthInfo.GetPrivileges
的goroutine可能会尝试多次初始化映射,导致相互覆盖。
可以使用互斥锁(mutex)来修正这个问题:
type AuthInfo struct {
sync.Mutex
UserID string
privileges map[string]Privilege
}
func (auth *AuthInfo) GetPrivileges() map[string]Privilege {
// 使用互斥锁以线程安全的方式初始化权限
auth.Lock()
defer auth.Unlock()
if auth.privileges == nil {
auth.privileges = GetPrivileges(auth.UserID)
}
return auth.privileges
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
也可以在中间件中一次性初始化权限来修正这个问题:
authInfo, err = authenticate(r)
if err ! =nil {
http.Error(w,err.Error(), http.StatusUnauthorized)
return
}
// 在创建结构体时在这里初始化权限
authInfo.GetPrivileges()
2
3
4
5
6
7
8
# 使用上下文进行取消操作
你可能想要取消一个计算过程,原因有很多:客户端可能已经断开连接,或者你可能有多个goroutine在进行一项计算,其中一个失败了,所以你不再希望其他的继续运行。你可以使用其他方法,比如关闭一个done
通道来发出取消信号,但根据具体的使用场景,上下文可能会更方便。上下文可以被多次取消(实际上只有第一次调用会执行取消操作,其余的调用将被忽略),而你不能关闭一个已经关闭的通道,否则会导致程序 panic。此外,你可以创建一个上下文树,取消一个上下文只会取消由它控制的goroutine,而不会影响其他的goroutine。
# 操作方法……
创建一个可取消的上下文并检测取消操作的步骤如下:
- 使用
context.WithCancel
基于现有上下文创建一个新的可取消上下文和一个取消函数(下列代码中的cancle函数):
ctx := context.Background()
cancelable, cancel := context.WithCancel(ctx)
defer cancel()
2
3
确保最终调用取消函数。取消操作会释放与该上下文相关的资源。 2. 将可取消上下文传递给可取消的计算任务或goroutine:
go cancelableGoroutine1(cancelable)
go cancelableGoroutine2(cancelable)
cancelableFunc(cancelable)
2
3
- 在可取消函数中,使用
ctx.Done()
通道或ctx.Err()
检查上下文是否已被取消:
func cancelableFunc(ctx context.Context) {
// 处理一些数据
// 检查上下文是否取消
select {
case <-ctx.Done():
// 上下文已取消
return
default:
}
// 继续计算
}
2
3
4
5
6
7
8
9
10
11
12
13
或者,使用以下方式:
func cancelableFunc(ctx context.Context) {
// 处理一些数据
// 检查上下文是否取消
if ctx.Err() != nil {
// 上下文已取消
return
}
// 继续计算
}
2
3
4
5
6
7
8
9
10
- 要手动取消一个函数,调用取消函数:
ctx := context.Background()
cancelable, cancel := context.WithCancel(ctx)
defer cancel()
wg := sync.WaitGroup{}
wg.Add(1)
go cancelableGoroutine1(cancelable, &wg)
if err := process(ctx); err != nil {
// 取消上下文
cancel()
// 执行其他操作
}
wg.Wait()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 确保最终调用取消函数(使用
defer cancel()
):
cancelable, cancel := context.WithCancel(ctx)
defer cancel()
...
2
3
警告
确保调用取消函数非常重要。如果你不取消可取消的上下文,与该上下文关联的goroutine将会泄漏(即,没有办法终止这些goroutine,它们会持续消耗内存)。
提示
取消函数可以多次调用,后续的调用将被忽略。
# 工作原理……
context.WithCancel
返回一个新的上下文和取消闭包。返回的上下文是基于原始上下文的可取消上下文:
// 空上下文,不可取消
originalContext := context.Background()
// 基于originalContext的可取消上下文
cancelableContext1, cancel1 := context.WithCancel(originalContext)
2
3
4
你可以使用这个上下文来控制多个goroutine:
go f1(cancelableContext1)
go f2(cancelableContext1)
2
你还可以基于一个可取消上下文创建其他可取消上下文:
cancelableContext2, cancel2 := context.WithCancel(cancelableContext1)
go g1(cancelableContext2)
go g2(cancelableContext2)
2
3
现在,我们有两个可取消上下文。调用cancel2
只会取消cancelableContext2
:
cancel2() // 仅取消g1和g2
调用cancel1
将取消cancelableContext1
和cancelableContext2
:
cancel1() // 取消f1、f2、g1、g2
上下文取消并不是自动取消goroutine的方式。你必须检查上下文是否取消并相应地进行清理:
func f1(cancelableContext context.Context) {
for {
if cancelableContext.Err() != nil {
// 上下文已取消
// 清理并返回
return
}
// 处理数据
}
}
2
3
4
5
6
7
8
9
10
11
# 使用上下文设置超时
超时实际上就是自动取消。在定时器到期后,上下文将被取消。这对于限制不太可能完成的计算任务的资源消耗很有用。
# 操作方法……
创建一个带超时的上下文并检测超时事件发生的步骤如下:
- 使用
context.WithTimeout
基于现有上下文创建一个新的可取消上下文,该上下文会在给定的持续时间后自动取消,并返回一个取消函数:
ctx := context.Background()
timeoutable, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
2
3
或者,你可以使用WithDeadline
在给定时刻取消上下文。确保最终调用取消函数。
2. 将带超时的上下文传递给可能会超时的计算任务或goroutine:
go longRunningGoroutine1(timeoutable)
go longRunningGoroutine2(timeoutable)
2
- 在goroutine中,使用
ctx.Done()
通道或ctx.Err()
检查上下文是否已被取消:
func longRunningGoroutine(ctx context.Context) {
// 处理一些数据
// 检查上下文是否取消
select {
case <-ctx.Done():
// 上下文已取消
return
default:
}
// 继续计算
}
2
3
4
5
6
7
8
9
10
11
12
或者,使用以下方式:
func cancelableFunc(ctx context.Context) {
// 处理一些数据
// 检查上下文是否取消
if ctx.Err() != nil {
// 上下文已取消
return
}
// 继续计算
}
2
3
4
5
6
7
8
9
10
- 要手动取消一个函数,调用取消函数:
ctx := context.Background()
timeoutable, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
wg := sync.WaitGroup{}
wg.Add(1)
go longRunningGoroutine(timeoutable, &wg)
if err := process(ctx); err != nil {
// 取消上下文
cancel()
// 执行其他操作
}
wg.Wait()
2
3
4
5
6
7
8
9
10
11
12
13
14
- 确保最终调用取消函数(使用
defer cancel()
):
timeoutable, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
...
2
3
# 工作原理……
超时功能实际上就是附加了一个定时器的取消操作。当定时器到期时,上下文就会被取消。
# 更多内容……
可能会出现这样的情况:一个goroutine阻塞了,却没有明显的方法来取消它。例如,你可能会阻塞等待从网络连接中读取数据:
func readData(conn net.Conn) {
// 从连接中读取一块数据
msg := make([]byte, 1024)
n, err := conn.Read(msg)
...
}
2
3
4
5
6
这个操作无法取消,因为Read
方法不接受Context
。如果你想取消这样的操作,可以异步关闭底层的连接(或文件)。以下代码片段展示了一个用例:必须在一秒内读取完连接中的所有数据,否则一个goroutine将异步关闭连接:
timeout, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// 当上下文超时时关闭连接
go func() {
// 等待取消信号
<-timeout.Done()
// 关闭连接
conn.Close()
}()
wg := sync.WaitGroup()
wg.Add(1)
// 这个goroutine必须在一秒内完成,否则连接将被关闭
go func() {
defer wg.Done()
// 从连接中读取一块数据
msg := make([]byte, 1024)
// 这个调用可能会阻塞
n, err := conn.Read(msg)
if err != nil {
return
}
// 处理数据
}()
wg.Wait() // 等待连接处理完成
...
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
# 在服务器中使用取消和超时机制
网络服务器通常在收到新请求时启动一个新的上下文。通常,当请求者关闭连接时,服务器会取消该上下文。包括标准库在内的大多数HTTP框架都遵循这个基本模式。如果你正在编写自己的TCP服务器,则必须自己实现这一机制。
# 操作方法……
处理带超时或取消机制的网络连接的步骤如下:
- 当接受一个网络连接时,创建一个带取消或超时的新上下文。
- 确保上下文最终被取消。
- 将上下文传递给处理函数:
ln, err := net.Listen("tcp", ":8080")
if err != nil {
return err
}
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go func(c net.Conn) {
// 步骤1:
// 请求在RequestTimeout时长后超时
ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout)
// 步骤2:
// 确保调用取消函数
defer cancel()
// 步骤3:
// 将上下文传递给处理函数
handleRequest(ctx, c)
}(conn)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 推荐阅读
讲解go context用法非常好的文档:
https://www.sohamkamani.com/golang/context/ (opens new window)