--- title: 小记Go里常用的并发控制手段(一) tags: - Go - Golang - 并发控制 - Context - Sync categories: - - Go - 应用技巧 keywords: 'go,并发,context,sync,控制' date: 2022-09-28 09:58:56 --- 并发编程是Go语言无可置疑的强项之一,为了解决并发编程中常见的一些控制需求,Go也同样在标准库中提供非常常用的控制手段。而且这些控制手段在日常进行Go语言编程的时候也非常的常见。 常用的控制功能主要是`context`包中提供的各种`Context`和`sync`包中提供的各种同步原语。 ## 面向上下文的编程 `Context`直译过来就是上下文,上下文的使用在Go中也是一种编程模式。在并发编程中,处理超时、取消等操作是比较常见的,一般的处理方式是在这些异常情况出现的时候进行抢占操作或者中断操作。俩要解决这个问题,我们首先想到的可能是使用`channel`,例如以下这个示例。 ```go func main() { messageChannel := make(chan int, 10) done := make(chan bool) defer close(messageChannel) ticker := time.NewTicker(1 * time.Second) for range ticker.C { select { case <-done: fmt.Println("interrupt...") return default: fmt.Printf("message: %d\n", <-messageChannel) } } // 这里可以向messageChannel中推送一些内容 close(done) } ``` 这段代码其实非常容易理解,往名称为`done`的这个`channel`发送任意的一个数据(包括关闭这个`channel`),就会使协程优雅的退出。在这种模式下,每控制一个协程就需要定义一个buffer为0的`channel`,如果协程变多了以后,那么需要控制的`channel`也就变的复杂不容易控制了。而且如果各个协程还需要一些超时控制、取消控制,那么可能在程序所花费的大部分精力就都集中在控制上了。 在实际的项目中,常常有这样一种情况,一个协程(例如名称为R1)存在有若干的子任务(例如名称为T1、T2、T3等等),协程R1对其下的子任务是带有超时控制的,而子任务T1、T2等对其下的第二级子任务ST1、ST2等也同样具有超时控制。那么在这种情况下,ST1、ST2这些第二级子任务除了需要感知协程R1的取消信号,也同样需要感知其归属子任务T1、T2这些第一级子任务的取消信号。如果还是采用之前基于`channel`的控制方案,那么就会出现`channel`的嵌套,整个控制逻辑就会变得非常复杂,很容易混乱,如果子任务的层级再多一些,程序的代码基本上就会进入到无法维护的境地了。 对于这种情况,最优雅的解决方案是下面这样的: 1. 上级任务被取消以后,其下的所有下级任务都会被取消。 1. 中间某一个层级的任务被取消以后,不会影响其上级的和其同级的任务。 Go为了实现这种优雅的控制方法,就引入了`Context`。标准库中的`Context`是一个接口,其中包含了四个方法,以下是`Context`接口的源码,可以看看其中各个方法的用途。 ```go type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) inteface{} } ``` 从直觉上看起来这个`Context`的结构有一种很熟悉的感觉,好像看起来也是一个类似于上面基于`channel`的控制结构。其实原理是差不多的。 - `Deadline()`会返回当前`context`被取消的截止时间,如果`context`中没有设置之歌截止时限,那么返回的`ok`将是`false`。 - `Done()`会返回一个用于表示当前`context`是否已经被取消的状态。如果`context`被取消,那么`Done()`将返回一个关闭的`channel`;如果`context`不会被取消,那么将会返回`nil`。 - `Err()`方法会配合`Done()`返回相应的内容来表示`context`的状态。如果`Done()`返回的`channel`没有被关闭,那么`Err()`将会返回`nil`;如果`channel`已经关闭了,那么`Err()`将会返回非空的值表示任务结束的原因;如果`context`被取消,那么`Err()`将会返回`Canceled`;如果`context`超时,那么`Err()`将会返回`DeadlineExceeded`。 - `Value()`用于返回在`context`存储的键值对中的指定键对应的值,如果找不到指定的键,那么就会返回`nil`。 从这四个方法可以得出,在并发任务中`Context`要怎样来使用。 1. `Done()`方法返回的`channel`是用来传递结束信号来抢占并中断当前的任务,相当于之前控制方法中名为`done`的`channel`。 1. `Deadline()`用来指示一段时间以后,`context`是否会被取消。 1. `Err()`用来解释`context`的取消原因。 1. `Context`会形成一棵`Context`树,其中每一个节点都会携带一些键值对,如果`Value()`方法在当前的节点中找不到指定的键,那么就会到上一级节点中继续查找,直到根节点为止。 用`Context`来重新实现上面的示例,就可以让控制过程变得比较清晰了。 ```go func main() { messageChannel := make(chan int, 10) // 这里可以向messageChannel中推送一些内容 // 设定5秒钟以后结束context ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second) defer cancel() go func(ctx context.Context) { ticker := timer.NewTicker(1 * time.Second) for range ticker.C { select { case <-ctx.Done(): fmt.Println("interrupt...") return default: fmt.Printf("message: %d\n", <-messageChannel) } } }(ctx) defer close(messageChannel) select { case <-ctx.Done(): fmt.Println("main process exit...") } } ``` 在这个示例中虽然只启动了一个协程,但是可以看出来,这个协程的控制是通过传入的`context`控制的。如果同时启动了多个协程,那么只要使用了相同的`Context`实例,那么这些协程就可以统一的被这一个`Context`实例所控制。如此以来,就可以简化协程的控制了。而且前面也提到,`Context`是可以形成一棵树的,当其中的一个节点变成了取消状态,那么这个取消状态很容易就可以被传递到其子代的节点上,这样也就实现了对子代协程的控制。 ## 常用的`Context`类型 从前面的`Context`源码可以看出来,`Context`只是标准库中规定的一个接口,并不是实际使用中的`Context`实现。其实在Go的标准库中已经提供了一些常用的`Context`实现,但是这些实现的共同特点就是不能被显式实例化,只能使用一系列`context`包中提供的方法来获取。 ### 基础`Context`类型 标准库中所提供的基础`Context`类型主要有四个,分别是`emptyCtx`、`valueCtx`、`cancelCtx`和`timerCtx`。它们分别被用作执行不同控制功能的`Context`使用。 #### `emptyCtx` `emptyCtx`是所有的`Context`类型的基础,它的定义非常简单: ```go type emptyCtx int ``` 所以`emptyCtx`就是一个`int`类型的别名,但是实现了`Context`接口所需要的全部方法。`emptyCtx`没有超时时间,不能被取消,也不能存储任何键值对信息,它的目的只有一个:作为`Context`树的根节点。 !!! warning "" 不要尝试直接创建`emptyCtx`的实例,在Go中,这种首字母为小写的内容都是私有的,标准库就是利用这个规则来阻止你直接实例化所有的`Context`实例。 #### `valueCtx` `valueCtx`就比前面的`emptyCtx`要复杂多了。在它里面可以保存一对键值对信息,它的定义为: ```go type valueCtx struct { Context key, val interface{} } ``` 跟`emptyCtx`不一样,`valueCtx`就已经是一个结构体了。`valueCtx`中利用`Context`类型的字段来记录其父节点,所以`valueCtx`也就继承了其父级`Context`的所有信息。`key`和`val`两个字段表示一个键和值都是任意类型的键值对。 看到这里可能会有疑问,`valueCtx`中只能保存一个键值对信息,那么如果要在`Context`中保存多个键值对要怎么办。其实在`Context`的标准库实现中,多个键值对信息是靠一个`Context`链来保存的。这里只需要看一下`valueCtx`实现的`Value()`方法就明白了。 ```go func (c *valueCtx) Value(key interface{}) interface{} { if c.key == key { return c.val } return c.Context.Value(key) } ``` `valueCtx`中`Value()`方法的最后,实例返回了其父级`Context`中`Value()`方法的值,所以在当前的`Context`实例中如果找不到指定`key`的值,`Context`实例会自动的沿着`Context`链向上寻找。 !!! note "" 如果一直到根节点都不能找到指定`key`,那么就会返回根节点实现的`Value()`方法返回的值,也就是`nil`。 #### `cancelCtx` `cancelCtx`就更加复杂了一些,其中主要是增加了用于表示和控制任务取消的功能。它的定义为: ```go type cancelCtx struct { Context mu sync.Mutex done chan struct{} children map[canceler]struct{} err error } type canceler struct { cancel(removeFromParent bool, err error) Done() <-chan struct{} } ``` 在`cancelCtx`中,依旧与`valueCtx`一样,使用`Context`字段来保存父级`Context`,但是多了一个通道类型的`done`字段来传递取消信号。`chidren`字段是`cancelCtx`用来记录其子`Context`的,当调用`cancelCtx`中的`cancel()`方法的时候,实际上会迭代的调用`children`字段中所有子`Context`的`cancel()`方法。 #### `timerCtx` `timerCtx`是基于`cancelCtx`构建的类型,相比`cancelCtx`,`timerCtx`只是增加了可以定时执行取消的功能。所以它的定义为: ```go type timerCtx struct { cancelCtx timer *time.Timer deadline time.Time } ``` 在`timerCtx`内部,`Context`的取消还是通过内部的`cancelCtx`来完成,但是取消的激活是靠`timer`和`deadline`字段来完成的。 ### `Background()`和`TODO()` 标准库中提供的`Background()`和`TODO()`两个方法是用来获取根`Context`节点的,因为`emptyCtx`不能被手动实例化,所以在使用的时候就需要标准库中提供的这两个方法。从代码上看,`Background()`和`TODO()`返回的内容是一样的。 ```go var( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo } ``` 但是根据标准库中的解释,这两个`Context`实例的功用还是不一样的。`Background()`通常都是被用在主函数中的,所以`Background()`是我们程序中常用的根`Context`节点;而`TODO()`则是在不知道需要使用什么`Context`实例的时候才用得到。 ### `WithValue()` `WithValue()`方法是用来根据指定的父级`Context`创建一个携带着键值对内容的`Context`节点的,也是向`Context`中记录内容的唯一方法。 ### `WithCancel()` `WithCancel()`方法是用来根据给定的父级`Context`创建一个可以被取消的`Context`节点,其在调用以后会返回两个值:一个是新创建的`Context`节点,一个是用于触发取消的`CancelFunc`。要取消这个`Context`只需要调用创建的时候返回的`CancelFunc`即可。 ### `WithTimeout()`和`WithDeadline()` `WithTimeout()`和`WithDealine()`都是会返回一个`timerCtx`的实例,但是同时也都会返回一个用于手动触发取消动作的`CancelFunc`。这两个方法所返回的`timerCtx`不一样的地方在于所设置的取消时间的设定,`WithDeadline()`设置的是一个过期的时间点;`WithTimeout()`设置的是一个相对于当前时间的过期时长。 ## `Context`树的形成 在标准库中每一个`Context`类型都会保存其父级`Context`实例,而所有用来获取`Context`的方法也都必须要提供一个父级`Context`,这样就会形成一个树。就像下面这张图所描述的那样。 {% oss_image concurrent-control-in-go/go-context-tree.svg Context树的形成 700 %} 每调用一次获取`Context`实例的方法,就会创建一个新的节点,然而对于可以激活取消的`Context`节点,一旦触发其取消方法,那么就会将其连同其下的所有子代节点都取消掉。这就是`Context`树真正的使用目的。 !!! note "一些额外的用法" 根据`Context`中提供的保存键值对的功能,可以利用其在整个程序中共享一些全局内容,比如数据库连接、公共配置等。所以需要尽可能的在程序的各个角落都使用`Context`。