原文:https://www.practical-go-lessons.com/chap-37-context
你将在本章中学到什么?
1.什么是上下文?
2.什么是链表?
3.如何使用上下文包?
涵盖的技术概念
Context derivation
Linked list
Context key-value pair
Cancellation
Timeout
Deadline
介绍
章节专门介绍上下文包。在本章的第一部分,我们将了解什么是“上下文”以及它的目的是什么。在第二部分中,我们将了解如何在实际程序中使用上下文包。
什么是面向上下文的编程?
定义
上下文一词源自拉丁语“contexo”,意为将某事物与一组其他事物结合、连接、链接。在这里,我们认为某事物的上下文是与其他事物的一组链接和联系。在日常语言中,我们通常使用以下表达方式来描述上下文:
断章取义
在某事的背景下
事物、行为、言语都有其所处的上下文,与其他事物有联系。如果我们断章取义,就会简化事物,可能会误解其真实含义。上下文是改善决策的重要信息来源。上下文的一部分包括但不限于以下内容:
地点
日期
历史
人们
为了更好地理解上下文对我们的重要性,以下是一些例子:
在一次会议上,某人说:“这个想法不行。”如果我们只看到这句话,就可能会认为这个想法完全不可行。但如果我们了解到这个想法是在一个讨论中提出的,而且有其他人提出了类似的想法,那么我们就能更好地理解这个人的意思。
在一个历史事件中,我们需要了解当时的政治、社会和文化背景,才能真正理解事件的意义和影响。
因此,了解上下文对我们做出正确决策非常重要。
上下文可以增加对事件的理解
想象一下,在散步时,您听到两个人之间的对话:
爱丽丝: 你看过上周的比赛吗?
鲍勃: 是的!
爱丽丝: 在此之后,我相信他们会赢得下一场比赛!
鲍勃: 当然,我愿意赌一千他们谈论“比赛”。
一支球队上周赢得了一场比赛,下周有很大机会赢得另一场比赛。我们不知道它是哪支球队和哪种运动。对话的上下文可以帮助我们理解它。如果对话发生在纽约,我们可以猜测它与棒球或篮球有关,因为这些运动在那里非常受欢迎。如果这次谈话发生在巴黎,他们谈论足球的可能性非常高。我们在这里所做的是添加上下文来理解某些内容。在这里我们谈到了这个地方。我们还可以在对话的上下文中添加时间因素。如果我们知道事情发生的时间,我们将能够浏览本周的体育比赛结果以更好地了解。
Context改变行为
对事件背景的分析可以改变参与者的行为。在回答以下问题时,请考虑上下文:
当你在自己的国家和在另一个国家时,你的礼仪是否有所不同?
在办公室和与家人交流时,你使用的语言水平是否相同?
在面试时,你是否会穿得比平时更正式?
答案可能是“否”,这是因为我们的行为受到环境的影响。具体情况会影响我们的行动方式,环境也会影响我们的行为和反应。
计算机科学中的Context
通常,我们设计计算机程序来执行预定义的任务。我们实现的指定例程始终以相同的方式执行,不会根据使用它的用户而改变。即使环境改变,程序的行为也不会改变。而面向上下文的编程思想则是在程序中引入受上下文影响的变化。1999年,Abowd提出了一个有趣的上下文定义:“上下文是我们可以用来描述实体情况的任何信息。实体是被认为与用户和应用程序之间的交互相关的人、地点或物体,包括用户和应用程序本身。”隐式和显式信息是上下文的构建块。程序员应该考虑上下文来构建可以在运行时调整其行为的应用程序。那么,什么是聪明呢?“智力”一词源自拉丁语词根“intellego”,意思是辨别、解开、注意、认识。如果某个东西能够辨别和理解,那么它就是聪明的。将上下文引入应用程序并不会让它们变得智能,但它们往往会让它们了解它们的环境和用户。
“Context”包:历史和用例
历史
该软件包首先由 Google 开发人员内部开发。它已被引入Go标准库中。在此之前,它可以在 Go 子存储库中使用。
用例
context 包有两个主要用途:
取消传播
为了理解这种用法,我们以一家名为 FooBar 的虚构建筑公司为例。 巴黎市委托这家公司建造一个巨大的游泳池。巴黎市长在民众代表中捍卫了其想法,该项目已获得批准。公司开始开展该项目;项目经理已经订购了建造水池所需的所有原材料。四个月过去了,市长换了,项目也取消了! FooBar 的项目经理很生气;该公司不得不取消156份订单。他开始通过电话一一加入他们。其中一些还从其他建筑公司订购原材料。每个人都在遭受这种形势的快速演变。 现在我们假设项目经理没有取消分包商的订单。其他公司将生产所需的商品,但不会获得报酬。这是对资源的极大浪费。 正如您在下图 中可以看到的那样,项目的取消正在传播给所有间接参与的工人。市议会取消该项目;FooBar 公司也取消了对承包商的订单。 在建筑和其他人类活动中,我们总是有办法取消工作。我们可以使用 context 包将取消政策引入到我们的程序中。当向 Web 服务器发出请求时,如果客户端断开连接,我们可以取消所有工作链!
调用堆栈中的数据传输
当向 Web 服务器发出请求时,负责处理请求的 Web 服务器功能不会单独完成该工作。请求将通过一系列函数和方法,然后发送响应。单个请求可以生成对微服务架构中其他微服务的新请求!这个
函数调用链就是调用堆栈。我们将在本节中了解为什么随调用堆栈一起传输数据会很有用。 我们将举另一个例子:为购物应用程序开发 Web 服务器。我们有一个与我们的应用程序交互的用户。
用户将使用其网络浏览器进入登录;
页面填写其登录详细信息;
Web 浏览器将向服务器发送身份验证请求,服务器将请求转发到身份验证服务;
服务器将构建“我的帐户”页面(例如通过模板)并发送用户的响应。
如果用户请求“最后订单”页面,服务器将需要调用订单服务来检索它们。
我们可以将哪些数据添加到上下文中?
我们可以将发送请求的设备类型保留在上下文中。
如果设备是手机,我们可以选择加载轻量级模板来提高用户体验。
订单服务还可以只加载最后五个订单,以减少页面的渲染时间。
我们可以将经过身份验证的用户的 ID 保留在上下文中。
我们还可以保留传入请求的 IP。
身份验证层可以使用它来阻止可疑活动(引入阻止列表、检测错误、多次登录尝试)
另一个非常常见的用例是生成单个请求 ID。requestId 被传递到应用程序的每一层。有了 ID,负责维护的团队就能够在日志中跟踪请求。(分布式链路跟踪)
设置截止日期和超时
截止日期:任务应该完成的时间。 超时:是一个非常相似的概念。我们不考虑日历中的精确日期和时间,而是考虑允许的最长持续时间。 我们可以使用上下文来定义长时间运行的进程的时间限制。这是一个例子:
您开发了一个服务器,并且客户端指定了 1 秒的超时。
可以设置一个超时时间为1秒的上下文;在此1秒之后,客户端将断开连接。
在这种情况下,我们再次希望避免浪费资源。
接口
context 包公开了一个由四个方法组成的接口:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
在下一节中,我们将了解如何使用该包
链表
context 包是用标准数据结构构建的:链表。为了充分理解上下文是如何工作的,我们首先需要理解链表。 链表是数据元素的集合。列表中存储的数据类型不受限制;它可以是整数、字符串、结构、浮点数……等。列表的每个元素都是一个节点。每个节点包含两件事:
数据值
列表中下一个元素在内存中的地址。换句话说,这是一个指向下一个值的指针。
您可以在图 3 中看到链表的直观表示。该列表是“链接的”,列表中的节点有一个子节点(列表中的下一个元素)和一个父节点(列表中的最后一个元素)。请注意,这是不对的;列表中的第一个节点没有父节点。它是根源、起源、列表的头部。还有另一个值得注意的例外,即最终节点没有任何子节点。
root上下文: Background
在大多数程序中,我们在程序的root创建一个context.Background()。例如,在将启动我们的应用程序的主函数中。要创建根上下文,您可以使用以下语法:
ctx := context.Background()
对Background()函数的调用将返回一个指向空上下文的指针。在内部,对Background()的调用将创建一个新的context.emptyCtx。此类型未公开: 此类型未公开:
type emptyCtx int
emptyCtx 的基础类型是 int。该类型实现了 Context 接口所需的四个方法:
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
}
请注意,emptyCtx 类型还实现了接口 fmt.Stringer。这允许我们执行 fmt.Println(ctx):
fmt.Println(reflect.TypeOf(ctx))
// *context.emptyCtx
fmt.Println(ctx)
// context.Background
为您的函数/方法添加上下文
创建根上下文后,我们可以将其传递给函数或方法。但在此之前,我们必须向函数添加一个上下文参数:
func foo1(ctx context.Context, a int) {
//...
}
在前面的清单中,您注意到 go 项目中广泛使用的两个 Go 习惯用法:
上下文是函数的第一个参数,
上下文参数名为 ctx.
派生上下文
我们在上一节中创建了根上下文。这个上下文是空的;它什么也不做。我们可以做的是从空上下文中派生另一个子上下文:
要派生上下文,您可以使用以下函数:
WithCancel
WithTimeout
WithDeadline
WithValue
WithCancel
WithCancel 函数只接受一个名为parent 的参数。这个论证代表了我们想要导出的上下文。我们将创建一个新上下文,父上下文将保留对此新子上下文的引用。让我们看一下 WithCancel 函数的签名:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
该函数返回下一个子上下文和 CancelFunc。CancelFunc 是上下文包的自定义类型:
type CancelFunc func()
CancelFunc 是一个命名类型,其底层类型是 func()。该函数“告诉操作放弃其工作”(Golang 来源)。调用 WithCancel 将为我们提供取消操作的方法。以下是创建派生上下文的方法:
ctx, cancel := context.WithCancel(context.Background())
要取消操作,您需要调用 cancel :
cancel()
WithTimeout / WithDeadline
超时: 是进程正常完成所需的最长时间。对于任何需要可变时间执行的进程,我们可以添加超时,即允许等待的固定时间。如果没有超时,我们的应用程序可以无限期地等待进程完成。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
截止日期: 是一个指定的时间点。当您设置最后期限时,您指定进程不会超过该期限。
deadline := time.Date(2021, 12, 12, 3, 30, 30, 30, time.UTC)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
举例
没有上下文的情况
举个例子:我们将设计一个应用程序,它必须向 Web 服务器发出 HTTP 请求以获取数据,然后将其显示给用户。我们将首先考虑没有上下文的应用程序,然后向其添加上下文。 客户端
package main
import (
"log"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "http://127.0.0.1:8989", nil)
if err != nil {
panic(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
log.Println("resp received", resp)
}
我们这里有一个简单的 http 客户端。我们创建一个将调用“http://127.0.0.1:8989”的 GET 请求。如果我们无法创建请求,我们的程序就会出现恐慌。然后我们使用默认的HTTP客户端(http.DefaultClient)将请求发送到服务器(使用方法Do)。 然后将收到的响应打印给用户。 服务端
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println("request received")
time.Sleep(time.Second * 3) // 等待3s
fmt.Fprintf(w, "Response") // 发送数据到服务端
log.Println("response sent")
})
err := http.ListenAndServe("127.0.0.1:8989", nil) // set listen port
if err != nil {
panic(err)
}
}
代码很简单。我们首先使用 function.http.HandleFunc 设置 http 处理程序。该函数有两个参数:路径和响应请求的函数。 我们使用 time.Sleep(time.Second * 3) 指令等待 3 秒,然后写入响应。这个睡眠是为了伪造服务器应答所需的时间。在这种情况下,响应只是“响应”。 然后我们启动服务器来监听 127.0.0.1:8989(本地主机,端口 8989)。 测试 首先,我们启动服务器;然后,我们启动客户端。3秒后,客户端收到响应。
$ go run server.go
2019/04/22 12:17:11 request received
2019/04/22 12:17:14 response sent
$ go run client.go
2019/04/22 12:17:14 resp received &{200 OK 200 HTTP/1.1 1 1 map[Content-Length:[8] Content-Type:[text/plain; charset=utf-8] Date:[Mon, 22 Apr 2019 10:17:14 GMT]] 0xc000132180 8 [] false false map[] 0xc00011c000 <nil>}
正如您所看到的,我们的客户端必须处理 3 秒的延迟。让我们在服务器的增加条件:假设我们现在睡了 1 分钟。我们的客户将等待1分钟;它会阻止我们的应用程序 1 分钟。我们可以在这里注意到, 我们的客户端应用程序注定要等待服务器,即使它需要无限长的时间。这不是一个很好的设计。用户不会乐意无限期地等待应用程序应答。在我看来,最好告诉用户发生了问题,而不是让他无限期地等待。
客户端上下文
我们将保留之前创建的代码的基础。我们将从创建根上下文开始:
rootCtx := context.Background()
然后我们将这个上下文导出到一个名为 ctx 的新上下文中:
ctx, cancel := context.WithTimeout(rootCtx, 50*time.Millisecond)
函数 WithTimeout 接受两个参数:上下文和 time.Duration。
第二个参数是超时持续时间。
这里我们将其设置为 50 毫秒。
我建议您在实际应用程序中创建一个配置变量来保存超时持续时间。通过这样做,您无需重新编译程序来更改超时。
context.WithTimeout 将返回:
派生上下文
取消功能
取消函数来警告子进程应该放弃正在执行的操作。调用 cancel 将释放与上下文关联的资源。为了确保在程序结束时调用取消函数,我们将使用 defer 语句:
defer cancel()
下一步包括创建请求并向其附加我们全新的上下文:
req, err := http.NewRequest("GET", "http://127.0.0.1:8989", nil)
if err != nil {
panic(err)
}
// 增加上下文到我们的请求中
req = req.WithContext(ctx)
其他行与没有上下文的版本相同。 完整的客户端代码:
// context/client-side/main.go
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
rootCtx := context.Background()
req, err := http.NewRequest("GET", "http://127.0.0.1:8989", nil)
if err != nil {
panic(err)
}
// create context
ctx, cancel := context.WithTimeout(rootCtx, 50*time.Millisecond)
defer cancel()
// attach context to our request
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
fmt.Println("resp received", resp)
}
现在让我们测试我们的新客户端。以下是服务器的日志:
2019/04/24 00:52:08 request received
2019/04/24 00:52:11 response sent
我们看到我们收到了一个请求,并在 3 秒后发送了响应。以下是我们客户的日志:
panic: Get http://127.0.0.1:8989: context deadline exceeded
我们看到 http.DefaultClient.Do 返回了一个错误。
文中说已超过最后期限。
我们的请求已被取消,因为我们的服务器花了 3 秒来完成其工作。即使客户端取消了请求,服务器仍会继续执行工作。我们必须找到一种在客户端和服务器之间共享上下文的方法。
服务端上下文
Header
HTTP 请求由一组标头、正文和查询字符串组成。当我们发送请求时,Go 不会传输任何有关请求上下文的信息。 如果想可视化请求的标头,您可以在服务器代码中添加以下行:
fmt.Println("headers :")
for name, headers := range r.Header {
for _, h := range headers {
fmt.Printf("%s: %s\n", name, h)
}
}
我们用循环遍历请求的标头并打印它们。以下是与我们的客户端传输的标头:
headers :
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip
我们只有两个标题。第一个提供了有关所使用的客户端的更多信息。第二个通知服务器客户端可以接受 gzip 压缩的数据。与最终超时无关。 但是如果我们看一下 http.Request 对象,我们可以注意到有一个名为 Context() 的方法。此方法将检索请求的上下文。如果尚未定义,它将返回一个空上下文:
func (r *Request) Context() context.Context {
if r.ctx != nil {
return r.ctx
}
return context.Background()
}
文档说“当客户端连接关闭时,上下文将被取消”。这意味着在 go 服务器实现内部,当客户端连接关闭时,会调用取消函数。这意味着在我们的服务器内部,我们必须监听 ctx.Done() 返回的通道。当我们在该频道上收到消息时,我们必须停止当前正在做的事情。
doWork function
让我们看看如何将其引入我们的服务器。 例如,我们将引入一个新函数 doWork。它将代表我们的服务器处理的计算密集型任务。此 doWork 是 CPU 密集型操作的占位符。
我们将在单独的 goroutine 中启动 doWork 函数。该函数将上下文和写入结果的通道作为参数。我们看一下这个函数的代码:
// context/server-side/main.go
//...
func doWork(ctx context.Context, resChan chan int) {
log.Println("[doWork] launch the doWork")
sum := 0
for {
log.Println("[doWork] one iteration")
time.Sleep(time.Millisecond)
select {
case <-ctx.Done():
log.Println("[doWork] ctx Done is received inside doWork")
return
default:
sum++
if sum > 1000 {
log.Println("[doWork] sum has reached 1000")
resChan <- sum
return
}
}
}
}<font color = black>
在图 5 中,您可以看到 doWork 函数的活动图。在此函数中,我们将使用通道与调用者进行通信。我们创建一个 for 循环,并在该循环内放置一条 select 语句。在这个 select 语句中,我们有两种情况:
ctx.Done() 返回的通道已关闭。这意味着我们收到完成工作的命令。
在这种情况下,我们将中断循环,记录一条消息并返回。
默认情况(如果之前的情况没有执行则执行)
在这种默认情况下,我们将增加总和。
如果变量 sum 变得严格大于 1.000,我们将在结果通道 (resChan) 上发送结果。
服务端的处理
// context/server-side/main.go
//...
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println("[Handler] request received")
// 取出请求中的上下文
rCtx := r.Context()
// 创建结果接受通道
resChan := make(chan int)
// 加载一个异步程序
go doWork(rCtx, resChan)
// Wait for
// 1. 客户端丢失链接.
// 2. 函数完成
select {
case <-rCtx.Done():
log.Println("[Handler] context canceled in main handler, client has diconnected")
return
case result := <-resChan:
log.Println("[Handler] Received 1000")
log.Println("[Handler] Send response")
fmt.Fprintf(w, "Response %d", result) // send data to client side
return
}
})
err := http.ListenAndServe("127.0.0.1:8989", nil) // set listen port
if err != nil {
panic(err)
}
}
我们更改了处理程序的代码以使用请求上下文。这里要做的第一件事是检索请求的上下文:
rCtx := r.Context()
然后我们设置一个整数通道 (resChan),它允许您与 doWork 函数进行通信。我们将在单独的 goroutine 中启动 doWork 函数。
resChan := make(chan int)
// launch the function doWork in a goroutine
go doWork(rCtx, resChan)
然后我们将使用 select 语句来等待两个可能的事件:
客户端关闭连接;因此,取消通道将被关闭。
函数 doWork 已经完成了它的工作。(我们从 resChan 通道收到一个整数)
在选项 1 中,我们记录一条消息,然后返回。当选项 2 发生时,我们使用 resChan 通道的结果,并将其写入响应写入器。我们的客户端将收到 doWork 函数计算的结果。 让我们运行我们的服务器和客户端。在图6中您可以看到客户端和服务器程序的执行日志。 可以看到处理程序收到请求,然后启动 doWork 函数。然后处理程序接收取消信号。然后该信号被传播到 doWork 函数。
WithDeadline
WithDeadline 和 WithTimeout 非常相似。如果我们查看 context 包的源代码,我们可以看到函数 WithTimeout 只是 WithDeadline 的包装:
// source : context.go (in the standard library)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
如果您查看前面的代码片段,您可以看到超时持续时间已添加到当前时间。让我们看看 WithDeadline 函数的签名:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
该函数有两个参数:
父上下文
特定时间。
用法
正如我们在上一节中所说,截止日期和超时是类似的概念。超时表示为持续时间,而截止日期表示为特定时间点
WithDeadline 可以在使用 WithTimeout 的地方使用。这是标准库的示例:
// golang standard library
// src/net/dnsclient_unix.go
// line 133
// exchange sends a query on the connection and hopes for a response.
func (r *Resolver) exchange(ctx context.Context, server string, q dnsmessage.Question, timeout time.Duration) (dnsmessage.Parser, dnsmessage.Header, error) {
//....
for _, network := range []string{"udp", "tcp"} {
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(timeout))
defer cancel()
c, err := r.dial(ctx, network, server)
if err != nil {
return dnsmessage.Parser{}, dnsmessage.Header{}, err
}
//...
}
return dnsmessage.Parser{}, dnsmessage.Header{}, errNoAnswerFromDNSServer
}
这里的exchange函数将上下文作为第一个参数。
对于每个网络(UDP 或 TCP),它派生作为参数传递的上下文。
输入上下文是通过调用 context.WithDeadline 派生的。截止时间是通过将超时持续时间添加到当前时间来创建的:time.Now().Add(timeout)
请注意,创建派生上下文后,立即会延迟调用 context.WithDeadline 返回的取消函数。这意味着当exchange函数返回时,取消函数将被调用。
例如,如果 dial 函数由于某种原因返回错误,则exchange函数将返回,取消函数将被调用,并且取消信号将传播到子上下文。
取消传播
本节将深入探讨取消传播的机制。让我们举个例子:
func main(){
ctx1 := context.Background()
ctx2, c := context.WithCancel(ctx1)
defer c()
}
在这个小程序中,我们首先定义一个根上下文:ctx1。然后我们通过调用 context.WithCancel 来派生此上下文。Go 将创建一个新的结构。将被调用的函数如下:
// src/context/context.go
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
创建了一个 cancelCtx 结构,并将我们的根上下文嵌入其中。这里是类型 struct cancelCtx :
// src/context/context.go
type cancelCtx struct {
Context
mu sync.Mutex // protects the following fields
done chan struct{} // created lazily, closed by the first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
}
我们有五个字段:
上下文(父级)是一个嵌入字段(它没有明确的字段名称)
互斥锁(名为 mu)
名为done的通道
名为children 的字段是一个map。键的类型为 canceller,值的类型为 struct{}
还有一个名为 err 的错误
Canceller 是一个接口:
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
当我们执行取消函数时会发生什么?ctx2 会发生什么?
互斥量 (mu) 将被锁定。因此,没有其他 goroutine 能够修改这个上下文。
通道(完成)将被关闭
ctx2 的所有子级也将被取消(在这种情况下,我们没有子级…)
互斥体将被解锁。
func main() {
ctx1 := context.Background()
ctx2, c2 := context.WithCancel(ctx1)
ctx3, c3 := context.WithCancel(ctx2)
//...
}
这里我们创建了 ctx3,一个类型为 cancelCtx 的新对象。子上下文 ctx3 将添加到父上下文 (ctx2)。父上下文 ctx2 将保留其子上下文的记忆。 目前,它只有一个子 ctx3。现在让我们看看当我们调用取消函数 c2 时发生了什么。
互斥量 (mu) 将被锁定。因此,没有其他 goroutine 能够修改这个上下文。
通道(完成)将被关闭
ctx2 的所有子级也将被取消(在这种情况下,我们没有子级…)ctx3将通过相同的过程被取消
ctx1(ctx2 的父级)是一个空的 Ctx,因此 ctx2 不会从 ctx1 中删除。
互斥体将被解锁。
三次传播
func main() {
ctx1 := context.Background()
ctx2, c2 := context.WithCancel(ctx1)
ctx3, c3 := context.WithCancel(ctx2)
ctx4, c4 := context.WithCancel(ctx3)
}
正如您在图 中看到的,我们有一个根上下文和三个后代。
最后一个是ctx4当我们调用 c2 时,它将取消 ctx2 及其子项 (ctx3)。
当 ctx3 被取消时,它也将取消其所有子项,并且 ctx4 将被取消。
本节的关键信息是“当您取消上下文时,取消操作将从父级传播到子级”。
一个重要的习惯用法:defer cancel()
下面两行代码很常见:
ctx, cancel = context.WithCancel(ctx)
defer cancel()
您可能会在标准库中遇到这些行,但也在许多库中遇到这些行。一旦我们派生出现有上下文,就会在 defer 语句中调用 cancel 函数。 正如我们之前所看到的,取消指令从父母传播到孩子;为什么我们需要显式调用cancel?在构建库时,您不确定是否有人会在父上下文中有效地执行取消函数。通过在延迟语句中添加对取消的调用,您可以确保调用取消:
当函数返回时(或到达其主体的末尾)
或者当运行该函数的 goroutine 发生恐慌时。
Goroutine泄露
为了理解这个现象,我们举一个例子。 首先,我们定义两个函数:doSth 和 doSth2。这两个函数是虚拟的。他们将上下文作为第一个参数。然后他们无限期地等待 ctx.Done() close 返回的通道:
func doSth2(ctx context.Context) {
select {
case <-ctx.Done():
log.Println("second goroutine return")
return
}
}
func doSth(ctx context.Context) {
select {
case <-ctx.Done():
log.Println("first goroutine return")
return
}
}
我们现在将在名为 launch 的第三个函数中使用这两个函数:
func launch() {
ctx := context.Background()
ctx, _ = context.WithCancel(ctx)
log.Println("launch first goroutine")
go doSth(ctx)
log.Println("launch second goroutine")
go doSth2(ctx)
}
在此函数中,我们首先创建一个根上下文(由 context.Background 返回)。然后我们导出这个根上下文。我们调用 WithCancel() 方法来获取可以取消的上下文。 然后我们启动两个 goroutine。现在让我们看一下我们的主要功能:
// context/goroutine-leak/main.go
// ...
func main() {
log.Println("begin program")
go launch()
time.Sleep(time.Millisecond)
log.Printf("Gouroutine count: %d\n", runtime.NumGoroutine())
for {
}
}
我们在 goroutine 中启动函数。然后我们暂停一下(1 毫秒),然后计算 goroutine 的数量。运行时包中定义了一个非常方便的函数:
runtime.NumGoroutine()
这里的goroutine数量应该是3:1个主goroutine + 1个执行doSth的goroutine + 1个执行doSth2的goroutine。如果我们不调用 cancel,最后两个 goroutine 将无限期地运行。请注意,我们在程序中创建了另一个 goroutine:启动 launch 的 goroutine。这个 goroutine 不会被计算在内,因为它几乎会立即返回。当我们取消上下文时,我们的两个 goroutine 将返回。因此,goroutine 的数量将减少到 1 个(主协程)。但在这里,我们根本不调用取消函数。
2019/05/04 19:01:16 begin program
2019/05/04 19:01:16 launch first goroutine
2019/05/04 19:01:16 launch second goroutine
2019/05/04 19:01:16 Gouroutine count: 3
在主函数中,我们无法取消上下文(因为它是在启动函数中定义的)。我们有 2 个泄漏的 goroutine!为了解决这个问题,我们只需修改函数 launch 并添加一个延迟语句:
func launch() {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
log.Println("launch first goroutine")
go doSth(ctx)
log.Println("launch second goroutine")
go doSth2(ctx)
}
2019/05/04 19:15:09 begin program
2019/05/04 19:15:09 launch first goroutine
2019/05/04 19:15:09 launch second goroutine
2019/05/04 19:15:09 first goroutine return
2019/05/04 19:15:09 second goroutine return
2019/05/04 19:15:09 Gouroutine count: 1
WithValue
上下文可以携带数据。此功能旨在与请求范围的数据一起使用,例如:
凭证(例如 JSON Web 令牌)
请求id(用于在系统中跟踪请求)
请求的IP
一些标头(例如:用户代理)
举例
/
/ context/with-value/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
uuid "github.com/satori/go.uuid"
)
func main() {
http.HandleFunc("/status", status)
err := http.ListenAndServe(":8091", nil)
if err != nil {
log.Fatal(err)
}
}
type key int
const (
requestID key = iota
jwt
)
func status(w http.ResponseWriter, req *http.Request) {
// 将请求ID添加到ctx中
ctx := context.WithValue(req.Context(), requestID, uuid.NewV4().String())
// 将认证凭证添加到ctx中
ctx = context.WithValue(ctx, jwt, req.Header.Get("Authorization"))
upDB, err := isDatabaseUp(ctx)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
upAuth, err := isMonitoringUp(ctx)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "DB up: %t | Monitoring up: %t\n", upDB, upAuth)
}
func isDatabaseUp(ctx context.Context) (bool, error) {
// 取出requestId
reqID, ok := ctx.Value(requestID).(string)
if !ok {
return false, fmt.Errorf("requestID in context does not have the expected type")
}
log.Printf("req %s - checking db status", reqID)
return true, nil
}
func isMonitoringUp(ctx context.Context) (bool, error) {
// 取出requestId
reqID, ok := ctx.Value(requestID).(string)
if !ok {
return false, fmt.Errorf("requestID in context does not have the expected type")
}
log.Printf("req %s - checking monitoring status", reqID)
return true, nil
}
我们创建了一个正在监听 localhost:8091 的服务器
该服务器只有一个路由:“/status”
我们使用 ctx := context.WithValue(req.Context(), requestID, uuid.NewV4().String()) 派生请求上下文 (req.Context())
我们向上下文添加一个键值对:requestID
然后我们更新操作。我们添加一个新的密钥对来保存请求凭据: ctx = context.WithValue(ctx, jwt, req.Header.Get(“Authorization”))
然后可以通过 isDatabaseUp 和 isMonitoringUp 访问上下文值:
reqID, ok := ctx.Value(requestID).(string)
if !ok {
return false, fmt.Errorf("requestID in context does not have the expected type")
}
★ 注: 在链路跟踪过程中,比较好的日志打印方式就是将traceID 作为日志的常驻字段后,将日志的子对象存入到ctx中,后面需要使用日志打印时,从ctx中取出log实例进行日志打印。
”
Key type
func WithValue(parent Context, key, val interface{}) Context
参数 key 和 val 的类型为 interface{}。换句话说,它们可以具有任何类型。仅应遵守一项限制,即key类型应具有可比性。我
们可以在多个包之间共享上下文。
您可能希望限制对添加值的包外部的上下文值的访问。
为此,您可以创建一个未导出的类型
所有的key都是这种类型的。
我们将在包内全局定义键:
type key int
const (
requestID key = iota
jwt
)
在前面的示例中,我们创建了一个具有基础类型 int (可比较)的类型键。然后我们定义了两个未导出的全局常量。然后使用这些常量添加值并从上下文中检索值:
/
/ add a value
ctx := context.WithValue(req.Context(), requestID, uuid.NewV4().String())
// get a value
reqID, ok := ctx.Value(requestID).(string)
预期缺失值和类型与实际类型不同
当在上下文中找不到键值对时,ctx.Value 将返回 nil。
这就是我们进行类型断言的原因:保护我们免受缺失值或不具有所需类型的值的影响。