Golang context.Context介绍

近日某公众号连推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)的步骤。

特别需要注意的一点:WithTimeout Context是在Context创建后就开始计时的,而非对应的函数调用后,因此WithTimeout context应当在函数调用前定义最好。

  • WithValue
func WithValue(parent Context, key, val interface{}) Context

WithValue相比于之前的3个With开头的函数有区别,他不会返回CancelFunc函数,可以为Context.Value传值。

官网明确说明WithValue的key应该是可比较的(否则panic),且不应该是字符串或任何其他内置类型,也不应当是可以被其他包访问的类型,建议传递一个包内的自定义类型,以免与其他使用context的包产生冲突。

直接示例之,下例展示了如何通过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) // 执行此函数时会检查给定的key参数是否与ctx中存储的key是否匹配,这里用的同一个变量当然匹配,因此会报found value
	f(ctx, favContextKey("color")) // 此函数执行时因为color并不是ctx中存储的key的值:language,因此会报key not found
}
// WithValue的适用场景很少,属于那种除非有实际需要,否则完全不必去主动了解的内容,待有实际需求场景时你自然就会想到WithValue ctx了。

三、总结:

一般来说当一个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传递更丰富的消息,有待挖掘补充。

一般来说,我们很少会有必要去自己实现context相关的对象,通常的场景是这样的:

一些第三方库或开源库的作者为了让自己的库变得更加人性化(如提供执行超时退出的功能),会为自己的函数加一个ctx参数,然后在函数内部不断地检查ctx.Done()的消息以便判断函数是否要退出。

而我们作为使用者,只需要先根据自己的需要构造一个Conext然后作为参数传给这个函数即可,select判断的逻辑根本不需要我们自己做,我们只需要关注一点:“构造自己需要的context”

例如我们想要函数超过5s自动报错,那就这样构造一个context传进去:

ctx, cancelFunc := context.WithTimeout(context.Background(), 5 * time.Second)
deffer cancelFunc()  // 无论ctx是否触发超时都要记得主动关闭cancelFunc,这是个好习惯
...
// 假设第三方函数格式类似如下:
execute(ctx context.Context, args ...interface{})
...

这样看和传入一个timeout参数,然后通过循环计数+1s来判断执行是否超时也是可以的,但是golang不是提供了time.After的channel嘛,用channel来判断更好些。

更重要的是替代传参timeout只是context的一种优势场景,我们还有其他context可以构造,并且使用统一的context包可以极大的简化ctx接受者的工作量,开源库作者只需要接收ctx然后判断即可,使用者只需要建造ctx然后传参即可,

大家在同一套框架下愉快的和谐共处~

posted @ 2021-12-16 21:47  realcp1018  阅读(865)  评论(0编辑  收藏  举报