This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
<audio id="audio" title="16 | Semaphore一篇文章搞懂信号量" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/e0/c899f25eb84e13da3b3a84cea7c342e0.mp3"></audio>
你好,我是鸟窝。
在前面的课程里我们学习了标准库的并发原语、原子操作和Channel掌握了这些你就可以解决80%的并发编程问题了。但是,如果你要想进一步提升你的并发编程能力,就需要学习一些第三方库。
所以在接下来的几节课里我会给你分享Go官方或者其他人提供的第三方库这节课我们先来学习信号量信号量Semaphore是用来控制多个goroutine同时访问多个资源的并发原语。
# 信号量是什么?都有什么操作?
信号量的概念是荷兰计算机科学家Edsger Dijkstra在1963年左右提出来的广泛应用在不同的操作系统中。在系统中会给每一个进程一个信号量代表每个进程目前的状态。未得到控制权的进程会在特定的地方被迫停下来等待可以继续进行的信号到来。
最简单的信号量就是一个变量加一些并发控制的能力这个变量是0到n之间的一个数值。当goroutine完成对此信号量的等待wait该计数值就减1当goroutine完成对此信号量的释放release该计数值就加1。当计数值为0的时候goroutine调用wait等待该信号量是不会成功的除非计数器又大于0等待的goroutine才有可能成功返回。
更复杂的信号量类型,就是使用抽象数据类型代替变量,用来代表复杂的资源类型。实际上,大部分的信号量都使用一个整型变量来表示一组资源,并没有实现太复杂的抽象数据类型,所以你只要知道有更复杂的信号量就行了,我们这节课主要是学习最简单的信号量。
说到这儿呢,我想借助一个生活中的例子,来帮你进一步理解信号量。
举个例子图书馆新购买了10本《Go并发编程的独家秘籍》有1万个学生都想读这本书“僧多粥少”。所以图书馆管理员先会让这1万个同学进行登记按照登记的顺序借阅此书。如果书全部被借走那么其他想看此书的同学就需要等待如果有人还书了图书馆管理员就会通知下一位同学来借阅这本书。这里的资源是《Go并发编程的独家秘籍》这十本书想读此书的同学就是goroutine图书管理员就是信号量。
怎么样现在是不是很好理解了那么接下来我们来学习下信号量的P/V操作。
## P/V操作
Dijkstra在他的论文中为信号量定义了两个操作P和V。P操作descrease、wait、acquire是减少信号量的计数值而V操作increase、signal、release是增加信号量的计数值。
使用伪代码表示如下(中括号代表原子操作):
```
function V(semaphore S, integer I):
[S ← S + I]
function P(semaphore S, integer I):
repeat:
[if S ≥ I:
S ← S I
break]
```
可以看到初始化信号量S有一个指定数量**n**的资源它就像是一个有n个资源的池子。P操作相当于请求资源如果资源可用就立即返回如果没有资源或者不够那么它可以不断尝试或者阻塞等待。V操作会释放自己持有的资源把资源返还给信号量。信号量的值除了初始化的操作以外只能由P/V操作改变。
现在,我们来总结下信号量的实现。
- 初始化信号量:设定初始的资源的数量。
- P操作将信号量的计数值减去1如果新值已经为负那么调用者会被阻塞并加入到等待队列中。否则调用者会继续执行并且获得一个资源。
- V操作将信号量的计数值加1如果先前的计数值为负就说明有等待的P操作的调用者。它会从等待队列中取出一个等待的调用者唤醒它让它继续执行。
讲到这里,我想再稍微说一个题外话,我们在[第2讲](https://time.geekbang.org/column/article/295850)提到过饥饿就是说在高并发的极端场景下会有些goroutine始终抢不到锁。为了处理饥饿的问题你可以在等待队列中做一些“文章”。比如实现一个优先级的队列或者先入先出的队列等等保持公平性并且照顾到优先级。
在正式进入实现信号量的具体实现原理之前,我想先讲一个知识点,就是信号量和互斥锁的区别与联系,这有助于我们掌握接下来的内容。
其实信号量可以分为计数信号量counting semaphre和二进位信号量binary semaphore。刚刚所说的图书馆借书的例子就是一个计数信号量它的计数可以是任意一个整数。在特殊的情况下如果计数值只能是0或者1那么这个信号量就是二进位信号量提供了互斥的功能要么是0要么是1所以有时候互斥锁也会使用二进位信号量来实现。
我们一般用信号量保护一组资源比如数据库连接池、一组客户端的连接、几个打印机资源等等。如果信号量蜕变成二进位信号量那么它的P/V就和互斥锁的Lock/Unlock一样了。
有人会很细致地区分二进位信号量和互斥锁。比如说有人提出在Windows系统中互斥锁只能由持有锁的线程释放锁而二进位信号量则没有这个限制[Stack Overflow](https://stackoverflow.com/questions/62814/difference-between-binary-semaphore-and-mutex)上也有相关的讨论。实际上虽然在Windows系统中它们的确有些区别但是对Go语言来说互斥锁也可以由非持有的goroutine来释放所以从行为上来说它们并没有严格的区别。
我个人认为,没必要进行细致的区分,因为互斥锁并不是一个很严格的定义。实际在遇到互斥并发的问题时,我们一般选用互斥锁。
好了,言归正传,刚刚我们掌握了信号量的含义和具体操作方式,下面,我们就来具体了解下官方扩展库的实现。
# Go官方扩展库的实现
在运行时Go内部使用信号量来控制goroutine的阻塞和唤醒。我们在学习基本并发原语的实现时也看到了比如互斥锁的第二个字段
```
type Mutex struct {
state int32
sema uint32
}
```
信号量的P/V操作是通过函数实现的
```
func runtime_Semacquire(s *uint32)
func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
func runtime_Semrelease(s *uint32, handoff bool, skipframes int)
```
遗憾的是它是Go运行时内部使用的并没有封装暴露成一个对外的信号量并发原语原则上我们没有办法使用。不过没关系Go在它的扩展包中提供了信号量[semaphore](https://godoc.org/golang.org/x/sync/semaphore)不过这个信号量的类型名并不叫Semaphore而是叫Weighted。
之所以叫做Weighted我想应该是因为可以在初始化创建这个信号量的时候设置权重初始化的资源数其实我觉得叫Semaphore或许会更好。
<img src="https://static001.geekbang.org/resource/image/1a/b0/1a13a551346cd6b910f38f5ed2bfc6b0.png" alt="">
我们来分析下这个信号量的几个实现方法。
1. **Acquire方法**相当于P操作你可以一次获取多个资源如果没有足够多的资源调用者就会被阻塞。它的第一个参数是Context这就意味着你可以通过Context增加超时或者cancel的机制。如果是正常获取了资源就返回nil否则就返回ctx.Err(),信号量不改变。
1. **Release方法**相当于V操作可以将n个资源释放返还给信号量。
1. **TryAcquire方法**尝试获取n个资源但是它不会阻塞要么成功获取n个资源返回true要么一个也不获取返回false。
知道了信号量的实现方法在实际的场景中我们应该怎么用呢我来举个Worker Pool的例子来帮助你理解。
我们创建和CPU核数一样多的Worker让它们去处理一个4倍数量的整数slice。每个Worker一次只能处理一个整数处理完之后才能处理下一个。
当然,这个问题的解决方案有很多种,这一次我们使用信号量,代码如下:
```
var (
maxWorkers = runtime.GOMAXPROCS(0) // worker数量
sema = semaphore.NewWeighted(int64(maxWorkers)) //信号量
task = make([]int, maxWorkers*4) // 任务数是worker的四倍
)
func main() {
ctx := context.Background()
for i := range task {
// 如果没有worker可用会阻塞在这里直到某个worker被释放
if err := sema.Acquire(ctx, 1); err != nil {
break
}
// 启动worker goroutine
go func(i int) {
defer sema.Release(1)
time.Sleep(100 * time.Millisecond) // 模拟一个耗时操作
task[i] = i + 1
}(i)
}
// 请求所有的worker,这样能确保前面的worker都执行完
if err := sema.Acquire(ctx, int64(maxWorkers)); err != nil {
log.Printf(&quot;获取所有的worker失败: %v&quot;, err)
}
fmt.Println(task)
}
```
在这段代码中main goroutine相当于一个dispacher负责任务的分发。它先请求信号量如果获取成功就会启动一个goroutine去处理计算然后这个goroutine会释放这个信号量有意思的是信号量的获取是在main goroutine信号量的释放是在worker goroutine中如果获取不成功就等到有信号量可以使用的时候再去获取。
需要提醒你的是其实在这个例子中还有一个值得我们学习的知识点就是最后的那一段处理第25行。**如果在实际应用中你想等所有的Worker都执行完就可以获取最大计数值的信号量**。
Go扩展库中的信号量是使用互斥锁+List实现的。互斥锁实现其它字段的保护而List实现了一个等待队列等待者的通知是通过Channel的通知机制实现的。
我们来看一下信号量Weighted的数据结构
```
type Weighted struct {
size int64 // 最大资源数
cur int64 // 当前已被使用的资源
mu sync.Mutex // 互斥锁,对字段的保护
waiters list.List // 等待队列
}
```
在信号量的几个实现方法里Acquire是代码最复杂的一个方法它不仅仅要监控资源是否可用而且还要检测Context的Done是否已关闭。我们来看下它的实现代码。
```
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock()
// fast path, 如果有足够的资源都不考虑ctx.Done的状态将cur加上n就返回
if s.size-s.cur &gt;= n &amp;&amp; s.waiters.Len() == 0 {
s.cur += n
s.mu.Unlock()
return nil
}
// 如果是不可能完成的任务,请求的资源数大于能提供的最大的资源数
if n &gt; s.size {
s.mu.Unlock()
// 依赖ctx的状态返回否则一直等待
&lt;-ctx.Done()
return ctx.Err()
}
// 否则就需要把调用者加入到等待队列中
// 创建了一个ready chan,以便被通知唤醒
ready := make(chan struct{})
w := waiter{n: n, ready: ready}
elem := s.waiters.PushBack(w)
s.mu.Unlock()
// 等待
select {
case &lt;-ctx.Done(): // context的Done被关闭
err := ctx.Err()
s.mu.Lock()
select {
case &lt;-ready: // 如果被唤醒了忽略ctx的状态
err = nil
default: 通知waiter
isFront := s.waiters.Front() == elem
s.waiters.Remove(elem)
// 通知其它的waiters,检查是否有足够的资源
if isFront &amp;&amp; s.size &gt; s.cur {
s.notifyWaiters()
}
}
s.mu.Unlock()
return err
case &lt;-ready: // 被唤醒了
return nil
}
}
```
其实为了提高性能这个方法中的fast path之外的代码可以抽取成acquireSlow方法以便其它Acquire被内联。
Release方法将当前计数值减去释放的资源数n并唤醒等待队列中的调用者看是否有足够的资源被获取。
```
func (s *Weighted) Release(n int64) {
s.mu.Lock()
s.cur -= n
if s.cur &lt; 0 {
s.mu.Unlock()
panic(&quot;semaphore: released more than held&quot;)
}
s.notifyWaiters()
s.mu.Unlock()
}
```
notifyWaiters方法就是逐个检查等待的调用者如果资源不够或者是没有等待者了就返回
```
func (s *Weighted) notifyWaiters() {
for {
next := s.waiters.Front()
if next == nil {
break // No more waiters blocked.
}
w := next.Value.(waiter)
if s.size-s.cur &lt; w.n {
//避免饥饿,这里还是按照先入先出的方式处理
break
}
s.cur += w.n
s.waiters.Remove(next)
close(w.ready)
}
}
```
notifyWaiters方法是按照先入先出的方式唤醒调用者。当释放100个资源的时候如果第一个等待者需要101个资源那么队列中的所有等待者都会继续等待即使有的等待者只需要1个资源。这样做的目的是避免饥饿否则的话资源可能总是被那些请求资源数小的调用者获取这样一来请求资源数巨大的调用者就没有机会获得资源了。
好了,到这里,你就知道了官方扩展库的信号量实现方法,接下来你就可以使用信号量了。不过,在此之前呢,我想给你讲几个使用时的常见错误。这部分内容可是帮助你避坑的,我建议你好好学习。
# 使用信号量的常见错误
保证信号量不出错的前提是正确地使用它否则公平性和安全性就会受到损害导致程序panic。
在使用信号量时,最常见的几个错误如下:
- 请求了资源,但是忘记释放它;
- 释放了从未请求的资源;
- 长时间持有一个资源,即使不需要它;
- 不持有一个资源,却直接使用它。
不过,即使你规避了这些坑,在同时使用多种资源,不同的信号量控制不同的资源的时候,也可能会出现死锁现象,比如[哲学家就餐问题](https://en.wikipedia.org/wiki/Dining_philosophers_problem)。
就Go扩展库实现的信号量来说在调用Release方法的时候你可以传递任意的整数。但是如果你传递一个比请求到的数量大的错误的数值程序就会panic。如果传递一个负数会导致资源永久被持有。如果你请求的资源数比最大的资源数还大那么调用者可能永远被阻塞。
所以,**使用信号量遵循的原则就是请求多少资源,就释放多少资源**。你一定要注意,必须使用正确的方法传递整数,不要“耍小聪明”,而且,请求的资源数一定不要超过最大资源数。
# 其它信号量的实现
除了官方扩展库的实现实际上我们还有很多方法实现信号量比较典型的就是使用Channel来实现。
根据之前的Channel类型的介绍以及Go内存模型的定义你应该能想到使用一个buffer为n的Channel很容易实现信号量比如下面的代码我们就是使用chan struct{}类型来实现的。
在初始化这个信号量的时候我们设置它的初始容量代表有多少个资源可以使用。它使用Lock和Unlock方法实现请求资源和释放资源正好实现了Locker接口。
```
// Semaphore 数据结构并且还实现了Locker接口
type semaphore struct {
sync.Locker
ch chan struct{}
}
// 创建一个新的信号量
func NewSemaphore(capacity int) sync.Locker {
if capacity &lt;= 0 {
capacity = 1 // 容量为1就变成了一个互斥锁
}
return &amp;semaphore{ch: make(chan struct{}, capacity)}
}
// 请求一个资源
func (s *semaphore) Lock() {
s.ch &lt;- struct{}{}
}
// 释放资源
func (s *semaphore) Unlock() {
&lt;-s.ch
}
```
当然你还可以自己扩展一些方法比如在请求资源的时候使用Context参数Acquire(ctx)、实现TryLock等功能。
看到这里,你可能会问,这个信号量的实现看起来非常简单,而且也能应对大部分的信号量的场景,为什么官方扩展库的信号量的实现不采用这种方法呢?其实,具体是什么原因,我也不知道,但是我必须要强调的是,官方的实现方式有这样一个功能:**它可以一次请求多个资源这是通过Channel实现的信号量所不具备的**。
除了Channel[marusama/semaphore](https://github.com/marusama/semaphore)也实现了一个可以动态更改资源容量的信号量,也是一个非常有特色的实现。如果你的资源数量并不是固定的,而是动态变化的,我建议你考虑一下这个信号量库。
# 总结
这是一个很奇怪的现象标准库中实现基本并发原语比如Mutex的时候强烈依赖信号量实现等待队列和通知唤醒但是标准库中却没有把这个实现直接暴露出来放到标准库而是通过第三库提供。
不管怎样信号量这个并发原语在多资源共享的并发控制的场景中被广泛使用有时候也会被Channel类型所取代因为一个buffered chan也可以代表n个资源。
但是,官方扩展的信号量也有它的优势,就是可以一次获取多个资源。**在批量获取资源的场景中,我建议你尝试使用官方扩展的信号量**。
<img src="https://static001.geekbang.org/resource/image/67/73/674bc464d4e3d11c96fa1ac71d317e73.jpg" alt="">
# 思考题
1. 你能用Channel实现信号量并发原语吗你能想到几种实现方式
1. 为什么信号量的资源数设计成int64而不是uint64呢
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,478 @@
<audio id="audio" title="17 | SingleFlight 和 CyclicBarrier请求合并和循环栅栏该怎么用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/ca/7beb85bf34c2bc7f6f399df485a23bca.mp3"></audio>
你好,我是鸟窝。
这节课我来给你介绍两个非常重要的扩展并发原语SingleFlight和CyclicBarrier。SingleFlight的作用是将并发请求合并成一个请求以减少对下层服务的压力而CyclicBarrier是一个可重用的栅栏并发原语用来控制一组请求同时执行的数据结构。
其实,它们两个并没有直接的关系,只是内容相对来说比较少,所以我打算用最短的时间带你掌握它们。一节课就能掌握两个“武器”,是不是很高效?
# 请求合并SingleFlight
SingleFlight是Go开发组提供的一个扩展并发原语。它的作用是在处理多个goroutine同时调用同一个函数的时候只让一个goroutine去调用这个函数等到这个goroutine返回结果的时候再把结果返回给这几个同时调用的goroutine这样可以减少并发调用的数量。
这里我想先回答一个问题标准库中的sync.Once也可以保证并发的goroutine只会执行一次函数f那么SingleFlight和sync.Once有什么区别呢
其实sync.Once不是只在并发的时候保证只有一个goroutine执行函数f而是会保证永远只执行一次而SingleFlight是每次调用都重新执行并且在多个请求同时调用的时候只有一个执行。它们两个面对的场景是不同的**sync.Once主要是用在单次初始化场景中而SingleFlight主要用在合并并发请求的场景中**,尤其是缓存场景。
如果你学会了SingleFlight在面对秒杀等大并发请求的场景而且这些请求都是读请求时你就可以把这些请求合并为一个请求这样你就可以将后端服务的压力从n降到1。尤其是在面对后端是数据库这样的服务的时候采用 SingleFlight可以极大地提高性能。那么话不多说就让我们开始学习SingleFlight吧。
## 实现原理
SingleFlight使用互斥锁Mutex和Map来实现。Mutex提供并发时的读写保护Map用来保存同一个key的正在处理in flight的请求。
SingleFlight的数据结构是Group它提供了三个方法。
<img src="https://static001.geekbang.org/resource/image/2a/da/2a260ccce4e06ea1be2cf3f7abbe84da.png" alt="">
- Do这个方法执行一个函数并返回函数执行的结果。你需要提供一个key对于同一个key在同一时间只有一个在执行同一个key并发的请求会等待。第一个执行的请求返回的结果就是它的返回结果。函数fn是一个无参的函数返回一个结果或者error而Do方法会返回函数执行的结果或者是errorshared会指示v是否返回给多个请求。
- DoChan类似Do方法只不过是返回一个chan等fn函数执行完产生了结果以后就能从这个chan中接收这个结果。
- Forget告诉Group忘记这个key。这样一来之后这个key请求会执行f而不是等待前一个未完成的fn函数的结果。
下面,我们来看具体的实现方法。
首先SingleFlight定义一个辅助对象call这个call就代表正在执行fn函数的请求或者是已经执行完的请求。Group代表SingleFlight。
```
// 代表一个正在处理的请求,或者已经处理完的请求
type call struct {
wg sync.WaitGroup
// 这个字段代表处理完的值在waitgroup完成之前只会写一次
// waitgroup完成之后就读取这个值
val interface{}
err error
// 指示当call在处理时是否要忘掉这个key
forgotten bool
dups int
chans []chan&lt;- Result
}
// group代表一个singleflight对象
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
```
我们只需要查看一个Do方法DoChan的处理方法是类似的。
```
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {//如果已经存在相同的key
c.dups++
g.mu.Unlock()
c.wg.Wait() //等待这个key的第一个请求完成
return c.val, c.err, true //使用第一个key的请求结果
}
c := new(call) // 第一个请求创建一个call
c.wg.Add(1)
g.m[key] = c //加入到key map中
g.mu.Unlock()
g.doCall(c, key, fn) // 调用方法
return c.val, c.err, c.dups &gt; 0
}
```
doCall方法会实际调用函数fn
```
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
c.val, c.err = fn()
c.wg.Done()
g.mu.Lock()
if !c.forgotten { // 已调用完删除这个key
delete(g.m, key)
}
for _, ch := range c.chans {
ch &lt;- Result{c.val, c.err, c.dups &gt; 0}
}
g.mu.Unlock()
}
```
在这段代码中你要注意下第7行。在默认情况下forgotten==false所以第8行默认会被调用也就是说第一个请求完成后后续的同一个key的请求又重新开始新一次的fn函数的调用。
Go标准库的代码中就有一个SingleFlight的[实现](https://github.com/golang/go/blob/50bd1c4d4eb4fac8ddeb5f063c099daccfb71b26/src/internal/singleflight/singleflight.go)而扩展库中的SingleFlight就是在标准库的代码基础上改的逻辑几乎一模一样我就不多说了。
## 应用场景
了解了SingleFlight的实现原理下面我们来看看它都应用于什么场景中。
Go代码库中有两个地方用到了SingleFlight。
第一个是在[net/lookup.go](https://github.com/golang/go/blob/b1b67841d1e229b483b0c9dd50ddcd1795b0f90f/src/net/lookup.go)中如果同时有查询同一个host的请求lookupGroup会把这些请求merge到一起只需要一个请求就可以了
```
// lookupGroup merges LookupIPAddr calls together for lookups for the same
// host. The lookupGroup key is the LookupIPAddr.host argument.
// The return values are ([]IPAddr, error).
lookupGroup singleflight.Group
```
第二个是Go在查询仓库版本信息时将并发的请求合并成1个请求
```
func metaImportsForPrefix(importPrefix string, mod ModuleMode, security web.SecurityMode) (*urlpkg.URL, []metaImport, error) {
// 使用缓存保存请求结果
setCache := func(res fetchResult) (fetchResult, error) {
fetchCacheMu.Lock()
defer fetchCacheMu.Unlock()
fetchCache[importPrefix] = res
return res, nil
// 使用 SingleFlight请求
resi, _, _ := fetchGroup.Do(importPrefix, func() (resi interface{}, err error) {
fetchCacheMu.Lock()
// 如果缓存中有数据,那么直接从缓存中取
if res, ok := fetchCache[importPrefix]; ok {
fetchCacheMu.Unlock()
return res, nil
}
fetchCacheMu.Unlock()
......
```
需要注意的是,这里涉及到了缓存的问题。上面的代码会把结果放在缓存中,这也是常用的一种解决缓存击穿的例子。
设计缓存问题时,我们常常需要解决缓存穿透、缓存雪崩和缓存击穿问题。缓存击穿问题是指,在平常高并发的系统中,大量的请求同时查询一个 key 时如果这个key正好过期失效了就会导致大量的请求都打到数据库上。这就是缓存击穿。
用SingleFlight来解决缓存击穿问题再合适不过了。因为这个时候只要这些对同一个key的并发请求的其中一个到数据库中查询就可以了这些并发的请求可以共享同一个结果。因为是缓存查询不用考虑幂等性问题。
事实上在Go生态圈知名的缓存框架groupcache中就使用了较早的Go标准库的SingleFlight实现。接下来我就来给你介绍一下groupcache是如何使用SingleFlight解决缓存击穿问题的。
groupcache中的SingleFlight只有一个方法
```
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error)
```
SingleFlight的作用是在加载一个缓存项的时候合并对同一个key的load的并发请求
```
type Group struct {
。。。。。。
// loadGroup ensures that each key is only fetched once
// (either locally or remotely), regardless of the number of
// concurrent callers.
loadGroup flightGroup
......
}
func (g *Group) load(ctx context.Context, key string, dest Sink) (value ByteView, destPopulated bool, err error) {
viewi, err := g.loadGroup.Do(key, func() (interface{}, error) {
// 从cache, peer, local尝试查询cache
return value, nil
})
if err == nil {
value = viewi.(ByteView)
}
return
}
```
其它的知名项目如Cockroachdb小强数据库、CoreDNSDNS服务器等都有SingleFlight应用你可以查看这些项目的代码加深对SingleFlight的理解。
总结来说使用SingleFlight时可以通过合并请求的方式降低对下游服务的并发压力从而提高系统的性能常常用于缓存系统中。最后我想给你留一个思考题你觉得SingleFlight能不能合并并发的写操作呢
# 循环栅栏CyclicBarrier
接下来我再给你介绍另外一个并发原语循环栅栏CyclicBarrier它常常应用于重复进行一组goroutine同时执行的场景中。
[CyclicBarrier](https://github.com/marusama/cyclicbarrier)允许一组goroutine彼此等待到达一个共同的执行点。同时因为它可以被重复使用所以叫循环栅栏。具体的机制是大家都在栅栏前等待等全部都到齐了就抬起栅栏放行。
事实上这个CyclicBarrier是参考[Java CyclicBarrier](https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/util/concurrent/CyclicBarrier.html)和[C# Barrier](https://docs.microsoft.com/en-us/dotnet/api/system.threading.barrier?redirectedfrom=MSDN&amp;view=netcore-3.1)的功能实现的。Java提供了CountDownLatch倒计时器和CyclicBarrier循环栅栏两个类似的用于保证多线程到达同一个执行点的类只不过前者是到达0的时候放行后者是到达某个指定的数的时候放行。C# Barrier功能也是类似的你可以查看链接了解它的具体用法。
你可能会觉得CyclicBarrier和WaitGroup的功能有点类似确实是这样。不过CyclicBarrier更适合用在“固定数量的goroutine等待同一个执行点”的场景中而且在放行goroutine之后CyclicBarrier可以重复利用不像WaitGroup重用的时候必须小心翼翼避免panic。
处理可重用的多goroutine等待同一个执行点的场景的时候CyclicBarrier和WaitGroup方法调用的对应关系如下
<img src="https://static001.geekbang.org/resource/image/c2/79/c2123588d4aa9f7dedec7fc35435c679.jpg" alt="">
可以看到如果使用WaitGroup实现的话调用比较复杂不像CyclicBarrier那么清爽。更重要的是如果想重用WaitGroup你还要保证将WaitGroup的计数值重置到n的时候不会出现并发问题。
WaitGroup更适合用在“一个goroutine等待一组goroutine到达同一个执行点”的场景中或者是不需要重用的场景中。
好了了解了CyclicBarrier的应用场景和功能下面我们来学习下它的具体实现。
## 实现原理
CyclicBarrier有两个初始化方法
1. 第一个是New方法它只需要一个参数来指定循环栅栏参与者的数量
1. 第二个方法是NewWithAction它额外提供一个函数可以在每一次到达执行点的时候执行一次。具体的时间点是在最后一个参与者到达之后但是其它的参与者还未被放行之前。我们可以利用它做放行之前的一些共享状态的更新等操作。
这两个方法的签名如下:
```
func New(parties int) CyclicBarrier
func NewWithAction(parties int, barrierAction func() error) CyclicBarrier
```
CyclicBarrier是一个接口定义的方法如下
```
type CyclicBarrier interface {
// 等待所有的参与者到达如果被ctx.Done()中断会返回ErrBrokenBarrier
Await(ctx context.Context) error
// 重置循环栅栏到初始化状态。如果当前有等待者那么它们会返回ErrBrokenBarrier
Reset()
// 返回当前等待者的数量
GetNumberWaiting() int
// 参与者的数量
GetParties() int
// 循环栅栏是否处于中断状态
IsBroken() bool
}
```
循环栅栏的使用也很简单。循环栅栏的参与者只需调用Await等待等所有的参与者都到达后再执行下一步。当执行下一步的时候循环栅栏的状态又恢复到初始的状态了可以迎接下一轮同样多的参与者。
有一道非常经典的并发编程的题目,非常适合使用循环栅栏,下面我们来看一下。
## 并发趣题:一氧化二氢制造工厂
题目是这样的:
>
有一个名叫大自然的搬运工的工厂,生产一种叫做一氧化二氢的神秘液体。这种液体的分子是由一个氧原子和两个氢原子组成的,也就是水。
>
这个工厂有多条生产线每条生产线负责生产氧原子或者是氢原子每条生产线由一个goroutine负责。
>
这些生产线会通过一个栅栏只有一个氧原子生产线和两个氢原子生产线都准备好才能生成出一个水分子否则所有的生产线都会处于等待状态。也就是说一个水分子必须由三个不同的生产线提供原子而且水分子是一个一个按照顺序产生的每生产一个水分子就会打印出HHO、HOH、OHH三种形式的其中一种。HHH、OOH、OHO、HOO、OOO都是不允许的。
>
生产线中氢原子的生产线为2N条氧原子的生产线为N条。
你可以先想一下,我们怎么来实现呢?
首先我们来定义一个H2O辅助数据类型它包含两个信号量的字段和一个循环栅栏。
1. semaH信号量控制氢原子。一个水分子需要两个氢原子所以氢原子的空槽数资源数设置为2。
1. semaO信号量控制氧原子。一个水分子需要一个氧原子所以资源数的空槽数设置为1。
1. 循环栅栏:等待两个氢原子和一个氧原子填补空槽,直到任务完成。
我们来看下具体的代码:
```
package water
import (
&quot;context&quot;
&quot;github.com/marusama/cyclicbarrier&quot;
&quot;golang.org/x/sync/semaphore&quot;
)
// 定义水分子合成的辅助数据结构
type H2O struct {
semaH *semaphore.Weighted // 氢原子的信号量
semaO *semaphore.Weighted // 氧原子的信号量
b cyclicbarrier.CyclicBarrier // 循环栅栏,用来控制合成
}
func New() *H2O {
return &amp;H2O{
semaH: semaphore.NewWeighted(2), //氢原子需要两个
semaO: semaphore.NewWeighted(1), // 氧原子需要一个
b: cyclicbarrier.New(3), // 需要三个原子才能合成
}
}
```
接下来,我们看看各条流水线的处理情况。
流水线分为氢原子处理流水线和氧原子处理流水线首先我们先看一下氢原子的流水线如果有可用的空槽氢原子的流水线的处理方法是hydrogenhydrogen方法就会占用一个空槽h2o.semaH.Acquire输出一个H字符然后等待栅栏放行。等其它的goroutine填补了氢原子的另一个空槽和氧原子的空槽之后程序才可以继续进行。
```
func (h2o *H2O) hydrogen(releaseHydrogen func()) {
h2o.semaH.Acquire(context.Background(), 1)
releaseHydrogen() // 输出H
h2o.b.Await(context.Background()) //等待栅栏放行
h2o.semaH.Release(1) // 释放氢原子空槽
}
```
然后是氧原子的流水线。氧原子的流水线处理方法是oxygen oxygen方法是等待氧原子的空槽然后输出一个O就等待栅栏放行。放行后释放氧原子空槽位。
```
func (h2o *H2O) oxygen(releaseOxygen func()) {
h2o.semaO.Acquire(context.Background(), 1)
releaseOxygen() // 输出O
h2o.b.Await(context.Background()) //等待栅栏放行
h2o.semaO.Release(1) // 释放氢原子空槽
}
```
在栅栏放行之前,只有两个氢原子的空槽位和一个氧原子的空槽位。只有等栅栏放行之后,这些空槽位才会被释放。栅栏放行,就意味着一个水分子组成成功。
这个算法是不是正确呢?我们来编写一个单元测试检测一下。
```
package water
import (
&quot;math/rand&quot;
&quot;sort&quot;
&quot;sync&quot;
&quot;testing&quot;
&quot;time&quot;
)
func TestWaterFactory(t *testing.T) {
//用来存放水分子结果的channel
var ch chan string
releaseHydrogen := func() {
ch &lt;- &quot;H&quot;
}
releaseOxygen := func() {
ch &lt;- &quot;O&quot;
}
// 300个原子300个goroutine,每个goroutine并发的产生一个原子
var N = 100
ch = make(chan string, N*3)
h2o := New()
// 用来等待所有的goroutine完成
var wg sync.WaitGroup
wg.Add(N * 3)
// 200个氢原子goroutine
for i := 0; i &lt; 2*N; i++ {
go func() {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
h2o.hydrogen(releaseHydrogen)
wg.Done()
}()
}
// 100个氧原子goroutine
for i := 0; i &lt; N; i++ {
go func() {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
h2o.oxygen(releaseOxygen)
wg.Done()
}()
}
//等待所有的goroutine执行完
wg.Wait()
// 结果中肯定是300个原子
if len(ch) != N*3 {
t.Fatalf(&quot;expect %d atom but got %d&quot;, N*3, len(ch))
}
// 每三个原子一组,分别进行检查。要求这一组原子中必须包含两个氢原子和一个氧原子,这样才能正确组成一个水分子。
var s = make([]string, 3)
for i := 0; i &lt; N; i++ {
s[0] = &lt;-ch
s[1] = &lt;-ch
s[2] = &lt;-ch
sort.Strings(s)
water := s[0] + s[1] + s[2]
if water != &quot;HHO&quot; {
t.Fatalf(&quot;expect a water molecule but got %s&quot;, water)
}
}
}
```
# 总结
每一个并发原语都有它存在的道理,也都有它应用的场景。
如果你没有学习CyclicBarrier你可能只会想到用WaitGroup来实现这个水分子制造工厂的例子。
```
type H2O struct {
semaH *semaphore.Weighted
semaO *semaphore.Weighted
wg sync.WaitGroup //将循环栅栏替换成WaitGroup
}
func New() *H2O {
var wg sync.WaitGroup
wg.Add(3)
return &amp;H2O{
semaH: semaphore.NewWeighted(2),
semaO: semaphore.NewWeighted(1),
wg: wg,
}
}
func (h2o *H2O) hydrogen(releaseHydrogen func()) {
h2o.semaH.Acquire(context.Background(), 1)
releaseHydrogen()
// 标记自己已达到等待其它goroutine到达
h2o.wg.Done()
h2o.wg.Wait()
h2o.semaH.Release(1)
}
func (h2o *H2O) oxygen(releaseOxygen func()) {
h2o.semaO.Acquire(context.Background(), 1)
releaseOxygen()
// 标记自己已达到等待其它goroutine到达
h2o.wg.Done()
h2o.wg.Wait()
//都到达后重置wg
h2o.wg.Add(3)
h2o.semaO.Release(1)
}
```
你一看代码就知道了使用WaitGroup非常复杂而且重用和Done方法的调用有并发的问题程序可能panic远远没有使用循环栅栏更加简单直接。
所以,我建议你多了解一些并发原语,甚至是从其它编程语言、操作系统中学习更多的并发原语,这样可以让你的知识库更加丰富,在面对并发场景的时候,你也能更加游刃有余。
<img src="https://static001.geekbang.org/resource/image/82/4f/826f346ac0ccd687dc5d9bcc46621d4f.jpg" alt="">
# 思考题
如果大自然的搬运工工厂生产的液体是双氧水(双氧水分子是两个氢原子和两个氧原子),你又该怎么实现呢?
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,730 @@
<audio id="audio" title="18 | 分组操作:处理一组子任务,该用什么并发原语?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/d5/d20bf5a3bac2fed94efea3b043cae4d5.mp3"></audio>
你好,我是鸟窝。
共享资源保护、任务编排和消息传递是Go并发编程中常见的场景而**分组执行一批相同的或类似的任务则是任务编排中一类情形**所以这节课我专门来介绍一下分组编排的一些常用场景和并发原语包括ErrGroup、gollback、Hunch和schedgroup。
我们先来学习一类非常常用的并发原语那就是ErrGroup。
# ErrGroup
[ErrGroup](https://github.com/golang/sync/tree/master/errgroup)是Go官方提供的一个同步扩展库。我们经常会碰到需要将一个通用的父任务拆成几个小任务并发执行的场景其实将一个大的任务拆成几个小任务并发执行可以有效地提高程序的并发度。就像你在厨房做饭一样你可以在蒸米饭的同时炒几个小菜米饭蒸好了菜同时也做好了很快就能吃到可口的饭菜。
ErrGroup就是用来应对这种场景的。它和WaitGroup有些类似但是它提供功能更加丰富
- 和Context集成
- error向上传播可以把子任务的错误传递给Wait的调用者。
接下来我来给你介绍一下ErrGroup的基本用法和几种应用场景。
## 基本用法
golang.org/x/sync/errgroup包下定义了一个Group struct它就是我们要介绍的ErrGroup并发原语底层也是基于WaitGroup实现的。
在使用ErrGroup时我们要用到三个方法分别是WithContext、Go和Wait。
**1.WithContext**
在创建一个Group对象时需要使用WithContext方法
```
func WithContext(ctx context.Context) (*Group, context.Context)
```
这个方法返回一个Group实例同时还会返回一个使用context.WithCancel(ctx)生成的新Context。一旦有一个子任务返回错误或者是Wait调用返回这个新Context就会被cancel。
Group的零值也是合法的只不过你就没有一个可以监控是否cancel的Context了。
注意如果传递给WithContext的ctx参数是一个可以cancel的Context的话那么它被cancel的时候并不会终止正在执行的子任务。
**2.Go**
我们再来学习下执行子任务的Go方法
```
func (g *Group) Go(f func() error)
```
传入的子任务函数f是类型为func() error的函数如果任务执行成功就返回nil否则就返回error并且会cancel 那个新的Context。
一个任务可以分成好多个子任务而且可能有多个子任务执行失败返回error不过Wait方法只会返回第一个错误所以如果想返回所有的错误需要特别的处理我先留个小悬念一会儿再讲。
**3.Wait**
类似WaitGroupGroup也有Wait方法等所有的子任务都完成后它才会返回否则只会阻塞等待。如果有多个子任务返回错误它只会返回第一个出现的错误如果所有的子任务都执行成功就返回nil
```
func (g *Group) Wait() error
```
## ErrGroup使用例子
好了知道了基本用法下面我来给你介绍几个例子帮助你全面地掌握ErrGroup的使用方法和应用场景。
### 简单例子:返回第一个错误
先来看一个简单的例子。在这个例子中启动了三个子任务其中子任务2会返回执行失败其它两个执行成功。在三个子任务都执行后group.Wait才会返回第2个子任务的错误。
```
package main
import (
&quot;errors&quot;
&quot;fmt&quot;
&quot;time&quot;
&quot;golang.org/x/sync/errgroup&quot;
)
func main() {
var g errgroup.Group
// 启动第一个子任务,它执行成功
g.Go(func() error {
time.Sleep(5 * time.Second)
fmt.Println(&quot;exec #1&quot;)
return nil
})
// 启动第二个子任务,它执行失败
g.Go(func() error {
time.Sleep(10 * time.Second)
fmt.Println(&quot;exec #2&quot;)
return errors.New(&quot;failed to exec #2&quot;)
})
// 启动第三个子任务,它执行成功
g.Go(func() error {
time.Sleep(15 * time.Second)
fmt.Println(&quot;exec #3&quot;)
return nil
})
// 等待三个任务都完成
if err := g.Wait(); err == nil {
fmt.Println(&quot;Successfully exec all&quot;)
} else {
fmt.Println(&quot;failed:&quot;, err)
}
}
```
如果执行下面的这个程序会显示三个任务都执行了而Wait返回了子任务2的错误
<img src="https://static001.geekbang.org/resource/image/92/11/92d746f7a1ab943e73b83796fb436a11.png" alt="">
### 更进一步,返回所有子任务的错误
Group只能返回子任务的第一个错误后续的错误都会被丢弃。但是有时候我们需要知道每个任务的执行情况。怎么办呢这个时候我们就可以用稍微有点曲折的方式去实现。我们使用一个result slice保存子任务的执行结果这样通过查询result就可以知道每一个子任务的结果了。
下面的这个例子就是使用result记录每个子任务成功或失败的结果。其实你不仅可以使用result记录error信息还可以用它记录计算结果。
```
package main
import (
&quot;errors&quot;
&quot;fmt&quot;
&quot;time&quot;
&quot;golang.org/x/sync/errgroup&quot;
)
func main() {
var g errgroup.Group
var result = make([]error, 3)
// 启动第一个子任务,它执行成功
g.Go(func() error {
time.Sleep(5 * time.Second)
fmt.Println(&quot;exec #1&quot;)
result[0] = nil // 保存成功或者失败的结果
return nil
})
// 启动第二个子任务,它执行失败
g.Go(func() error {
time.Sleep(10 * time.Second)
fmt.Println(&quot;exec #2&quot;)
result[1] = errors.New(&quot;failed to exec #2&quot;) // 保存成功或者失败的结果
return result[1]
})
// 启动第三个子任务,它执行成功
g.Go(func() error {
time.Sleep(15 * time.Second)
fmt.Println(&quot;exec #3&quot;)
result[2] = nil // 保存成功或者失败的结果
return nil
})
if err := g.Wait(); err == nil {
fmt.Printf(&quot;Successfully exec all. result: %v\n&quot;, result)
} else {
fmt.Printf(&quot;failed: %v\n&quot;, result)
}
}
```
### 任务执行流水线Pipeline
Go官方文档中还提供了一个pipeline的例子。这个例子是说由一个子任务遍历文件夹下的文件然后把遍历出的文件交给20个goroutine让这些goroutine并行计算文件的md5。
这个例子中的计算逻辑你不需要重点掌握,我来把这个例子简化一下(如果你想看原始的代码,可以看[这里](https://godoc.org/golang.org/x/sync/errgroup#example-Group--Pipeline)
```
package main
import (
......
&quot;golang.org/x/sync/errgroup&quot;
)
// 一个多阶段的pipeline.使用有限的goroutine计算每个文件的md5值.
func main() {
m, err := MD5All(context.Background(), &quot;.&quot;)
if err != nil {
log.Fatal(err)
}
for k, sum := range m {
fmt.Printf(&quot;%s:\t%x\n&quot;, k, sum)
}
}
type result struct {
path string
sum [md5.Size]byte
}
// 遍历根目录下所有的文件和子文件夹,计算它们的md5的值.
func MD5All(ctx context.Context, root string) (map[string][md5.Size]byte, error) {
g, ctx := errgroup.WithContext(ctx)
paths := make(chan string) // 文件路径channel
g.Go(func() error {
defer close(paths) // 遍历完关闭paths chan
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
...... //将文件路径放入到paths
return nil
})
})
// 启动20个goroutine执行计算md5的任务计算的文件由上一阶段的文件遍历子任务生成.
c := make(chan result)
const numDigesters = 20
for i := 0; i &lt; numDigesters; i++ {
g.Go(func() error {
for path := range paths { // 遍历直到paths chan被关闭
...... // 计算path的md5值放入到c中
}
return nil
})
}
go func() {
g.Wait() // 20个goroutine以及遍历文件的goroutine都执行完
close(c) // 关闭收集结果的chan
}()
m := make(map[string][md5.Size]byte)
for r := range c { // 将md5结果从chan中读取到map中,直到c被关闭才退出
m[r.path] = r.sum
}
// 再次调用Wait依然可以得到group的error信息
if err := g.Wait(); err != nil {
return nil, err
}
return m, nil
}
```
通过这个例子你可以学习到多阶段pipeline的实现这个例子是遍历文件夹和计算md5两个阶段还可以学习到如何控制执行子任务的goroutine数量。
很多公司都在使用ErrGroup处理并发子任务比如Facebook、bilibili等公司的一些项目但是这些公司在使用的时候发现了一些不方便的地方或者说官方的ErrGroup的功能还不够丰富。所以他们都对ErrGroup进行了扩展。接下来呢我就带你看看几个扩展库。
## 扩展库
### [bilibili/errgroup](https://godoc.org/github.com/bilibili/kratos/pkg/sync/errgroup)
如果我们无限制地直接调用ErrGroup的Go方法就可能会创建出非常多的goroutine太多的goroutine会带来调度和GC的压力而且也会占用更多的内存资源。就像[go#34457](https://github.com/golang/go/issues/34457)指出的那样当前Go运行时创建的g对象只会增长和重用不会回收所以在高并发的情况下也要尽可能减少goroutine的使用。
常用的一个手段就是使用worker pool(goroutine pool),或者是类似[containerd/stargz-snapshotter](https://github.com/containerd/stargz-snapshotter/pull/157)的方案使用前面我们讲的信号量信号量的资源的数量就是可以并行的goroutine的数量。但是在这一讲我来介绍一些其它的手段比如下面介绍的bilibili实现的errgroup。
bilibili实现了一个扩展的ErrGroup可以使用一个固定数量的goroutine处理子任务。如果不设置goroutine的数量那么每个子任务都会比较“放肆地”创建一个goroutine并发执行。
这个链接里的文档已经很详细地介绍了它的几个扩展功能,所以我就不通过示例的方式来进行讲解了。
除了可以控制并发goroutine的数量它还提供了2个功能
1. cancel失败的子任务可以cancel所有正在执行任务
1. recover而且会把panic的堆栈信息放到error中避免子任务panic导致的程序崩溃。
但是有一点不太好的地方就是一旦你设置了并发数超过并发数的子任务需要等到调用者调用Wait之后才会执行而不是只要goroutine空闲下来就去执行。如果不注意这一点的话可能会出现子任务不能及时处理的情况这是这个库可以优化的一点。
另外这个库其实是有一个并发问题的。在高并发的情况下如果任务数大于设定的goroutine的数量并且这些任务被集中加入到Group中这个库的处理方式是把子任务加入到一个数组中但是这个数组不是线程安全的有并发问题问题就在于下面图片中的标记为96行的那一行这一行对slice的append操作不是线程安全的
<img src="https://static001.geekbang.org/resource/image/ef/5b/ef65c08c041f7b98c71e461f1497bc5b.png" alt="">
我们可以写一个简单的程序来测试这个问题:
```
package main
import (
&quot;context&quot;
&quot;fmt&quot;
&quot;sync/atomic&quot;
&quot;time&quot;
&quot;github.com/bilibili/kratos/pkg/sync/errgroup&quot;
)
func main() {
var g errgroup.Group
g.GOMAXPROCS(1) // 只使用一个goroutine处理子任务
var count int64
g.Go(func(ctx context.Context) error {
time.Sleep(time.Second) //睡眠5秒把这个goroutine占住
return nil
})
total := 10000
for i := 0; i &lt; total; i++ { // 并发一万个goroutine执行子任务理论上这些子任务都会加入到Group的待处理列表中
go func() {
g.Go(func(ctx context.Context) error {
atomic.AddInt64(&amp;count, 1)
return nil
})
}()
}
// 等待所有的子任务完成。理论上10001个子任务都会被完成
if err := g.Wait(); err != nil {
panic(err)
}
got := atomic.LoadInt64(&amp;count)
if got != int64(total) {
panic(fmt.Sprintf(&quot;expect %d but got %d&quot;, total, got))
}
}
```
运行这个程序的话你就会发现死锁问题因为我们的测试程序是一个简单的命令行工具程序退出的时候Go runtime能检测到死锁问题。如果是一直运行的服务器程序死锁问题有可能是检测不出来的程序一直会hang在Wait的调用上。
### [neilotoole/errgroup](https://github.com/neilotoole/errgroup)
neilotoole/errgroup是今年年中新出现的一个ErrGroup扩展库它可以直接替换官方的ErrGroup方法都一样原有功能也一样只不过**增加了可以控制并发goroutine的功能**。它的方法集如下:
```
type Group
func WithContext(ctx context.Context) (*Group, context.Context)
func WithContextN(ctx context.Context, numG, qSize int) (*Group, context.Context)
func (g *Group) Go(f func() error)
func (g *Group) Wait() error
```
新增加的方法WithContextN可以设置并发的goroutine数以及等待处理的子任务队列的大小。当队列满的时候如果调用Go方法就会被阻塞直到子任务可以放入到队列中才返回。如果你传给这两个参数的值不是正整数它就会使用runtime.NumCPU代替你传入的参数。
当然你也可以把bilibili的recover功能扩展到这个库中以避免子任务的panic导致程序崩溃。
### [facebookgo/errgroup](https://github.com/facebookarchive/errgroup)
Facebook提供的这个ErrGroup其实并不是对Go扩展库ErrGroup的扩展而是对标准库WaitGroup的扩展。不过因为它们的名字一样处理的场景也类似所以我把它也列在了这里。
标准库的WaitGroup只提供了Add、Done、Wait方法而且Wait方法也没有返回子goroutine的error。而Facebook提供的ErrGroup提供的Wait方法可以返回error而且可以包含多个error。子任务在调用Done之前可以把自己的error信息设置给ErrGroup。接着Wait在返回的时候就会把这些error信息返回给调用者。
我们来看下Group的方法
```
type Group
func (g *Group) Add(delta int)
func (g *Group) Done()
func (g *Group) Error(e error)
func (g *Group) Wait() error
```
关于Wait方法我刚刚已经介绍了它和标准库WaitGroup的不同我就不多说了。这里还有一个不同的方法就是Error方法
我举个例子演示一下Error的使用方法。
在下面的这个例子中第26行的子goroutine设置了error信息第39行会把这个error信息输出出来。
```
package main
import (
&quot;errors&quot;
&quot;fmt&quot;
&quot;time&quot;
&quot;github.com/facebookgo/errgroup&quot;
)
func main() {
var g errgroup.Group
g.Add(3)
// 启动第一个子任务,它执行成功
go func() {
time.Sleep(5 * time.Second)
fmt.Println(&quot;exec #1&quot;)
g.Done()
}()
// 启动第二个子任务,它执行失败
go func() {
time.Sleep(10 * time.Second)
fmt.Println(&quot;exec #2&quot;)
g.Error(errors.New(&quot;failed to exec #2&quot;))
g.Done()
}()
// 启动第三个子任务,它执行成功
go func() {
time.Sleep(15 * time.Second)
fmt.Println(&quot;exec #3&quot;)
g.Done()
}()
// 等待所有的goroutine完成并检查error
if err := g.Wait(); err == nil {
fmt.Println(&quot;Successfully exec all&quot;)
} else {
fmt.Println(&quot;failed:&quot;, err)
}
}
```
关于ErrGroup你掌握这些就足够了接下来我再介绍几种有趣而实用的Group并发原语。这些并发原语都是控制一组子goroutine执行的面向特定场景的并发原语当你遇见这些特定场景时就可以参考这些库。
## 其它实用的Group并发原语
### SizedGroup/ErrSizedGroup
[go-pkgz/syncs](https://github.com/go-pkgz/syncs)提供了两个Group并发原语分别是SizedGroup和ErrSizedGroup。
SizedGroup内部是使用信号量和WaitGroup实现的它通过信号量控制并发的goroutine数量或者是不控制goroutine数量只控制子任务并发执行时候的数量通过
它的代码实现非常简洁,你可以到它的代码库中了解它的具体实现,你一看就明白了,我就不多说了。下面我重点说说它的功能。
**默认情况下SizedGroup控制的是子任务的并发数量而不是goroutine的数量**。在这种方式下每次调用Go方法都不会被阻塞而是新建一个goroutine去执行。
如果想控制goroutine的数量你可以使用syncs.Preemptive设置这个并发原语的可选项。如果设置了这个可选项但在调用Go方法的时候没有可用的goroutine那么调用者就会等待直到有goroutine可以处理这个子任务才返回这个控制在内部是使用信号量实现的。
我们来看一个使用SizedGroup的例子
```
package main
import (
&quot;context&quot;
&quot;fmt&quot;
&quot;sync/atomic&quot;
&quot;time&quot;
&quot;github.com/go-pkgz/syncs&quot;
)
func main() {
// 设置goroutine数是10
swg := syncs.NewSizedGroup(10)
// swg := syncs.NewSizedGroup(10, syncs.Preemptive)
var c uint32
// 执行1000个子任务只会有10个goroutine去执行
for i := 0; i &lt; 1000; i++ {
swg.Go(func(ctx context.Context) {
time.Sleep(5 * time.Millisecond)
atomic.AddUint32(&amp;c, 1)
})
}
// 等待任务完成
swg.Wait()
// 输出结果
fmt.Println(c)
}
```
ErrSizedGroup为SizedGroup提供了error处理的功能它的功能和Go官方扩展库的功能一样就是等待子任务完成并返回第一个出现的error。不过它还提供了额外的功能我来介绍一下。
第一个额外的功能就是可以控制并发的goroutine数量这和SizedGroup的功能一样。
第二个功能是如果设置了termOnError子任务出现第一个错误的时候会cancel Context而且后续的Go调用会直接返回Wait调用者会得到这个错误这相当于是遇到错误快速返回。如果没有设置termOnErrorWait会返回所有的子任务的错误。
不过ErrSizedGroup和SizedGroup设计得不太一致的地方是**SizedGroup可以把Context传递给子任务这样可以通过cancel让子任务中断执行但是ErrSizedGroup却没有实现。我认为这是一个值得加强的地方**。
总体来说syncs包提供的并发原语的质量和功能还是非常赞的。不过目前的star只有十几个这和它的功能严重不匹配我建议你star这个项目支持一下作者。
好了关于ErrGroup你掌握这些就足够了下面我再来给你介绍一些非ErrGroup的并发原语它们用来编排子任务。
# gollback
[gollback](https://github.com/vardius/gollback)也是用来处理一组子任务的执行的不过它解决了ErrGroup收集子任务返回结果的痛点。使用ErrGroup时如果你要收到子任务的结果和错误你需要定义额外的变量收集执行结果和错误但是这个库可以提供更便利的方式。
我刚刚在说官方扩展库ErrGroup的时候举了一些例子返回第一个错误的例子和返回所有子任务错误的例子在例子中如果想得到每一个子任务的结果或者error我们需要额外提供一个result slice进行收集。使用gollback的话就不需要这些额外的处理了因为它的方法会把结果和error信息都返回。
接下来,我们看一下它提供的三个方法,分别是**All、Race和Retry**。
**All方法**
All方法的签名如下
```
func All(ctx context.Context, fns ...AsyncFunc) ([]interface{}, []error)
```
它会等待所有的异步函数AsyncFunc都执行完才返回而且返回结果的顺序和传入的函数的顺序保持一致。第一个返回参数是子任务的执行结果第二个参数是子任务执行时的错误信息。
其中,异步函数的定义如下:
```
type AsyncFunc func(ctx context.Context) (interface{}, error)
```
可以看到ctx会被传递给子任务。如果你cancel这个ctx可以取消子任务。
我们来看一个使用All方法的例子
```
package main
import (
&quot;context&quot;
&quot;errors&quot;
&quot;fmt&quot;
&quot;github.com/vardius/gollback&quot;
&quot;time&quot;
)
func main() {
rs, errs := gollback.All( // 调用All方法
context.Background(),
func(ctx context.Context) (interface{}, error) {
time.Sleep(3 * time.Second)
return 1, nil // 第一个任务没有错误返回1
},
func(ctx context.Context) (interface{}, error) {
return nil, errors.New(&quot;failed&quot;) // 第二个任务返回一个错误
},
func(ctx context.Context) (interface{}, error) {
return 3, nil // 第三个任务没有错误返回3
},
)
fmt.Println(rs) // 输出子任务的结果
fmt.Println(errs) // 输出子任务的错误信息
}
```
**Race方法**
Race方法跟All方法类似只不过在使用Race方法的时候只要一个异步函数执行没有错误就立马返回而不会返回所有的子任务信息。如果所有的子任务都没有成功就会返回最后一个error信息。
Race方法签名如下
```
func Race(ctx context.Context, fns ...AsyncFunc) (interface{}, error)
```
如果有一个正常的子任务的结果返回Race会把传入到其它子任务的Context cancel掉这样子任务就可以中断自己的执行。
Race的使用方法也跟All方法类似我就不再举例子了你可以把All方法的例子中的All替换成Race方式测试下。
**Retry方法**
**Retry不是执行一组子任务而是执行一个子任务**。如果子任务执行失败,它会尝试一定的次数,如果一直不成功 ,就会返回失败错误 如果执行成功它会立即返回。如果retires等于0它会永远尝试直到成功。
```
func Retry(ctx context.Context, retires int, fn AsyncFunc) (interface{}, error)
```
再来看一个使用Retry的例子
```
package main
import (
&quot;context&quot;
&quot;errors&quot;
&quot;fmt&quot;
&quot;github.com/vardius/gollback&quot;
&quot;time&quot;
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 尝试5次或者超时返回
res, err := gollback.Retry(ctx, 5, func(ctx context.Context) (interface{}, error) {
return nil, errors.New(&quot;failed&quot;)
})
fmt.Println(res) // 输出结果
fmt.Println(err) // 输出错误信息
}
```
# Hunch
[Hunch](https://github.com/AaronJan/Hunch)提供的功能和gollback类似不过它提供的方法更多而且它提供的和gollback相应的方法也有一些不同。我来一一介绍下。
它定义了执行子任务的函数这和gollback的AyncFunc是一样的它的定义如下
```
type Executable func(context.Context) (interface{}, error)
```
**All方法**
All方法的签名如下
```
func All(parentCtx context.Context, execs ...Executable) ([]interface{}, error)
```
它会传入一组可执行的函数子任务返回子任务的执行结果。和gollback的All方法不一样的是一旦一个子任务出现错误它就会返回错误信息执行结果第一个返回参数为nil。
**Take方法**
Take方法的签名如下
```
func Take(parentCtx context.Context, num int, execs ...Executable) ([]interface{}, error)
```
你可以指定num参数只要有num个子任务正常执行完没有错误这个方法就会返回这几个子任务的结果。一旦一个子任务出现错误它就会返回错误信息执行结果第一个返回参数为nil。
**Last方法**
Last方法的签名如下
```
func Last(parentCtx context.Context, num int, execs ...Executable) ([]interface{}, error)
```
它只返回最后num个正常执行的、没有错误的子任务的结果。一旦一个子任务出现错误它就会返回错误信息执行结果第一个返回参数为nil。
比如num等于1那么它只会返回最后一个无错的子任务的结果。
**Retry方法**
Retry方法的签名如下
```
func Retry(parentCtx context.Context, retries int, fn Executable) (interface{}, error)
```
它的功能和gollback的Retry方法的功能一样如果子任务执行出错就会不断尝试直到成功或者是达到重试上限。如果达到重试上限就会返回错误。如果retries等于0它会不断尝试。
**Waterfall方法**
Waterfall方法签名如下
```
func Waterfall(parentCtx context.Context, execs ...ExecutableInSequence) (interface{}, error)
```
它其实是一个pipeline的处理方式所有的子任务都是串行执行的前一个子任务的执行结果会被当作参数传给下一个子任务直到所有的任务都完成返回最后的执行结果。一旦一个子任务出现错误它就会返回错误信息执行结果第一个返回参数为nil。
gollback和Hunch是属于同一类的并发原语对一组子任务的执行结果可以选择一个结果或者多个结果这也是现在热门的微服务常用的服务治理的方法。
# schedgroup
接下来,我再介绍一个**和时间相关的处理一组goroutine的并发原语schedgroup**。
[schedgroup](https://github.com/mdlayher/schedgroup)是Matt Layher开发的worker pool可以指定任务在某个时间或者某个时间之后执行。Matt Layher也是一个知名的Gopher经常在一些会议上分享一些他的Go开发经验他在GopherCon Europe 2020大会上专门介绍了这个并发原语[schedgroup: a timer-based goroutine concurrency primitive](https://talks.godoc.org/github.com/mdlayher/talks/conferences/2020/gopherconeu/schedgroup.slide) ,课下你可以点开这个链接看一下,下面我来给你介绍一些重点。
这个并发原语包含的方法如下:
```
type Group
func New(ctx context.Context) *Group
func (g *Group) Delay(delay time.Duration, fn func())
func (g *Group) Schedule(when time.Time, fn func())
func (g *Group) Wait() error
```
我来介绍下这些方法。
先说Delay和Schedule。
它们的功能其实是一样的都是用来指定在某个时间或者之后执行一个函数。只不过Delay传入的是一个time.Duration参数它会在time.Now()+delay之后执行函数而Schedule可以指定明确的某个时间执行。
再来说说Wait方法。
这个方法调用会阻塞调用者直到之前安排的所有子任务都执行完才返回。如果Context被取消那么Wait方法会返回这个cancel error。
在使用Wait方法的时候有2点需要注意一下。
**第一点是如果调用了Wait方法你就不能再调用它的Delay和Schedule方法否则会panic。**
**第二点是Wait方法只能调用一次如果多次调用的话就会panic。**
你可能认为简单地使用timer就可以实现这个功能。其实如果只有几个子任务使用timer不是问题但一旦有大量的子任务而且还要能够cancel那么使用timer的话CPU资源消耗就比较大了。所以schedgroup在实现的时候就使用container/heap按照子任务的执行时间进行排序这样可以避免使用大量的timer从而提高性能。
我们来看一个使用schedgroup的例子下面代码会依次输出1、2、3
```
sg := schedgroup.New(context.Background())
// 设置子任务分别在100、200、300之后执行
for i := 0; i &lt; 3; i++ {
n := i + 1
sg.Delay(time.Duration(n)*100*time.Millisecond, func() {
log.Println(n) //输出任务编号
})
}
// 等待所有的子任务都完成
if err := sg.Wait(); err != nil {
log.Fatalf(&quot;failed to wait: %v&quot;, err)
}
```
# 总结
这节课我给你介绍了几种常见的处理一组子任务的并发原语包括ErrGroup、gollback、Hunch、schedgroup等等。这些常见的业务场景共性处理方式的总结你可以把它们加入到你的知识库中等以后遇到相同的业务场景时你就可以考虑使用这些并发原语。
当然,类似的并发原语还有别的,比如[go-waitgroup](https://github.com/pieterclaerhout/go-waitgroup)等,而且,我相信还会有新的并发原语不断出现。所以,你不仅仅要掌握这些并发原语,而且还要通过学习这些并发原语,学会构造新的并发原语来处理应对你的特有场景,实现代码重用和业务逻辑简化。
<img src="https://static001.geekbang.org/resource/image/ee/9c/ee46d1dbed154a24063d3b0795fb5d9c.jpg" alt="">
# 思考题
这节课我讲的官方扩展库ErrGroup没有实现可以取消子任务的功能请你课下可以自己去实现一个子任务可取消的ErrGroup。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。