12 KiB
title | tags | categories | keywords | date | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
小记Go里常用的并发控制手段(一) |
|
|
go,并发,context,sync,控制 | 2022-09-28 09:58:56 |
并发编程是Go语言无可置疑的强项之一,为了解决并发编程中常见的一些控制需求,Go也同样在标准库中提供非常常用的控制手段。而且这些控制手段在日常进行Go语言编程的时候也非常的常见。
常用的控制功能主要是context
包中提供的各种Context
和sync
包中提供的各种同步原语。
面向上下文的编程
Context
直译过来就是上下文,上下文的使用在Go中也是一种编程模式。在并发编程中,处理超时、取消等操作是比较常见的,一般的处理方式是在这些异常情况出现的时候进行抢占操作或者中断操作。俩要解决这个问题,我们首先想到的可能是使用channel
,例如以下这个示例。
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
的嵌套,整个控制逻辑就会变得非常复杂,很容易混乱,如果子任务的层级再多一些,程序的代码基本上就会进入到无法维护的境地了。
对于这种情况,最优雅的解决方案是下面这样的:
- 上级任务被取消以后,其下的所有下级任务都会被取消。
- 中间某一个层级的任务被取消以后,不会影响其上级的和其同级的任务。
Go为了实现这种优雅的控制方法,就引入了Context
。标准库中的Context
是一个接口,其中包含了四个方法,以下是Context
接口的源码,可以看看其中各个方法的用途。
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
要怎样来使用。
Done()
方法返回的channel
是用来传递结束信号来抢占并中断当前的任务,相当于之前控制方法中名为done
的channel
。Deadline()
用来指示一段时间以后,context
是否会被取消。Err()
用来解释context
的取消原因。Context
会形成一棵Context
树,其中每一个节点都会携带一些键值对,如果Value()
方法在当前的节点中找不到指定的键,那么就会到上一级节点中继续查找,直到根节点为止。
用Context
来重新实现上面的示例,就可以让控制过程变得比较清晰了。
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
类型的基础,它的定义非常简单:
type emptyCtx int
所以emptyCtx
就是一个int
类型的别名,但是实现了Context
接口所需要的全部方法。emptyCtx
没有超时时间,不能被取消,也不能存储任何键值对信息,它的目的只有一个:作为Context
树的根节点。
!!! warning ""
不要尝试直接创建emptyCtx
的实例,在Go中,这种首字母为小写的内容都是私有的,标准库就是利用这个规则来阻止你直接实例化所有的Context
实例。
valueCtx
valueCtx
就比前面的emptyCtx
要复杂多了。在它里面可以保存一对键值对信息,它的定义为:
type valueCtx struct {
Context
key, val interface{}
}
跟emptyCtx
不一样,valueCtx
就已经是一个结构体了。valueCtx
中利用Context
类型的字段来记录其父节点,所以valueCtx
也就继承了其父级Context
的所有信息。key
和val
两个字段表示一个键和值都是任意类型的键值对。
看到这里可能会有疑问,valueCtx
中只能保存一个键值对信息,那么如果要在Context
中保存多个键值对要怎么办。其实在Context
的标准库实现中,多个键值对信息是靠一个Context
链来保存的。这里只需要看一下valueCtx
实现的Value()
方法就明白了。
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
就更加复杂了一些,其中主要是增加了用于表示和控制任务取消的功能。它的定义为:
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
只是增加了可以定时执行取消的功能。所以它的定义为:
type timerCtx struct {
cancelCtx
timer *time.Timer
deadline time.Time
}
在timerCtx
内部,Context
的取消还是通过内部的cancelCtx
来完成,但是取消的激活是靠timer
和deadline
字段来完成的。
Background()
和TODO()
标准库中提供的Background()
和TODO()
两个方法是用来获取根Context
节点的,因为emptyCtx
不能被手动实例化,所以在使用的时候就需要标准库中提供的这两个方法。从代码上看,Background()
和TODO()
返回的内容是一样的。
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
。