近日某公众号连推2篇关于context的文章,图文不符的错误多处,也不适合我理解,因此查看官方文档后总结一篇笔记。
context package - context - pkg.go.dev
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{} }
本文不会直接讲述context的设计初衷和由来,也不会直接讲述context相比于其他并发控制方式的优劣。
本文旨在通过解析context包官方文档和示例来探明context的使用方式,从而反推其使用场景。
这里先贴一下官网文档的Overview部分的简单直译。这部分内容描述了context包的使用方式、主要结构、使用场景,当写完整篇笔记后再来印证可以更深刻理解。
一、Overview部分直译,为便于理解有删减,完整内容查看官网链接:
context包提供了Context类型,这种类型可以承载deadlines、取消信号等可以在API边界和进程之间传递消息的对象。
向server发出请求时应当创建一个context,server处理呼叫应当接收context。整个调用链中context应当作为参数被处理函数传递。这些context可以是也最好是WithCancel, WithDeadline, WithTimeout 或者 WithValue这些衍生出来的child context。当一个Context被取消时,他的child context也会取消。
WithCancel, WithDeadline, WithTimeout这几个函数接收一个父Context对象,返回子Context对象和一个CancelFunc。当调用对应的CancelFunc时,对应的子Context对象就会被取消。调用CancelFunc失败则child context就会泄露直到父context被取消或者自身超时。
使用context的程序应当遵循以下规则,以便允许静态分析工具可以获知context的传播链路:
1. 不要在struct type中存储context,而应当将其作为函数的参数进行传递,即想要使用context时应该给函数额外加一个ctx的参数。
func DoSomething(ctx context.Context, arg Arg) error { // ... use ctx ... }
2. 永远不要传递nil context,如果你不确定该使用哪种context,那么可以先传个context.TODO替代。
3. Values不应当用作业务参数的传递(虽然这么做确实可以),而应当用来在APIs、processes之间传递消息。
4. 多个goroutine函数可以共用Context, context是并发安全的。
可以通过此地址查看示例:Go Concurrency Patterns: Context - go.dev,获知server是如何使用context传递消息的。
二、context包提供了四种child context使用示例:
Context接口并不需要我们自己实现,context包已经提供了2个函数(context.Background()和context.TODO())来返回空Context类型,并提供了4个With开头函数来生成具有特定功能的child Context。
- WithCancel
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
WithCancel返回一个child Context ctx,相比于输入的parent,其重写了Done(),实现的功能是:当CancelFunc被调用或parent的Done被写入时,ctx的Done channel会被写入(struct{}{}的空消息),使用ctx的goroutine就可以通过读取ctx.Done()来获知取消信号了。
package main import ( "context" "fmt" ) func main() { // gen是一个函数,用于不断的返回整数数字,因为是返回的是只读的unbuffered channel,因此只有当返回值被消费时才会继续返回下一个 gen := func(ctx context.Context) <-chan int { dst := make(chan int) n := 1 go func() { // 在gen内部,通过for select不断的检查ctx.Done()信息来确定自己是否需要return,未接收到ctx.Done()消息时就返回数字等待被消费 for { select { case <-ctx.Done(): return // 当从ctx.Done()接收到消息时return函数,防止泄露 case dst <- n: n++ } } }() return dst } // 在gen外部使用WithCancel创建一个ctx,可以看到其parent是context.Background(),context.Background()返回一个非nil的空Context ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 当主函数退出时执行cancel函数,此时ctx.Done() channel就会被写入,从而使gen退出 // 主函数遍历gen()的输出,当n=5时break循环,break之后defer cancel()触发,之后上述case <-ctx.Done():被触发从而退出gen函数 for n := range gen(ctx) { fmt.Println(n) if n == 5 { break } } // 试想下如果没有context会怎样: 当main函数for n := range gen(ctx) break之后,gen()会卡在n=6上无限阻塞而不会释放,这就是goroutine泄露(当然在本例中并不会,因为main函数执行完之后整个进程就退出了)
- WithDeadline
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
WithDeadline返回一个child Context ctx,相比于输入的parent,其deadline不晚于指定的d时刻。如果parent的deadline比d更早,那么按parent的deadline来算。
什么情况下ctx的Done channel会有消息:1. 当到达deadline时刻 2.CancelFunc被调用 3.parent的Done channel被写入。
package main import ( "context" "fmt" "time" ) const shortDuration = 1 * time.Millisecond func main() { d := time.Now().Add(shortDuration) // 定义一个基于当前时间1ms后的时刻:d ctx, cancel := context.WithDeadline(context.Background(), d) // 将d作为ctx超时的时刻 defer cancel() // 当main goroutine结束时调用cancel函数 select { case <-time.After(1 * time.Second): fmt.Println("overslept") case <-ctx.Done(): fmt.Println(ctx.Err()) } // 检查time.After(1 * time.Second)和ctx.Done()哪个channel有消息,都没消息就阻塞于此一直检查,发现一个有消息就执行对应逻辑然后执行defer cancel() // 主函数直到结束才会调用cancel(),time.After(1 * time.Second)时长高达1s,而deadline时刻只有1ms的长度,所以在1ms后ctx.Done()就会因为deadline到达而被写入,因此这个select会在1ms后就直接接收到ctx.Done()消息,然后执行fmt.Println(ctx.Err()),打印出错误:context deadline exceeded。 // 在这之后继续执行defer cancel()会继续给ctx.Done() channel发消息,那会遇到send on closed channel的panic吗?不会,Done()的返回是幂等的:Successive calls to Done return the same value.。 // 既然ctx的Done()会因为deadline到达而被提前写入消息,那还有必要defer cancel()吗?官网的解释是有必要,因为这可以确保ctx及其父context的释放。 }
- WithTimeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
除了这句无需解释了,WithTimeout就是WithDeadline的一个简易入口,使我们可以直接定义ctx多长时间超时,省了自己time.Now().Add(timeout)的步骤。
示例也不需要了我想。
- WithValue
func WithValue(parent Context, key, val interface{}) Context
WithValue相比于之前的3个With开头的函数有区别,他不会返回CancelFunc函数,可以为Context.Value传值。
但是官网明确说明WithValue的key不应该是字符串或任何其他内置类型,以免与context包自用的产生冲突。应当传递业务自定义类型。直接示例之:
package main import ( "context" "fmt" ) func main() { type favContextKey string // 自定义一个string的类型:favContextKey // 定义函数f,接收ctx和k参数 f := func(ctx context.Context, k favContextKey) { // 获取ctx中存储的key-value pair,如果匹配到了输入参数k对应的值,则把值存入v中并打印 if v := ctx.Value(k); v != nil { fmt.Println("found value:", v) return } // 如果在ctx中未匹配到k对应的value,那么打印未找到信息 fmt.Println("key not found:", k) } k := favContextKey("language") ctx := context.WithValue(context.Background(), k, "Go") // WithValue返回的ctx存储了一个key-value pair,其key为k,value为Go f(ctx, k) f(ctx, favContextKey("color")) }
三、总结:
一般来说当一个goroutine启动之后我们就很难控制他的运行了,除非预先定义了一个channel,然后在goroutine内部不断的检查channel的消息来决定后继运行逻辑。
基于此逻辑我们来总结context的使用:
通过上述3个示例,我们可以看到整个context包其实就是围绕Context.Done()这个channel来做文章的。无论是CancelFunc还是Deadline(),Err(),其目的都是辅助Done(),目的就是当满足某些条件时给Done channel传递消息,在goroutine内部则使用select检查ctx.Done()是否有消息来决定下一步的执行逻辑。
context包提供了一个更人性化的channel定义方式,免了开发者自定义各种通信channel的烦恼。
与sync.WaitGroup的区别何在?
很明显的wg用于等到本组内的goroutine自然终结,而context提供了主动终结goroutine的能力,虽然这种能力是建立在需要goroutine内部检查ctx相关状态的基础上的。
最后WithValue的使用与其他几个有很大区别,看起来更加的灵活,可以为goroutine传递更丰富的消息,有待挖掘补充。