mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-01 23:03:47 +08:00
mod
This commit is contained in:
327
极客时间专栏/Go 并发编程实战课/扩展并发原语/16 | Semaphore:一篇文章搞懂信号量.md
Normal file
327
极客时间专栏/Go 并发编程实战课/扩展并发原语/16 | Semaphore:一篇文章搞懂信号量.md
Normal 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("获取所有的worker失败: %v", 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 >= n && s.waiters.Len() == 0 {
|
||||
s.cur += n
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果是不可能完成的任务,请求的资源数大于能提供的最大的资源数
|
||||
if n > s.size {
|
||||
s.mu.Unlock()
|
||||
// 依赖ctx的状态返回,否则一直等待
|
||||
<-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 <-ctx.Done(): // context的Done被关闭
|
||||
err := ctx.Err()
|
||||
s.mu.Lock()
|
||||
select {
|
||||
case <-ready: // 如果被唤醒了,忽略ctx的状态
|
||||
err = nil
|
||||
default: 通知waiter
|
||||
isFront := s.waiters.Front() == elem
|
||||
s.waiters.Remove(elem)
|
||||
// 通知其它的waiters,检查是否有足够的资源
|
||||
if isFront && s.size > s.cur {
|
||||
s.notifyWaiters()
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
case <-ready: // 被唤醒了
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其实,为了提高性能,这个方法中的fast path之外的代码,可以抽取成acquireSlow方法,以便其它Acquire被内联。
|
||||
|
||||
Release方法将当前计数值减去释放的资源数n,并唤醒等待队列中的调用者,看是否有足够的资源被获取。
|
||||
|
||||
```
|
||||
func (s *Weighted) Release(n int64) {
|
||||
s.mu.Lock()
|
||||
s.cur -= n
|
||||
if s.cur < 0 {
|
||||
s.mu.Unlock()
|
||||
panic("semaphore: released more than held")
|
||||
}
|
||||
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 < 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 <= 0 {
|
||||
capacity = 1 // 容量为1就变成了一个互斥锁
|
||||
}
|
||||
return &semaphore{ch: make(chan struct{}, capacity)}
|
||||
}
|
||||
|
||||
// 请求一个资源
|
||||
func (s *semaphore) Lock() {
|
||||
s.ch <- struct{}{}
|
||||
}
|
||||
|
||||
// 释放资源
|
||||
func (s *semaphore) Unlock() {
|
||||
<-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呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
@@ -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方法会返回函数执行的结果或者是error,shared会指示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<- 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 > 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 <- Result{c.val, c.err, c.dups > 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(小强数据库)、CoreDNS(DNS服务器)等都有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&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 (
|
||||
"context"
|
||||
"github.com/marusama/cyclicbarrier"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
// 定义水分子合成的辅助数据结构
|
||||
type H2O struct {
|
||||
semaH *semaphore.Weighted // 氢原子的信号量
|
||||
semaO *semaphore.Weighted // 氧原子的信号量
|
||||
b cyclicbarrier.CyclicBarrier // 循环栅栏,用来控制合成
|
||||
}
|
||||
func New() *H2O {
|
||||
return &H2O{
|
||||
semaH: semaphore.NewWeighted(2), //氢原子需要两个
|
||||
semaO: semaphore.NewWeighted(1), // 氧原子需要一个
|
||||
b: cyclicbarrier.New(3), // 需要三个原子才能合成
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们看看各条流水线的处理情况。
|
||||
|
||||
流水线分为氢原子处理流水线和氧原子处理流水线,首先,我们先看一下氢原子的流水线:如果有可用的空槽,氢原子的流水线的处理方法是hydrogen,hydrogen方法就会占用一个空槽(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 (
|
||||
"math/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
func TestWaterFactory(t *testing.T) {
|
||||
//用来存放水分子结果的channel
|
||||
var ch chan string
|
||||
releaseHydrogen := func() {
|
||||
ch <- "H"
|
||||
}
|
||||
releaseOxygen := func() {
|
||||
ch <- "O"
|
||||
}
|
||||
|
||||
// 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 < 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 < 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("expect %d atom but got %d", N*3, len(ch))
|
||||
}
|
||||
|
||||
// 每三个原子一组,分别进行检查。要求这一组原子中必须包含两个氢原子和一个氧原子,这样才能正确组成一个水分子。
|
||||
var s = make([]string, 3)
|
||||
for i := 0; i < N; i++ {
|
||||
s[0] = <-ch
|
||||
s[1] = <-ch
|
||||
s[2] = <-ch
|
||||
sort.Strings(s)
|
||||
|
||||
|
||||
water := s[0] + s[1] + s[2]
|
||||
if water != "HHO" {
|
||||
t.Fatalf("expect a water molecule but got %s", 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 &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="">
|
||||
|
||||
# 思考题
|
||||
|
||||
如果大自然的搬运工工厂生产的液体是双氧水(双氧水分子是两个氢原子和两个氧原子),你又该怎么实现呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
730
极客时间专栏/Go 并发编程实战课/扩展并发原语/18 | 分组操作:处理一组子任务,该用什么并发原语?.md
Normal file
730
极客时间专栏/Go 并发编程实战课/扩展并发原语/18 | 分组操作:处理一组子任务,该用什么并发原语?.md
Normal 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**
|
||||
|
||||
类似WaitGroup,Group也有Wait方法,等所有的子任务都完成后,它才会返回,否则只会阻塞等待。如果有多个子任务返回错误,它只会返回第一个出现的错误,如果所有的子任务都执行成功,就返回nil:
|
||||
|
||||
```
|
||||
func (g *Group) Wait() error
|
||||
|
||||
```
|
||||
|
||||
## ErrGroup使用例子
|
||||
|
||||
好了,知道了基本用法,下面我来给你介绍几个例子,帮助你全面地掌握ErrGroup的使用方法和应用场景。
|
||||
|
||||
### 简单例子:返回第一个错误
|
||||
|
||||
先来看一个简单的例子。在这个例子中,启动了三个子任务,其中,子任务2会返回执行失败,其它两个执行成功。在三个子任务都执行后,group.Wait才会返回第2个子任务的错误。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var g errgroup.Group
|
||||
|
||||
|
||||
// 启动第一个子任务,它执行成功
|
||||
g.Go(func() error {
|
||||
time.Sleep(5 * time.Second)
|
||||
fmt.Println("exec #1")
|
||||
return nil
|
||||
})
|
||||
// 启动第二个子任务,它执行失败
|
||||
g.Go(func() error {
|
||||
time.Sleep(10 * time.Second)
|
||||
fmt.Println("exec #2")
|
||||
return errors.New("failed to exec #2")
|
||||
})
|
||||
|
||||
// 启动第三个子任务,它执行成功
|
||||
g.Go(func() error {
|
||||
time.Sleep(15 * time.Second)
|
||||
fmt.Println("exec #3")
|
||||
return nil
|
||||
})
|
||||
// 等待三个任务都完成
|
||||
if err := g.Wait(); err == nil {
|
||||
fmt.Println("Successfully exec all")
|
||||
} else {
|
||||
fmt.Println("failed:", 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var g errgroup.Group
|
||||
var result = make([]error, 3)
|
||||
|
||||
// 启动第一个子任务,它执行成功
|
||||
g.Go(func() error {
|
||||
time.Sleep(5 * time.Second)
|
||||
fmt.Println("exec #1")
|
||||
result[0] = nil // 保存成功或者失败的结果
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
// 启动第二个子任务,它执行失败
|
||||
g.Go(func() error {
|
||||
time.Sleep(10 * time.Second)
|
||||
fmt.Println("exec #2")
|
||||
|
||||
result[1] = errors.New("failed to exec #2") // 保存成功或者失败的结果
|
||||
return result[1]
|
||||
})
|
||||
|
||||
// 启动第三个子任务,它执行成功
|
||||
g.Go(func() error {
|
||||
time.Sleep(15 * time.Second)
|
||||
fmt.Println("exec #3")
|
||||
result[2] = nil // 保存成功或者失败的结果
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err == nil {
|
||||
fmt.Printf("Successfully exec all. result: %v\n", result)
|
||||
} else {
|
||||
fmt.Printf("failed: %v\n", result)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 任务执行流水线Pipeline
|
||||
|
||||
Go官方文档中还提供了一个pipeline的例子。这个例子是说,由一个子任务遍历文件夹下的文件,然后把遍历出的文件交给20个goroutine,让这些goroutine并行计算文件的md5。
|
||||
|
||||
这个例子中的计算逻辑你不需要重点掌握,我来把这个例子简化一下(如果你想看原始的代码,可以看[这里](https://godoc.org/golang.org/x/sync/errgroup#example-Group--Pipeline)):
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
import (
|
||||
......
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// 一个多阶段的pipeline.使用有限的goroutine计算每个文件的md5值.
|
||||
func main() {
|
||||
m, err := MD5All(context.Background(), ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for k, sum := range m {
|
||||
fmt.Printf("%s:\t%x\n", 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 < 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bilibili/kratos/pkg/sync/errgroup"
|
||||
)
|
||||
|
||||
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 < total; i++ { // 并发一万个goroutine执行子任务,理论上这些子任务都会加入到Group的待处理列表中
|
||||
go func() {
|
||||
g.Go(func(ctx context.Context) error {
|
||||
atomic.AddInt64(&count, 1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// 等待所有的子任务完成。理论上10001个子任务都会被完成
|
||||
if err := g.Wait(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
got := atomic.LoadInt64(&count)
|
||||
if got != int64(total) {
|
||||
panic(fmt.Sprintf("expect %d but got %d", 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/facebookgo/errgroup"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var g errgroup.Group
|
||||
g.Add(3)
|
||||
|
||||
// 启动第一个子任务,它执行成功
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
fmt.Println("exec #1")
|
||||
g.Done()
|
||||
}()
|
||||
|
||||
// 启动第二个子任务,它执行失败
|
||||
go func() {
|
||||
time.Sleep(10 * time.Second)
|
||||
fmt.Println("exec #2")
|
||||
g.Error(errors.New("failed to exec #2"))
|
||||
g.Done()
|
||||
}()
|
||||
|
||||
// 启动第三个子任务,它执行成功
|
||||
go func() {
|
||||
time.Sleep(15 * time.Second)
|
||||
fmt.Println("exec #3")
|
||||
g.Done()
|
||||
}()
|
||||
|
||||
// 等待所有的goroutine完成,并检查error
|
||||
if err := g.Wait(); err == nil {
|
||||
fmt.Println("Successfully exec all")
|
||||
} else {
|
||||
fmt.Println("failed:", 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/syncs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 设置goroutine数是10
|
||||
swg := syncs.NewSizedGroup(10)
|
||||
// swg := syncs.NewSizedGroup(10, syncs.Preemptive)
|
||||
var c uint32
|
||||
|
||||
// 执行1000个子任务,只会有10个goroutine去执行
|
||||
for i := 0; i < 1000; i++ {
|
||||
swg.Go(func(ctx context.Context) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
atomic.AddUint32(&c, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// 等待任务完成
|
||||
swg.Wait()
|
||||
// 输出结果
|
||||
fmt.Println(c)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ErrSizedGroup为SizedGroup提供了error处理的功能,它的功能和Go官方扩展库的功能一样,就是等待子任务完成并返回第一个出现的error。不过,它还提供了额外的功能,我来介绍一下。
|
||||
|
||||
第一个额外的功能,就是可以控制并发的goroutine数量,这和SizedGroup的功能一样。
|
||||
|
||||
第二个功能是,如果设置了termOnError,子任务出现第一个错误的时候会cancel Context,而且后续的Go调用会直接返回,Wait调用者会得到这个错误,这相当于是遇到错误快速返回。如果没有设置termOnError,Wait会返回所有的子任务的错误。
|
||||
|
||||
不过,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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/vardius/gollback"
|
||||
"time"
|
||||
)
|
||||
|
||||
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("failed") // 第二个任务返回一个错误
|
||||
},
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/vardius/gollback"
|
||||
"time"
|
||||
)
|
||||
|
||||
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("failed")
|
||||
})
|
||||
|
||||
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 < 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("failed to wait: %v", 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。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
Reference in New Issue
Block a user