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,584 @@
<audio id="audio" title="13 | Channel另辟蹊径解决并发问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/d6/4426613dd4a1bb429yyyy3035107a2d6.mp3"></audio>
你好,我是鸟窝。
Channel是Go语言内建的first-class类型也是Go语言与众不同的特性之一。Go语言的Channel设计精巧简单以至于也有人用其它语言编写了类似Go风格的Channel库比如[docker/libchan](https://github.com/docker/libchan)、[tylertreat/chan](https://github.com/tylertreat/chan)但是并不像Go语言一样把Channel内置到了语言规范中。从这一点你也可以看出来Channel的地位在编程语言中的地位之高比较罕见。
所以这节课我们就来学习下Channel。
# Channel的发展
要想了解Channel这种Go编程语言中的特有的数据结构我们要追溯到CSP模型学习一下它的历史以及它对Go创始人设计Channel类型的影响。
CSP是Communicating Sequential Process 的简称,中文直译为通信顺序进程,或者叫做交换信息的循序进程,是用来描述并发系统中进行交互的一种模式。
CSP最早出现于计算机科学家Tony Hoare 在1978年发表的[论文](https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf)中你可能不熟悉Tony Hoare这个名字但是你一定很熟悉排序算法中的Quicksort算法他就是Quicksort算法的作者图灵奖的获得者。最初论文中提出的CSP版本在本质上不是一种进程演算而是一种并发编程语言但之后又经过了一系列的改进最终发展并精炼出CSP的理论。**CSP允许使用进程组件来描述系统它们独立运行并且只通过消息传递的方式通信。**
就像Go的创始人之一Rob Pike所说的“每一个计算机程序员都应该读一读Tony Hoare 1978年的关于CSP的论文。”他和Ken Thompson在设计Go语言的时候也深受此论文的影响并将CSP理论真正应用于语言本身Russ Cox专门写了一篇文章记录这个[历史](https://swtch.com/~rsc/thread/)通过引入Channel这个新的类型来实现CSP的思想。
**Channel类型是Go语言内置的类型你无需引入某个包就能使用它**。虽然Go也提供了传统的并发原语但是它们都是通过库的方式提供的你必须要引入sync包或者atomic包才能使用它们而Channel就不一样了它是内置类型使用起来非常方便。
Channel和Go的另一个独特的特性goroutine一起为并发编程提供了优雅的、便利的、与传统并发控制不同的方案并演化出很多并发模式。接下来我们就来看一看Channel的应用场景。
# Channel的应用场景
首先我想先带你看一条Go语言中流传很广的谚语
>
Dont communicate by sharing memory, share memory by communicating.
>
Go Proverbs by Rob Pike
这是Rob Pike在2015年的一次Gopher会议中提到的一句话虽然有一点绕但也指出了使用Go语言的哲学我尝试着来翻译一下“**执行业务处理的goroutine不要通过共享内存的方式通信而是要通过Channel通信的方式分享数据。**”
“communicate by sharing memory”和“share memory by communicating”是两种不同的并发处理模式。“communicate by sharing memory”是传统的并发编程处理方式就是指共享的数据需要用锁进行保护goroutine需要获取到锁才能并发访问数据。
“share memory by communicating”则是类似于CSP模型的方式通过通信的方式一个goroutine可以把数据的“所有权”交给另外一个goroutine虽然Go中没有“所有权”的概念但是从逻辑上说你可以把它理解为是所有权的转移
从Channel的历史和设计哲学上我们就可以了解到Channel类型和基本并发原语是有竞争关系的它应用于并发场景涉及到goroutine之间的通讯可以提供并发的保护等等。
综合起来我把Channel的应用场景分为五种类型。这里你先有个印象这样你可以有目的地去学习Channel的基本原理。下节课我会借助具体的例子来带你掌握这几种类型。
1. **数据交流**当作并发的buffer或者queue解决生产者-消费者问题。多个goroutine可以并发当作生产者Producer和消费者Consumer
1. **数据传递**一个goroutine将数据交给另一个goroutine相当于把数据的拥有权(引用)托付出去。
1. **信号通知**一个goroutine可以将信号(closing、closed、data ready等)传递给另一个或者另一组goroutine 。
1. **任务编排**可以让一组goroutine按照一定的顺序并发或者串行的执行这就是编排的功能。
1. **锁**利用Channel也可以实现互斥锁的机制。
下面我们来具体学习下Channel的基本用法。
# Channel基本用法
你可以往Channel中发送数据也可以从Channel中接收数据所以Channel类型为了说起来方便我们下面都把Channel叫做chan分为**只能接收**、**只能发送**、**既可以接收又可以发送**三种类型。下面是它的语法定义:
```
ChannelType = ( &quot;chan&quot; | &quot;chan&quot; &quot;&lt;-&quot; | &quot;&lt;-&quot; &quot;chan&quot; ) ElementType .
```
相应地Channel的正确语法如下
```
chan string // 可以发送接收string
chan&lt;- struct{} // 只能发送struct{}
&lt;-chan int // 只能从chan接收int
```
我们把既能接收又能发送的chan叫做双向的chan把只能发送和只能接收的chan叫做单向的chan。其中&lt;-”表示单向的chan如果你记不住我告诉你一个简便的方法**这个箭头总是射向左边的元素类型总在最右边。如果箭头指向chan就表示可以往chan中塞数据如果箭头远离chan就表示chan会往外吐数据**。
chan中的元素是任意的类型所以也可能是chan类型我来举个例子比如下面的chan类型也是合法的
```
chan&lt;- chan int
chan&lt;- &lt;-chan int
&lt;-chan &lt;-chan int
chan (&lt;-chan int)
```
可是怎么判定箭头符号属于哪个chan呢其实&lt;-”有个规则总是尽量和左边的chan结合The `&lt;-` operator associates with the leftmost `chan` possible:),因此,上面的定义和下面的使用括号的划分是一样的:
```
chan&lt;- chan int // &lt;- 和第一个chan结合
chan&lt;- &lt;-chan int // 第一个&lt;-和最左边的chan结合第二个&lt;-和左边第二个chan结合
&lt;-chan &lt;-chan int // 第一个&lt;-和最左边的chan结合第二个&lt;-和左边第二个chan结合
chan (&lt;-chan int) // 因为括号的原因,&lt;-和括号内第一个chan结合
```
通过make我们可以初始化一个chan未初始化的chan的零值是nil。你可以设置它的容量比如下面的chan的容量是9527我们把这样的chan叫做buffered chan如果没有设置它的容量是0我们把这样的chan叫做unbuffered chan。
```
make(chan int, 9527)
```
如果chan中还有数据那么从这个chan接收数据的时候就不会阻塞如果chan还未满“满”指达到其容量给它发送数据也不会阻塞否则就会阻塞。unbuffered chan只有读写都准备好之后才不会阻塞这也是很多使用unbuffered chan时的常见Bug。
还有一个知识点需要你记住nil是chan的零值是一种特殊的chan对值是nil的chan的发送接收调用者总是会阻塞。
下面我来具体给你介绍几种基本操作分别是发送数据、接收数据以及一些其它操作。学会了这几种操作你就能真正地掌握Channel的用法了。
**1.发送数据**
往chan中发送一个数据使用“ch&lt;-”,发送数据是一条语句:
```
ch &lt;- 2000
```
这里的ch是chan int类型或者是chan &lt;-int。
**2.接收数据**
从chan中接收一条数据使用“&lt;-ch”接收数据也是一条语句
```
x := &lt;-ch // 把接收的一条数据赋值给变量x
foo(&lt;-ch) // 把接收的一个的数据作为参数传给函数
&lt;-ch // 丢弃接收的一条数据
```
这里的ch类型是chan T或者&lt;-chan T。
接收数据时还可以返回两个值。第一个值是返回的chan中的元素很多人不太熟悉的是第二个值。第二个值是bool类型代表是否成功地从chan中读取到一个值如果第二个参数是falsechan已经被close而且chan中没有缓存的数据这个时候第一个值是零值。所以如果从chan读取到一个零值可能是sender真正发送的零值也可能是closed的并且没有缓存元素产生的零值。
**3.其它操作**
Go内建的函数close、cap、len都可以操作chan类型close会把chan关闭掉cap返回chan的容量len返回chan中缓存的还未被取走的元素数量。
send和recv都可以作为select语句的case clause如下面的例子
```
func main() {
var ch = make(chan int, 10)
for i := 0; i &lt; 10; i++ {
select {
case ch &lt;- i:
case v := &lt;-ch:
fmt.Println(v)
}
}
}
```
chan还可以应用于for-range语句中比如
```
for v := range ch {
fmt.Println(v)
}
```
或者是忽略读取的值只是清空chan
```
for range ch {
}
```
好了到这里Channel的基本用法我们就学完了。下面我从代码实现的角度分析chan类型的实现。毕竟只有掌握了原理你才能真正地用好它。
# Channel的实现原理
接下来我会给你介绍chan的数据结构、初始化的方法以及三个重要的操作方法分别是send、recv和close。通过学习Channel的底层实现你会对Channel的功能和异常情况有更深的理解。
## chan数据结构
chan类型的数据结构如下图所示它的数据类型是[runtime.hchan](https://github.com/golang/go/blob/master/src/runtime/chan.go#L32)。
<img src="https://static001.geekbang.org/resource/image/81/dd/81304c1f1845d21c66195798b6ba48dd.jpg" alt="">
下面我来具体解释各个字段的意义。
- qcount代表chan中已经接收但还没被取走的元素的个数。内建函数len可以返回这个字段的值。
- dataqsiz队列的大小。chan使用一个循环队列来存放元素循环队列很适合这种生产者-消费者的场景我很好奇为什么这个字段省略size中的e
- buf存放元素的循环队列的buffer。
- elemtype和elemsizechan中元素的类型和size。因为chan一旦声明它的元素类型是固定的即普通类型或者指针类型所以元素大小也是固定的。
- sendx处理发送数据的指针在buf中的位置。一旦接收了新的数据指针就会加上elemsize移向下一个位置。buf的总大小是elemsize的整数倍而且buf是一个循环列表。
- recvx处理接收请求时的指针在buf中的位置。一旦取出数据此指针会移动到下一个位置。
- recvqchan是多生产者多消费者的模式如果消费者因为没有数据可读而被阻塞了就会被加入到recvq队列中。
- sendq如果生产者因为buf满了而阻塞会被加入到sendq队列中。
## 初始化
Go在编译的时候会根据容量的大小选择调用makechan64还是makechan。
下面的代码是处理make chan的逻辑它会决定是使用makechan还是makechan64来实现chan的初始化
<img src="https://static001.geekbang.org/resource/image/e9/d7/e96f2fee0633c8157a88b8b725f702d7.png" alt="">
**我们只关注makechan就好了因为makechan64只是做了size检查底层还是调用makechan实现的**。makechan的目标就是生成hchan对象。
那么接下来就让我们来看一下makechan的主要逻辑。主要的逻辑我都加上了注释它会根据chan的容量的大小和元素的类型不同初始化不同的存储空间
```
func makechan(t *chantype, size int) *hchan {
elem := t.elem
// 略去检查代码
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
//
var c *hchan
switch {
case mem == 0:
// chan的size或者元素的size是0不必创建buf
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// 元素不是指针分配一块连续的内存给hchan数据结构和buf
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
// hchan数据结构后面紧接着就是buf
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// 元素包含指针那么单独分配buf
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
// 元素大小、类型、容量都记录下来
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&amp;c.lock, lockRankHchan)
return c
}
```
最终针对不同的容量和元素类型这段代码分配了不同的对象来初始化hchan对象的字段返回hchan对象。
## send
Go在编译发送数据给chan的时候会把send语句转换成chansend1函数chansend1函数会调用chansend我们分段学习它的逻辑
```
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// 第一部分
if c == nil {
if !block {
return false
}
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw(&quot;unreachable&quot;)
}
......
}
```
最开始第一部分是进行判断如果chan是nil的话就把调用者goroutine park阻塞休眠 调用者就永远被阻塞住了所以第11行是不可能执行到的代码。
```
// 第二部分如果chan没有被close,并且chan满了直接返回
if !block &amp;&amp; c.closed == 0 &amp;&amp; full(c) {
return false
}
```
第二部分的逻辑是当你往一个已经满了的chan实例发送数据时并且想不阻塞当前调用那么这里的逻辑是直接返回。chansend1方法在调用chansend的时候设置了阻塞参数所以不会执行到第二部分的分支里。
```
// 第三部分chan已经被close的情景
lock(&amp;c.lock) // 开始加锁
if c.closed != 0 {
unlock(&amp;c.lock)
panic(plainError(&quot;send on closed channel&quot;))
}
```
第三部分显示的是如果chan已经被close了再往里面发送数据的话会panic。
```
// 第四部分从接收队列中出队一个等待的receiver
if sg := c.recvq.dequeue(); sg != nil {
//
send(c, sg, ep, func() { unlock(&amp;c.lock) }, 3)
return true
}
```
第四部分如果等待队列中有等待的receiver那么这段代码就把它从队列中弹出然后直接把数据交给它通过memmove(dst, src, t.size)而不需要放入到buf中速度可以更快一些。
```
// 第五部分buf还没满
if c.qcount &lt; c.dataqsiz {
qp := chanbuf(c, c.sendx)
if raceenabled {
raceacquire(qp)
racerelease(qp)
}
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&amp;c.lock)
return true
}
```
第五部分说明当前没有receiver需要把数据放入到buf中放入之后就成功返回了。
```
// 第六部分buf满。
// chansend1不会进入if块里因为chansend1的block=true
if !block {
unlock(&amp;c.lock)
return false
}
......
```
第六部分是处理buf满的情况。如果buf满了发送者的goroutine就会加入到发送者的等待队列中直到被唤醒。这个时候数据或者被取走了或者chan被close了。
## recv
在处理从chan中接收数据时Go会把代码转换成chanrecv1函数如果要返回两个返回值会转换成chanrecv2chanrecv1函数和chanrecv2会调用chanrecv。我们分段学习它的逻辑
```
func chanrecv1(c *hchan, elem unsafe.Pointer) {
chanrecv(c, elem, true)
}
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
_, received = chanrecv(c, elem, true)
return
}
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 第一部分chan为nil
if c == nil {
if !block {
return
}
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw(&quot;unreachable&quot;)
}
```
chanrecv1和chanrecv2传入的block参数的值是true都是阻塞方式所以我们分析chanrecv的实现的时候不考虑block=false的情况。
第一部分是chan为nil的情况。和send一样从nil chan中接收读取、获取数据时调用者会被永远阻塞。
```
// 第二部分, block=false且c为空
if !block &amp;&amp; empty(c) {
......
}
```
第二部分你可以直接忽略,因为不是我们这次要分析的场景。
```
// 加锁,返回时释放锁
lock(&amp;c.lock)
// 第三部分c已经被close,且chan为空empty
if c.closed != 0 &amp;&amp; c.qcount == 0 {
unlock(&amp;c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
```
第三部分是chan已经被close的情况。如果chan已经被close了并且队列中没有缓存的元素那么返回true、false。
```
// 第四部分如果sendq队列中有等待发送的sender
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep, func() { unlock(&amp;c.lock) }, 3)
return true, true
}
```
第四部分是处理buf满的情况。这个时候如果是unbuffer的chan就直接将sender的数据复制给receiver否则就从队列头部读取一个值并把这个sender的值加入到队列尾部。
```
// 第五部分, 没有等待的sender, buf中有数据
if c.qcount &gt; 0 {
qp := chanbuf(c, c.recvx)
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
unlock(&amp;c.lock)
return true, true
}
if !block {
unlock(&amp;c.lock)
return false, false
}
// 第六部分, buf中没有元素阻塞
......
```
第五部分是处理没有等待的sender的情况。这个是和chansend共用一把大锁所以不会有并发的问题。如果buf有元素就取出一个元素给receiver。
第六部分是处理buf中没有元素的情况。如果没有元素那么当前的receiver就会被阻塞直到它从sender中接收了数据或者是chan被close才返回。
## close
通过close函数可以把chan关闭编译器会替换成closechan方法的调用。
下面的代码是close chan的主要逻辑。如果chan为nilclose会panic如果chan已经closed再次close也会panic。否则的话如果chan不为nilchan也没有closed就把等待队列中的senderwriter和receiverreader从队列中全部移除并唤醒。
下面的代码就是close chan的逻辑:
```
func closechan(c *hchan) {
if c == nil { // chan为nil, panic
panic(plainError(&quot;close of nil channel&quot;))
}
lock(&amp;c.lock)
if c.closed != 0 {// chan已经closed, panic
unlock(&amp;c.lock)
panic(plainError(&quot;close of closed channel&quot;))
}
c.closed = 1
var glist gList
// 释放所有的reader
for {
sg := c.recvq.dequeue()
......
gp := sg.g
......
glist.push(gp)
}
// 释放所有的writer (它们会panic)
for {
sg := c.sendq.dequeue()
......
gp := sg.g
......
glist.push(gp)
}
unlock(&amp;c.lock)
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}
```
掌握了Channel的基本用法和实现原理下面我再来给你讲一讲容易犯的错误。你一定要认真看毕竟这些可都是帮助你避坑的。
# 使用Channel容易犯的错误
根据2019年第一篇全面分析Go并发Bug的[论文](https://songlh.github.io/paper/go-study.pdf)那些知名的Go项目中使用Channel所犯的Bug反而比传统的并发原语的Bug还要多。主要有两个原因一个是Channel的概念还比较新程序员还不能很好地掌握相应的使用方法和最佳实践第二个是Channel有时候比传统的并发原语更复杂使用起来很容易顾此失彼。
**使用Channel最常见的错误是panic和goroutine泄漏**
首先我们来总结下会panic的情况总共有3种
1. close为nil的chan
1. send已经close的chan
1. close已经close的chan。
goroutine泄漏的问题也很常见下面的代码也是一个实际项目中的例子
```
func process(timeout time.Duration) bool {
ch := make(chan bool)
go func() {
// 模拟处理耗时的业务
time.Sleep((timeout + time.Second))
ch &lt;- true // block
fmt.Println(&quot;exit goroutine&quot;)
}()
select {
case result := &lt;-ch:
return result
case &lt;-time.After(timeout):
return false
}
}
```
在这个例子中process函数会启动一个goroutine去处理需要长时间处理的业务处理完之后会发送true到chan中目的是通知其它等待的goroutine可以继续处理了。
我们来看一下第10行到第15行主goroutine接收到任务处理完成的通知或者超时后就返回了。这段代码有问题吗
如果发生超时process函数就返回了这就会导致unbuffered的chan从来就没有被读取。我们知道unbuffered chan必须等reader和writer都准备好了才能交流否则就会阻塞。超时导致未读结果就是子goroutine就阻塞在第7行永远结束不了进而导致goroutine泄漏。
解决这个Bug的办法很简单就是将unbuffered chan改成容量为1的chan这样第7行就不会被阻塞了。
Go的开发者极力推荐使用Channel不过这两年大家意识到Channel并不是处理并发问题的“银弹”有时候使用并发原语更简单而且不容易出错。所以我给你提供一套选择的方法:
1. 共享资源的并发访问使用传统并发原语;
1. 复杂的任务编排和消息传递使用Channel
1. 消息通知机制使用Channel除非只想signal一个goroutine才使用Cond
1. 简单等待所有任务的完成用WaitGroup也有Channel的推崇者用Channel都可以
1. 需要和Select语句结合使用Channel
1. 需要和超时配合时使用Channel和Context。
# 它们踩过的坑
接下来我带你围观下知名Go项目的Channel相关的Bug。
[etcd issue 6857](https://github.com/etcd-io/etcd/pull/6857)是一个程序hang住的问题在异常情况下没有往chan实例中填充所需的元素导致等待者永远等待。具体来说Status方法的逻辑是生成一个chan Status然后把这个chan交给其它的goroutine去处理和写入数据最后Status返回获取的状态信息。
不幸的是如果正好节点停止了没有goroutine去填充这个chan会导致方法hang在返回的那一行上下面的截图中的第466行。解决办法就是在等待status chan返回元素的同时也检查节点是不是已经停止了done这个chan是不是close了
当前的etcd的代码就是修复后的代码如下所示
<img src="https://static001.geekbang.org/resource/image/5f/da/5f3c15c110077714be81be8eb1fd3fda.png" alt="">
其实我感觉这个修改还是有问题的。问题就在于如果程序执行了466行成功地把c写入到Status待处理队列后执行到第467行时如果停止了这个节点那么这个Status方法还是会阻塞在第467行。你可以自己研究研究看看是不是这样。
[etcd issue 5505](https://github.com/etcd-io/etcd/issues/5505) 虽然没有任何的Bug描述但是从修复内容上看它是一个往已经close的chan写数据导致panic的问题。
[etcd issue 11256](https://github.com/etcd-io/etcd/issues/11256) 是因为unbuffered chan goroutine泄漏的问题。TestNodeProposeAddLearnerNode方法中一开始定义了一个unbuffered的chan也就是applyConfChan然后启动一个子goroutine这个子goroutine会在循环中执行业务逻辑并且不断地往这个chan中添加一个元素。TestNodeProposeAddLearnerNode方法的末尾处会从这个chan中读取一个元素。
这段代码在for循环中就往此chan中写入了一个元素结果导致TestNodeProposeAddLearnerNode从这个chan中读取到元素就返回了。悲剧的是子goroutine的for循环还在执行阻塞在下图中红色的第851行并且一直hang在那里。
这个Bug的修复也很简单只要改动一下applyConfChan的处理逻辑就可以了只有子goroutine的for循环中的主要逻辑完成之后才往applyConfChan发送一个元素这样TestNodeProposeAddLearnerNode收到通知继续执行子goroutine也不会被阻塞住了。
<img src="https://static001.geekbang.org/resource/image/d5/9f/d53573c8fc515f78ea590bf73396969f.png" alt="">
[etcd issue 9956](https://github.com/etcd-io/etcd/issues/9956) 是往一个已close的chan发送数据其实它是grpc的一个bug[grpc issue 2695](https://github.com/grpc/grpc-go/pull/2695)修复办法就是不close这个chan就好了
<img src="https://static001.geekbang.org/resource/image/65/21/650f0911b1c7278cc0438c85bbc4yy21.png" alt="">
# 总结
chan的值和状态有多种情况而不同的操作send、recv、close又可能得到不同的结果这是使用chan类型时经常让人困惑的地方。
为了帮助你快速地了解不同状态下各种操作的结果我总结了一个表格你一定要特别关注下那些panic的情况另外还要掌握那些会block的场景它们是导致死锁或者goroutine泄露的罪魁祸首。
还有一个值得注意的点是只要一个chan还有未读的数据即使把它close掉你还是可以继续把这些未读的数据消费完之后才是读取零值数据。
<img src="https://static001.geekbang.org/resource/image/51/98/5108954ea36559860e5e5aaa42b2f998.jpg" alt="">
# 思考题
<li>
有一道经典的使用Channel进行任务编排的题你可以尝试做一下有四个goroutine编号为1、2、3、4。每秒钟会有一个goroutine打印出它自己的编号要求你编写一个程序让输出的编号总是按照1、2、3、4、1、2、3、4、……的顺序打印出来。
</li>
<li>
chan T 是否可以给&lt;- chan T和chan&lt;- T类型的变量赋值反过来呢
</li>
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,724 @@
<audio id="audio" title="14 | Channel透过代码看典型的应用模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/51/895031072cf84e0fb081db7c336e8251.mp3"></audio>
你好,我是鸟窝。
前一讲我介绍了Channel的基础知识并且总结了几种应用场景。这一讲我将通过实例的方式带你逐个学习Channel解决这些问题的方法帮你巩固和完全掌握它的用法。
在开始上课之前我先补充一个知识点通过反射的方式执行select语句在处理很多的case clause尤其是不定长的case clause的时候非常有用。而且在后面介绍任务编排的实现时我也会采用这种方法所以我先带你具体学习下Channel的反射用法。
# 使用反射操作Channel
select语句可以处理chan的send和recvsend和recv都可以作为case clause。如果我们同时处理两个chan就可以写成下面的样子
```
select {
case v := &lt;-ch1:
fmt.Println(v)
case v := &lt;-ch2:
fmt.Println(v)
}
```
如果需要处理三个chan你就可以再添加一个case clause用它来处理第三个chan。可是如果要处理100个chan呢一万个chan呢
或者是chan的数量在编译的时候是不定的在运行的时候需要处理一个slice of chan这个时候也没有办法在编译前写成字面意义的select。那该怎么办
这个时候,就要“祭”出我们的反射大法了。
通过reflect.Select函数你可以将一组运行时的case clause传入当作参数执行。Go的select是伪随机的它可以在执行的case中随机选择一个case并把选择的这个case的索引chosen返回如果没有可用的case返回会返回一个bool类型的返回值这个返回值用来表示是否有case成功被选择。如果是recv case还会返回接收的元素。Select的方法签名如下
```
func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool)
```
下面我来借助一个例子来演示一下动态处理两个chan的情形。因为这样的方式可以动态处理case数据所以你可以传入几百几千几万的chan这就解决了不能动态处理n个chan的问题。
首先createCases函数分别为每个chan生成了recv case和send case并返回一个reflect.SelectCase数组。
然后通过一个循环10次的for循环执行reflect.Select这个方法会从cases中选择一个case执行。第一次肯定是send case因为此时chan还没有元素recv还不可用。等chan中有了数据以后recv case就可以被选择了。这样你就可以处理不定数量的chan了。
```
func main() {
var ch1 = make(chan int, 10)
var ch2 = make(chan int, 10)
// 创建SelectCase
var cases = createCases(ch1, ch2)
// 执行10次select
for i := 0; i &lt; 10; i++ {
chosen, recv, ok := reflect.Select(cases)
if recv.IsValid() { // recv case
fmt.Println(&quot;recv:&quot;, cases[chosen].Dir, recv, ok)
} else { // send case
fmt.Println(&quot;send:&quot;, cases[chosen].Dir, ok)
}
}
}
func createCases(chs ...chan int) []reflect.SelectCase {
var cases []reflect.SelectCase
// 创建recv case
for _, ch := range chs {
cases = append(cases, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(ch),
})
}
// 创建send case
for i, ch := range chs {
v := reflect.ValueOf(i)
cases = append(cases, reflect.SelectCase{
Dir: reflect.SelectSend,
Chan: reflect.ValueOf(ch),
Send: v,
})
}
return cases
}
```
# 典型的应用场景
了解刚刚的反射用法我们就解决了今天的基础知识问题接下来我就带你具体学习下Channel的应用场景。
首先来看消息交流。
## 消息交流
从chan的内部实现看它是以一个循环队列的方式存放数据所以它有时候也会被当成线程安全的队列和buffer使用。一个goroutine可以安全地往Channel中塞数据另外一个goroutine可以安全地从Channel中读取数据goroutine就可以安全地实现信息交流了。
我们来看几个例子。
第一个例子是worker池的例子。Marcio Castilho 在 [使用Go每分钟处理百万请求](http://marcio.io/2015/07/handling-1-million-requests-per-minute-with-golang/) 这篇文章中,就介绍了他们应对大并发请求的设计。他们将用户的请求放在一个 chan Job 中这个chan Job就相当于一个待处理任务队列。除此之外还有一个chan chan Job队列用来存放可以处理任务的worker的缓存队列。
dispatcher会把待处理任务队列中的任务放到一个可用的缓存队列中worker会一直处理它的缓存队列。通过使用Channel实现了一个worker池的任务处理中心并且解耦了前端HTTP请求处理和后端任务处理的逻辑。
我在讲Pool的时候提到了一些第三方实现的worker池它们全部都是通过Channel实现的这是Channel的一个常见的应用场景。worker池的生产者和消费者的消息交流都是通过Channel实现的。
第二个例子是etcd中的node节点的实现包含大量的chan字段比如recvc是消息处理的chan待处理的protobuf消息都扔到这个chan中node有一个专门的run goroutine负责处理这些消息。
<img src="https://static001.geekbang.org/resource/image/06/a4/0643503a1yy135b476d41345d71766a4.png" alt="">
## 数据传递
“击鼓传花”的游戏很多人都玩过,花从一个人手中传给另外一个人,就有点类似流水线的操作。这个花就是数据,花在游戏者之间流转,这就类似编程中的数据传递。
还记得上节课我给你留了一道任务编排的题吗?其实它就可以用数据传递的方式实现。
>
有4个goroutine编号为1、2、3、4。每秒钟会有一个goroutine打印出它自己的编号要求你编写程序让输出的编号总是按照1、2、3、4、1、2、3、4……这个顺序打印出来。
为了实现顺序的数据传递,我们可以定义一个令牌的变量,谁得到令牌,谁就可以打印一次自己的编号,同时将令牌**传递**给下一个goroutine我们尝试使用chan来实现可以看下下面的代码。
```
type Token struct{}
func newWorker(id int, ch chan Token, nextCh chan Token) {
for {
token := &lt;-ch // 取得令牌
fmt.Println((id + 1)) // id从1开始
time.Sleep(time.Second)
nextCh &lt;- token
}
}
func main() {
chs := []chan Token{make(chan Token), make(chan Token), make(chan Token), make(chan Token)}
// 创建4个worker
for i := 0; i &lt; 4; i++ {
go newWorker(i, chs[i], chs[(i+1)%4])
}
//首先把令牌交给第一个worker
chs[0] &lt;- struct{}{}
select {}
}
```
我来给你具体解释下这个实现方式。
首先我们定义一个令牌类型Token接着定义一个创建worker的方法这个方法会从它自己的chan中读取令牌。哪个goroutine取得了令牌就可以打印出自己编号因为需要每秒打印一次数据所以我们让它休眠1秒后再把令牌交给它的下家。
接着在第16行启动每个worker的goroutine并在第20行将令牌先交给第一个worker。
如果你运行这个程序就会在命令行中看到每一秒就会输出一个编号而且编号是以1、2、3、4这样的顺序输出的。
这类场景有一个特点就是当前持有数据的goroutine都有一个信箱信箱使用chan实现goroutine只需要关注自己的信箱中的数据处理完毕后就把结果发送到下一家的信箱中。
## 信号通知
chan类型有这样一个特点chan如果为空那么receiver接收数据的时候就会阻塞等待直到chan被关闭或者有新的数据到来。利用这个机制我们可以实现wait/notify的设计模式。
传统的并发原语Cond也能实现这个功能。但是Cond使用起来比较复杂容易出错而使用chan实现wait/notify模式就方便多了。
除了正常的业务处理时的wait/notify我们经常碰到的一个场景就是程序关闭的时候我们需要在退出之前做一些清理doCleanup方法的动作。这个时候我们经常要使用chan。
比如使用chan实现程序的graceful shutdown在退出之前执行一些连接关闭、文件close、缓存落盘等一些动作。
```
func main() {
go func() {
...... // 执行业务处理
}()
// 处理CTRL+C等中断信号
termChan := make(chan os.Signal)
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
&lt;-termChan
// 执行退出之前的清理动作
doCleanup()
fmt.Println(&quot;优雅退出&quot;)
}
```
有时候doCleanup可能是一个很耗时的操作比如十几分钟才能完成如果程序退出需要等待这么长时间用户是不能接受的所以在实践中我们需要设置一个最长的等待时间。只要超过了这个时间程序就不再等待可以直接退出。所以退出的时候分为两个阶段
1. closing代表程序退出但是清理工作还没做
1. closed代表清理工作已经做完。
所以,上面的例子可以改写如下:
```
func main() {
var closing = make(chan struct{})
var closed = make(chan struct{})
go func() {
// 模拟业务处理
for {
select {
case &lt;-closing:
return
default:
// ....... 业务计算
time.Sleep(100 * time.Millisecond)
}
}
}()
// 处理CTRL+C等中断信号
termChan := make(chan os.Signal)
signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
&lt;-termChan
close(closing)
// 执行退出之前的清理动作
go doCleanup(closed)
select {
case &lt;-closed:
case &lt;-time.After(time.Second):
fmt.Println(&quot;清理超时,不等了&quot;)
}
fmt.Println(&quot;优雅退出&quot;)
}
func doCleanup(closed chan struct{}) {
time.Sleep((time.Minute))
close(closed)
}
```
## 锁
使用chan也可以实现互斥锁。
在chan的内部实现中就有一把互斥锁保护着它的所有字段。从外在表现上chan的发送和接收之间也存在着happens-before的关系保证元素放进去之后receiver才能读取到关于happends-before的关系是指事件发生的先后顺序关系我会在下一讲详细介绍这里你只需要知道它是一种描述事件先后顺序的方法
要想使用chan实现互斥锁至少有两种方式。一种方式是先初始化一个capacity等于1的Channel然后再放入一个元素。这个元素就代表锁谁取得了这个元素就相当于获取了这把锁。另一种方式是先初始化一个capacity等于1的Channel它的“空槽”代表锁谁能成功地把元素发送到这个Channel谁就获取了这把锁。
这是使用Channel实现锁的两种不同实现方式我重点介绍下第一种。理解了这种实现方式第二种方式也就很容易掌握了我就不多说了。
```
// 使用chan实现互斥锁
type Mutex struct {
ch chan struct{}
}
// 使用锁需要初始化
func NewMutex() *Mutex {
mu := &amp;Mutex{make(chan struct{}, 1)}
mu.ch &lt;- struct{}{}
return mu
}
// 请求锁,直到获取到
func (m *Mutex) Lock() {
&lt;-m.ch
}
// 解锁
func (m *Mutex) Unlock() {
select {
case m.ch &lt;- struct{}{}:
default:
panic(&quot;unlock of unlocked mutex&quot;)
}
}
// 尝试获取锁
func (m *Mutex) TryLock() bool {
select {
case &lt;-m.ch:
return true
default:
}
return false
}
// 加入一个超时的设置
func (m *Mutex) LockTimeout(timeout time.Duration) bool {
timer := time.NewTimer(timeout)
select {
case &lt;-m.ch:
timer.Stop()
return true
case &lt;-timer.C:
}
return false
}
// 锁是否已被持有
func (m *Mutex) IsLocked() bool {
return len(m.ch) == 0
}
func main() {
m := NewMutex()
ok := m.TryLock()
fmt.Printf(&quot;locked v %v\n&quot;, ok)
ok = m.TryLock()
fmt.Printf(&quot;locked %v\n&quot;, ok)
}
```
你可以用buffer等于1的chan实现互斥锁在初始化这个锁的时候往Channel中先塞入一个元素谁把这个元素取走谁就获取了这把锁把元素放回去就是释放了锁。元素在放回到chan之前不会有goroutine能从chan中取出元素的这就保证了互斥性。
在这段代码中还有一点需要我们注意下利用select+chan的方式很容易实现TryLock、Timeout的功能。具体来说就是在select语句中我们可以使用default实现TryLock使用一个Timer来实现Timeout的功能。
## 任务编排
前面所说的消息交流的场景是一个特殊的任务编排的场景,这个“击鼓传花”的模式也被称为流水线模式。
在[第6讲](https://time.geekbang.org/column/article/298516)我们学习了WaitGroup我们可以利用它实现等待模式启动一组goroutine执行任务然后等待这些任务都完成。其实我们也可以使用chan实现WaitGroup的功能。这个比较简单我就不举例子了接下来我介绍几种更复杂的编排模式。
这里的编排既指安排goroutine按照指定的顺序执行也指多个chan按照指定的方式组合处理的方式。goroutine的编排类似“击鼓传花”的例子我们通过编排数据在chan之间的流转就可以控制goroutine的执行。接下来我来重点介绍下多个chan的编排方式总共5种分别是Or-Done模式、扇入模式、扇出模式、Stream和map-reduce。
### Or-Done模式
首先来看Or-Done模式。Or-Done模式是信号通知模式中更宽泛的一种模式。这里提到了“信号通知模式”我先来解释一下。
我们会使用“信号通知”实现某个任务执行完成后的通知机制在实现时我们为这个任务定义一个类型为chan struct{}类型的done变量等任务结束后我们就可以close这个变量然后其它receiver就会收到这个通知。
这是有一个任务的情况如果有多个任务只要有任意一个任务执行完我们就想获得这个信号这就是Or-Done模式。
比如,你发送同一个请求到多个微服务节点,只要任意一个微服务节点返回结果,就算成功,这个时候,就可以参考下面的实现:
```
func or(channels ...&lt;-chan interface{}) &lt;-chan interface{} {
// 特殊情况只有零个或者1个chan
switch len(channels) {
case 0:
return nil
case 1:
return channels[0]
}
orDone := make(chan interface{})
go func() {
defer close(orDone)
switch len(channels) {
case 2: // 2个也是一种特殊情况
select {
case &lt;-channels[0]:
case &lt;-channels[1]:
}
default: //超过两个,二分法递归处理
m := len(channels) / 2
select {
case &lt;-or(channels[:m]...):
case &lt;-or(channels[m:]...):
}
}
}()
return orDone
}
```
我们可以写一个测试程序测试它:
```
func sig(after time.Duration) &lt;-chan interface{} {
c := make(chan interface{})
go func() {
defer close(c)
time.Sleep(after)
}()
return c
}
func main() {
start := time.Now()
&lt;-or(
sig(10*time.Second),
sig(20*time.Second),
sig(30*time.Second),
sig(40*time.Second),
sig(50*time.Second),
sig(01*time.Minute),
)
fmt.Printf(&quot;done after %v&quot;, time.Since(start))
}
```
这里的实现使用了一个巧妙的方式,**当chan的数量大于2时使用递归的方式等待信号**。
在chan数量比较多的情况下递归并不是一个很好的解决方式根据这一讲最开始介绍的反射的方法我们也可以实现Or-Done模式
```
func or(channels ...&lt;-chan interface{}) &lt;-chan interface{} {
//特殊情况只有0个或者1个
switch len(channels) {
case 0:
return nil
case 1:
return channels[0]
}
orDone := make(chan interface{})
go func() {
defer close(orDone)
// 利用反射构建SelectCase
var cases []reflect.SelectCase
for _, c := range channels {
cases = append(cases, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(c),
})
}
// 随机选择一个可用的case
reflect.Select(cases)
}()
return orDone
}
```
这是递归和反射两种方法实现Or-Done模式的代码。反射方式避免了深层递归的情况可以处理有大量chan的情况。其实最笨的一种方法就是为每一个Channel启动一个goroutine不过这会启动非常多的goroutine太多的goroutine会影响性能所以不太常用。你只要知道这种用法就行了不用重点掌握。
### 扇入模式
扇入借鉴了数字电路的概念,它定义了单个逻辑门能够接受的数字信号输入最大量的术语。一个逻辑门可以有多个输入,一个输出。
在软件工程中模块的扇入是指有多少个上级模块调用它。而对于我们这里的Channel扇入模式来说就是指有多个源Channel输入、一个目的Channel输出的情况。扇入比就是源Channel数量比1。
每个源Channel的元素都会发送给目标Channel相当于目标Channel的receiver只需要监听目标Channel就可以接收所有发送给源Channel的数据。
扇入模式也可以使用反射、递归或者是用最笨的每个goroutine处理一个Channel的方式来实现。
这里我列举下递归和反射的方式,帮你加深一下对这个技巧的理解。
反射的代码比较简短易于理解主要就是构造出SelectCase slice然后传递给reflect.Select语句。
```
func fanInReflect(chans ...&lt;-chan interface{}) &lt;-chan interface{} {
out := make(chan interface{})
go func() {
defer close(out)
// 构造SelectCase slice
var cases []reflect.SelectCase
for _, c := range chans {
cases = append(cases, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(c),
})
}
// 循环从cases中选择一个可用的
for len(cases) &gt; 0 {
i, v, ok := reflect.Select(cases)
if !ok { // 此channel已经close
cases = append(cases[:i], cases[i+1:]...)
continue
}
out &lt;- v.Interface()
}
}()
return out
}
```
递归模式也是在Channel大于2时采用二分法递归merge。
```
func fanInRec(chans ...&lt;-chan interface{}) &lt;-chan interface{} {
switch len(chans) {
case 0:
c := make(chan interface{})
close(c)
return c
case 1:
return chans[0]
case 2:
return mergeTwo(chans[0], chans[1])
default:
m := len(chans) / 2
return mergeTwo(
fanInRec(chans[:m]...),
fanInRec(chans[m:]...))
}
}
```
这里有一个mergeTwo的方法是将两个Channel合并成一个Channel是扇入形式的一种特例只处理两个Channel。 下面我来借助一段代码帮你理解下这个方法。
```
func mergeTwo(a, b &lt;-chan interface{}) &lt;-chan interface{} {
c := make(chan interface{})
go func() {
defer close(c)
for a != nil || b != nil { //只要还有可读的chan
select {
case v, ok := &lt;-a:
if !ok { // a 已关闭设置为nil
a = nil
continue
}
c &lt;- v
case v, ok := &lt;-b:
if !ok { // b 已关闭设置为nil
b = nil
continue
}
c &lt;- v
}
}
}()
return c
}
```
### 扇出模式
有扇入模式,就有扇出模式,扇出模式是和扇入模式相反的。
扇出模式只有一个输入源Channel有多个目标Channel扇出比就是1比目标Channel数的值经常用在设计模式中的[观察者模式](https://baike.baidu.com/item/%E8%A7%82%E5%AF%9F%E8%80%85%E6%A8%A1%E5%BC%8F/5881786?fr=aladdin)中(观察者设计模式定义了对象间的一种一对多的组合关系。这样一来,一个对象的状态发生变化时,所有依赖于它的对象都会得到通知并自动刷新)。在观察者模式中,数据变动后,多个观察者都会收到这个变更信号。
下面是一个扇出模式的实现。从源Channel取出一个数据后依次发送给目标Channel。在发送给目标Channel的时候可以同步发送也可以异步发送
```
func fanOut(ch &lt;-chan interface{}, out []chan interface{}, async bool) {
go func() {
defer func() { //退出时关闭所有的输出chan
for i := 0; i &lt; len(out); i++ {
close(out[i])
}
}()
for v := range ch { // 从输入chan中读取数据
v := v
for i := 0; i &lt; len(out); i++ {
i := i
if async { //异步
go func() {
out[i] &lt;- v // 放入到输出chan中,异步方式
}()
} else {
out[i] &lt;- v // 放入到输出chan中同步方式
}
}
}
}()
}
```
你也可以尝试使用反射的方式来实现,我就不列相关代码了,希望你课后可以自己思考下。
### Stream
这里我来介绍一种把Channel当作流式管道使用的方式也就是把Channel看作流Stream提供跳过几个元素或者是只取其中的几个元素等方法。
首先我们提供创建流的方法。这个方法把一个数据slice转换成流
```
func asStream(done &lt;-chan struct{}, values ...interface{}) &lt;-chan interface{} {
s := make(chan interface{}) //创建一个unbuffered的channel
go func() { // 启动一个goroutine往s中塞数据
defer close(s) // 退出时关闭chan
for _, v := range values { // 遍历数组
select {
case &lt;-done:
return
case s &lt;- v: // 将数组元素塞入到chan中
}
}
}()
return s
}
```
流创建好以后,该咋处理呢?下面我再给你介绍下实现流的方法。
1. takeN只取流中的前n个数据
1. takeFn筛选流中的数据只保留满足条件的数据
1. takeWhile只取前面满足条件的数据一旦不满足条件就不再取
1. skipN跳过流中前几个数据
1. skipFn跳过满足条件的数据
1. skipWhile跳过前面满足条件的数据一旦不满足条件当前这个元素和以后的元素都会输出给Channel的receiver。
这些方法的实现很类似我们以takeN为例来具体解释一下。
```
func takeN(done &lt;-chan struct{}, valueStream &lt;-chan interface{}, num int) &lt;-chan interface{} {
takeStream := make(chan interface{}) // 创建输出流
go func() {
defer close(takeStream)
for i := 0; i &lt; num; i++ { // 只读取前num个元素
select {
case &lt;-done:
return
case takeStream &lt;- &lt;-valueStream: //从输入流中读取元素
}
}
}()
return takeStream
}
```
### map-reduce
map-reduce是一种处理数据的方式最早是由Google公司研究提出的一种面向大规模数据处理的并行计算模型和方法开源的版本是hadoop前几年比较火。
不过我要讲的并不是分布式的map-reduce而是单机单进程的map-reduce方法。
map-reduce分为两个步骤第一步是映射map处理队列中的数据第二步是规约reduce把列表中的每一个元素按照一定的处理方式处理成结果放入到结果队列中。
就像做汉堡一样map就是单独处理每一种食材reduce就是从每一份食材中取一部分做成一个汉堡。
我们先来看下map函数的处理逻辑:
```
func mapChan(in &lt;-chan interface{}, fn func(interface{}) interface{}) &lt;-chan interface{} {
out := make(chan interface{}) //创建一个输出chan
if in == nil { // 异常检查
close(out)
return out
}
go func() { // 启动一个goroutine,实现map的主要逻辑
defer close(out)
for v := range in { // 从输入chan读取数据执行业务操作也就是map操作
out &lt;- fn(v)
}
}()
return out
}
```
reduce函数的处理逻辑如下
```
func reduce(in &lt;-chan interface{}, fn func(r, v interface{}) interface{}) interface{} {
if in == nil { // 异常检查
return nil
}
out := &lt;-in // 先读取第一个元素
for v := range in { // 实现reduce的主要逻辑
out = fn(out, v)
}
return out
}
```
我们可以写一个程序这个程序使用map-reduce模式处理一组整数map函数就是为每个整数乘以10reduce函数就是把map处理的结果累加起来
```
// 生成一个数据流
func asStream(done &lt;-chan struct{}) &lt;-chan interface{} {
s := make(chan interface{})
values := []int{1, 2, 3, 4, 5}
go func() {
defer close(s)
for _, v := range values { // 从数组生成
select {
case &lt;-done:
return
case s &lt;- v:
}
}
}()
return s
}
func main() {
in := asStream(nil)
// map操作: 乘以10
mapFn := func(v interface{}) interface{} {
return v.(int) * 10
}
// reduce操作: 对map的结果进行累加
reduceFn := func(r, v interface{}) interface{} {
return r.(int) + v.(int)
}
sum := reduce(mapChan(in, mapFn), reduceFn) //返回累加结果
fmt.Println(sum)
}
```
# 总结
这节课我借助代码示例带你学习了Channel的应用场景和应用模式。这几种模式不是我们学习的终点而是学习的起点。掌握了这几种模式之后我们可以延伸出更多的模式。
虽然Channel最初是基于CSP设计的用于goroutine之间的消息传递的一种数据类型但是除了消息传递这个功能之外大家居然还演化出了各式各样的应用模式。我不确定Go的创始人在设计这个类型的时候有没有想到这一点但是我确实被各位大牛利用Channel的各种点子折服了比如有人实现了一个基于TCP网络的分布式的Channel。
在使用Go开发程序的时候你也不妨多考虑考虑是否能够使用chan类型看看你是不是也能创造出别具一格的应用模式。
<img src="https://static001.geekbang.org/resource/image/41/c9/4140728d1f331beaf92e712cd34681c9.jpg" alt="">
# 思考题
想一想我们在利用chan实现互斥锁的时候如果buffer设置的不是1而是一个更大的值会出现什么状况吗能解决什么问题吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,507 @@
<audio id="audio" title="15 | 内存模型Go如何保证并发读写的顺序" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/aa/8b4b79d1a716766582d453c45eb87daa.mp3"></audio>
你好,我是鸟窝。
Go官方文档里专门介绍了Go的[内存模型](https://golang.org/ref/mem)你不要误解这里的内存模型的含义它并不是指Go对象的内存分配、内存回收和内存整理的规范它描述的是并发环境中多goroutine读相同变量的时候变量的可见性条件。具体点说就是指在什么条件下goroutine在读取一个变量的值的时候能够看到其它goroutine对这个变量进行的写的结果。
由于CPU指令重排和多级Cache的存在保证多核访问同一个变量这件事儿变得非常复杂。毕竟不同CPU架构x86/amd64、ARM、Power等的处理方式也不一样再加上编译器的优化也可能对指令进行重排所以编程语言需要一个规范来明确多线程同时访问同一个变量的可见性和顺序 Russ Cox在麻省理工学院 [6.824 分布式系统Distributed Systems课程](https://pdos.csail.mit.edu/6.824/) 的一课,专门介绍了相关的[知识](http://nil.csail.mit.edu/6.824/2016/notes/gomem.pdf))。在编程语言中,这个规范被叫做内存模型。
除了GoJava、C++、C、C#、Rust等编程语言也有内存模型。为什么这些编程语言都要定义内存模型呢在我看来主要是两个目的。
- 向广大的程序员提供一种保证以便他们在做设计和开发程序时面对同一个数据同时被多个goroutine访问的情况可以做一些串行化访问的控制比如使用Channel或者sync包和sync/atomic包中的并发原语。
- 允许编译器和硬件对程序做一些优化。这一点其实主要是为编译器开发者提供的保证这样可以方便他们对Go的编译器做优化。
既然内存模型这么重要,今天,我们就来花一节课的时间学习一下。
首先,我们要先弄明白重排和可见性的问题,因为它们影响着程序实际执行的顺序关系。
# 重排和可见性的问题
**由于指令重排,代码并不一定会按照你写的顺序执行**
举个例子当两个goroutine同时对一个数据进行读写时假设goroutine g1对这个变量进行写操作wgoroutine g2同时对这个变量进行读操作r那么如果g2在执行读操作r的时候已经看到了g1写操作w的结果那么也不意味着g2能看到在w之前的其它的写操作。这是一个反直观的结果不过的确可能会存在。
接下来我再举几个具体的例子带你来感受一下重排以及多核CPU并发执行导致程序的运行和代码的书写顺序不一样的情况。
先看第一个例子,代码如下:
```
var a, b int
func f() {
a = 1 // w之前的写操作
b = 2 // 写操作w
}
func g() {
print(b) // 读操作r
print(a) // ???
}
func main() {
go f() //g1
g() //g2
}
```
可以看到第9行是要打印b的值。需要注意的是即使这里打印出的值是2但是依然可能在打印a的值时打印出初始值0而不是1。这是因为程序运行的时候不能保证g2看到的a和b的赋值有先后关系。
再来看一个类似的例子。
```
var a string
var done bool
func setup() {
a = &quot;hello, world&quot;
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
```
在这段代码中主goroutine main即使观察到done变成true了最后读取到的a的值仍然可能为空。
更糟糕的情况是main根本就观察不到另一个goroutine对done的写操作这就会导致main程序一直被hang住。甚至可能还会出现**半初始化**的情况,比如:
```
type T struct {
msg string
}
var g *T
func setup() {
t := new(T)
t.msg = &quot;hello, world&quot;
g = t
}
func main() {
go setup()
for g == nil {
}
print(g.msg)
}
```
即使main goroutine观察到g不为nil也可能打印出空的msg第17行
看到这里你可能要说了我都运行这个程序几百万次了怎么也没有观察到这种现象我可以这么告诉你能不能观察到和提供保证guarantee是两码事儿。由于CPU架构和Go编译器的不同即使你运行程序时没有遇到这些现象也不代表Go可以100%保证不会出现这些问题。
刚刚说了程序在运行的时候两个操作的顺序可能不会得到保证那该怎么办呢接下来我要带你了解一下Go内存模型中很重要的一个概念happens-before这是用来描述两个时间的顺序关系的。如果某些操作能提供happens-before关系那么我们就可以100%保证它们之间的顺序。
# happens-before
在一个goroutine内部程序的执行顺序和它们的代码指定的顺序是一样的即使编译器或者CPU重排了读写顺序从行为上来看也和代码指定的顺序一样。
这是一个非常重要的保证,我们一定要记住。
我们来看一个例子。在下面的代码中即使编译器或者CPU对a、b、c的初始化进行了重排但是打印结果依然能保证是1、2、3而不会出现1、0、0或1、0、1等情况。
```
func foo() {
var a = 1
var b = 2
var c = 3
println(a)
println(b)
println(c)
}
```
但是对于另一个goroutine来说重排却会产生非常大的影响。**因为Go只保证goroutine内部重排对读写的顺序没有影响**比如刚刚我们在讲“可见性”问题时提到的三个例子那该怎么办呢这就要用到happens-before关系了。
如果两个actionread 或者 write有明确的happens-before关系你就可以确定它们之间的执行顺序或者是行为表现上的顺序
Go内存模型通过happens-before定义两个事件读、写action的顺序如果事件e1 happens before 事件e2那么我们就可以说事件e2在事件e1之后发生happens after。如果e1 不是happens before e2 同时也不happens after e2那么我们就可以说事件e1和e2是同时发生的。
如果要保证对“变量**v**的读操作**r**”能够观察到一个对“变量**v**的写操作**w**”,并且**r**只能观察到**w**对变量**v**的写没有其它对v的写操作也就是说我们要保证**r**绝对能观察到**w**操作的结果,那么就需要同时满足两个条件:
1. w happens before r
1. 其它对v的写操作w2、w3、w4, ...... 要么happens before w要么happens after r绝对不会和w、r同时发生或者是在它们之间发生。
你可能会说,这是很显然的事情啊,但我要和你说的是,这是一个非常严格、严谨的数学定义。
对于单个的goroutine来说它有一个特殊的happens-before关系Go内存模型中是这么讲的
>
Within a single goroutine, the happens-before order is the order expressed by the program.
我来解释下这句话。它的意思是在单个的goroutine内部 happens-before的关系和代码编写的顺序是一致的。
其实,在这一章的开头我已经用橙色把这句话标注出来了。我再具体解释下。
在goroutine内部对一个局部变量v的读一定能观察到最近一次对这个局部变量v的写。如果要保证多个goroutine之间对一个共享变量的读写顺序在Go语言中可以使用并发原语为读写操作建立happens-before关系这样就可以保证顺序了。
说到这儿我想先给你补充三个Go语言中和内存模型有关的小知识掌握了这些你就能更好地理解下面的内容。
1. 在Go语言中对变量进行零值的初始化就是一个写操作。
1. 如果对超过机器word64bit、32bit或者其它大小的值进行读写那么就可以看作是对拆成word大小的几个读写无序进行。
1. Go并不提供直接的CPU屏障CPU fence来提示编译器或者CPU保证顺序性而是使用不同架构的内存屏障指令来实现统一的并发原语。
接下来我就带你学习下Go语言中提供的happens-before关系保证。
# Go语言中保证的happens-before关系
除了单个goroutine内部提供的happens-before保证Go语言中还提供了一些其它的happens-before关系的保证下面我来一个一个介绍下。
## init函数
应用程序的初始化是在单一的goroutine执行的。如果包p导入了包q那么q的init函数的执行一定 happens before p的任何初始化代码。
这里有一个特殊情况需要你记住:**main函数一定在导入的包的init函数之后执行**。
包级别的变量在同一个文件中是按照声明顺序逐个初始化的,除非初始化它的时候依赖其它的变量。同一个包下的多个文件,会按照文件名的排列顺序进行初始化。这个顺序被定义在[Go语言规范](https://golang.org/ref/spec#Program_initialization_and_execution)中而不是Go的内存模型规范中。你可以看看下面的例子中各个变量的值
```
var (
a = c + b // == 9
b = f() // == 4
c = f() // == 5
d = 3 // == 5 全部初始化完成后
)
func f() int {
d++
return d
}
```
具体怎么对这些变量进行初始化呢Go采用的是依赖分析技术。不过依赖分析技术保证的顺序只是针对同一包下的变量而且只有引用关系是本包变量、函数和非接口的方法才能保证它们的顺序性。
同一个包下可以有多个init函数甚至一个文件中也可以包含多个相同签名的init函数。
刚刚讲的这些都是不同包的init函数执行顺序下面我举一个具体的例子把这些内容串起来你一看就明白了。
这个例子是一个**main**程序它依赖包p1包p1依赖包p2包p2依赖p3。
<img src="https://static001.geekbang.org/resource/image/d5/2a/d5059fab1977602934339e18f9eddb2a.jpg" alt="">
为了追踪初始化过程,并输出有意义的日志,我定义了一个辅助方法,打印出日志并返回一个用来初始化的整数值:
```
func Trace(t string, v int) int {
fmt.Println(t, &quot;:&quot;, v)
return v
}
```
包**p3**包含两个文件分别定义了一个init函数。第一个文件中定义了两个变量这两个变量的值还会在init函数中进行修改。
我们来分别看下包p3的这两个文件
```
// lib1.go in p3
var V1_p3 = trace.Trace(&quot;init v1_p3&quot;, 3)
var V2_p3 = trace.Trace(&quot;init v2_p3&quot;, 3)
func init() {
fmt.Println(&quot;init func in p3&quot;)
V1_p3 = 300
V2_p3 = 300
}
```
```
// lib2.go in p3
func init() {
fmt.Println(&quot;another init func in p3&quot;)
}
```
下面再来看看包p2。包p2定义了变量和init函数。第一个变量初始化为2并在init函数中更改为200。第二个变量是复制的p3.V2_p3。
```
var V1_p2 = trace.Trace(&quot;init v1_p2&quot;, 2)
var V2_p2 = trace.Trace(&quot;init v2_p2&quot;, p3.V2_p3)
func init() {
fmt.Println(&quot;init func in p2&quot;)
V1_p2 = 200
}
```
包**p1**定义了变量和init函数。它的两个变量的值是复制的p2对应的两个变量值。
```
var V1_p1 = trace.Trace(&quot;init v1_p1&quot;, p2.V1_p2)
var V2_p1 = trace.Trace(&quot;init v2_p1&quot;, p2.V2_p2)
func init() {
fmt.Println(&quot;init func in p1&quot;)
}
```
**main**定义了init函数和main函数。
```
func init() {
fmt.Println(&quot;init func in main&quot;)
}
func main() {
fmt.Println(&quot;V1_p1:&quot;, p1.V1_p1)
fmt.Println(&quot;V2_p1:&quot;, p1.V2_p1)
}
```
运行main函数会依次输出p3、p2、p1、main的初始化变量时的日志变量初始化时的日志和init函数调用时的日志
```
// 包p3的变量初始化
init v1_p3 : 3
init v2_p3 : 3
// p3的init函数
init func in p3
// p3的另一个init函数
another init func in p3
// 包p2的变量初始化
init v1_p2 : 2
init v2_p2 : 300
// 包p2的init函数
init func in p2
// 包p1的变量初始化
init v1_p1 : 200
init v2_p1 : 300
// 包p1的init函数
init func in p1
// 包main的init函数
init func in main
// main函数
V1_p1: 200
V2_p1: 300
```
下面我们再来看看goroutine对happens-before关系的保证情况。
## goroutine
首先,我们需要明确一个规则:**启动goroutine的go语句的执行一定happens before此goroutine内的代码执行。**
根据这个规则我们就可以知道如果go语句传入的参数是一个函数执行的结果那么这个函数一定先于goroutine内部的代码被执行。
我们来看一个例子。在下面的代码中第8行a的赋值和第9行的go语句是在同一个goroutine中执行的所以在主goroutine看来第8行肯定happens before 第9行又由于刚才的保证第9行子goroutine的启动happens before 第4行的变量输出那么我们就可以推断出第8行happens before 第4行。也就是说在第4行打印a的值的时候肯定会打印出“hello world”。
```
var a string
func f() {
print(a)
}
func hello() {
a = &quot;hello, world&quot;
go f()
}
```
刚刚说的是启动goroutine的情况goroutine退出的时候是没有任何happens-before保证的。所以如果你想观察某个goroutine的执行效果你需要使用同步机制建立happens-before关系比如Mutex或者Channel。接下来我会讲Channel的happens-before的关系保证。
## Channel
Channel是goroutine同步交流的主要方法。往一个Channel中发送一条数据通常对应着另一个goroutine从这个Channel中接收一条数据。
通用的Channel happens-before关系保证有4条规则我分别来介绍下。
**第1条规则是**往Channel中的发送操作happens before 从该Channel接收相应数据的动作完成之前即第n个send一定happens before第n个receive的完成。
```
var ch = make(chan struct{}, 10) // buffered或者unbuffered
var s string
func f() {
s = &quot;hello, world&quot;
ch &lt;- struct{}{}
}
func main() {
go f()
&lt;-ch
print(s)
}
```
在这个例子中s的初始化第5行happens before 往ch中发送数据 往ch发送数据 happens before从ch中读取出一条数据第11行第12行打印s的值 happens after第11行所以打印的结果肯定是初始化后的s的值“hello world”。
**第2条规则是**close一个Channel的调用肯定happens before 从关闭的Channel中读取出一个零值。
还是拿刚刚的这个例子来说如果你把第6行替换成 close(ch)也能保证同样的执行顺序。因为第11行从关闭的ch中读取出零值后第6行肯定被调用了。
**第3条规则是**对于unbuffered的Channel也就是容量是0的Channel从此Channel中读取数据的调用一定happens before 往此Channel发送数据的调用完成。
所以,在上面的这个例子中呢,如果想保持同样的执行顺序,也可以写成这样:
```
var ch = make(chan int)
var s string
func f() {
s = &quot;hello, world&quot;
&lt;-ch
}
func main() {
go f()
ch &lt;- struct{}{}
print(s)
}
```
如果第11行发送语句执行成功完毕那么根据这个规则第6行接收的调用肯定发生了执行完成不完成不重要重要的是这一句“肯定执行了”那么s也肯定初始化了所以一定会打印出“hello world”。
这一条比较晦涩但是因为Channel是unbuffered的Channel所以这个规则也成立。
**第4条规则是**如果Channel的容量是mm&gt;0那么第n个receive一定happens before 第 n+m 个 send的完成。
前一条规则是针对unbuffered channel的这里给出了更广泛的针对buffered channel的保证。利用这个规则我们可以实现信号量Semaphore的并发原语。Channel的容量相当于可用的资源发送一条数据相当于请求信号量接收一条数据相当于释放信号。关于信号量这个并发原语我会在下一讲专门给你介绍一下这里你只需要知道它可以控制多个资源的并发访问就可以了。
## Mutex/RWMutex
对于互斥锁Mutex m或者读写锁RWMutex m有3条happens-before关系的保证。
1. 第n次的m.Unlock一定happens before第n+1 m.Lock方法的返回
1. 对于读写锁RWMutex m如果它的第n个m.Lock方法的调用已返回那么它的第n个m.Unlock的方法调用一定happens before 任何一个m.RLock方法调用的返回只要这些m.RLock方法调用 happens after 第n次m.Lock的调用的返回。这就可以保证只有释放了持有的写锁那些等待的读请求才能请求到读锁。
1. 对于读写锁RWMutex m如果它的第n个m.RLock方法的调用已返回那么它的第k k&lt;=n个成功的m.RUnlock方法的返回一定happens before 任意的m.RUnlockLock方法调用只要这些m.Lock方法调用happens after第n次m.RLock。
读写锁的保证有点绕,我再带你看看官方的描述:
>
对于读写锁l的 l.RLock方法调用如果存在一个**n**这次的l.RLock调用 happens after 第n次的l.Unlock那么和这个RLock相对应的l.RUnlock一定happens before 第n+1次l.Lock。意思是读写锁的Lock必须等待既有的读锁释放后才能获取到。
我再举个例子。在下面的代码中第6行第一次的Unlock一定happens before第二次的Lock第12行所以这也能保证正确地打印出“hello world”。
```
var mu sync.Mutex
var s string
func foo() {
s = &quot;hello, world&quot;
mu.Unlock()
}
func main() {
mu.Lock()
go foo()
mu.Lock()
print(s)
```
## WaitGroup
接下来是WaitGroup的保证。
对于一个WaitGroup实例wg在某个时刻t0时它的计数值已经不是零了假如t0时刻之后调用了一系列的wg.Add(n)或者wg.Done()并且只有最后一次调用wg的计数值变为了0那么可以保证这些wg.Add或者wg.Done()一定 happens before t0时刻之后调用的wg.Wait方法的返回。
这个保证的通俗说法,就是**Wait方法等到计数值归零之后才返回**。
## Once
我们在[第8讲](https://time.geekbang.org/column/article/301113)学过Once了相信你已经很熟悉它的功能了。它提供的保证是**对于once.Do(f)调用f函数的那个单次调用一定happens before 任何once.Do(f)调用的返回**。换句话说就是函数f一定会在Do方法返回之前执行。
还是以hello world的例子为例这次我们使用Once并发原语实现可以看下下面的代码
```
var s string
var once sync.Once
func foo() {
s = &quot;hello, world&quot;
}
func twoprint() {
once.Do(foo)
print(s)
}
```
第5行的执行一定happens before第9行的返回所以执行到第10行的时候sd已经初始化了所以会正确地打印“hello world”。
最后我再来说说atomic的保证。
## atomic
其实Go内存模型的官方文档并没有明确给出atomic的保证有一个相关的issue [go# 5045](https://github.com/golang/go/issues/5045)记录了相关的讨论。光看issue号就知道这个讨论由来已久了。Russ Cox想让atomic有一个弱保证这样可以为以后留下充足的可扩展空间所以Go内存模型规范上并没有严格的定义。
对于Go 1.15的官方实现来说可以保证使用atomic的Load/Store的变量之间的顺序性。
在下面的例子中打印出的a的结果总是1但是官方并没有做任何文档上的说明和保证。
依照Ian Lance Taylor的说法Go核心开发组的成员几乎没有关注这个方向上的研究因为这个问题太复杂有很多问题需要去研究所以现阶段还是不要使用atomic来保证顺序性。
```
func main() {
var a, b int32 = 0, 0
go func() {
atomic.StoreInt32(&amp;a, 1)
atomic.StoreInt32(&amp;b, 1)
}()
for atomic.LoadInt32(&amp;b) == 0{
runtime.Gosched()
}
fmt.Println(atomic.LoadInt32(&amp;a))
}
```
# 总结
Go的内存模型规范中一开始有这么一段话
>
If you must read the rest of this document to understand the behavior of your program, you are being too clever.
>
Don't be clever.
我来说说我对这句话的理解:你通过学习这节课来理解你的程序的行为是聪明的,但是,不要自作聪明。
谨慎地使用这些保证能够让你的程序按照设想的happens-before关系执行但是不要以为完全理解这些概念和保证就可以随意地制造所谓的各种技巧否则就很容易掉进“坑”里而且会给代码埋下了很多的“定时炸弹”。
比如Go里面已经有值得信赖的互斥锁了如果没有额外的需求就不要使用Channel创造出自己的互斥锁。
当然我也不希望你畏手畏脚地把思想局限住我还是建议你去做一些有意义的尝试比如使用Channel实现信号量等扩展并发原语。
<img src="https://static001.geekbang.org/resource/image/dc/4d/dc68fc5f93a4af96c8f4d45d6282104d.jpg" alt="">
# 思考题
我们知道Channel可以实现互斥锁那么我想请你思考一下它是如何利用happens-before关系保证锁的请求和释放的呢
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,491 @@
<audio id="audio" title="19 | 在分布式环境中Leader选举、互斥锁和读写锁该如何实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/ca/4d1621ca63edb968d07a78681262e2ca.mp3"></audio>
你好,我是鸟窝。
在前面的课程里,我们学习的并发原语都是在进程内使用的,也就是我们常见的一个运行程序为了控制共享资源、实现任务编排和进行消息传递而提供的控制类型。在接下来的这两节课里,我要讲的是几个分布式的并发原语,它们控制的资源或编排的任务分布在不同进程、不同机器上。
分布式的并发原语实现更加复杂,因为在分布式环境中,网络状况、服务状态都是不可控的。不过还好有相应的软件系统去做这些事情。这些软件系统会专门去处理这些节点之间的协调和异常情况,并且保证数据的一致性。我们要做的就是在它们的基础上实现我们的业务。
常用来做协调工作的软件系统是Zookeeper、etcd、Consul之类的软件Zookeeper为Java生态群提供了丰富的分布式并发原语通过Curator库但是缺少Go相关的并发原语库。Consul在提供分布式并发原语这件事儿上不是很积极而etcd就提供了非常好的分布式并发原语比如分布式互斥锁、分布式读写锁、Leader选举等等。所以今天我就以etcd为基础给你介绍几种分布式并发原语。
既然我们依赖etcd那么在生产环境中要有一个etcd集群而且应该保证这个etcd集群是7*24工作的。在学习过程中你可以使用一个etcd节点进行测试。
这节课我要介绍的就是Leader选举、互斥锁和读写锁。
# Leader选举
Leader选举常常用在主从架构的系统中。主从架构中的服务节点分为主Leader、Master和从Follower、Slave两种角色实际节点包括1主n从一共是n+1个节点。
主节点常常执行写操作,从节点常常执行读操作,如果读写都在主节点,从节点只是提供一个备份功能的话,那么,主从架构就会退化成主备模式架构。
主从架构中最重要的是如何确定节点的角色,也就是,到底哪个节点是主,哪个节点是从?
**在同一时刻系统中不能有两个主节点否则如果两个节点都是主都执行写操作的话就有可能出现数据不一致的情况所以我们需要一个选主机制选择一个节点作为主节点这个过程就是Leader选举**
当主节点宕机或者是不可用时,就需要新一轮的选举,从其它的从节点中选择出一个节点,让它作为新主节点,宕机的原主节点恢复后,可以变为从节点,或者被摘掉。
我们可以通过etcd基础服务来实现leader选举。具体点说我们可以将Leader选举的逻辑交给etcd基础服务这样我们只需要把重心放在业务开发上。etcd基础服务可以通过多节点的方式保证7*24服务所以我们也不用担心Leader选举不可用的问题。如下图所示
<img src="https://static001.geekbang.org/resource/image/78/47/78010df8677171d9bf29c64d346d9647.jpg" alt="">
接下来我会给你介绍业务开发中跟Leader选举相关的选举、查询、Leader变动监控等功能。
我要先提醒你一句如果你想运行我下面讲到的测试代码就要先部署一个etcd的集群或者部署一个etcd节点做测试。
首先,我们来实现一个测试分布式程序的框架:它会先从命令行中读取命令,然后再执行相应的命令。你可以打开两个窗口,模拟不同的节点,分别执行不同的命令。
这个测试程序如下:
```
package main
// 导入所需的库
import (
&quot;bufio&quot;
&quot;context&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strconv&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
)
// 可以设置一些参数比如节点ID
var (
nodeID = flag.Int(&quot;id&quot;, 0, &quot;node ID&quot;)
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
electName = flag.String(&quot;name&quot;, &quot;my-test-elect&quot;, &quot;election name&quot;)
)
func main() {
flag.Parse()
// 将etcd的地址解析成slice of string
endpoints := strings.Split(*addr, &quot;,&quot;)
// 生成一个etcd的clien
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建session,如果程序宕机导致session断掉etcd能检测到
session, err := concurrency.NewSession(cli)
defer session.Close()
// 生成一个选举对象。下面主要使用它进行选举和查询等操作
// 另一个方法ResumeElection可以使用既有的leader初始化Election
e1 := concurrency.NewElection(session, *electName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
switch action {
case &quot;elect&quot;: // 选举命令
go elect(e1, *electName)
case &quot;proclaim&quot;: // 只更新leader的value
proclaim(e1, *electName)
case &quot;resign&quot;: // 辞去leader,重新选举
resign(e1, *electName)
case &quot;watch&quot;: // 监控leader的变动
go watch(e1, *electName)
case &quot;query&quot;: // 查询当前的leader
query(e1, *electName)
case &quot;rev&quot;:
rev(e1, *electName)
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
部署完以后,我们就可以开始选举了。
## 选举
如果你的业务集群还没有主节点,或者主节点宕机了,你就需要发起新一轮的选主操作,主要会用到**Campaign和Proclaim**。如果你需要主节点放弃主的角色,让其它从节点有机会成为主节点,就可以调用**Resign**方法。
这里我提到了三个和选主相关的方法,下面我来介绍下它们的用法。
**第一个方法是Campaign**。它的作用是,把一个节点选举为主节点,并且会设置一个值。它的签名如下所示:
```
func (e *Election) Campaign(ctx context.Context, val string) error
```
需要注意的是,这是一个阻塞方法,在调用它的时候会被阻塞,直到满足下面的三个条件之一,才会取消阻塞。
1. 成功当选为主;
1. 此方法返回错误;
1. ctx被取消。
**第二个方法是Proclaim**。它的作用是重新设置Leader的值但是不会重新选主这个方法会返回新值设置成功或者失败的信息。方法签名如下所示
```
func (e *Election) Proclaim(ctx context.Context, val string) error
```
**第三个方法是Resign**:开始新一次选举。这个方法会返回新的选举成功或者失败的信息。它的签名如下所示:
```
func (e *Election) Resign(ctx context.Context) (err error)
```
这三个方法的测试代码如下。你可以使用测试程序进行测试,具体做法是,启动两个节点,执行和这三个方法相关的命令。
```
var count int
// 选主
func elect(e1 *concurrency.Election, electName string) {
log.Println(&quot;acampaigning for ID:&quot;, *nodeID)
// 调用Campaign方法选主,主的值为value-&lt;主节点ID&gt;-&lt;count&gt;
if err := e1.Campaign(context.Background(), fmt.Sprintf(&quot;value-%d-%d&quot;, *nodeID, count)); err != nil {
log.Println(err)
}
log.Println(&quot;campaigned for ID:&quot;, *nodeID)
count++
}
// 为主设置新值
func proclaim(e1 *concurrency.Election, electName string) {
log.Println(&quot;proclaiming for ID:&quot;, *nodeID)
// 调用Proclaim方法设置新值,新值为value-&lt;主节点ID&gt;-&lt;count&gt;
if err := e1.Proclaim(context.Background(), fmt.Sprintf(&quot;value-%d-%d&quot;, *nodeID, count)); err != nil {
log.Println(err)
}
log.Println(&quot;proclaimed for ID:&quot;, *nodeID)
count++
}
// 重新选主,有可能另外一个节点被选为了主
func resign(e1 *concurrency.Election, electName string) {
log.Println(&quot;resigning for ID:&quot;, *nodeID)
// 调用Resign重新选主
if err := e1.Resign(context.TODO()); err != nil {
log.Println(err)
}
log.Println(&quot;resigned for ID:&quot;, *nodeID)
}
```
## 查询
除了选举Leader程序在启动的过程中或者在运行的时候还有可能需要查询当前的主节点是哪一个节点主节点的值是什么版本是多少不光是主从节点需要查询和知道哪一个节点在分布式系统中还有其它一些节点也需要知道集群中的哪一个节点是主节点哪一个节点是从节点这样它们才能把读写请求分别发往相应的主从节点上。
etcd提供了查询当前Leader的方法**Leader**如果当前还没有Leader就返回一个错误你可以使用这个方法来查询主节点信息。这个方法的签名如下
```
func (e *Election) Leader(ctx context.Context) (*v3.GetResponse, error)
```
每次主节点的变动都会生成一个新的版本号,你还可以查询版本号信息(**Rev**方法),了解主节点变动情况:
```
func (e *Election) Rev() int64
```
你可以在测试完选主命令后测试查询命令query、rev代码如下
```
// 查询主的信息
func query(e1 *concurrency.Election, electName string) {
// 调用Leader返回主的信息包括key和value等信息
resp, err := e1.Leader(context.Background())
if err != nil {
log.Printf(&quot;failed to get the current leader: %v&quot;, err)
}
log.Println(&quot;current leader:&quot;, string(resp.Kvs[0].Key), string(resp.Kvs[0].Value))
}
// 可以直接查询主的rev信息
func rev(e1 *concurrency.Election, electName string) {
rev := e1.Rev()
log.Println(&quot;current rev:&quot;, rev)
}
```
## 监控
有了选举和查询方法,我们还需要一个监控方法。毕竟,如果主节点变化了,我们需要得到最新的主节点信息。
我们可以通过Observe来监控主的变化它的签名如下
```
func (e *Election) Observe(ctx context.Context) &lt;-chan v3.GetResponse
```
它会返回一个chan显示主节点的变动信息。需要注意的是它不会返回主节点的全部历史变动信息而是只返回最近的一条变动信息以及之后的变动信息。
它的测试代码如下:
```
func watch(e1 *concurrency.Election, electName string) {
ch := e1.Observe(context.TODO())
log.Println(&quot;start to watch for ID:&quot;, *nodeID)
for i := 0; i &lt; 10; i++ {
resp := &lt;-ch
log.Println(&quot;leader changed to&quot;, string(resp.Kvs[0].Key), string(resp.Kvs[0].Value))
}
}
```
etcd提供了选主的逻辑而你要做的就是利用这些方法让它们为你的业务服务。在使用的过程中你还需要做一些额外的设置比如查询当前的主节点、启动一个goroutine阻塞调用Campaign方法等等。虽然你需要做一些额外的工作但是跟自己实现一个分布式的选主逻辑相比大大地减少了工作量。
接下来我们继续看etcd提供的分布式并发原语互斥锁。
# 互斥锁
互斥锁是非常常用的一种并发原语我专门花了4讲的时间重点介绍了互斥锁的功能、原理和易错场景。
不过,前面说的互斥锁都是用来保护同一进程内的共享资源的,今天,我们要掌握的是分布式环境中的互斥锁。**我们要重点学习下分布在不同机器中的不同进程内的goroutine如何利用分布式互斥锁来保护共享资源。**
互斥锁的应用场景和主从架构的应用场景不太一样。**使用互斥锁的不同节点是没有主从这样的角色的,所有的节点都是一样的,只不过在同一时刻,只允许其中的一个节点持有锁**。
下面我们就来学习下互斥锁相关的两个原语即Locker和Mutex。
## Locker
etcd提供了一个简单的Locker原语它类似于Go标准库中的sync.Locker接口也提供了Lock/UnLock的机制
```
func NewLocker(s *Session, pfx string) sync.Locker
```
可以看到它的返回值是一个sync.Locker因为你对标准库的Locker已经非常了解了而且它只有Lock/Unlock两个方法所以接下来使用这个锁就非常容易了。下面的代码是一个使用Locker并发原语的例子
```
package main
import (
&quot;flag&quot;
&quot;log&quot;
&quot;math/rand&quot;
&quot;strings&quot;
&quot;time&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
lockName = flag.String(&quot;name&quot;, &quot;my-test-lock&quot;, &quot;lock name&quot;)
)
func main() {
flag.Parse()
rand.Seed(time.Now().UnixNano())
// etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 生成一个etcd client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
useLock(cli) // 测试锁
}
func useLock(cli *clientv3.Client) {
// 为锁生成session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
//得到一个分布式锁
locker := concurrency.NewLocker(s1, *lockName)
// 请求锁
log.Println(&quot;acquiring lock&quot;)
locker.Lock()
log.Println(&quot;acquired lock&quot;)
// 等待一段时间
time.Sleep(time.Duration(rand.Intn(30)) * time.Second)
locker.Unlock() // 释放锁
log.Println(&quot;released lock&quot;)
}
```
你可以同时在两个终端中运行这个测试程序。可以看到,它们获得锁是有先后顺序的,一个节点释放了锁之后,另外一个节点才能获取到这个分布式锁。
## Mutex
事实上刚刚说的Locker是基于Mutex实现的只不过Mutex提供了查询Mutex的key的信息的功能。测试代码也类似
```
func useMutex(cli *clientv3.Client) {
// 为锁生成session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
m1 := concurrency.NewMutex(s1, *lockName)
//在请求锁之前查询key
log.Printf(&quot;before acquiring. key: %s&quot;, m1.Key())
// 请求锁
log.Println(&quot;acquiring lock&quot;)
if err := m1.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
log.Printf(&quot;acquired lock. key: %s&quot;, m1.Key())
//等待一段时间
time.Sleep(time.Duration(rand.Intn(30)) * time.Second)
// 释放锁
if err := m1.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
log.Println(&quot;released lock&quot;)
}
```
可以看到Mutex并没有实现sync.Locker接口它的Lock/Unlock方法需要提供一个context.Context实例做参数这也就意味着在请求锁的时候你可以设置超时时间或者主动取消请求。
# 读写锁
学完了分布式Locker和互斥锁Mutex你肯定会联想到读写锁RWMutex。是的etcd也提供了分布式的读写锁。不过互斥锁Mutex是在github.com/coreos/etcd/clientv3/concurrency包中提供的读写锁RWMutex却是在github.com/coreos/etcd/contrib/recipes包中提供的。
etcd提供的分布式读写锁的功能和标准库的读写锁的功能是一样的。只不过**etcd提供的读写锁可以在分布式环境中的不同的节点使用**。它提供的方法也和标准库中的读写锁的方法一致分别提供了RLock/RUnlock、Lock/Unlock方法。下面的代码是使用读写锁的例子它从命令行中读取命令执行读写锁的操作
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;math/rand&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;time&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
lockName = flag.String(&quot;name&quot;, &quot;my-test-lock&quot;, &quot;lock name&quot;)
action = flag.String(&quot;rw&quot;, &quot;w&quot;, &quot;r means acquiring read lock, w means acquiring write lock&quot;)
)
func main() {
flag.Parse()
rand.Seed(time.Now().UnixNano())
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
m1 := recipe.NewRWMutex(s1, *lockName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
switch action {
case &quot;w&quot;: // 请求写锁
testWriteLocker(m1)
case &quot;r&quot;: // 请求读锁
testReadLocker(m1)
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
func testWriteLocker(m1 *recipe.RWMutex) {
// 请求写锁
log.Println(&quot;acquiring write lock&quot;)
if err := m1.Lock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;acquired write lock&quot;)
// 等待一段时间
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
// 释放写锁
if err := m1.Unlock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;released write lock&quot;)
}
func testReadLocker(m1 *recipe.RWMutex) {
// 请求读锁
log.Println(&quot;acquiring read lock&quot;)
if err := m1.RLock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;acquired read lock&quot;)
// 等待一段时间
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
// 释放写锁
if err := m1.RUnlock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;released read lock&quot;)
}
```
# 总结
自己实现分布式环境的并发原语,是相当困难的一件事,因为你需要考虑网络的延迟和异常、节点的可用性、数据的一致性等多种情况。
所以我们可以借助etcd这样成熟的框架基于它提供的分布式并发原语处理分布式的场景。需要注意的是在使用这些分布式并发原语的时候你需要考虑异常的情况比如网络断掉等。同时分布式并发原语需要网络之间的通讯所以会比使用标准库中的并发原语耗时更长。
<img src="https://static001.geekbang.org/resource/image/a1/23/a18a98aa9ac5de17373c953484ee4c23.jpg" alt="">
好了这节课就到这里下节课我会带你继续学习其它的分布式并发原语包括队列、栅栏和STM敬请期待。
# 思考题
1. 如果持有互斥锁或者读写锁的节点意外宕机了,它持有的锁会不会被释放?
1. etcd提供的读写锁中的读和写有没有优先级
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,634 @@
<audio id="audio" title="20 | 在分布式环境中队列、栅栏和STM该如何实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/26/cd/26a60acb79429791ddc4f63fc485fdcd.mp3"></audio>
你好,我是鸟窝。
上一讲我已经带你认识了基于etcd实现的Leader选举、互斥锁和读写锁今天我们来学习下基于etcd的分布式队列、栅栏和STM。
只要你学过计算机算法和数据结构相关的知识, 队列这种数据结构你一定不陌生它是一种先进先出的类型有出队dequeue和入队enqueue两种操作。在[第12讲](https://time.geekbang.org/column/article/304127)中我专门讲到了一种叫做lock-free的队列。队列在单机的应用程序中常常使用但是在分布式环境中多节点如何并发地执行入队和出队的操作呢这一讲我会带你认识一下基于etcd实现的分布式队列。
除此之外我还会讲用分布式栅栏编排一组分布式节点同时执行的方法以及简化多个key的操作并且提供事务功能的STMSoftware Transactional Memory软件事务内存
# 分布式队列和优先级队列
前一讲我也讲到我们并不是从零开始实现一个分布式队列而是站在etcd的肩膀上利用etcd提供的功能实现分布式队列。
etcd集群的可用性由etcd集群的维护者来保证我们不用担心网络分区、节点宕机等问题。我们可以把这些通通交给etcd的运维人员把我们自己的关注点放在使用上。
下面我们就来了解下etcd提供的分布式队列。etcd通过github.com/coreos/etcd/contrib/recipes包提供了分布式队列这种数据结构。
创建分布式队列的方法非常简单只有一个即NewQueue你只需要传入etcd的client和这个队列的名字就可以了。代码如下
```
func NewQueue(client *v3.Client, keyPrefix string) *Queue
```
**这个队列只有两个方法,分别是出队和入队,队列中的元素是字符串类型**。这两个方法的签名如下所示:
```
// 入队
func (q *Queue) Enqueue(val string) error
//出队
func (q *Queue) Dequeue() (string, error)
```
需要注意的是如果这个分布式队列当前为空调用Dequeue方法的话会被阻塞直到有元素可以出队才返回。
既然是分布式的队列,那就意味着,我们可以在一个节点将元素放入队列,在另外一个节点把它取出。
在我接下来讲的例子中你就可以启动两个节点一个节点往队列中放入元素一个节点从队列中取出元素看看是否能正常取出来。etcd的分布式队列是一种多读多写的队列所以你也可以启动多个写节点和多个读节点。
下面我们来借助代码,看一下如何实现分布式队列。
首先,我们启动一个程序,它会从命令行读取你的命令,然后执行。你可以输入`push &lt;value&gt;`,将一个元素入队,输入`pop`,将一个元素弹出。另外,你还可以使用这个程序启动多个实例,用来模拟分布式的环境:
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
queueName = flag.String(&quot;name&quot;, &quot;my-test-queue&quot;, &quot;queue name&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建/获取队列
q := recipe.NewQueue(cli, *queueName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;push&quot;: // 加入队列
if len(items) != 2 {
fmt.Println(&quot;must set value to push&quot;)
continue
}
q.Enqueue(items[1]) // 入队
case &quot;pop&quot;: // 从队列弹出
v, err := q.Dequeue() // 出队
if err != nil {
log.Fatal(err)
}
fmt.Println(v) // 输出出队的元素
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
我们可以打开两个终端,分别执行这个程序。在第一个终端中执行入队操作,在第二个终端中执行出队操作,并且观察一下出队、入队是否正常。
除了刚刚说的分布式队列etcd还提供了优先级队列PriorityQueue
它的用法和队列类似也提供了出队和入队的操作只不过在入队的时候除了需要把一个值加入到队列我们还需要提供uint16类型的一个整数作为此值的优先级优先级高的元素会优先出队。
优先级队列的测试程序如下,你可以在一个节点输入一些不同优先级的元素,在另外一个节点读取出来,看看它们是不是按照优先级顺序弹出的:
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strconv&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
queueName = flag.String(&quot;name&quot;, &quot;my-test-queue&quot;, &quot;queue name&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建/获取队列
q := recipe.NewPriorityQueue(cli, *queueName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;push&quot;: // 加入队列
if len(items) != 3 {
fmt.Println(&quot;must set value and priority to push&quot;)
continue
}
pr, err := strconv.Atoi(items[2]) // 读取优先级
if err != nil {
fmt.Println(&quot;must set uint16 as priority&quot;)
continue
}
q.Enqueue(items[1], uint16(pr)) // 入队
case &quot;pop&quot;: // 从队列弹出
v, err := q.Dequeue() // 出队
if err != nil {
log.Fatal(err)
}
fmt.Println(v) // 输出出队的元素
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
你看利用etcd实现分布式队列和分布式优先队列就是这么简单。所以在实际项目中如果有这类需求的话你就可以选择用etcd实现。
不过,在使用分布式并发原语时,除了需要考虑可用性和数据一致性,还需要考虑分布式设计带来的性能损耗问题。所以,在使用之前,你一定要做好性能的评估。
# 分布式栅栏
在[第17讲](https://time.geekbang.org/column/article/309098)中我们学习了循环栅栏CyclicBarrier它和[第6讲](https://time.geekbang.org/column/article/298516)的标准库中的WaitGroup本质上是同一类并发原语都是等待同一组goroutine同时执行或者是等待同一组goroutine都完成。
在分布式环境中,我们也会遇到这样的场景:一组节点协同工作,共同等待一个信号,在信号未出现前,这些节点会被阻塞住,而一旦信号出现,这些阻塞的节点就会同时开始继续执行下一步的任务。
etcd也提供了相应的分布式并发原语。
- **Barrier分布式栅栏**。如果持有Barrier的节点释放了它所有等待这个Barrier的节点就不会被阻塞而是会继续执行。
- **DoubleBarrier计数型栅栏**。在初始化计数型栅栏的时候我们就必须提供参与节点的数量当这些数量的节点都Enter或者Leave的时候这个栅栏就会放开。所以我们把它称为计数型栅栏。
## Barrier分布式栅栏
我们先来学习下分布式Barrier。
分布式Barrier的创建很简单你只需要提供etcd的Client和Barrier的名字就可以了如下所示
```
func NewBarrier(client *v3.Client, key string) *Barrier
```
Barrier提供了三个方法分别是Hold、**Release和Wait**代码如下:
```
func (b *Barrier) Hold() error
func (b *Barrier) Release() error
func (b *Barrier) Wait() error
```
- **Hold方法**是创建一个Barrier。如果Barrier已经创建好了有节点调用它的Wait方法就会被阻塞。
- **Release方法**是释放这个Barrier也就是打开栅栏。如果使用了这个方法所有被阻塞的节点都会被放行继续执行。
- **Wait方法**会阻塞当前的调用者直到这个Barrier被release。如果这个栅栏不存在调用者不会被阻塞而是会继续执行。
**学习并发原语最好的方式就是使用它**。下面我们就来借助一个例子来看看Barrier该怎么用。
你可以在一个终端中运行这个程序,执行"hold""release"命令,模拟栅栏的持有和释放。在另外一个终端中运行这个程序,不断调用"wait"方法,看看是否能正常地跳出阻塞继续执行:
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
barrierName = flag.String(&quot;name&quot;, &quot;my-test-queue&quot;, &quot;barrier name&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建/获取栅栏
b := recipe.NewBarrier(cli, *barrierName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;hold&quot;: // 持有这个barrier
b.Hold()
fmt.Println(&quot;hold&quot;)
case &quot;release&quot;: // 释放这个barrier
b.Release()
fmt.Println(&quot;released&quot;)
case &quot;wait&quot;: // 等待barrier被释放
b.Wait()
fmt.Println(&quot;after wait&quot;)
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
## DoubleBarrier计数型栅栏
etcd还提供了另外一种栅栏叫做DoubleBarrier这也是一种非常有用的栅栏。这个栅栏初始化的时候需要提供一个计数count如下所示
```
func NewDoubleBarrier(s *concurrency.Session, key string, count int) *DoubleBarrier
```
同时它还提供了两个方法分别是Enter和Leave代码如下
```
func (b *DoubleBarrier) Enter() error
func (b *DoubleBarrier) Leave() error
```
我来解释下这两个方法的作用。
当调用者调用Enter时会被阻塞住直到一共有count初始化这个栅栏的时候设定的值个节点调用了Enter这count个被阻塞的节点才能继续执行。所以你可以利用它编排一组节点让这些节点在同一个时刻开始执行任务。
同理如果你想让一组节点在同一个时刻完成任务就可以调用Leave方法。节点调用Leave方法的时候会被阻塞直到有count个节点都调用了Leave方法这些节点才能继续执行。
我们再来看一下DoubleBarrier的使用例子。你可以起两个节点同时执行Enter方法看看这两个节点是不是先阻塞之后才继续执行。然后你再执行Leave方法也观察一下是不是先阻塞又继续执行的。
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
barrierName = flag.String(&quot;name&quot;, &quot;my-test-doublebarrier&quot;, &quot;barrier name&quot;)
count = flag.Int(&quot;c&quot;, 2, &quot;&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
// 创建/获取栅栏
b := recipe.NewDoubleBarrier(s1, *barrierName, *count)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;enter&quot;: // 持有这个barrier
b.Enter()
fmt.Println(&quot;enter&quot;)
case &quot;leave&quot;: // 释放这个barrier
b.Leave()
fmt.Println(&quot;leave&quot;)
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
好了我们先来简单总结一下。我们在第17讲学习的循环栅栏控制的是同一个进程中的不同goroutine的执行而**分布式栅栏和计数型栅栏控制的是不同节点、不同进程的执行**。当你需要协调一组分布式节点在某个时间点同时运行的时候可以考虑etcd提供的这组并发原语。
# STM
提到事务,你肯定不陌生。在开发基于数据库的应用程序的时候,我们经常用到事务。事务就是要保证一组操作要么全部成功,要么全部失败。
在学习STM之前我们要先了解一下etcd的事务以及它的问题。
etcd提供了在一个事务中对多个key的更新功能这一组key的操作要么全部成功要么全部失败。etcd的事务实现方式是基于CAS方式实现的融合了Get、Put和Delete操作。
etcd的事务操作如下分为条件块、成功块和失败块条件块用来检测事务是否成功如果成功就执行Then(...)如果失败就执行Else(...)
```
Txn().If(cond1, cond2, ...).Then(op1, op2, ...,).Else(op1, op2, …)
```
我们来看一个利用etcd的事务实现转账的小例子。我们从账户from 向账户to转账 amount代码如下
```
func doTxnXfer(etcd *v3.Client, from, to string, amount uint) (bool, error) {
// 一个查询事务
getresp, err := etcd.Txn(ctx.TODO()).Then(OpGet(from), OpGet(to)).Commit()
if err != nil {
return false, err
}
// 获取转账账户的值
fromKV := getresp.Responses[0].GetRangeResponse().Kvs[0]
toKV := getresp.Responses[1].GetRangeResponse().Kvs[1]
fromV, toV := toUInt64(fromKV.Value), toUint64(toKV.Value)
if fromV &lt; amount {
return false, fmt.Errorf(“insufficient value”)
}
// 转账事务
// 条件块
txn := etcd.Txn(ctx.TODO()).If(
v3.Compare(v3.ModRevision(from), “=”, fromKV.ModRevision),
v3.Compare(v3.ModRevision(to), “=”, toKV.ModRevision))
// 成功块
txn = txn.Then(
OpPut(from, fromUint64(fromV - amount)),
OpPut(to, fromUint64(toV + amount))
//提交事务
putresp, err := txn.Commit()
// 检查事务的执行结果
if err != nil {
return false, err
}
return putresp.Succeeded, nil
}
```
从刚刚的这段代码中我们可以看到虽然可以利用etcd实现事务操作但是逻辑还是比较复杂的。
因为事务使用起来非常麻烦所以etcd又在这些基础API上进行了封装新增了一种叫做STM的操作提供了更加便利的方法。
下面我们来看一看STM怎么用。
要使用STM你需要先编写一个apply函数这个函数的执行是在一个事务之中的
```
apply func(STM) error
```
这个方法包含一个STM类型的参数它提供了对key值的读写操作。
STM提供了4个方法分别是Get、Put、Receive和Delete代码如下
```
type STM interface {
Get(key ...string) string
Put(key, val string, opts ...v3.OpOption)
Rev(key string) int64
Del(key string)
}
```
使用etcd STM的时候我们只需要定义一个apply方法比如说转账方法exchange然后通过concurrency.NewSTM(cli, exchange),就可以完成转账事务的执行了。
STM咋用呢我们还是借助一个例子来学习下。
下面这个例子创建了5个银行账号然后随机选择一些账号两两转账。在转账的时候要把源账号一半的钱要转给目标账号。这个例子启动了10个goroutine去执行这些事务每个goroutine要完成100个事务。
为了确认事务是否出错了,我们最后要校验每个账号的钱数和总钱数。总钱数不变,就代表执行成功了。这个例子的代码如下:
```
package main
import (
&quot;context&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;math/rand&quot;
&quot;strings&quot;
&quot;sync&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 设置5个账户每个账号都有100元总共500元
totalAccounts := 5
for i := 0; i &lt; totalAccounts; i++ {
k := fmt.Sprintf(&quot;accts/%d&quot;, i)
if _, err = cli.Put(context.TODO(), k, &quot;100&quot;); err != nil {
log.Fatal(err)
}
}
// STM的应用函数主要的事务逻辑
exchange := func(stm concurrency.STM) error {
// 随机得到两个转账账号
from, to := rand.Intn(totalAccounts), rand.Intn(totalAccounts)
if from == to {
// 自己不和自己转账
return nil
}
// 读取账号的值
fromK, toK := fmt.Sprintf(&quot;accts/%d&quot;, from), fmt.Sprintf(&quot;accts/%d&quot;, to)
fromV, toV := stm.Get(fromK), stm.Get(toK)
fromInt, toInt := 0, 0
fmt.Sscanf(fromV, &quot;%d&quot;, &amp;fromInt)
fmt.Sscanf(toV, &quot;%d&quot;, &amp;toInt)
// 把源账号一半的钱转账给目标账号
xfer := fromInt / 2
fromInt, toInt = fromInt-xfer, toInt+xfer
// 把转账后的值写回
stm.Put(fromK, fmt.Sprintf(&quot;%d&quot;, fromInt))
stm.Put(toK, fmt.Sprintf(&quot;%d&quot;, toInt))
return nil
}
// 启动10个goroutine进行转账操作
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i &lt; 10; i++ {
go func() {
defer wg.Done()
for j := 0; j &lt; 100; j++ {
if _, serr := concurrency.NewSTM(cli, exchange); serr != nil {
log.Fatal(serr)
}
}
}()
}
wg.Wait()
// 检查账号最后的数目
sum := 0
accts, err := cli.Get(context.TODO(), &quot;accts/&quot;, clientv3.WithPrefix()) // 得到所有账号
if err != nil {
log.Fatal(err)
}
for _, kv := range accts.Kvs { // 遍历账号的值
v := 0
fmt.Sscanf(string(kv.Value), &quot;%d&quot;, &amp;v)
sum += v
log.Printf(&quot;account %s: %d&quot;, kv.Key, v)
}
log.Println(&quot;account sum is&quot;, sum) // 总数
}
```
总结一下当你利用etcd做存储时是可以利用STM实现事务操作的一个事务可以包含多个账号的数据更改操作事务能够保证这些更改要么全成功要么全失败。
# 总结
如果我们把眼光放得更宽广一些其实并不只是etcd提供了这些并发原语比如我上节课一开始就提到了Zookeeper很早也提供了类似的并发原语只不过只提供了Java的库并没有提供合适的Go库。另外根据Consul官方的反馈他们并没有开发这些并发原语的计划所以从目前来看etcd是个不错的选择。
当然,也有一些其它不太知名的分布式原语库,但是活跃度不高,可用性低,所以我们也不需要去了解了。
其实你也可以使用Redis实现分布式锁或者是基于MySQL实现分布式锁这也是常用的选择。对于大厂来说选择起来是非常简单的只需要看看厂内提供了哪个基础服务哪个更稳定些。对于没有etcd、Redis这些基础服务的公司来说很重要的一点就是自己搭建一套这样的基础服务并且运维好这就需要考察你们对etcd、Redis、MySQL的技术把控能力了哪个用得更顺手就用哪个。
一般来说我不建议你自己去实现分布式原语最好是直接使用etcd、Redis这些成熟的软件提供的功能这也意味着我们将程序的风险转嫁到了这些基础服务上这些基础服务必须要能够提供足够的服务保障。
<img src="https://static001.geekbang.org/resource/image/c0/1d/c0d48fd09b91685c836829570fdc7b1d.jpg" alt="">
# 思考题
1. 部署一个3节点的etcd集群测试一下分布式队列的性能。
1. etcd提供的STM是分布式事务吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,363 @@
<audio id="audio" title="12 | atomic要保证原子操作一定要使用这几种方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d9/0b/d9f05ca980a6578883ab3f5079b4970b.mp3"></audio>
你好,我是鸟窝。
前面我们在学习Mutex、RWMutex等并发原语的实现时你可以看到最底层是通过atomic包中的一些原子操作来实现的。当时为了让你的注意力集中在这些原语的功能实现上我并没有展开介绍这些原子操作是干什么用的。
你可能会说,这些并发原语已经可以应对大多数的并发场景了,为啥还要学习原子操作呢?其实,这是因为,在很多场景中,使用并发原语实现起来比较复杂,而原子操作可以帮助我们更轻松地实现底层的优化。
所以现在我会专门用一节课带你仔细地了解一下什么是原子操作atomic包都提供了哪些实现原子操作的方法。另外我还会带你实现一个基于原子操作的数据结构。好了接下来我们先来学习下什么是原子操作。
# 原子操作的基础知识
Package sync/atomic 实现了同步算法底层的原子的内存操作原语,我们把它叫做原子操作原语,它提供了一些实现原子操作的方法。
之所以叫原子操作,是因为一个原子在执行的时候,其它线程不会看到执行一半的操作结果。在其它线程看来,原子操作要么执行完了,要么还没有执行,就像一个最小的粒子-原子一样,不可分割。
CPU提供了基础的原子操作不过不同架构的系统的原子操作是不一样的。
对于单处理器单核系统来说如果一个操作是由一个CPU指令来实现的那么它就是原子操作比如它的XCHG和INC等指令。如果操作是基于多条指令来实现的那么执行的过程中可能会被中断并执行上下文切换这样的话原子性的保证就被打破了因为这个时候操作可能只执行了一半。
在多处理器多核系统中,原子操作的实现就比较复杂了。
由于cache的存在单个核上的单个指令进行原子操作的时候你要确保其它处理器或者核不访问此原子操作的地址或者是确保其它处理器或者核总是访问原子操作之后的最新的值。x86架构中提供了指令前缀LOCKLOCK保证了指令比如LOCK CMPXCHG op1、op2不会受其它处理器或CPU核的影响有些指令比如XCHG本身就提供Lock的机制。不同的CPU架构提供的原子操作指令的方式也是不同的比如对于多核的MIPS和ARM提供了LL/SCLoad Link/Store Conditional指令可以帮助实现原子操作ARMLL/SC指令 LDREX和STREX
**因为不同的CPU架构甚至不同的版本提供的原子操作的指令是不同的所以要用一种编程语言实现支持不同架构的原子操作是相当有难度的**。不过还好这些都不需要你操心因为Go提供了一个通用的原子操作的API将更底层的不同的架构下的实现封装成atomic包提供了修改类型的原子操作[atomic read-modify-write](https://preshing.com/20150402/you-can-do-any-kind-of-atomic-read-modify-write-operation/)RMW和加载存储类型的原子操作[Load和Store](https://preshing.com/20130618/atomic-vs-non-atomic-operations/)的API稍后我会一一介绍。
有的代码也会因为架构的不同而不同。有时看起来貌似一个操作是原子操作但实际上对于不同的架构来说情况是不一样的。比如下面的代码的第4行是将一个64位的值赋值给变量i
```
const x int64 = 1 + 1&lt;&lt;33
func main() {
var i = x
_ = i
}
```
如果你使用GOARCH=386的架构去编译这段代码那么第5行其实是被拆成了两个指令分别操作低32位和高32位使用 GOARCH=386 go tool compile -N -l test.goGOARCH=386 go tool objdump -gnu test.o反编译试试
<img src="https://static001.geekbang.org/resource/image/45/62/4563ac42f379d1500d191377db16a162.png" alt="">
如果GOARCH=amd64的架构去编译这段代码那么第5行其中的赋值操作其实是一条指令
<img src="https://static001.geekbang.org/resource/image/6e/66/6e20a0f44d95d78c1bca4303f1a32966.png" alt="">
所以如果要想保证原子操作切记一定要使用atomic提供的方法。
好了了解了什么是原子操作以及不同系统的不同原子操作接下来我来介绍下atomic原子操作的应用场景。
# atomic原子操作的应用场景
开篇我说过使用atomic的一些方法我们可以实现更底层的一些优化。如果使用Mutex等并发原语进行这些优化虽然可以解决问题但是这些并发原语的实现逻辑比较复杂对性能还是有一定的影响的。
举个例子假设你想在程序中使用一个标志flag比如一个bool类型的变量来标识一个定时任务是否已经启动执行了你会怎么做呢
我们先来看看加锁的方法。如果使用Mutex和RWMutex在读取和设置这个标志的时候加锁是可以做到互斥的、保证同一时刻只有一个定时任务在执行的所以使用Mutex或者RWMutex是一种解决方案。
其实这个场景中的问题不涉及到对资源复杂的竞争逻辑只是会并发地读写这个标志这类场景就适合使用atomic的原子操作。具体怎么做呢你可以使用一个uint32类型的变量如果这个变量的值是0就标识没有任务在执行如果它的值是1就标识已经有任务在完成了。你看是不是很简单呢
再来看一个例子。假设你在开发应用程序的时候需要从配置服务器中读取一个节点的配置信息。而且在这个节点的配置发生变更的时候你需要重新从配置服务器中拉取一份新的配置并更新。你的程序中可能有多个goroutine都依赖这份配置涉及到对这个配置对象的并发读写你可以使用读写锁实现对配置对象的保护。在大部分情况下你也可以利用atomic实现配置对象的更新和加载。
分析到这里可以看到这两个例子都可以使用基本并发原语来实现的只不过我们不需要这些基本并发原语里面的复杂逻辑而是只需要其中的简单原子操作所以这些场景可以直接使用atomic包中的方法去实现。
**有时候你也可以使用atomic实现自己定义的基本并发原语**比如Go issue有人提议的CondMutex、Mutex.LockContext、WaitGroup.Go等我们可以使用atomic或者基于它的更高一级的并发原语去实现。我先前讲的几种基本并发原语的底层比如Mutex就是基于通过atomic的方法实现的。
除此之外atomic原子操作还是实现lock-free数据结构的基石。
在实现lock-free的数据结构时我们可以不使用互斥锁这样就不会让线程因为等待互斥锁而阻塞休眠而是让线程保持继续处理的状态。另外不使用互斥锁的话lock-free的数据结构还可以提供并发的性能。
不过lock-free的数据结构实现起来比较复杂需要考虑的东西很多有兴趣的同学可以看一位微软专家写的一篇经验分享[Lockless Programming Considerations for Xbox 360 and Microsoft Windows](https://docs.microsoft.com/zh-cn/windows/win32/dxtecharts/lockless-programming)这里我们不细谈了。不过这节课的最后我会带你开发一个lock-free的queue来学习下使用atomic操作实现lock-free数据结构的方法你可以拿它和使用互斥锁实现的queue做性能对比看看在性能上是否有所提升。
看到这里你是不是觉得atomic非常重要呢不过要想能够灵活地应用atomic我们首先得知道atomic提供的所有方法。
# atomic提供的方法
目前的Go的泛型的特性还没有发布Go的标准库中的很多实现会显得非常啰嗦多个类型会实现很多类似的方法尤其是atomic包最为明显。相信泛型支持之后atomic的API会清爽很多。
atomic为了支持int32、int64、uint32、uint64、uintptr、PointerAdd方法不支持类型分别提供了AddXXX、CompareAndSwapXXX、SwapXXX、LoadXXX、StoreXXX等方法。不过你也不要担心你只要记住了一种数据类型的方法的意义其它数据类型的方法也是一样的。
关于atomic还有一个地方你一定要记住**atomic操作的对象是一个地址你需要把可寻址的变量的地址作为参数传递给方法而不是把变量的值传递给方法**。
好了下面我就来给你介绍一下atomic提供的方法。掌握了这些你就可以说完全掌握了atomic包。
## Add
首先我们来看Add方法的签名
<img src="https://static001.geekbang.org/resource/image/95/de/95dcf8742593b1191e87beaca16f59de.png" alt="">
其实Add方法就是给第一个参数地址中的值增加一个delta值。
对于有符号的整数来说delta可以是一个负数相当于减去一个值。对于无符号的整数和uinptr类型来说怎么实现减去一个值呢毕竟atomic并没有提供单独的减法操作。
我来跟你说一种方法。你可以利用计算机补码的规则把减法变成加法。以uint32类型为例
```
AddUint32(&amp;x, ^uint32(c-1)).
```
如果是对uint64的值进行操作那么就把上面的代码中的uint32替换成uint64。
尤其是减1这种特殊的操作我们可以简化为
```
AddUint32(&amp;x, ^uint32(0))
```
好了我们再来看看CAS方法。
## CAS CompareAndSwap
以int32为例我们学习一下CAS提供的功能。在CAS的方法签名中需要提供要操作的地址、原数据值、新值如下所示
```
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
```
我们来看下这个方法的功能。
这个方法会比较当前addr地址里的值是不是old如果不等于old就返回false如果等于old就把此地址的值替换成new值返回true。这就相当于“判断相等才替换”。
如果使用伪代码来表示这个原子操作,代码如下:
```
if *addr == old {
*addr = new
return true
}
return false
```
它支持的类型和方法如图所示:
<img src="https://static001.geekbang.org/resource/image/1b/77/1b0ffac37d8f952ca485ff58daf27177.png" alt="">
## Swap
如果不需要比较旧值只是比较粗暴地替换的话就可以使用Swap方法它替换后还可以返回旧值伪代码如下
```
old = *addr
*addr = new
return old
```
它支持的数据类型和方法如图所示:
<img src="https://static001.geekbang.org/resource/image/c0/0a/c02e210607aa45734bb1812c97f77c0a.png" alt="">
## Load
Load方法会取出addr地址中的值即使在多处理器、多核、有CPU cache的情况下这个操作也能保证Load是一个原子操作。
它支持的数据类型和方法如图所示:
<img src="https://static001.geekbang.org/resource/image/3f/5d/3faba284bda2a666caa5727d0f0c275d.png" alt="">
## Store
Store方法会把一个值存入到指定的addr地址中即使在多处理器、多核、有CPU cache的情况下这个操作也能保证Store是一个原子操作。别的goroutine通过Load读取出来不会看到存取了一半的值。
它支持的数据类型和方法如图所示:
<img src="https://static001.geekbang.org/resource/image/8b/a0/8b77dc0e1ede98394aa21cf10fecc9a0.png" alt="">
## Value类型
刚刚说的都是一些比较常见的类型其实atomic还提供了一个特殊的类型Value。它可以原子地存取对象类型但也只能存取不能CAS和Swap常常用在配置变更等场景中。
<img src="https://static001.geekbang.org/resource/image/47/76/478b665391766de77043ffeb0d6fff76.png" alt="">
接下来我以一个配置变更的例子来演示Value类型的使用。这里定义了一个Value类型的变量config 用来存储配置信息。
首先我们启动一个goroutine然后让它随机sleep一段时间之后就变更一下配置并通过我们前面学到的Cond并发原语通知其它的reader去加载新的配置。
接下来我们启动一个goroutine等待配置变更的信号一旦有变更它就会加载最新的配置。
通过这个例子你可以了解到Value的Store/Load方法的使用因为它只有这两个方法只要掌握了它们的使用你就完全掌握了Value类型。
```
type Config struct {
NodeName string
Addr string
Count int32
}
func loadNewConfig() Config {
return Config{
NodeName: &quot;北京&quot;,
Addr: &quot;10.77.95.27&quot;,
Count: rand.Int31(),
}
}
func main() {
var config atomic.Value
config.Store(loadNewConfig())
var cond = sync.NewCond(&amp;sync.Mutex{})
// 设置新的config
go func() {
for {
time.Sleep(time.Duration(5+rand.Int63n(5)) * time.Second)
config.Store(loadNewConfig())
cond.Broadcast() // 通知等待着配置已变更
}
}()
go func() {
for {
cond.L.Lock()
cond.Wait() // 等待变更信号
c := config.Load().(Config) // 读取新的配置
fmt.Printf(&quot;new config: %+v\n&quot;, c)
cond.L.Unlock()
}
}()
select {}
}
```
好了关于标准库的atomic提供的方法到这里我们就学完了。事实上atomic包提供了非常好的支持各种平台的一致性的API绝大部分项目都是直接使用它。接下来我再给你介绍一下第三方库帮助你稍微开拓一下思维。
# 第三方库的扩展
其实atomic的API已经算是很简单的了它提供了包一级的函数可以对几种类型的数据执行原子操作。
不过有一点让人觉得不爽的是或者是让熟悉面向对象编程的程序员不爽的是函数调用有一点点麻烦。所以有些人就对这些函数做了进一步的包装跟atomic中的Value类型类似这些类型也提供了面向对象的使用方式比如关注度比较高的[uber-go/atomic](https://github.com/uber-go/atomic)它定义和封装了几种与常见类型相对应的原子操作类型这些类型提供了原子操作的方法。这些类型包括Bool、Duration、Error、Float64、Int32、Int64、String、Uint32、Uint64等。
比如Bool类型提供了CAS、Store、Swap、Toggle等原子方法还提供String、MarshalJSON、UnmarshalJSON等辅助方法确实是一个精心设计的atomic扩展库。关于这些方法你一看名字就能猜出来它们的功能我就不多说了。
其它的数据类型也和Bool类型相似使用起来就像面向对象的编程一样你可以看下下面的这段代码。
```
var running atomic.Bool
running.Store(true)
running.Toggle()
fmt.Println(running.Load()) // false
```
# 使用atomic实现Lock-Free queue
atomic常常用来实现Lock-Free的数据结构这次我会给你展示一个Lock-Free queue的实现。
Lock-Free queue最出名的就是 Maged M. Michael 和 Michael L. Scott 1996年发表的[论文](https://www.cs.rochester.edu/u/scott/papers/1996_PODC_queues.pdf)中的算法算法比较简单容易实现伪代码的每一行都提供了注释我就不在这里贴出伪代码了因为我们使用Go实现这个数据结构的代码几乎和伪代码一样
```
package queue
import (
&quot;sync/atomic&quot;
&quot;unsafe&quot;
)
// lock-free的queue
type LKQueue struct {
head unsafe.Pointer
tail unsafe.Pointer
}
// 通过链表实现,这个数据结构代表链表中的节点
type node struct {
value interface{}
next unsafe.Pointer
}
func NewLKQueue() *LKQueue {
n := unsafe.Pointer(&amp;node{})
return &amp;LKQueue{head: n, tail: n}
}
// 入队
func (q *LKQueue) Enqueue(v interface{}) {
n := &amp;node{value: v}
for {
tail := load(&amp;q.tail)
next := load(&amp;tail.next)
if tail == load(&amp;q.tail) { // 尾还是尾
if next == nil { // 还没有新数据入队
if cas(&amp;tail.next, next, n) { //增加到队尾
cas(&amp;q.tail, tail, n) //入队成功,移动尾巴指针
return
}
} else { // 已有新数据加到队列后面,需要移动尾指针
cas(&amp;q.tail, tail, next)
}
}
}
}
// 出队没有元素则返回nil
func (q *LKQueue) Dequeue() interface{} {
for {
head := load(&amp;q.head)
tail := load(&amp;q.tail)
next := load(&amp;head.next)
if head == load(&amp;q.head) { // head还是那个head
if head == tail { // head和tail一样
if next == nil { // 说明是空队列
return nil
}
// 只是尾指针还没有调整,尝试调整它指向下一个
cas(&amp;q.tail, tail, next)
} else {
// 读取出队的数据
v := next.value
// 既然要出队了,头指针移动到下一个
if cas(&amp;q.head, head, next) {
return v // Dequeue is done. return
}
}
}
}
}
// 将unsafe.Pointer原子加载转换成node
func load(p *unsafe.Pointer) (n *node) {
return (*node)(atomic.LoadPointer(p))
}
// 封装CAS,避免直接将*node转换成unsafe.Pointer
func cas(p *unsafe.Pointer, old, new *node) (ok bool) {
return atomic.CompareAndSwapPointer(
p, unsafe.Pointer(old), unsafe.Pointer(new))
}
```
我来给你介绍下这里的主要逻辑。
这个lock-free的实现使用了一个辅助头指针head头指针不包含有意义的数据只是一个辅助的节点这样的话出队入队中的节点会更简单。
入队的时候通过CAS操作将一个元素添加到队尾并且移动尾指针。
出队的时候移除一个节点并通过CAS操作移动head指针同时在必要的时候移动尾指针。
# 总结
好了我们来小结一下。这节课我们学习了atomic的基本使用方法以及它提供的几种方法包括Add、CAS、Swap、Load、Store、Value类型。除此之外我还介绍了一些第三方库并且带你实现了Lock-free queue。到这里相信你已经掌握了atomic提供的各种方法并且能够应用到实践中了。
最后,我还想和你讨论一个额外的问题:对一个地址的赋值是原子操作吗?
这是一个很有趣的问题如果是原子操作还要atomic包干什么官方的文档中并没有特意的介绍不过在一些issue或者论坛中每当有人谈到这个问题时总是会被建议用atomic包。
[Dave Cheney](https://dave.cheney.net/2018/01/06/if-aligned-memory-writes-are-atomic-why-do-we-need-the-sync-atomic-package)就谈到过这个问题讲得非常好。我来给你总结一下他讲的知识点这样你就比较容易理解使用atomic和直接内存操作的区别了。
在现在的系统中write的地址基本上都是对齐的aligned。 比如32位的操作系统、CPU以及编译器write的地址总是4的倍数64位的系统总是8的倍数还记得WaitGroup针对64位系统和32位系统对state1的字段不同的处理吗。对齐地址的写不会导致其他人看到只写了一半的数据因为它通过一个指令就可以实现对地址的操作。如果地址不是对齐的话那么处理器就需要分成两个指令去处理如果执行了一个指令其它人就会看到更新了一半的错误的数据这被称做撕裂写torn write 。所以,你可以认为赋值操作是一个原子操作,这个“原子操作”可以认为是保证数据的完整性。
但是对于现代的多处理多核的系统来说由于cache、指令重排可见性等问题我们对原子操作的意义有了更多的追求。在多核系统中一个核对地址的值的更改在更新到主内存中之前是在多级缓存中存放的。这时多个核看到的数据可能是不一样的其它的核可能还没有看到更新的数据还在使用旧的数据。
多处理器多核心系统为了处理这类问题使用了一种叫做内存屏障memory fence或memory barrier的方式。一个写内存屏障会告诉处理器必须要等到它管道中的未完成的操作特别是写操作都被刷新到内存中再进行操作。此操作还会让相关的处理器的CPU缓存失效以便让它们从主存中拉取最新的值。
atomic包提供的方法会提供内存屏障的功能所以atomic不仅仅可以保证赋值的数据完整性还能保证数据的可见性一旦一个核更新了该地址的值其它处理器总是能读取到它的最新值。但是需要注意的是因为需要处理器之间保证数据的一致性atomic的操作也是会降低性能的。
<img src="https://static001.geekbang.org/resource/image/53/13/53d55255fe851754659d90cbee814f13.jpg" alt="">
# 思考题
atomic.Value只有Load/Store方法你是不是感觉意犹未尽你可以尝试为Value类型增加 Swap和CompareAndSwap方法可以参考一下[这份资料](https://github.com/golang/go/issues/39351))。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,341 @@
<audio id="audio" title="01 | Mutex如何解决资源并发访问问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/ec/0732a2c4ff90645c329b351b739ae3ec.mp3"></audio>
你好,我是鸟窝。
今天是我们Go并发编程实战课的第一讲我们就直接从解决并发访问这个棘手问题入手。
说起并发访问问题真是太常见了比如多个goroutine并发更新同一个资源像计数器同时更新用户的账户信息秒杀系统往同一个buffer中并发写入数据等等。如果没有互斥控制就会出现一些异常情况比如计数器的计数不准确、用户的账户可能出现透支、秒杀系统出现超卖、buffer中的数据混乱等等后果都很严重。
这些问题怎么解决呢用互斥锁那在Go语言里就是**Mutex。**
这节课我会带你详细了解互斥锁的实现机制以及Go标准库的互斥锁Mutex的基本使用方法。在后面的3节课里我还会讲解Mutex的具体实现原理、易错场景和一些拓展用法。
好了,我们先来看看互斥锁的实现机制。
## 互斥锁的实现机制
互斥锁是并发控制的一个基本手段,是为了避免竞争而建立的一种并发控制机制。在学习它的具体实现原理前,我们要先搞懂一个概念,就是**临界区**。
在并发编程中,如果程序中的一部分会被并发访问或修改,那么,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序,就叫做临界区。
可以说,临界区就是一个被共享的资源,或者说是一个整体的一组共享资源,比如对数据库的访问、对某一个共享数据结构的操作、对一个 I/O 设备的使用、对一个连接池中的连接的调用,等等。
如果很多线程同步访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以**使用互斥锁,限定临界区只能同时由一个线程持有**。
当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。
<img src="https://static001.geekbang.org/resource/image/44/b8/44c08abdd0aff633ca932fc89386ebb8.jpg" alt="">
你看互斥锁就很好地解决了资源竞争问题有人也把互斥锁叫做排它锁。那在Go 标准库中,它提供了 Mutex 来实现互斥锁这个功能。
根据2019年第一篇全面分析Go并发Bug的论文[Understanding Real-World Concurrency Bugs in Go](https://songlh.github.io/paper/go-study.pdf)**Mutex是使用最广泛的同步原语**Synchronization primitives有人也叫做**并发原语**。我们在这个课程中根据英文直译优先用同步原语但是并发原语的指代范围更大还可以包括任务编排的类型所以后面我们讲Channel或者扩展类型时也会用并发原语。关于同步原语并没有一个严格的定义你可以把它看作解决并发问题的一个基础的数据结构。
在这门课的前两个模块我会和你讲互斥锁Mutex、读写锁RWMutex、并发编排WaitGroup、条件变量Cond、Channel等同步原语。所以在这里我先和你说一下同步原语的适用场景。
- 共享资源。并发地读写共享资源会出现数据竞争data race的问题所以需要Mutex、RWMutex这样的并发原语来保护。
- 任务编排。需要goroutine按照一定的规律执行而goroutine之间有相互等待或者依赖的顺序关系我们常常使用WaitGroup或者Channel来实现。
- 消息传递。信息交流以及不同的goroutine之间的线程安全的数据交流常常使用Channel来实现。
今天这一讲咱们就从公认的使用最广泛的Mutex开始学习吧。是骡子是马咱得拉出来遛遛看看我们到底可以怎么使用Mutex。
## Mutex的基本使用方法
在正式看Mutex用法之前呢我想先给你交代一件事Locker接口。
在Go的标准库中package sync提供了锁相关的一系列同步原语这个package还定义了一个Locker的接口Mutex就实现了这个接口。
Locker的接口定义了锁同步原语的方法集
```
type Locker interface {
Lock()
Unlock()
}
```
可以看到Go定义的锁接口的方法集很简单就是请求锁Lock和释放锁Unlock这两个方法秉承了Go语言一贯的简洁风格。
但是,这个接口在实际项目应用得不多,因为我们一般会直接使用具体的同步原语,而不是通过接口。
我们这一讲介绍的Mutex以及后面会介绍的读写锁RWMutex都实现了Locker接口所以首先我把这个接口介绍了让你做到心中有数。
下面我们直接看Mutex。
简单来说,**互斥锁Mutex就提供两个方法Lock和Unlock进入临界区之前调用Lock方法退出临界区的时候调用Unlock方法**
```
func(m *Mutex)Lock()
func(m *Mutex)Unlock()
```
**当一个goroutine通过调用Lock方法获得了这个锁的拥有权后 其它请求锁的goroutine就会阻塞在Lock方法的调用上直到锁被释放并且自己获取到了这个锁的拥有权。**
看到这儿,你可能会问,为啥一定要加锁呢?别急,我带你来看一个并发访问场景中不使用锁的例子,看看实现起来会出现什么状况。
在这个例子中我们创建了10个goroutine同时不断地对一个变量count进行加1操作每个goroutine负责执行10万次的加1操作我们期望的最后计数的结果是10 * 100000 = 1000000 (一百万)。
```
import (
&quot;fmt&quot;
&quot;sync&quot;
)
func main() {
var count = 0
// 使用WaitGroup等待10个goroutine完成
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i &lt; 10; i++ {
go func() {
defer wg.Done()
// 对变量count执行10次加1
for j := 0; j &lt; 100000; j++ {
count++
}
}()
}
// 等待10个goroutine完成
wg.Wait()
fmt.Println(count)
}
```
在这段代码中我们使用sync.WaitGroup来等待所有的goroutine执行完毕后再输出最终的结果。sync.WaitGroup这个同步原语我会在后面的课程中具体介绍现在你只需要知道我们使用它来控制等待一组goroutine全部做完任务。
但是,每次运行,你都可能得到不同的结果,基本上不会得到理想中的一百万的结果。
<img src="https://static001.geekbang.org/resource/image/60/e2/6080fdf493e047917aa099ea33279de2.png" alt="">
这是为什么呢?
其实,这是因为,**count++** 不是一个原子操作它至少包含几个步骤比如读取变量count的当前值对这个值加1把结果再保存到count中。因为不是原子操作就可能有并发的问题。
比如10个goroutine同时读取到count的值为9527接着各自按照自己的逻辑加1值变成了9528然后把这个结果再写回到count变量。但是实际上此时我们增加的总数应该是10才对这里却只增加了1好多计数都被“吞”掉了。这是并发访问共享数据的常见错误。
```
// count++操作的汇编代码
MOVQ &quot;&quot;.count(SB), AX
LEAQ 1(AX), CX
MOVQ CX, &quot;&quot;.count(SB)
```
这个问题有经验的开发人员还是比较容易发现的但是很多时候并发问题隐藏得非常深即使是有经验的人也不太容易发现或者Debug出来。
针对这个问题Go提供了一个检测并发访问共享资源是否有问题的工具 [race detector](https://blog.golang.org/race-detector)它可以帮助我们自动发现程序有没有data race的问题。
**Go race detector**是基于Google的 C/C++ [sanitizers](https://github.com/google/sanitizers) 技术实现的编译器通过探测所有的内存访问加入代码能监视对这些内存地址的访问读还是写。在代码运行的时候race detector就能监控到对共享变量的非同步访问出现race的时候就会打印出警告信息。
这个技术在Google内部帮了大忙探测出了Chromium等代码的大量并发问题。Go 1.1中就引入了这种技术并且一下子就发现了标准库中的42个并发问题。现在race detector已经成了Go持续集成过程中的一部分。
我们来看看这个工具怎么用。
在编译compile、测试test或者运行runGo代码的时候加上**race**参数就有可能发现并发问题。比如在上面的例子中我们可以加上race参数运行检测一下是不是有并发问题。如果你go run -race counter.go就会输出警告信息。
<img src="https://static001.geekbang.org/resource/image/f5/ff/f5eec2d6458e4bddc882ebb213f05aff.png" alt="">
这个警告不但会告诉你有并发问题而且还会告诉你哪个goroutine在哪一行对哪个变量有写操作同时哪个goroutine在哪一行对哪个变量有读操作就是这些并发的读写访问引起了data race。
例子中的goroutine 10对内存地址0x00c000126010有读的操作counter.go文件第16行同时goroutine 7对内存地址0x00c000126010有写的操作counter.go文件第16行。而且还可能有多个goroutine在同时进行读写所以警告信息可能会很长。
虽然这个工具使用起来很方便但是因为它的实现方式只能通过真正对实际地址进行读写访问的时候才能探测所以它并不能在编译的时候发现data race的问题。而且在运行的时候只有在触发了data race之后才能检测到如果碰巧没有触发比如一个data race问题只能在2月14号零点或者11月11号零点才出现是检测不出来的。
而且把开启了race的程序部署在线上还是比较影响性能的。运行 go tool compile -race -S counter.go可以查看计数器例子的代码重点关注一下count++前后的编译后的代码:
```
0x002a 00042 (counter.go:13) CALL runtime.racefuncenter(SB)
......
0x0061 00097 (counter.go:14) JMP 173
0x0063 00099 (counter.go:15) MOVQ AX, &quot;&quot;.j+8(SP)
0x0068 00104 (counter.go:16) PCDATA $0, $1
0x0068 00104 (counter.go:16) MOVQ &quot;&quot;.&amp;count+128(SP), AX
0x0070 00112 (counter.go:16) PCDATA $0, $0
0x0070 00112 (counter.go:16) MOVQ AX, (SP)
0x0074 00116 (counter.go:16) CALL runtime.raceread(SB)
0x0079 00121 (counter.go:16) PCDATA $0, $1
0x0079 00121 (counter.go:16) MOVQ &quot;&quot;.&amp;count+128(SP), AX
0x0081 00129 (counter.go:16) MOVQ (AX), CX
0x0084 00132 (counter.go:16) MOVQ CX, &quot;&quot;..autotmp_8+16(SP)
0x0089 00137 (counter.go:16) PCDATA $0, $0
0x0089 00137 (counter.go:16) MOVQ AX, (SP)
0x008d 00141 (counter.go:16) CALL runtime.racewrite(SB)
0x0092 00146 (counter.go:16) MOVQ &quot;&quot;..autotmp_8+16(SP), AX
......
0x00b6 00182 (counter.go:18) CALL runtime.deferreturn(SB)
0x00bb 00187 (counter.go:18) CALL runtime.racefuncexit(SB)
0x00c0 00192 (counter.go:18) MOVQ 104(SP), BP
0x00c5 00197 (counter.go:18) ADDQ $112, SP
```
在编译的代码中增加了runtime.racefuncenter、runtime.raceread、runtime.racewrite、runtime.racefuncexit等检测data race的方法。通过这些插入的指令Go race detector工具就能够成功地检测出data race问题了。
总结一下通过在编译的时候插入一些指令在运行时通过这些插入的指令检测并发读写从而发现data race问题就是这个工具的实现机制。
既然这个例子存在data race问题我们就要想办法来解决它。这个时候我们这节课的主角Mutex就要登场了它可以轻松地消除掉data race。
具体怎么做呢下面我就结合这个例子来具体给你讲一讲Mutex的基本用法。
我们知道这里的共享资源是count变量临界区是count++只要在临界区前面获取锁在离开临界区的时候释放锁就能完美地解决data race的问题了。
```
package main
import (
&quot;fmt&quot;
&quot;sync&quot;
)
func main() {
// 互斥锁保护计数器
var mu sync.Mutex
// 计数器的值
var count = 0
// 辅助变量用来确认所有的goroutine都完成
var wg sync.WaitGroup
wg.Add(10)
// 启动10个gourontine
for i := 0; i &lt; 10; i++ {
go func() {
defer wg.Done()
// 累加10万次
for j := 0; j &lt; 100000; j++ {
mu.Lock()
count++
mu.Unlock()
}
}()
}
wg.Wait()
fmt.Println(count)
}
```
如果你再运行一下程序就会发现data race警告没有了系统干脆地输出了1000000
<img src="https://static001.geekbang.org/resource/image/d3/8e/d3c577aec0322488e349acf17789a08e.png" alt="">
怎么样使用Mutex是不是非常高效效果很惊喜。
这里有一点需要注意Mutex的零值是还没有goroutine等待的未加锁的状态所以你不需要额外的初始化直接声明变量如 var mu sync.Mutex即可。
那Mutex还有哪些用法呢
很多情况下,**Mutex会嵌入到其它struct中使用**,比如下面的方式:
```
type Counter struct {
mu sync.Mutex
Count uint64
}
```
在初始化嵌入的struct时也不必初始化这个Mutex字段不会因为没有初始化出现空指针或者是无法获取到锁的情况。
有时候,我们还可以**采用嵌入字段的方式**。通过嵌入字段你可以在这个struct上直接调用Lock/Unlock方法。
```
func main() {
var counter Counter
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i &lt; 10; i++ {
go func() {
defer wg.Done()
for j := 0; j &lt; 100000; j++ {
counter.Lock()
counter.Count++
counter.Unlock()
}
}()
}
wg.Wait()
fmt.Println(counter.Count)
}
type Counter struct {
sync.Mutex
Count uint64
}
```
**如果嵌入的struct有多个字段我们一般会把Mutex放在要控制的字段上面然后使用空格把字段分隔开来。**即使你不这样做,代码也可以正常编译,只不过,用这种风格去写的话,逻辑会更清晰,也更易于维护。
甚至,你还可以**把获取锁、释放锁、计数加一的逻辑封装成一个方法**,对外不需要暴露锁等逻辑:
```
func main() {
// 封装好的计数器
var counter Counter
var wg sync.WaitGroup
wg.Add(10)
// 启动10个goroutine
for i := 0; i &lt; 10; i++ {
go func() {
defer wg.Done()
// 执行10万次累加
for j := 0; j &lt; 100000; j++ {
counter.Incr() // 受到锁保护的方法
}
}()
}
wg.Wait()
fmt.Println(counter.Count())
}
// 线程安全的计数器类型
type Counter struct {
CounterType int
Name string
mu sync.Mutex
count uint64
}
// 加1的方法内部使用互斥锁保护
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// 得到计数器的值,也需要锁保护
func (c *Counter) Count() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
```
## 总结
这节课我介绍了并发问题的背景知识、标准库中Mutex的使用和最佳实践、通过race detector工具发现计数器程序的问题以及修复方法。相信你已经大致了解了Mutex这个同步原语。
在项目开发的初始阶段,我们可能并没有仔细地考虑资源的并发问题,因为在初始阶段,我们还不确定这个资源是否被共享。经过更加深入的设计,或者新功能的增加、代码的完善,这个时候,我们就需要考虑共享资源的并发问题了。当然,如果你能在初始阶段预见到资源会被共享并发访问就更好了。
意识到共享资源的并发访问的早晚不重要,重要的是,一旦你意识到这个问题,你就要及时通过互斥锁等手段去解决。
比如Docker issue [37583](https://github.com/moby/moby/pull/37583)、[35517](https://github.com/moby/moby/pull/35517)、[32826](https://github.com/moby/moby/pull/32826)、[30696](https://github.com/moby/moby/pull/30696)等、kubernetes issue [72361](https://github.com/kubernetes/kubernetes/pull/72361)、[71617](https://github.com/kubernetes/kubernetes/pull/71617)等都是后来发现的data race而采用互斥锁Mutex进行修复的。
## 思考题
你已经知道如果Mutex已经被一个goroutine获取了锁其它等待中的goroutine们只能一直等待。那么等这个锁释放后等待中的goroutine中哪一个会优先获取Mutex呢
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,555 @@
<audio id="audio" title="02 | Mutex庖丁解牛看实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/d6/d5a8bd6a5bc4e43ef511dc3f5bd397d6.mp3"></audio>
你好,我是鸟窝。
上一讲我们一起体验了Mutex的使用竟是那么简单只有简简单单两个方法Lock和Unlock进入临界区之前调用Lock方法退出临界区的时候调用Unlock方法。这个时候你一定会有一丝好奇“它的实现是不是也很简单呢
其实不是的。如果你阅读Go标准库里Mutex的源代码并且追溯Mutex的演进历史你会发现从一个简单易于理解的互斥锁的实现到一个非常复杂的数据结构这是一个逐步完善的过程。Go开发者们做了种种努力精心设计。我自己每次看都会被这种匠心和精益求精的精神打动。
所以今天我就想带着你一起去探索Mutex的实现及演进之路希望你能和我一样体验到这种技术追求的美妙。我们从Mutex的一个简单实现开始看看它是怎样逐步提升性能和公平性的。在这个过程中我们可以学习如何逐步设计一个完善的同步原语并能对复杂度、性能、结构设计的权衡考量有新的认识。经过这样一个学习我们不仅能通透掌握Mutex更好地使用这个工具同时对我们自己设计并发数据接口也非常有帮助。
那具体怎么来讲呢我把Mutex的架构演进分成了四个阶段下面给你画了一张图来说明。
“**初版**”的Mutex使用一个flag来表示锁是否被持有实现比较简单后来照顾到新来的goroutine所以会让新的goroutine也尽可能地先获取到锁这是第二个阶段我把它叫作“**给新人机会**”;那么,接下来就是第三阶段“**多给些机会**”照顾新来的和被唤醒的goroutine但是这样会带来饥饿问题所以目前又加入了饥饿的解决方案也就是第四阶段“**解决饥饿**”。
<img src="https://static001.geekbang.org/resource/image/c2/35/c28531b47ff7f220d5bc3c9650180835.jpg" alt="">
有了这四个阶段我们学习的路径就清晰了那接下来我会从代码层面带你领略Go开发者这些大牛们是如何逐步解决这些问题的。
# 初版的互斥锁
我们先来看怎么实现一个最简单的互斥锁。在开始之前,你可以先想一想,如果是你,你会怎么设计呢?
你可能会想到可以通过一个flag变量标记当前的锁是否被某个goroutine持有。如果这个flag的值是1就代表锁已经被持有那么其它竞争的goroutine只能等待如果这个flag的值是0就可以通过CAScompare-and-swap或者compare-and-set将这个flag设置为1标识锁被当前的这个goroutine持有了。
实际上Russ Cox在2008年提交的第一版Mutex就是这样实现的。
```
// CAS操作当时还没有抽象出atomic包
func cas(val *int32, old, new int32) bool
func semacquire(*int32)
func semrelease(*int32)
// 互斥锁的结构,包含两个字段
type Mutex struct {
key int32 // 锁是否被持有的标识
sema int32 // 信号量专用,用以阻塞/唤醒goroutine
}
// 保证成功在val上增加delta的值
func xadd(val *int32, delta int32) (new int32) {
for {
v := *val
if cas(val, v, v+delta) {
return v + delta
}
}
panic(&quot;unreached&quot;)
}
// 请求锁
func (m *Mutex) Lock() {
if xadd(&amp;m.key, 1) == 1 { //标识加1如果等于1成功获取到锁
return
}
semacquire(&amp;m.sema) // 否则阻塞等待
}
func (m *Mutex) Unlock() {
if xadd(&amp;m.key, -1) == 0 { // 将标识减去1如果等于0则没有其它等待者
return
}
semrelease(&amp;m.sema) // 唤醒其它阻塞的goroutine
}
```
这里呢我先简单补充介绍下刚刚提到的CAS。
CAS指令将**给定的值**和**一个内存地址中的值**进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性的。那啥是原子性呢?如果你还不太理解这个概念,那么在这里只需要明确一点就行了,那就是**原子性保证这个指令总是基于最新的值进行计算如果同时有其它线程已经修改了这个值那么CAS会返回失败**。
CAS是实现互斥锁和同步原语的基础我们很有必要掌握它。
好了,我们继续来分析下刚才的这段代码。
虽然当时的Go语法和现在的稍微有些不同而且标准库的布局、实现和现在的也有很大的差异但是这些差异不会影响我们对代码的理解因为最核心的结构体struct和函数、方法的定义几乎是一样的。
Mutex 结构体包含两个字段:
- **字段key**是一个flag用来标识这个排外锁是否被某个goroutine所持有如果key大于等于1说明这个排外锁已经被持有
- **字段sema**是个信号量变量用来控制等待goroutine的阻塞休眠和唤醒。
<img src="https://static001.geekbang.org/resource/image/82/25/825e23e1af96e78f3773e0b45de38e25.jpg" alt="">
调用Lock请求锁的时候通过xadd方法进行CAS操作第24行xadd方法通过循环执行CAS操作直到成功保证对key加1的操作成功完成。如果比较幸运锁没有被别的goroutine持有那么Lock方法成功地将key设置为1这个goroutine就持有了这个锁如果锁已经被别的goroutine持有了那么当前的goroutine会把key加1而且还会调用semacquire方法第27行使用信号量将自己休眠等锁释放的时候信号量会将它唤醒。
持有锁的goroutine调用Unlock释放锁时它会将key减1第31行。如果当前没有其它等待这个锁的goroutine这个方法就返回了。但是如果还有等待此锁的其它goroutine那么它会调用semrelease方法第34行利用信号量唤醒等待锁的其它goroutine中的一个。
所以到这里我们就知道了初版的Mutex利用CAS原子操作对key这个标志量进行设置。key不仅仅标识了锁是否被goroutine所持有还记录了当前持有和等待获取锁的goroutine的数量。
Mutex的整体设计非常简洁学习起来一点也没有障碍。但是注意我要划重点了。
**Unlock方法可以被任意的goroutine调用释放锁即使是没持有这个互斥锁的goroutine也可以进行这个操作。这是因为Mutex本身并没有包含持有这把锁的goroutine的信息所以Unlock也不会对此进行检查。Mutex的这个设计一直保持至今。**
这就带来了一个有趣而危险的功能。为什么这么说呢?
你看其它goroutine可以强制释放锁这是一个非常危险的操作因为在临界区的goroutine可能不知道锁已经被释放了还会继续执行临界区的业务操作这可能会带来意想不到的结果因为这个goroutine还以为自己持有锁呢有可能导致data race问题。
所以我们在使用Mutex的时候必须要保证goroutine尽可能不去释放自己未持有的锁一定要遵循“**谁申请,谁释放**”的原则。在真实的实践中,我们使用互斥锁的时候,很少在一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。
如果你接触过其它语言比如Java语言的互斥锁的实现就会发现这一点和其它语言的互斥锁不同所以如果是从其它语言转到Go语言开发的同学一定要注意。
以前我们经常会基于性能的考虑及时释放掉锁所以在一些if-else分支中加上释放锁的代码代码看起来很臃肿。而且在重构的时候也很容易因为误删或者是漏掉而出现死锁的现象。
```
type Foo struct {
mu sync.Mutex
count int
}
func (f *Foo) Bar() {
f.mu.Lock()
if f.count &lt; 1000 {
f.count += 3
f.mu.Unlock() // 此处释放锁
return
}
f.count++
f.mu.Unlock() // 此处释放锁
return
}
```
从1.14版本起Go对defer做了优化采用更有效的内联方式取代之前的生成defer对象到defer chain中defer对耗时的影响微乎其微了所以基本上修改成下面简洁的写法也没问题
```
func (f *Foo) Bar() {
f.mu.Lock()
defer f.mu.Unlock()
if f.count &lt; 1000 {
f.count += 3
return
}
f.count++
return
}
```
这样做的好处就是Lock/Unlock总是成对紧凑出现不会遗漏或者多调用代码更少。
但是如果临界区只是方法中的一部分为了尽快释放锁还是应该第一时间调用Unlock而不是一直等到方法返回时才释放。
初版的Mutex实现之后Go开发组又对Mutex做了一些微调比如把字段类型变成了uint32类型调用Unlock方法会做检查使用atomic包的同步原语执行原子操作等等这些小的改动都不是核心功能你简单知道就行了我就不详细介绍了。
但是初版的Mutex实现有一个问题请求锁的goroutine会排队等待获取互斥锁。虽然这貌似很公平但是从性能上来看却不是最优的。因为如果我们能够把锁交给正在占用CPU时间片的goroutine的话那就不需要做上下文的切换在高并发的情况下可能会有更好的性能。
接下来我们就继续探索Go开发者是怎么解决这个问题的。
# 给新人机会
Go开发者在2011年6月30日的commit中对Mutex做了一次大的调整调整后的Mutex实现如下
```
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 &lt;&lt; iota // mutex is locked
mutexWoken
mutexWaiterShift = iota
)
```
虽然Mutex结构体还是包含两个字段但是第一个字段已经改成了state它的含义也不一样了。
<img src="https://static001.geekbang.org/resource/image/4c/15/4c4a3dd2310059821f41af7b84925615.jpg" alt="">
state是一个复合型的字段一个字段包含多个意义这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位最小的一位来表示这个锁是否被持有第二位代表是否有唤醒的goroutine剩余的位数代表的是等待此锁的goroutine数。所以state这一个字段被分成了三部分代表三个数据。
请求锁的方法Lock也变得复杂了。复杂之处不仅仅在于对字段state的操作难以理解而且代码逻辑也变得相当复杂。
```
func (m *Mutex) Lock() {
// Fast path: 幸运case能够直接获取到锁
if atomic.CompareAndSwapInt32(&amp;m.state, 0, mutexLocked) {
return
}
awoke := false
for {
old := m.state
new := old | mutexLocked // 新状态加锁
if old&amp;mutexLocked != 0 {
new = old + 1&lt;&lt;mutexWaiterShift //等待者数量加一
}
if awoke {
// goroutine是被唤醒的
// 新状态清除唤醒标志
new &amp;^= mutexWoken
}
if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {//设置新状态
if old&amp;mutexLocked == 0 { // 锁原状态未加锁
break
}
runtime.Semacquire(&amp;m.sema) // 请求信号量
awoke = true
}
}
}
```
首先是通过CAS检测state字段中的标志第3行如果没有goroutine持有锁也没有等待持有锁的gorutine那么当前的goroutine就很幸运可以直接获得锁这也是注释中的Fast path的意思。
如果不够幸运state不是零值那么就通过一个循环进行检查。接下来的第7行到第26行这段代码虽然只有几行但是理解起来却要费一番功夫因为涉及到对state不同标志位的操作。这里的位操作以及操作后的结果和数值比较并没有明确的解释有时候你需要根据后续的处理进行推断。所以说如果你充分理解了这段代码那么对最新版的Mutex也会比较容易掌握了因为你已经清楚了这些位操作的含义。
我们先前知道如果想要获取锁的goroutine没有机会获取到锁就会进行休眠但是在锁释放唤醒之后它并不能像先前一样直接获取到锁还是要和正在请求锁的goroutine进行竞争。这会给后来请求锁的goroutine一个机会也让CPU中正在执行的goroutine有更多的机会获取到锁在一定程度上提高了程序的性能。
for循环是不断尝试获取锁如果获取不到就通过runtime.Semacquire(&amp;m.sema)休眠休眠醒来之后awoke置为true尝试争抢锁。
代码中的第10行将当前的flag设置为加锁状态如果能成功地通过CAS把这个新值赋予state第19行和第20行就代表抢夺锁的操作成功了。
不过需要注意的是如果成功地设置了state的值但是之前的state是有锁的状态那么state只是清除mutexWoken标志或者增加一个waiter而已。
请求锁的goroutine有两类一类是新来请求锁的goroutine另一类是被唤醒的等待请求锁的goroutine。锁的状态也有两种加锁和未加锁。我用一张表格来说明一下goroutine不同来源不同状态下的处理逻辑。
<img src="https://static001.geekbang.org/resource/image/15/6f/1571ace962ae481229bbf534da1a676f.jpg" alt="">
刚刚说的都是获取锁接下来我们再来看看释放锁。释放锁的Unlock方法也有些复杂我们来看一下。
```
func (m *Mutex) Unlock() {
// Fast path: drop lock bit.
new := atomic.AddInt32(&amp;m.state, -mutexLocked) //去掉锁标志
if (new+mutexLocked)&amp;mutexLocked == 0 { //本来就没有加锁
panic(&quot;sync: unlock of unlocked mutex&quot;)
}
old := new
for {
if old&gt;&gt;mutexWaiterShift == 0 || old&amp;(mutexLocked|mutexWoken) != 0 { // 没有等待者或者有唤醒的waiter或者锁原来已加锁
return
}
new = (old - 1&lt;&lt;mutexWaiterShift) | mutexWoken // 新状态准备唤醒goroutine并设置唤醒标志
if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {
runtime.Semrelease(&amp;m.sema)
return
}
old = m.state
}
}
```
下面我来给你解释一下这个方法。
第3行是尝试将持有锁的标识设置为未加锁的状态这是通过减1而不是将标志位置零的方式实现。第4到6行还会检测原来锁的状态是否已经未加锁的状态如果是Unlock一个未加锁的Mutex会直接panic。
不过即使将加锁置为未加锁的状态这个方法也不能直接返回还需要一些额外的操作因为还可能有一些等待这个锁的goroutine有时候我也把它们称之为waiter需要通过信号量的方式唤醒它们中的一个。所以接下来的逻辑有两种情况。
第一种情况如果没有其它的waiter说明对这个锁的竞争的goroutine只有一个那就可以直接返回了如果这个时候有唤醒的goroutine或者是又被别人加了锁那么无需我们操劳其它goroutine自己干得都很好当前的这个goroutine就可以放心返回了。
第二种情况如果有等待者并且没有唤醒的waiter那就需要唤醒一个等待的waiter。在唤醒之前需要将waiter数量减1并且将mutexWoken标志设置上这样Unlock就可以返回了。
通过这样复杂的检查、判断和设置,我们就可以安全地将一把互斥锁释放了。
**相对于初版的设计这次的改动主要就是新来的goroutine也有机会先获取到锁甚至一个goroutine可能连续获取到锁打破了先来先得的逻辑。但是代码复杂度也显而易见。**
虽然这一版的Mutex已经给新来请求锁的goroutine一些机会让它参与竞争没有空闲的锁或者竞争失败才加入到等待队列中。但是其实还可以进一步优化。我们接着往下看。
# 多给些机会
在2015年2月的改动中如果新来的goroutine或者是被唤醒的goroutine首次获取不到锁它们就会通过自旋spin通过循环不断尝试spin的逻辑是在[runtime实现](https://github.com/golang/go/blob/846dce9d05f19a1f53465e62a304dea21b99f910/src/runtime/proc.go#L5580)的)的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑。
```
func (m *Mutex) Lock() {
// Fast path: 幸运之路,正好获取到锁
if atomic.CompareAndSwapInt32(&amp;m.state, 0, mutexLocked) {
return
}
awoke := false
iter := 0
for { // 不管是新来的请求锁的goroutine, 还是被唤醒的goroutine都不断尝试请求锁
old := m.state // 先保存当前锁的状态
new := old | mutexLocked // 新状态设置加锁标志
if old&amp;mutexLocked != 0 { // 锁还没被释放
if runtime_canSpin(iter) { // 还可以自旋
if !awoke &amp;&amp; old&amp;mutexWoken == 0 &amp;&amp; old&gt;&gt;mutexWaiterShift != 0 &amp;&amp;
atomic.CompareAndSwapInt32(&amp;m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
continue // 自旋,再次尝试请求锁
}
new = old + 1&lt;&lt;mutexWaiterShift
}
if awoke { // 唤醒状态
if new&amp;mutexWoken == 0 {
panic(&quot;sync: inconsistent mutex state&quot;)
}
new &amp;^= mutexWoken // 新状态清除唤醒标记
}
if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {
if old&amp;mutexLocked == 0 { // 旧状态锁已释放,新状态成功持有了锁,直接返回
break
}
runtime_Semacquire(&amp;m.sema) // 阻塞等待
awoke = true // 被唤醒
iter = 0
}
}
}
```
这次的优化增加了第13行到21行、第25行到第27行以及第36行。我来解释一下主要的逻辑也就是第13行到21行。
如果可以spin的话第9行的for循环会重新检查锁是否释放。对于临界区代码执行非常短的场景来说这是一个非常好的优化。因为临界区的代码耗时很短锁很快就能释放而抢夺锁的goroutine不用通过休眠唤醒方式等待调度直接spin几次可能就获得了锁。
# 解决饥饿
经过几次优化Mutex的代码越来越复杂应对高并发争抢锁的场景也更加公平。但是你有没有想过因为新来的goroutine也参与竞争有可能每次都会被新来的goroutine抢到获取锁的机会在极端情况下等待中的goroutine可能会一直获取不到锁这就是**饥饿问题**。
说到这儿,我突然想到了最近看到的一种叫做鹳的鸟。如果鹳妈妈寻找食物很艰难,找到的食物只够一个幼鸟吃的,鹳妈妈就会把食物给最强壮的一只,这样一来,饥饿弱小的幼鸟总是得不到食物吃,最后就会被啄出巢去。
先前版本的Mutex遇到的也是同样的困境“悲惨”的goroutine总是得不到锁。
Mutex不能容忍这种事情发生。所以2016年Go 1.9中Mutex增加了饥饿模式让锁变得更公平不公平的等待时间限制在1毫秒并且修复了一个大Bug总是把唤醒的goroutine放在等待队列的尾部会导致更加不公平的等待时间。
之后2018年Go开发者将fast path和slow path拆成独立的方法以便内联提高性能。2019年也有一个Mutex的优化虽然没有对Mutex做修改但是对于Mutex唤醒后持有锁的那个waiter调度器可以有更高的优先级去执行这已经是很细致的性能优化了。
为了避免代码过多这里只列出当前的Mutex实现。想要理解当前的Mutex我们需要好好泡一杯茶仔细地品一品了。
当然现在的Mutex代码已经复杂得接近不可读的状态了而且代码也非常长删减后占了几乎三页纸。但是作为第一个要详细介绍的同步原语我还是希望能更清楚地剖析Mutex的实现向你展示它的演化和为了一个貌似很小的feature不得不将代码变得非常复杂的原因。
<img src="https://static001.geekbang.org/resource/image/e0/76/e0c23794c8a1d355a7a183400c036276.jpg" alt="">
当然,你也可以暂时略过这一段,以后慢慢品,**只需要记住Mutex绝不容忍一个goroutine被落下永远没有机会获取锁。不抛弃不放弃是它的宗旨而且它也尽可能地让等待较长的goroutine更有机会获取到锁**。
```
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 &lt;&lt; iota // mutex is locked
mutexWoken
mutexStarving // 从state字段中分出一个饥饿标记
mutexWaiterShift = iota
starvationThresholdNs = 1e6
)
func (m *Mutex) Lock() {
// Fast path: 幸运之路,一下就获取到了锁
if atomic.CompareAndSwapInt32(&amp;m.state, 0, mutexLocked) {
return
}
// Slow path缓慢之路尝试自旋竞争或饥饿状态下饥饿goroutine竞争
m.lockSlow()
}
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false // 此goroutine的饥饿标记
awoke := false // 唤醒标记
iter := 0 // 自旋次数
old := m.state // 当前的锁的状态
for {
// 锁是非饥饿状态,锁还没被释放,尝试自旋
if old&amp;(mutexLocked|mutexStarving) == mutexLocked &amp;&amp; runtime_canSpin(iter) {
if !awoke &amp;&amp; old&amp;mutexWoken == 0 &amp;&amp; old&gt;&gt;mutexWaiterShift != 0 &amp;&amp;
atomic.CompareAndSwapInt32(&amp;m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin()
iter++
old = m.state // 再次获取锁的状态,之后会检查是否锁被释放了
continue
}
new := old
if old&amp;mutexStarving == 0 {
new |= mutexLocked // 非饥饿状态,加锁
}
if old&amp;(mutexLocked|mutexStarving) != 0 {
new += 1 &lt;&lt; mutexWaiterShift // waiter数量加1
}
if starving &amp;&amp; old&amp;mutexLocked != 0 {
new |= mutexStarving // 设置饥饿状态
}
if awoke {
if new&amp;mutexWoken == 0 {
throw(&quot;sync: inconsistent mutex state&quot;)
}
new &amp;^= mutexWoken // 新状态清除唤醒标记
}
// 成功设置新状态
if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {
// 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回
if old&amp;(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
// 处理饥饿状态
// 如果以前就在队列里面,加入到队列头
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 阻塞等待
runtime_SemacquireMutex(&amp;m.sema, queueLifo, 1)
// 唤醒之后检查锁是否应该处于饥饿状态
starving = starving || runtime_nanotime()-waitStartTime &gt; starvationThresholdNs
old = m.state
// 如果锁已经处于饥饿状态,直接抢到锁,返回
if old&amp;mutexStarving != 0 {
if old&amp;(mutexLocked|mutexWoken) != 0 || old&gt;&gt;mutexWaiterShift == 0 {
throw(&quot;sync: inconsistent mutex state&quot;)
}
// 有点绕加锁并且将waiter数减1
delta := int32(mutexLocked - 1&lt;&lt;mutexWaiterShift)
if !starving || old&gt;&gt;mutexWaiterShift == 1 {
delta -= mutexStarving // 最后一个waiter或者已经不饥饿了清除饥饿标记
}
atomic.AddInt32(&amp;m.state, delta)
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
}
func (m *Mutex) Unlock() {
// Fast path: drop lock bit.
new := atomic.AddInt32(&amp;m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&amp;mutexLocked == 0 {
throw(&quot;sync: unlock of unlocked mutex&quot;)
}
if new&amp;mutexStarving == 0 {
old := new
for {
if old&gt;&gt;mutexWaiterShift == 0 || old&amp;(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
new = (old - 1&lt;&lt;mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&amp;m.state, old, new) {
runtime_Semrelease(&amp;m.sema, false, 1)
return
}
old = m.state
}
} else {
runtime_Semrelease(&amp;m.sema, true, 1)
}
}
```
跟之前的实现相比当前的Mutex最重要的变化就是增加饥饿模式。第12行将饥饿模式的最大等待时间阈值设置成了1毫秒这就意味着一旦等待者等待的时间超过了这个阈值Mutex的处理就有可能进入饥饿模式优先让等待者先获取到锁新来的同学主动谦让一下给老同志一些机会。
通过加入饥饿模式可以避免把机会全都留给新来的goroutine保证了请求锁的goroutine获取锁的公平性对于我们使用锁的业务代码来说不会有业务一直等待锁不被处理。
那么,接下来的部分就是选学内容了。如果你还有精力,并且对饥饿模式很感兴趣,那就跟着我一起继续来挑战吧。如果你现在理解起来觉得有困难,也没关系,后面可以随时回来复习。
## 饥饿模式和正常模式
Mutex可能处于两种操作模式下**正常模式**和**饥饿模式**。
接下来我们分析一下Mutex对饥饿模式和正常模式的处理。
请求锁时调用的Lock方法中一开始是fast path这是一个幸运的场景当前的goroutine幸运地获得了锁没有竞争直接返回否则就进入了lockSlow方法。这样的设计方便编译器对Lock方法进行内联你也可以在程序开发中应用这个技巧。
正常模式下waiter都是进入先入先出队列被唤醒的waiter并不会直接持有锁而是要和新来的goroutine进行竞争。新来的goroutine有先天的优势它们正在CPU中运行可能它们的数量还不少所以在高并发情况下被唤醒的waiter可能比较悲剧地获取不到锁这时它会被插入到队列的前面。如果waiter获取不到锁的时间超过阈值1毫秒那么这个Mutex就进入到了饥饿模式。
在饥饿模式下Mutex的拥有者将直接把锁交给队列最前面的waiter。新来的goroutine不会尝试获取锁即使看起来锁没有被持有它也不会去抢也不会spin它会乖乖地加入到等待队列的尾部。
如果拥有Mutex的waiter发现下面两种情况的其中之一它就会把这个Mutex转换成正常模式:
- 此waiter已经是队列中的最后一个waiter了没有其它的等待锁的goroutine了
- 此waiter的等待时间小于1毫秒。
正常模式拥有更好的性能因为即使有等待抢锁的waitergoroutine也可以连续多次获取到锁。
饥饿模式是对公平性和性能的一种平衡它避免了某些goroutine长时间的等待锁。在饥饿模式下优先对待的是那些一直在等待的waiter。
接下来,**我们逐步分析下Mutex代码的关键行彻底搞清楚饥饿模式的细节**。
我们从请求锁lockSlow的逻辑看起。
第9行对state字段又分出了一位用来标记锁是否处于饥饿状态。现在一个state的字段被划分成了阻塞等待的waiter数量、饥饿标记、唤醒标记和持有锁的标记四个部分。
第25行记录此goroutine请求锁的初始时间第26行标记是否处于饥饿状态第27行标记是否是唤醒的第28行记录spin的次数。
第31行到第40行和以前的逻辑类似只不过加了一个不能是饥饿状态的逻辑。它会对正常状态抢夺锁的goroutine尝试spin和以前的目的一样就是在临界区耗时很短的情况下提高性能。
第42行到第44行非饥饿状态下抢锁。怎么抢就是要把state的锁的那一位置为加锁状态后续CAS如果成功就可能获取到了锁。
第46行到第48行如果锁已经被持有或者锁处于饥饿状态我们最好的归宿就是等待所以waiter的数量加1。
第49行到第51行如果此goroutine已经处在饥饿状态并且锁还被持有那么我们需要把此Mutex设置为饥饿状态。
第52行到第57行是清除mutexWoken标记因为不管是获得了锁还是进入休眠我们都需要清除mutexWoken标记。
第59行就是尝试使用CAS设置state。如果成功第61行到第63行是检查原来的锁的状态是未加锁状态并且也不是饥饿状态的话就成功获取了锁返回。
第67行判断是否第一次加入到waiter队列。到这里你应该就能明白第25行为什么不对waitStartTime进行初始化了我们需要利用它在这里进行条件判断。
第72行将此waiter加入到队列如果是首次加入到队尾先进先出。如果不是首次那么加入到队首这样等待最久的goroutine优先能够获取到锁。此goroutine会进行休眠。
第74行判断此goroutine是否处于饥饿状态。注意执行这一句的时候它已经被唤醒了。
第77行到第88行是对锁处于饥饿状态下的一些处理。
第82行设置一个标志这个标志稍后会用来加锁而且还会将waiter数减1。
第84行设置标志在没有其它的waiter或者此goroutine等待还没超过1毫秒则会将Mutex转为正常状态。
第86行则是将这个标识应用到state字段上。
释放锁Unlock时调用的Unlock的fast path不用多少所以我们主要看unlockSlow方法就行。
如果Mutex处于饥饿状态第123行直接唤醒等待队列中的waiter。
如果Mutex处于正常状态如果没有waiter或者已经有在处理的情况了那么释放就好不做额外的处理第112行到第114行
否则waiter数减1mutexWoken标志设置上通过CAS更新state的值第115行到第119行
# 总结
“罗马不是一天建成的”Mutex的设计也是从简单设计到复杂处理逐渐演变的。初版的Mutex设计非常简洁充分展示了Go创始者的简单、简洁的设计哲学。但是随着大家的使用逐渐暴露出一些缺陷为了弥补这些缺陷Mutex不得不越来越复杂。
有一点值得我们学习的同时也体现了Go创始者的哲学就是他们强调GO语言和标准库的稳定性新版本要向下兼容用新的版本总能编译老的代码。Go语言从出生到现在已经10多年了这个Mutex对外的接口却没有变化依然向下兼容即使现在Go出了两个版本每个版本也会向下兼容保持Go语言的稳定性你也能领悟他们软件开发和设计的思想。
还有一点你也可以观察到为了一个程序20%的特性你可能需要添加80%的代码,这也是程序越来越复杂的原因。所以,最开始的时候,如果能够有一个清晰而且易于扩展的设计,未来增加新特性时,也会更加方便。
# 思考题
最后,给你留两个小问题:
1. 目前Mutex的state字段有几个意义这几个意义分别是由哪些字段表示的
1. 等待一个Mutex的goroutine数最大是多少是否能满足现实的需求
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,469 @@
<audio id="audio" title="03Mutex4种易错场景大盘点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ec/c0/ecb7a9b6c314c0ce50dbb0138ab0bec0.mp3"></audio>
你好,我是鸟窝。
上一讲我带你一起领略了Mutex的架构演进之美现在我们已经清楚Mutex的实现细节了。当前Mutex的实现貌似非常复杂其实主要还是针对饥饿模式和公平性问题做了一些额外处理。但是我们在第一讲中已经体验过了Mutex使用起来还是非常简单的毕竟它只有Lock和Unlock两个方法使用起来还能复杂到哪里去
正常使用Mutex时确实是这样的很简单基本不会有什么错误即使出现错误也是在一些复杂的场景中比如跨函数调用Mutex或者是在重构或者修补Bug时误操作。但是我们使用Mutex时确实会出现一些Bug比如说忘记释放锁、重入锁、复制已使用了的Mutex等情况。那在这一讲中我们就一起来看看使用Mutex常犯的几个错误做到“Bug提前知后面早防范”。
# 常见的4种错误场景
我总结了一下使用Mutex常见的错误场景有4类分别是Lock/Unlock不是成对出现、Copy已使用的Mutex、重入和死锁。下面我们一一来看。
## Lock/Unlock不是成对出现
Lock/Unlock没有成对出现就意味着会出现死锁的情况或者是因为Unlock一个未加锁的Mutex而导致panic。
我们先来看看缺少Unlock的场景常见的有三种情况
1. 代码中有太多的if-else分支可能在某个分支中漏写了Unlock
1. 在重构的时候把Unlock给删除了
1. Unlock误写成了Lock。
在这种情况下锁被获取之后就不会被释放了这也就意味着其它的goroutine永远都没机会获取到锁。
我们再来看缺少Lock的场景这就很简单了一般来说就是误操作删除了Lock。 比如先前使用Mutex都是正常的结果后来其他人重构代码的时候由于对代码不熟悉或者由于开发者的马虎把Lock调用给删除了或者注释掉了。比如下面的代码mu.Lock()一行代码被删除了直接Unlock一个未加锁的Mutex会panic
```
func foo() {
var mu sync.Mutex
defer mu.Unlock()
fmt.Println(&quot;hello world!&quot;)
}
```
运行的时候panic
<img src="https://static001.geekbang.org/resource/image/55/4f/5597316079a8fa37abef2a82bdac7b4f.png" alt="">
## Copy已使用的Mutex
第二种误用是Copy已使用的Mutex。在正式分析这个错误之前我先交代一个小知识点那就是Package sync的同步原语在使用后是不能复制的。我们知道Mutex是最常用的一个同步原语那它也是不能复制的。为什么呢
原因在于Mutex是一个有状态的对象它的state字段记录这个锁的状态。如果你要复制一个已经加锁的Mutex给一个新的变量那么新的刚初始化的变量居然被加锁了这显然不符合你的期望因为你期望的是一个零值的Mutex。关键是在并发环境下你根本不知道要复制的Mutex状态是什么因为要复制的Mutex是由其它goroutine并发访问的状态可能总是在变化。
当然,你可能说,你说的我都懂,你的警告我都记下了,但是实际在使用的时候,一不小心就踩了这个坑,我们来看一个例子。
```
type Counter struct {
sync.Mutex
Count int
}
func main() {
var c Counter
c.Lock()
defer c.Unlock()
c.Count++
foo(c) // 复制锁
}
// 这里Counter的参数是通过复制的方式传入的
func foo(c Counter) {
c.Lock()
defer c.Unlock()
fmt.Println(&quot;in foo&quot;)
}
```
第12行在调用foo函数的时候调用者会复制Mutex变量c作为foo函数的参数不幸的是复制之前已经使用了这个锁这就导致复制的Counter是一个带状态Counter。
怎么办呢Go在运行时有**死锁的检查机制**[checkdead()](https://golang.org/src/runtime/proc.go?h=checkdead#L4345) 方法它能够发现死锁的goroutine。这个例子中因为复制了一个使用了的Mutex导致锁无法使用程序处于死锁的状态。程序运行的时候死锁检查机制能够发现这种死锁情况并输出错误信息如下图中错误信息以及错误堆栈
<img src="https://static001.geekbang.org/resource/image/cf/ee/cfb7a4a0e744c5ff534a676fd830d0ee.png" alt="">
你肯定不想运行的时候才发现这个因为复制Mutex导致的死锁问题那么你怎么能够及时发现问题呢可以使用**vet工具**把检查写在Makefile文件中在持续集成的时候跑一跑这样可以及时发现问题及时修复。我们可以使用go vet检查这个Go文件
<img src="https://static001.geekbang.org/resource/image/fa/b8/fa56520yy37009ca58d6640a933f01b8.png" alt="">
你看使用这个工具就可以发现Mutex复制的问题错误信息显示得很清楚是在调用foo函数的时候发生了lock value复制的情况还告诉我们出问题的代码行数以及copy lock导致的错误。
那么vet工具是怎么发现Mutex复制使用问题的呢我带你简单分析一下。
检查是通过[copylock](https://github.com/golang/tools/blob/master/go/analysis/passes/copylock/copylock.go)分析器静态分析实现的。这个分析器会分析函数调用、range遍历、复制、声明、函数返回值等位置有没有锁的值copy的情景以此来判断有没有问题。可以说只要是实现了Locker接口就会被分析。我们看到下面的代码就是确定什么类型会被分析其实就是实现了Lock/Unlock两个方法的Locker接口
```
var lockerType *types.Interface
// Construct a sync.Locker interface type.
func init() {
nullary := types.NewSignature(nil, nil, nil, false) // func()
methods := []*types.Func{
types.NewFunc(token.NoPos, nil, &quot;Lock&quot;, nullary),
types.NewFunc(token.NoPos, nil, &quot;Unlock&quot;, nullary),
}
lockerType = types.NewInterface(methods, nil).Complete()
}
```
其实有些没有实现Locker接口的同步原语比如WaitGroup也能被分析。我先卖个关子后面我们会介绍这种情况是怎么实现的。
## 重入
接下来,我们来讨论“重入”这个问题。在说这个问题前,我先解释一下个概念,叫“可重入锁”。
如果你学过Java可能会很熟悉ReentrantLock就是可重入锁这是Java并发包中非常常用的一个同步原语。它的基本行为和互斥锁相同但是加了一些扩展功能。
如果你没接触过Java也没关系这里只是提一下帮助会Java的同学对比来学。那下面我来具体讲解可重入锁是咋回事儿。
当一个线程获取锁时,如果没有其它线程拥有这个锁,那么,这个线程就成功获取到这个锁。之后,如果其它线程再请求这个锁,就会处于阻塞等待的状态。但是,如果拥有这把锁的线程再请求这把锁的话,不会阻塞,而是成功返回,所以叫可重入锁(有时候也叫做递归锁)。只要你拥有这把锁,你可以可着劲儿地调用,比如通过递归实现一些算法,调用者不会阻塞或者死锁。
了解了可重入锁的概念那我们来看Mutex使用的错误场景。划重点了**Mutex不是可重入的锁。**
想想也不奇怪因为Mutex的实现中没有记录哪个goroutine拥有这把锁。理论上任何goroutine都可以随意地Unlock这把锁所以没办法计算重入条件毕竟“臣妾做不到啊”
所以一旦误用Mutex的重入就会导致报错。下面是一个误用Mutex的重入例子
```
func foo(l sync.Locker) {
fmt.Println(&quot;in foo&quot;)
l.Lock()
bar(l)
l.Unlock()
}
func bar(l sync.Locker) {
l.Lock()
fmt.Println(&quot;in bar&quot;)
l.Unlock()
}
func main() {
l := &amp;sync.Mutex{}
foo(l)
}
```
写完这个Mutex重入的例子后运行一下你会发现类似下面的错误。程序一直在请求锁但是一直没有办法获取到锁结果就是Go运行时发现死锁了没有其它地方能够释放锁让程序运行下去你通过下面的错误堆栈信息就能定位到哪一行阻塞请求锁
<img src="https://static001.geekbang.org/resource/image/0b/79/0bc98ef74c15d9640806d52bf030f979.png" alt="">
学到这里你可能要问了虽然标准库Mutex不是可重入锁但是如果我就是想要实现一个可重入锁可以吗
可以那我们就自己实现一个。这里的关键就是实现的锁要能记住当前是哪个goroutine持有这个锁。我来提供两个方案。
- 方案一通过hacker的方式获取到goroutine id记录下获取锁的goroutine id它可以实现Locker接口。
- 方案二调用Lock/Unlock方法时由goroutine提供一个token用来标识它自己而不是我们通过hacker的方式获取到goroutine id但是这样一来就不满足Locker接口了。
可重入锁递归锁解决了代码重入或者递归调用带来的死锁问题同时它也带来了另一个好处就是我们可以要求只有持有锁的goroutine才能unlock这个锁。这也很容易实现因为在上面这两个方案中都已经记录了是哪一个goroutine持有这个锁。
下面我们具体来看这两个方案怎么实现。
**方案一****goroutine id**
这个方案的关键第一步是获取goroutine id方式有两种分别是简单方式和hacker方式。
简单方式就是通过runtime.Stack方法获取栈帧信息栈帧信息里包含goroutine id。你可以看看上面panic时候的贴图goroutine id明明白白地显示在那里。runtime.Stack方法可以获取当前的goroutine信息第二个参数为true会输出所有的goroutine信息信息的格式如下
```
goroutine 1 [running]:
main.main()
....../main.go:19 +0xb1
```
第一行格式为goroutine xxx其中xxx就是goroutine id你只要解析出这个id即可。解析的方法可以采用下面的代码
```
func GoID() int {
var buf [64]byte
n := runtime.Stack(buf[:], false)
// 得到id字符串
idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), &quot;goroutine &quot;))[0]
id, err := strconv.Atoi(idField)
if err != nil {
panic(fmt.Sprintf(&quot;cannot get goroutine id: %v&quot;, err))
}
return id
}
```
了解了简单方式接下来我们来看hacker的方式这也是我们方案一采取的方式。
首先我们获取运行时的g指针反解出对应的g的结构。每个运行的goroutine结构的g指针保存在当前goroutine的一个叫做TLS对象中。
第一步我们先获取到TLS对象
第二步再从TLS中获取goroutine结构的g指针
第三步再从g指针中取出goroutine id。
需要注意的是不同Go版本的goroutine的结构可能不同所以需要根据Go的[不同版本](https://github.com/golang/go/blob/89f687d6dbc11613f715d1644b4983905293dd33/src/runtime/runtime2.go#L412)进行调整。当然了如果想要搞清楚各个版本的goroutine结构差异所涉及的内容又过于底层而且复杂学习成本太高。怎么办呢我们可以重点关注一些库。我们没有必要重复发明轮子直接使用第三方的库来获取goroutine id就可以了。
好消息是现在已经有很多成熟的方法了可以支持多个Go版本的goroutine id给你推荐一个常用的库[petermattis/goid](https://github.com/petermattis/goid)。
知道了如何获取goroutine id接下来就是最后的关键一步了我们实现一个可以使用的可重入锁
```
// RecursiveMutex 包装一个Mutex,实现可重入
type RecursiveMutex struct {
sync.Mutex
owner int64 // 当前持有锁的goroutine id
recursion int32 // 这个goroutine 重入的次数
}
func (m *RecursiveMutex) Lock() {
gid := goid.Get()
// 如果当前持有锁的goroutine就是这次调用的goroutine,说明是重入
if atomic.LoadInt64(&amp;m.owner) == gid {
m.recursion++
return
}
m.Mutex.Lock()
// 获得锁的goroutine第一次调用记录下它的goroutine id,调用次数加1
atomic.StoreInt64(&amp;m.owner, gid)
m.recursion = 1
}
func (m *RecursiveMutex) Unlock() {
gid := goid.Get()
// 非持有锁的goroutine尝试释放锁错误的使用
if atomic.LoadInt64(&amp;m.owner) != gid {
panic(fmt.Sprintf(&quot;wrong the owner(%d): %d!&quot;, m.owner, gid))
}
// 调用次数减1
m.recursion--
if m.recursion != 0 { // 如果这个goroutine还没有完全释放则直接返回
return
}
// 此goroutine最后一次调用需要释放锁
atomic.StoreInt64(&amp;m.owner, -1)
m.Mutex.Unlock()
}
```
上面这段代码你可以拿来即用。我们一起来看下这个实现真是非常巧妙它相当于给Mutex打一个补丁解决了记录锁的持有者的问题。可以看到我们用owner字段记录当前锁的拥有者goroutine的idrecursion 是辅助字段,用于记录重入的次数。
有一点我要提醒你一句尽管拥有者可以多次调用Lock但是也必须调用相同次数的Unlock这样才能把锁释放掉。这是一个合理的设计可以保证Lock和Unlock一一对应。
**方案二****token**
方案一是用goroutine id做goroutine的标识我们也可以让goroutine自己来提供标识。不管怎么说Go开发者不期望你利用goroutine id做一些不确定的东西所以他们没有暴露获取goroutine id的方法。
下面的代码是第二种方案。调用者自己提供一个token获取锁的时候把这个token传入释放锁的时候也需要把这个token传入。通过用户传入的token替换方案一中goroutine id其它逻辑和方案一一致。
```
// Token方式的递归锁
type TokenRecursiveMutex struct {
sync.Mutex
token int64
recursion int32
}
// 请求锁需要传入token
func (m *TokenRecursiveMutex) Lock(token int64) {
if atomic.LoadInt64(&amp;m.token) == token { //如果传入的token和持有锁的token一致说明是递归调用
m.recursion++
return
}
m.Mutex.Lock() // 传入的token不一致说明不是递归调用
// 抢到锁之后记录这个token
atomic.StoreInt64(&amp;m.token, token)
m.recursion = 1
}
// 释放锁
func (m *TokenRecursiveMutex) Unlock(token int64) {
if atomic.LoadInt64(&amp;m.token) != token { // 释放其它token持有的锁
panic(fmt.Sprintf(&quot;wrong the owner(%d): %d!&quot;, m.token, token))
}
m.recursion-- // 当前持有这个锁的token释放锁
if m.recursion != 0 { // 还没有回退到最初的递归调用
return
}
atomic.StoreInt64(&amp;m.token, 0) // 没有递归调用了,释放锁
m.Mutex.Unlock()
}
```
## 死锁
接下来,我们来看第四种错误场景:死锁。
我先解释下什么是死锁。两个或两个以上的进程或线程goroutine在执行过程中因争夺共享资源而处于一种互相等待的状态如果没有外部干涉它们都将无法推进下去此时我们称系统处于死锁状态或系统产生了死锁。
我们来分析一下死锁产生的必要条件。如果你想避免死锁,只要破坏这四个条件中的一个或者几个,就可以了。
1. **互斥** 至少一个资源是被排他性独享的,其他线程必须处于等待状态,直到资源被释放。
1. **持有和等待**goroutine持有一个资源并且还在请求其它goroutine持有的资源也就是咱们常说的“吃着碗里看着锅里”的意思。
1. **不可剥夺**资源只能由持有它的goroutine来释放。
1. **环路等待**一般来说存在一组等待进程P={P1P2PN}P1等待P2持有的资源P2等待P3持有的资源依此类推最后是PN等待P1持有的资源这就形成了一个环路等待的死结。
<img src="https://static001.geekbang.org/resource/image/4a/d5/4ace1eecf856ef80607yyb6f7a45abd5.jpg" alt="">
你看,死锁问题还真是挺有意思的,所以有很多人研究这个事儿。一个经典的死锁问题就是[哲学家就餐问题](https://zh.wikipedia.org/wiki/%E5%93%B2%E5%AD%A6%E5%AE%B6%E5%B0%B1%E9%A4%90%E9%97%AE%E9%A2%98),我不做介绍了,你可以点击链接进一步了解。其实,死锁问题在现实生活中也比比皆是。
举个例子。有一次我去派出所开证明派出所要求物业先证明我是本物业的业主但是物业要我提供派出所的证明才能给我开物业证明结果就陷入了死锁状态。你可以把派出所和物业看成两个goroutine派出所证明和物业证明是两个资源双方都持有自己的资源而要求对方的资源而且自己的资源自己持有不可剥夺。
这是一个最简单的只有两个goroutine相互等待的死锁的例子转化成代码如下
```
package main
import (
&quot;fmt&quot;
&quot;sync&quot;
&quot;time&quot;
)
func main() {
// 派出所证明
var psCertificate sync.Mutex
// 物业证明
var propertyCertificate sync.Mutex
var wg sync.WaitGroup
wg.Add(2) // 需要派出所和物业都处理
// 派出所处理goroutine
go func() {
defer wg.Done() // 派出所处理完成
psCertificate.Lock()
defer psCertificate.Unlock()
// 检查材料
time.Sleep(5 * time.Second)
// 请求物业的证明
propertyCertificate.Lock()
propertyCertificate.Unlock()
}()
// 物业处理goroutine
go func() {
defer wg.Done() // 物业处理完成
propertyCertificate.Lock()
defer propertyCertificate.Unlock()
// 检查材料
time.Sleep(5 * time.Second)
// 请求派出所的证明
psCertificate.Lock()
psCertificate.Unlock()
}()
wg.Wait()
fmt.Println(&quot;成功完成&quot;)
}
```
这个程序没有办法运行成功,因为派出所的处理和物业的处理是一个环路等待的死结。
<img src="https://static001.geekbang.org/resource/image/3e/f4/3ea07805dea9d33d5a5c1a8244a7ccf4.png" alt="">
Go运行时有死锁探测的功能能够检查出是否出现了死锁的情况如果出现了这个时候你就需要调整策略来处理了。
你可以引入一个第三方的锁,大家都依赖这个锁进行业务处理,比如现在政府推行的一站式政务服务中心。或者是解决持有等待问题,物业不需要看到派出所的证明才给开物业证明,等等。
好了到这里我给你讲了使用Mutex常见的4类问题。你是不是觉得哎呀这几类问题也太不应该了吧真的会有人犯这么基础的错误吗
还真是有。虽然Mutex使用起来很简单但是仍然可能出现使用错误的问题。而且就连一些经验丰富的开发人员也会出现一些Mutex使用的问题。接下来我就带你围观几个非常流行的Go开发项目看看这些错误是怎么产生和修复的。
# 流行的Go开发项目踩坑记
## Docker
Docker 容器是一个开源的应用容器引擎开发者可以以统一的方式把他们的应用和依赖包打包到一个可移植的容器中然后发布到任何安装了docker引擎的服务器上。
Docker是使用Go开发的也算是Go的一个杀手级产品了它的Mutex相关的Bug也不少我们来看几个典型的Bug。
### issue 36114
Docker的[issue 36114](https://github.com/moby/moby/pull/36114/files) 是一个死锁问题。
原因在于hotAddVHDsAtStart方法执行的时候执行了加锁svm操作。但是在其中调用hotRemoveVHDsAtStart方法时这个hotRemoveVHDsAtStart方法也是要加锁svm的。很不幸Go标准库中的Mutex是不可重入的所以代码执行到这里就出现了死锁的现象。
<img src="https://static001.geekbang.org/resource/image/da/8c/dac838666ee09c98dd9ac5db479aae8c.png" alt="">
针对这个问题解决办法就是再提供一个不需要锁的hotRemoveVHDsNoLock方法避免Mutex的重入。
### issue 34881
[issue 34881](https://github.com/moby/moby/pull/34881/files)本来是修复Docker的一个简单问题如果节点在初始化的时候发现自己不是一个swarm mananger就快速返回这个修复就几行代码你看出问题来了吗
<img src="https://static001.geekbang.org/resource/image/bf/34/bf78904a947d4228dc006fff94f97334.png" alt="">
在第34行节点发现不满足条件就返回了但是c.mu这个锁没有释放为什么会出现这个问题呢其实这是在重构或者添加新功能的时候经常犯的一个错误因为不太了解上下文或者是没有仔细看函数的逻辑从而导致锁没有被释放。现在的Docker当然已经没有这个问题了。
<img src="https://static001.geekbang.org/resource/image/1c/f1/1c7a5f8e12f642d82aac8045c046c6f1.png" alt="">
这样的issue还有很多我就不一一列举了。我给你推荐几个关于Mutex的issue或者pull request你可以关注一下分别是36840、37583、35517、35482、33305、32826、30696、29554、29191、28912、26507等。
## Kubernetes
### issue 72361
issue 72361 增加Mutex为了保护资源。这是为了解决data race问题而做的一个修复修复方法也很简单使用互斥锁即可这也是我们解决data race时常用的方法。
<img src="https://static001.geekbang.org/resource/image/21/31/2171a7a0de179904ceba463026ee7231.png" alt="">
### issue 45192
[issue 45192](https://github.com/kubernetes/kubernetes/pull/45192/files)也是一个返回时忘记Unlock的典型例子和 docker issue 34881犯的错误都是一样的。
两大知名项目的开发者都犯了这个错误所以你就可以知道引入这个Bug是多么容易记住晁老师这句话**保证Lock/Unlock成对出现尽可能采用defer mutex.Unlock的方式把它们成对、紧凑地写在一起**。
<img src="https://static001.geekbang.org/resource/image/ba/61/ba0f7671fd64951a47365e46ab68db61.png" alt="">
除了这些我也建议你关注一下其它的Mutex相关的issue比如 71617、70605等。
## **gRPC**
gRPC是Google发起的一个开源远程过程调用 Remote procedure call系统。该系统基于 HTTP/2 协议传输使用Protocol Buffers 作为接口描述语言。它提供Go语言的实现。
即使是Google官方出品的系统也有一些Mutex的issue。
### issue 795
[issue 795](https://github.com/grpc/grpc-go/pull/795)是一个你可能想不到的bug那就是将Unlock误写成了Lock。
<img src="https://static001.geekbang.org/resource/image/b6/f0/b6e97a6938586e95c3427e693eb712f0.png" alt="">
关于这个项目还有一些其他的为了保护共享资源而添加Mutex的issue比如1318、2074、2542等。
## etcd
etcd是一个非常知名的分布式一致性的 key-value 存储技术, 被用来做配置共享和服务发现。
## issue 10419
[issue 10419](https://github.com/etcd-io/etcd/pull/10419/files)是一个锁重入导致的问题。 Store方法内对请求了锁而调用的Compact的方法内又请求了锁这个时候会导致死锁一直等待解决办法就是提供不需要加锁的Compact方法。
<img src="https://static001.geekbang.org/resource/image/5f/7f/5fed22fb735c107d130477562c28477f.png" alt="">
# 总结
这节课我们学习了Mutex的一些易错场景而且我们还分析了流行的Go开源项目的错误我也给你分享了我自己在开发中的经验总结。需要强调的是**手误和重入导致的死锁是最常见的使用Mutex的Bug**。
Go死锁探测工具只能探测整个程序是否因为死锁而冻结了不能检测出一组goroutine死锁导致的某一块业务冻结的情况。你还可以通过Go运行时自带的死锁检测工具或者是第三方的工具比如[go-deadlock](https://github.com/sasha-s/go-deadlock)、[go-tools](https://github.com/dominikh/go-tools))进行检查,这样可以尽早发现一些死锁的问题。不过,有些时候,死锁在某些特定情况下才会被触发,所以,如果你的测试或者短时间的运行没问题,不代表程序一定不会有死锁问题。
并发程序最难跟踪调试的就是很难重现因为并发问题不是按照我们指定的顺序执行的由于计算机调度的问题和事件触发的时机不同死锁的Bug可能会在极端的情况下出现。通过搜索日志、查看日志我们能够知道程序有异常了比如某个流程一直没有结束。这个时候可以通过Go pprof工具分析它提供了一个block profiler监控阻塞的goroutine。除此之外我们还可以查看全部的goroutine的堆栈信息通过它你可以查看阻塞的groutine究竟阻塞在哪一行哪一个对象上了。
# 思考题
查找知名的数据库系统TiDB的issue看看有没有Mutex相关的issue看看它们都是哪些相关的Bug。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,249 @@
<audio id="audio" title="04 Mutex骇客编程如何拓展额外功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/1e/c4ebe13731bcce419d2c1d9290e9611e.mp3"></audio>
你好,我是鸟窝。
前面三讲我们学习了互斥锁Mutex的基本用法、实现原理以及易错场景可以说是涵盖了互斥锁的方方面面。如果你能熟练掌握这些内容那么在大多数的开发场景中你都可以得心应手。
但是,在一些特定的场景中,这些基础功能是不足以应对的。这个时候,我们就需要开发一些扩展功能了。我来举几个例子。
比如说我们知道如果互斥锁被某个goroutine获取了而且还没有释放那么其他请求这把锁的goroutine就会阻塞等待直到有机会获得这把锁。有时候阻塞并不是一个很好的主意比如你请求锁更新一个计数器如果获取不到锁的话没必要等待大不了这次不更新我下次更新就好了如果阻塞的话会导致业务处理能力的下降。
再比如如果我们要监控锁的竞争情况一个监控指标就是等待这把锁的goroutine数量。我们可以把这个指标推送到时间序列数据库中再通过一些监控系统比如Grafana展示出来。要知道**锁是性能下降的“罪魁祸首”之一所以有效地降低锁的竞争就能够很好地提高性能。因此监控关键互斥锁上等待的goroutine的数量是我们分析锁竞争的激烈程度的一个重要指标**。
实际上不论是不希望锁的goroutine继续等待还是想监控锁我们都可以基于标准库中Mutex的实现通过Hacker的方式为Mutex增加一些额外的功能。这节课我就来教你实现几个扩展功能包括实现TryLock获取等待者的数量等指标以及实现一个线程安全的队列。
# TryLock
我们可以为Mutex添加一个TryLock的方法也就是尝试获取排外锁。
这个方法具体是什么意思呢我来解释一下这里的逻辑。当一个goroutine调用这个TryLock方法请求锁的时候如果这把锁没有被其他goroutine所持有那么这个goroutine就持有了这把锁并返回true如果这把锁已经被其他goroutine所持有或者是正在准备交给某个被唤醒的goroutine那么这个请求锁的goroutine就直接返回false不会阻塞在方法调用上。
如下图所示如果Mutex已经被一个goroutine持有调用Lock的goroutine阻塞排队等待调用TryLock的goroutine直接得到一个false返回。
<img src="https://static001.geekbang.org/resource/image/e7/65/e7787d959b60d66cc3a46ee921098865.jpg" alt="">
在实际开发中如果要更新配置数据我们通常需要加锁这样可以避免同时有多个goroutine并发修改数据。有的时候我们也会使用TryLock。这样一来当某个goroutine想要更改配置数据时如果发现已经有goroutine在更改了其他的goroutine调用TryLock返回了false这个goroutine就会放弃更改。
很多语言比如Java都为锁提供了TryLock的方法但是Go官方[issue 6123](https://github.com/golang/go/issues/6123)有一个讨论后来一些issue中也提到过标准库的Mutex不会添加TryLock方法。虽然通过Go的Channel我们也可以实现TryLock的功能但是基于Channel的实现我们会放在Channel那一讲中去介绍这一次我们还是基于Mutex去实现毕竟大部分的程序员还是熟悉传统的同步原语而且传统的同步原语也不容易出错。所以这节课还是希望带你掌握基于Mutex实现的方法。
那怎么实现一个扩展TryLock方法的Mutex呢我们直接来看代码。
```
// 复制Mutex定义的常量
const (
mutexLocked = 1 &lt;&lt; iota // 加锁标识位置
mutexWoken // 唤醒标识位置
mutexStarving // 锁饥饿标识位置
mutexWaiterShift = iota // 标识waiter的起始bit位置
)
// 扩展一个Mutex结构
type Mutex struct {
sync.Mutex
}
// 尝试获取锁
func (m *Mutex) TryLock() bool {
// 如果能成功抢到锁
if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)), 0, mutexLocked) {
return true
}
// 如果处于唤醒、加锁或者饥饿状态这次请求就不参与竞争了返回false
old := atomic.LoadInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)))
if old&amp;(mutexLocked|mutexStarving|mutexWoken) != 0 {
return false
}
// 尝试在竞争的状态下请求锁
new := old | mutexLocked
return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)), old, new)
}
```
第17行是一个fast path如果幸运没有其他goroutine争这把锁那么这把锁就会被这个请求的goroutine获取直接返回。
如果锁已经被其他goroutine所持有或者被其他唤醒的goroutine准备持有那么就直接返回false不再请求代码逻辑在第23行。
如果没有被持有也没有其它唤醒的goroutine来竞争锁锁也不处于饥饿状态就尝试获取这把锁第29行不论是否成功都将结果返回。因为这个时候可能还有其他的goroutine也在竞争这把锁所以不能保证成功获取这把锁。
我们可以写一个简单的测试程序来测试我们的TryLock的机制是否工作。
这个测试程序的工作机制是这样子的程序运行时会启动一个goroutine持有这把我们自己实现的锁经过随机的时间才释放。主goroutine会尝试获取这把锁。如果前一个goroutine一秒内释放了这把锁那么主goroutine就有可能获取到这把锁了输出“got the lock”否则没有获取到也不会被阻塞会直接输出“`can't get the lock`”。
```
func try() {
var mu Mutex
go func() { // 启动一个goroutine持有一段时间的锁
mu.Lock()
time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
mu.Unlock()
}()
time.Sleep(time.Second)
ok := mu.TryLock() // 尝试获取到锁
if ok { // 获取成功
fmt.Println(&quot;got the lock&quot;)
// do something
mu.Unlock()
return
}
// 没有获取到
fmt.Println(&quot;can't get the lock&quot;)
}
```
# 获取等待者的数量等指标
接下来,我想和你聊聊怎么获取等待者数量等指标。
第二讲中我们已经学习了Mutex的结构。先来回顾一下Mutex的数据结构如下面的代码所示。它包含两个字段state和sema。前四个字节int32就是state字段。
```
type Mutex struct {
state int32
sema uint32
}
```
Mutex结构中的state字段有很多个含义通过state字段你可以知道锁是否已经被某个goroutine持有、当前是否处于饥饿状态、是否有等待的goroutine被唤醒、等待者的数量等信息。但是state这个字段并没有暴露出来所以我们需要想办法获取到这个字段并进行解析。
怎么获取未暴露的字段呢很简单我们可以通过unsafe的方式实现。我来举一个例子你一看就明白了。
```
const (
mutexLocked = 1 &lt;&lt; iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
)
type Mutex struct {
sync.Mutex
}
func (m *Mutex) Count() int {
// 获取state字段的值
v := atomic.LoadInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)))
v = v &gt;&gt; mutexWaiterShift //得到等待者的数值
v = v + (v &amp; mutexLocked) //再加上锁持有者的数量0或者1
return int(v)
}
```
这个例子的第14行通过unsafe操作我们可以得到state字段的值。第15行我们右移三位这里的常量mutexWaiterShift的值为3就得到了当前等待者的数量。如果当前的锁已经被其他goroutine持有那么我们就稍微调整一下这个值加上一个1第16行你基本上可以把它看作是当前持有和等待这把锁的goroutine的总数。
state这个字段的第一位是用来标记锁是否被持有第二位用来标记是否已经唤醒了一个等待者第三位标记锁是否处于饥饿状态通过分析这个state字段我们就可以得到这些状态信息。我们可以为这些状态提供查询的方法这样就可以实时地知道锁的状态了。
```
// 锁是否被持有
func (m *Mutex) IsLocked() bool {
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)))
return state&amp;mutexLocked == mutexLocked
}
// 是否有等待者被唤醒
func (m *Mutex) IsWoken() bool {
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)))
return state&amp;mutexWoken == mutexWoken
}
// 锁是否处于饥饿状态
func (m *Mutex) IsStarving() bool {
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&amp;m.Mutex)))
return state&amp;mutexStarving == mutexStarving
}
```
我们可以写一个程序测试一下比如在1000个goroutine并发访问的情况下我们可以把锁的状态信息输出出来
```
func count() {
var mu Mutex
for i := 0; i &lt; 1000; i++ { // 启动1000个goroutine
go func() {
mu.Lock()
time.Sleep(time.Second)
mu.Unlock()
}()
}
time.Sleep(time.Second)
// 输出锁的信息
fmt.Printf(&quot;waitings: %d, isLocked: %t, woken: %t, starving: %t\n&quot;, mu.Count(), mu.IsLocked(), mu.IsWoken(), mu.IsStarving())
}
```
有一点你需要注意一下在获取state字段的时候并没有通过Lock获取这把锁所以获取的这个state的值是一个瞬态的值可能在你解析出这个字段之后锁的状态已经发生了变化。不过没关系因为你查看的就是调用的那一时刻的锁的状态。
# 使用Mutex实现一个线程安全的队列
最后我们来讨论一下如何使用Mutex实现一个线程安全的队列。
为什么要讨论这个话题呢因为Mutex经常会和其他非线程安全对于Go来说我们其实指的是goroutine安全的数据结构一起组合成一个线程安全的数据结构。新数据结构的业务逻辑由原来的数据结构提供而**Mutex提供了锁的机制<strong><strong>来**</strong>保证线程安全</strong>
比如队列我们可以通过Slice来实现但是通过Slice实现的队列不是线程安全的出队Dequeue和入队Enqueue会有data race的问题。这个时候Mutex就要隆重出场了通过它我们可以在出队和入队的时候加上锁的保护。
```
type SliceQueue struct {
data []interface{}
mu sync.Mutex
}
func NewSliceQueue(n int) (q *SliceQueue) {
return &amp;SliceQueue{data: make([]interface{}, 0, n)}
}
// Enqueue 把值放在队尾
func (q *SliceQueue) Enqueue(v interface{}) {
q.mu.Lock()
q.data = append(q.data, v)
q.mu.Unlock()
}
// Dequeue 移去队头并返回
func (q *SliceQueue) Dequeue() interface{} {
q.mu.Lock()
if len(q.data) == 0 {
q.mu.Unlock()
return nil
}
v := q.data[0]
q.data = q.data[1:]
q.mu.Unlock()
return v
}
```
因为标准库中没有线程安全的队列数据结构的实现所以你可以通过Mutex实现一个简单的队列。通过Mutex我们就可以为一个非线程安全的data interface{}实现线程安全的访问。
# 总结
好了,我们来做个总结。
Mutex是package sync的基石其他的一些同步原语也是基于它实现的所以我们“隆重”地用了四讲来深度学习它。学到后面你一定能感受到多花些时间来完全掌握Mutex是值得的。
今天这一讲我和你分享了几个Mutex的拓展功能这些方法是不是给你带来了一种“骇客”的编程体验呢通过Hacker的方式我们真的可以让Mutex变得更强大。
我们学习了基于Mutex实现TryLock通过unsafe的方式读取到Mutex内部的state字段这样我们就解决了开篇列举的问题一是不希望锁的goroutine继续等待一是想监控锁。
另外使用Mutex组合成更丰富的数据结构是我们常见的场景今天我们就实现了一个线程安全的队列未来我们还会讲到实现线程安全的map对象。
到这里Mutex我们就系统学习完了最后给你总结了一张Mutex知识地图帮你复习一下。
<img src="https://static001.geekbang.org/resource/image/5a/0b/5ayy6cd9ec9fe0bcc13113302056ac0b.jpg" alt="">
# 思考题
你可以为Mutex获取锁时加上Timeout机制吗会有什么问题吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,362 @@
<audio id="audio" title="05 RWMutex读写锁的实现原理及避坑指南" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/df/d29888d8e25ddb6c619a39950cff21df.mp3"></audio>
你好,我是鸟窝。
在前面的四节课中我们学习了第一个同步原语即Mutex我们使用它来保证读写共享资源的安全性。不管是读还是写我们都通过Mutex来保证只有一个goroutine访问共享资源这在某些情况下有点“浪费”。比如说在写少读多的情况下即使一段时间内没有写操作大量并发的读访问也不得不在Mutex的保护下变成了串行访问这个时候使用Mutex对性能的影响就比较大。
怎么办呢?你是不是已经有思路了,对,就是区分读写操作。
我来具体解释一下。如果某个读操作的goroutine持有了锁在这种情况下其它读操作的goroutine就不必一直傻傻地等待了而是可以并发地访问共享变量这样我们就可以**将串行的读变成并行读**提高读操作的性能。当写操作的goroutine持有锁的时候它就是一个排外锁其它的写操作和读操作的goroutine需要阻塞等待持有这个锁的goroutine释放锁。
这一类并发读写问题叫作[readers-writers问题](https://en.wikipedia.org/wiki/Readers%E2%80%93writers_problem),意思就是,同时可能有多个读或者多个写,但是只要有一个线程在执行写操作,其它的线程都不能执行读写操作。
**Go标准库中的RWMutex读写锁就是用来解决这类readers-writers问题的**。所以这节课我们就一起来学习RWMutex。我会给你介绍读写锁的使用场景、实现原理以及容易掉入的坑你一定要记住这些陷阱避免在实际的开发中犯相同的错误。
# 什么是RWMutex
我先简单解释一下读写锁RWMutex。标准库中的RWMutex是一个 reader/writer 互斥锁。RWMutex在某一时刻只能由任意数量的reader持有或者是只被单个的writer持有。
RWMutex的方法也很少总共有5个。
- **Lock/Unlock写操作时调用的方法**。如果锁已经被reader或者writer持有那么Lock方法会一直阻塞直到能获取到锁Unlock则是配对的释放锁的方法。
- **RLock/RUnlock读操作时调用的方法**。如果锁已经被writer持有的话RLock方法会一直阻塞直到能获取到锁否则就直接返回而RUnlock是reader释放锁的方法。
- **RLocker**这个方法的作用是为读操作返回一个Locker接口的对象。它的Lock方法会调用RWMutex的RLock方法它的Unlock方法会调用RWMutex的RUnlock方法。
RWMutex的零值是未加锁的状态所以当你使用RWMutex的时候无论是声明变量还是嵌入到其它struct中都不必显式地初始化。
我以计数器为例来说明一下如何使用RWMutex保护共享资源。计数器的**count++<strong>操作是**写</strong>操作而获取count的值是**读**操作这个场景非常适合读写锁因为读操作可以并行执行写操作时只允许一个线程执行这正是readers-writers问题。
在这个例子中使用10个goroutine进行读操作每读取一次sleep 1毫秒同时还有一个gorotine进行写操作每一秒写一次这是一个 **1** writer-**n** reader 的读写场景,而且写操作还不是很频繁(一秒一次):
```
func main() {
var counter Counter
for i := 0; i &lt; 10; i++ { // 10个reader
go func() {
for {
counter.Count() // 计数器读操作
time.Sleep(time.Millisecond)
}
}()
}
for { // 一个writer
counter.Incr() // 计数器写操作
time.Sleep(time.Second)
}
}
// 一个线程安全的计数器
type Counter struct {
mu sync.RWMutex
count uint64
}
// 使用写锁保护
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// 使用读锁保护
func (c *Counter) Count() uint64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.count
}
```
可以看到Incr方法会修改计数器的值是一个写操作我们使用Lock/Unlock进行保护。Count方法会读取当前计数器的值是一个读操作我们使用RLock/RUnlock方法进行保护。
Incr方法每秒才调用一次所以writer竞争锁的频次是比较低的而10个goroutine每毫秒都要执行一次查询通过读写锁可以极大提升计数器的性能因为在读取的时候可以并发进行。如果使用Mutex性能就不会像读写锁这么好。因为多个reader并发读的时候使用互斥锁导致了reader要排队读的情况没有RWMutex并发读的性能好。
**如果你遇到可以明确区分reader和writer goroutine的场景且有大量的并发读、少量的并发写并且有强烈的性能需求你就可以考虑使用读写锁RWMutex替换Mutex。**
在实际使用RWMutex的时候如果我们在struct中使用RWMutex保护某个字段一般会把它和这个字段放在一起用来指示两个字段是一组字段。除此之外我们还可以采用匿名字段的方式嵌入struct这样在使用这个struct时我们就可以直接调用Lock/Unlock、RLock/RUnlock方法了这和我们前面在[01讲](https://time.geekbang.org/column/article/294905)中介绍Mutex的使用方法很类似你可以回去复习一下。
# RWMutex的实现原理
RWMutex是很常见的并发原语很多编程语言的库都提供了类似的并发类型。RWMutex一般都是基于互斥锁、条件变量condition variables或者信号量semaphores等并发原语来实现。**Go标准库中的RWMutex是基于Mutex实现的。**
readers-writers问题一般有三类基于对读和写操作的优先级读写锁的设计和实现也分成三类。
- **Read-preferring**:读优先的设计可以提供很高的并发性,但是,在竞争激烈的情况下可能会导致写饥饿。这是因为,如果有大量的读,这种设计会导致只有所有的读都释放了锁之后,写才可能获取到锁。
- **Write-preferring**写优先的设计意味着如果已经有一个writer在等待请求锁的话它会阻止新来的请求锁的reader获取到锁所以优先保障writer。当然如果有一些reader已经请求了锁的话新请求的writer也会等待已经存在的reader都释放锁之后才能获取。所以写优先级设计中的优先权是针对新来的请求而言的。这种设计主要避免了writer的饥饿问题。
- **不指定优先级**这种设计比较简单不区分reader和writer优先级某些场景下这种不指定优先级的设计反而更有效因为第一类优先级会导致写饥饿第二类优先级可能会导致读饥饿这种不指定优先级的访问不再区分读写大家都是同一个优先级解决了饥饿的问题。
**Go标准库中的RWMutex设计是Write-preferring方案。一个正在阻塞的Lock调用会排除新的reader请求到锁。**
RWMutex包含一个Mutex以及四个辅助字段writerSem、readerSem、readerCount和readerWait
```
type RWMutex struct {
w Mutex // 互斥锁解决多个writer的竞争
writerSem uint32 // writer信号量
readerSem uint32 // reader信号量
readerCount int32 // reader的数量
readerWait int32 // writer等待完成的reader的数量
}
const rwmutexMaxReaders = 1 &lt;&lt; 30
```
我来简单解释一下这几个字段。
- 字段w为writer的竞争锁而设计
- 字段readerCount记录当前reader的数量以及是否有writer竞争锁
- readerWait记录writer请求锁时需要等待read完成的reader的数量
- writerSem 和readerSem都是为了阻塞设计的信号量。
这里的常量rwmutexMaxReaders定义了最大的reader数量。
好了知道了RWMutex的设计方案和具体字段下面我来解释一下具体的方法实现。
## RLock/RUnlock的实现
首先我们看一下移除了race等无关紧要的代码后的RLock和RUnlock方法
```
func (rw *RWMutex) RLock() {
if atomic.AddInt32(&amp;rw.readerCount, 1) &lt; 0 {
// rw.readerCount是负值的时候意味着此时有writer等待请求锁因为writer优先级高所以把后来的reader阻塞休眠
runtime_SemacquireMutex(&amp;rw.readerSem, false, 0)
}
}
func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&amp;rw.readerCount, -1); r &lt; 0 {
rw.rUnlockSlow(r) // 有等待的writer
}
}
func (rw *RWMutex) rUnlockSlow(r int32) {
if atomic.AddInt32(&amp;rw.readerWait, -1) == 0 {
// 最后一个reader了writer终于有机会获得锁了
runtime_Semrelease(&amp;rw.writerSem, false, 1)
}
}
```
第2行是对reader计数加1。你可能比较困惑的是readerCount怎么还可能为负数呢其实这是因为readerCount这个字段有双重含义
- 没有writer竞争或持有锁时readerCount和我们正常理解的reader的计数是一样的
- 但是如果有writer竞争锁或者持有锁时那么readerCount不仅仅承担着reader的计数功能还能够标识当前是否有writer竞争或持有锁在这种情况下请求锁的reader的处理进入第4行阻塞等待锁的释放。
调用RUnlock的时候我们需要将Reader的计数减去1第8行因为reader的数量减少了一个。但是第8行的AddInt32的返回值还有另外一个含义。如果它是负值就表示当前有writer竞争锁在这种情况下还会调用rUnlockSlow方法检查是不是reader都释放读锁了如果读锁都释放了那么可以唤醒请求写锁的writer了。
当一个或者多个reader持有锁的时候竞争锁的writer会等待这些reader释放完才可能持有这把锁。打个比方在房地产行业中有条规矩叫做“**买卖不破租赁**”意思是说就算房东把房子卖了新业主也不能把当前的租户赶走而是要等到租约结束后才能接管房子。这和RWMutex的设计是一样的。当writer请求锁的时候是无法改变既有的reader持有锁的现实的也不会强制这些reader释放锁它的优先权只是限定后来的reader不要和它抢。
所以rUnlockSlow将持有锁的reader计数减少1的时候会检查既有的reader是不是都已经释放了锁如果都释放了锁就会唤醒writer让writer持有锁。
## Lock
RWMutex是一个多writer多reader的读写锁所以同时可能有多个writer和reader。那么为了避免writer之间的竞争RWMutex就会使用一个Mutex来保证writer的互斥。
一旦一个writer获得了内部的互斥锁就会反转readerCount字段把它从原来的正整数readerCount(&gt;=0)修改为负数readerCount-rwmutexMaxReaders让这个字段保持两个含义既保存了reader的数量又表示当前有writer
我们来看下下面的代码。第5行还会记录当前活跃的reader数量所谓活跃的reader就是指持有读锁还没有释放的那些reader。
```
func (rw *RWMutex) Lock() {
// 首先解决其他writer竞争问题
rw.w.Lock()
// 反转readerCount告诉reader有writer竞争锁
r := atomic.AddInt32(&amp;rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 如果当前有reader持有锁那么需要等待
if r != 0 &amp;&amp; atomic.AddInt32(&amp;rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&amp;rw.writerSem, false, 0)
}
}
```
如果readerCount不是0就说明当前有持有读锁的readerRWMutex需要把这个当前readerCount赋值给readerWait字段保存下来第7行 同时这个writer进入阻塞等待状态第8行
每当一个reader释放读锁的时候调用RUnlock方法时readerWait字段就减1直到所有的活跃的reader都释放了读锁才会唤醒这个writer。
## Unlock
当一个writer释放锁的时候它会再次反转readerCount字段。可以肯定的是因为当前锁由writer持有所以readerCount字段是反转过的并且减去了rwmutexMaxReaders这个常数变成了负数。所以这里的反转方法就是给它增加rwmutexMaxReaders这个常数值。
既然writer要释放锁了那么就需要唤醒之后新来的reader不必再阻塞它们了让它们开开心心地继续执行就好了。
在RWMutex的Unlock返回之前需要把内部的互斥锁释放。释放完毕后其他的writer才可以继续竞争这把锁。
```
func (rw *RWMutex) Unlock() {
// 告诉reader没有活跃的writer了
r := atomic.AddInt32(&amp;rw.readerCount, rwmutexMaxReaders)
// 唤醒阻塞的reader们
for i := 0; i &lt; int(r); i++ {
runtime_Semrelease(&amp;rw.readerSem, false, 0)
}
// 释放内部的互斥锁
rw.w.Unlock()
}
```
在这段代码中我删除了race的处理和异常情况的检查总体看来还是比较简单的。这里有几个重点我要再提醒你一下。首先你要理解readerCount这个字段的含义以及反转方式。其次你还要注意字段的更改和内部互斥锁的顺序关系。在Lock方法中是先获取内部互斥锁才会修改的其他字段而在Unlock方法中是先修改的其他字段才会释放内部互斥锁这样才能保证字段的修改也受到互斥锁的保护。
好了到这里我们就完整学习了RWMutex的概念和实现原理。RWMutex的应用场景非常明确就是解决readers-writers问题。学完了今天的内容之后当你遇到这类问题时要优先想到RWMutex。另外Go并发原语代码实现的质量都很高非常精炼和高效所以你可以通过看它们的实现原理学习一些编程的技巧。当然还有非常重要的一点就是要知道reader或者writer请求锁的时候既有的reader/writer和后续请求锁的reader/writer之间的释放锁/请求锁)顺序关系。
有个很有意思的事儿就是官方的文档对RWMutex介绍是错误的或者说是[不明确的](https://github.com/golang/go/issues/41555)在下一个版本Go 1.16官方会更改对RWMutex的介绍具体是这样的
>
A RWMutex is a reader/writer mutual exclusion lock.
>
The lock can be held by any number of readers or a single writer, and
>
a blocked writer also blocks new readers from acquiring the lock.
这个描述是相当精确的它指出了RWMutex可以被谁持有以及writer比后续的reader有获取锁的优先级。
虽然RWMutex暴露的API也很简单使用起来也没有复杂的逻辑但是和Mutex一样在实际使用的时候也会很容易踩到一些坑。接下来我给你重点介绍3个常见的踩坑点。
# RWMutex的3个踩坑点
## 坑点1不可复制
前面刚刚说过RWMutex是由一个互斥锁和四个辅助字段组成的。我们很容易想到互斥锁是不可复制的再加上四个有状态的字段RWMutex就更加不能复制使用了。
不能复制的原因和互斥锁一样。一旦读写锁被使用,它的字段就会记录它当前的一些状态。这个时候你去复制这把锁,就会把它的状态也给复制过来。但是,原来的锁在释放的时候,并不会修改你复制出来的这个读写锁,这就会导致复制出来的读写锁的状态不对,可能永远无法释放锁。
那该怎么办呢其实解决方案也和互斥锁一样。你可以借助vet工具在变量赋值、函数传参、函数返回值、遍历数据、struct初始化等时检查是否有读写锁隐式复制的情景。
## 坑点2重入导致死锁
读写锁因为重入(或递归调用)导致死锁的情况更多。
我先介绍第一种情况。因为读写锁内部基于互斥锁实现对writer的并发访问而互斥锁本身是有重入问题的所以writer重入调用Lock的时候就会出现死锁的现象这个问题我们在学习互斥锁的时候已经了解过了。
```
func foo(l *sync.RWMutex) {
fmt.Println(&quot;in foo&quot;)
l.Lock()
bar(l)
l.Unlock()
}
func bar(l *sync.RWMutex) {
l.Lock()
fmt.Println(&quot;in bar&quot;)
l.Unlock()
}
func main() {
l := &amp;sync.RWMutex{}
foo(l)
}
```
运行这个程序你就会得到死锁的错误输出在Go运行的时候很容易就能检测出来。
第二种死锁的场景有点隐蔽。我们知道有活跃reader的时候writer会等待如果我们在reader的读操作时调用writer的写操作它会调用Lock方法那么这个reader和writer就会形成互相依赖的死锁状态。Reader想等待writer完成后再释放锁而writer需要这个reader释放锁之后才能不阻塞地继续执行。这是一个读写锁常见的死锁场景。
第三种死锁的场景更加隐蔽。
当一个writer请求锁的时候如果已经有一些活跃的reader它会等待这些活跃的reader完成才有可能获取到锁但是如果之后活跃的reader再依赖新的reader的话这些新的reader就会等待writer释放锁之后才能继续执行这就形成了一个环形依赖 **writer依赖活跃的reader -&gt; 活跃的reader依赖新来的reader -&gt; 新来的reader依赖writer**
<img src="https://static001.geekbang.org/resource/image/c1/35/c18e897967d29e2d5273b88afe626035.jpg" alt="">
这个死锁相当隐蔽原因在于它和RWMutex的设计和实现有关。啥意思呢我们来看一个计算阶乘(n!)的例子:
```
func main() {
var mu sync.RWMutex
// writer,稍微等待然后制造一个调用Lock的场景
go func() {
time.Sleep(200 * time.Millisecond)
mu.Lock()
fmt.Println(&quot;Lock&quot;)
time.Sleep(100 * time.Millisecond)
mu.Unlock()
fmt.Println(&quot;Unlock&quot;)
}()
go func() {
factorial(&amp;mu, 10) // 计算10的阶乘, 10!
}()
select {}
}
// 递归调用计算阶乘
func factorial(m *sync.RWMutex, n int) int {
if n &lt; 1 { // 阶乘退出条件
return 0
}
fmt.Println(&quot;RLock&quot;)
m.RLock()
defer func() {
fmt.Println(&quot;RUnlock&quot;)
m.RUnlock()
}()
time.Sleep(100 * time.Millisecond)
return factorial(m, n-1) * n // 递归调用
}
```
factoria方法是一个递归计算阶乘的方法我们用它来模拟reader。为了更容易地制造出死锁场景我在这里加上了sleep的调用延缓逻辑的执行。这个方法会调用读锁第27行在第33行递归地调用此方法每次调用都会产生一次读锁的调用所以可以不断地产生读锁的调用而且必须等到新请求的读锁释放这个读锁才能释放。
同时我们使用另一个goroutine去调用Lock方法来实现writer这个writer会等待200毫秒后才会调用Lock这样在调用Lock的时候factoria方法还在执行中不断调用RLock。
这两个goroutine互相持有锁并等待谁也不会退让一步满足了“writer依赖活跃的reader -&gt; 活跃的reader依赖新来的reader -&gt; 新来的reader依赖writer”的死锁条件所以就导致了死锁的产生。
所以,使用读写锁最需要注意的一点就是尽量避免重入,重入带来的死锁非常隐蔽,而且难以诊断。
## 坑点3释放未加锁的RWMutex
和互斥锁一样Lock和Unlock的调用总是成对出现的RLock和RUnlock的调用也必须成对出现。Lock和RLock多余的调用会导致锁没有被释放可能会出现死锁而Unlock和RUnlock多余的调用会导致panic。在生产环境中出现panic是大忌你总不希望半夜爬起来处理生产环境程序崩溃的问题吧所以在使用读写锁的时候一定要注意**不遗漏不多余**。
# 流行的Go开发项目中的坑
好了又到了泡一杯宁夏枸杞加新疆大滩枣的养生茶静静地欣赏知名项目出现Bug的时候了这次被拉出来的是RWMutex的Bug。
## Docker
### issue 36840
[issue 36840](https://github.com/moby/moby/pull/36840/files)修复的是错误地把writer当成reader的Bug。 这个地方本来需要修改数据需要调用的是写锁结果用的却是读锁。或许是被它紧挨着的findNode方法调用迷惑了认为这只是一个读操作。可实际上代码后面还会有changeNodeState方法的调用这是一个写操作。修复办法也很简单只需要改成Lock/Unlock即可。
<img src="https://static001.geekbang.org/resource/image/e4/4b/e4d153cb5f81873a726b09bc436b8a4b.png" alt="">
## Kubernetes
### issue 62464
[issue 62464](https://github.com/kubernetes/kubernetes/pull/62464)就是读写锁第二种死锁的场景这是一个典型的reader导致的死锁的例子。知道墨菲定律吧“凡是可能出错的事必定会出错”。你可能觉得我前面讲的RWMutex的坑绝对不会被人踩的因为道理大家都懂但是你看Kubernetes就踩了这个重入的坑。
这个issue在移除pod的时候可能会发生原因就在于GetCPUSetOrDefault方法会请求读锁同时它还会调用GetCPUSet或GetDefaultCPUSet方法。当这两个方法都请求写锁时是获取不到的因为GetCPUSetOrDefault方法还没有执行完不会释放读锁这就形成了死锁。
<img src="https://static001.geekbang.org/resource/image/06/c2/062ae5d2a6190f86cb7bf57db643d8c2.png" alt="">
# 总结
在开发过程中一开始考虑共享资源并发访问问题的时候我们就会想到互斥锁Mutex。因为刚开始的时候我们还并不太了解并发的情况所以就会使用最简单的同步原语来解决问题。等到系统成熟真正到了需要性能优化的时候我们就能静下心来分析并发场景的可能性这个时候我们就要考虑将Mutex修改为RWMutex来压榨系统的性能。
当然如果一开始你的场景就非常明确了比如我就要实现一个线程安全的map那么一开始你就可以考虑使用读写锁。
正如我在前面提到的如果你能意识到你要解决的问题是一个readers-writers问题那么你就可以毫不犹豫地选择RWMutex不用考虑其它选择。那在使用RWMutex时最需要注意的一点就是尽量避免重入重入带来的死锁非常隐蔽而且难以诊断。
另外我们也可以扩展RWMutex不过实现方法和互斥锁Mutex差不多在技术上是一样的都是通过unsafe来实现我就不再具体讲了。课下你可以参照我们[上节课](https://time.geekbang.org/column/article/296793)学习的方法实现一个扩展的RWMutex。
这一讲我们系统学习了读写锁的相关知识,这里提供给你一个知识地图,帮助你复习本节课的知识。
<img src="https://static001.geekbang.org/resource/image/69/42/695b9aa6027b5d3a61e92cbcbba10042.jpg" alt="">
# 思考题
请你写一个扩展的读写锁比如提供TryLock查询当前是否有writer、reader的数量等方法。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,442 @@
<audio id="audio" title="06 | WaitGroup协同等待任务编排利器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/77/5d840ffc398326f13dab8b218ff9f777.mp3"></audio>
你好,我是鸟窝。
WaitGroup我们以前都多多少少学习过或者是使用过。其实WaitGroup很简单就是package sync用来做任务编排的一个并发原语。它要解决的就是并发-等待的问题现在有一个goroutine A 在检查点checkpoint等待一组goroutine全部完成如果在执行任务的这些goroutine还没全部完成那么goroutine A就会阻塞在检查点直到所有goroutine都完成后才能继续执行。
我们来看一个使用WaitGroup的场景。
比如我们要完成一个大的任务需要使用并行的goroutine执行三个小任务只有这三个小任务都完成我们才能去执行后面的任务。如果通过轮询的方式定时询问三个小任务是否完成会存在两个问题一是性能比较低因为三个小任务可能早就完成了却要等很长时间才被轮询到二是会有很多无谓的轮询空耗CPU资源。
那么这个时候使用WaitGroup并发原语就比较有效了它可以阻塞等待的goroutine。等到三个小任务都完成了再即时唤醒它们。
其实很多操作系统和编程语言都提供了类似的并发原语。比如Linux中的barrier、PthreadPOSIX线程中的barrier、C++中的std::barrier、Java中的CyclicBarrier和CountDownLatch等。由此可见这个并发原语还是一个非常基础的并发类型。所以我们要认真掌握今天的内容这样就可以举一反三轻松应对其他场景下的需求了。
我们还是从WaitGroup的基本用法学起吧。
## WaitGroup的基本用法
Go标准库中的WaitGroup提供了三个方法保持了Go简洁的风格。
```
func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
```
我们分别看下这三个方法:
- Add用来设置WaitGroup的计数值
- Done用来将WaitGroup的计数值减1其实就是调用了Add(-1)
- Wait调用这个方法的goroutine会一直阻塞直到WaitGroup的计数值变为0。
接下来我们通过一个使用WaitGroup的例子来看下Add、Done、Wait方法的基本用法。
在这个例子中我们使用了以前实现的计数器struct。我们启动了10个worker分别对计数值加一10个worker都完成后我们期望输出计数器的值。
```
// 线程安全的计数器
type Counter struct {
mu sync.Mutex
count uint64
}
// 对计数值加一
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// 获取当前的计数值
func (c *Counter) Count() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
// sleep 1秒然后计数值加1
func worker(c *Counter, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(time.Second)
c.Incr()
}
func main() {
var counter Counter
var wg sync.WaitGroup
wg.Add(10) // WaitGroup的值设置为10
for i := 0; i &lt; 10; i++ { // 启动10个goroutine执行加1任务
go worker(&amp;counter, &amp;wg)
}
// 检查点等待goroutine都完成任务
wg.Wait()
// 输出当前计数器的值
fmt.Println(counter.Count())
}
```
我们一起来分析下这段代码。
- 第28行声明了一个WaitGroup变量初始值为零。
- 第29行把WaitGroup变量的计数值设置为10。因为我们需要编排10个goroutine(worker)去执行任务并且等待goroutine完成。
- 第35行调用Wait方法阻塞等待。
- 第32行启动了goroutine并把我们定义的WaitGroup指针当作参数传递进去。goroutine完成后需要调用Done方法把WaitGroup的计数值减1。等10个goroutine都调用了Done方法后WaitGroup的计数值降为0这时第35行的主goroutine就不再阻塞会继续执行在第37行输出计数值。
这就是我们使用WaitGroup编排这类任务的常用方式。而“这类任务”指的就是需要启动多个goroutine执行任务主goroutine需要等待子goroutine都完成后才继续执行。
熟悉了WaitGroup的基本用法后我们再看看它具体是如何实现的吧。
## WaitGroup的实现
首先我们看看WaitGroup的数据结构。它包括了一个noCopy的辅助字段一个state1记录WaitGroup状态的数组。
- noCopy的辅助字段主要就是辅助vet工具检查是否通过copy赋值这个WaitGroup实例。我会在后面和你详细分析这个字段
- state1一个具有复合意义的字段包含WaitGroup的计数、阻塞在检查点的waiter数和信号量。
WaitGroup的数据结构定义以及state信息的获取方法如下
```
type WaitGroup struct {
// 避免复制使用的一个技巧可以告诉vet工具违反了复制使用的规则
noCopy noCopy
// 64bit(8bytes)的值分成两段高32bit是计数值低32bit是waiter的计数
// 另外32bit是用作信号量的
// 因为64bit值的原子操作需要64bit对齐但是32bit编译器不支持所以数组中的元素在不同的架构中不一样具体处理看下面的方法
// 总之会找到对齐的那64bit作为state其余的32bit做信号量
state1 [3]uint32
}
// 得到state的地址和信号量的地址
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
if uintptr(unsafe.Pointer(&amp;wg.state1))%8 == 0 {
// 如果地址是64bit对齐的数组前两个元素做state后一个元素做信号量
return (*uint64)(unsafe.Pointer(&amp;wg.state1)), &amp;wg.state1[2]
} else {
// 如果地址是32bit对齐的数组后两个元素用来做state它可以用来做64bit的原子操作第一个元素32bit用来做信号量
return (*uint64)(unsafe.Pointer(&amp;wg.state1[1])), &amp;wg.state1[0]
}
}
```
因为对64位整数的原子操作要求整数的地址是64位对齐的所以针对64位和32位环境的state字段的组成是不一样的。
在64位环境下state1的第一个元素是waiter数第二个元素是WaitGroup的计数值第三个元素是信号量。
<img src="https://static001.geekbang.org/resource/image/71/ea/71b5fyy6284140986d04c0b6f87aedea.jpg" alt="">
在32位环境下如果state1不是64位对齐的地址那么state1的第一个元素是信号量后两个元素分别是waiter数和计数值。
<img src="https://static001.geekbang.org/resource/image/22/ac/22c40ac54cfeb53669a6ae39020c23ac.jpg" alt="">
然后我们继续深入源码看一下Add、Done和Wait这三个方法的实现。
在查看这部分源码实现时我们会发现除了这些方法本身的实现外还会有一些额外的代码主要是race检查和异常检查的代码。其中有几个检查非常关键如果检查不通过会出现panic这部分内容我会在下一小节分析WaitGroup的错误使用场景时介绍。现在我们先专注在Add、Wait和Done本身的实现代码上。
我先为你梳理下**Add方法的逻辑**。Add方法主要操作的是state的计数部分。你可以为计数值增加一个delta值内部通过原子操作把这个值加到计数值上。需要注意的是这个delta也可以是个负数相当于为计数值减去一个值Done方法内部其实就是通过Add(-1)实现的。
它的实现代码如下:
```
func (wg *WaitGroup) Add(delta int) {
statep, semap := wg.state()
// 高32bit是计数值v所以把delta左移32增加到计数上
state := atomic.AddUint64(statep, uint64(delta)&lt;&lt;32)
v := int32(state &gt;&gt; 32) // 当前计数值
w := uint32(state) // waiter count
if v &gt; 0 || w == 0 {
return
}
// 如果计数值v为0并且waiter的数量w不为0那么state的值就是waiter的数量
// 将waiter的数量设置为0因为计数值v也是0,所以它们俩的组合*statep直接设置为0即可。此时需要并唤醒所有的waiter
*statep = 0
for ; w != 0; w-- {
runtime_Semrelease(semap, false, 0)
}
}
// Done方法实际就是计数器减1
func (wg *WaitGroup) Done() {
wg.Add(-1)
}
```
Wait方法的实现逻辑是不断检查state的值。如果其中的计数值变为了0那么说明所有的任务已完成调用者不必再等待直接返回。如果计数值大于0说明此时还有任务没完成那么调用者就变成了等待者需要加入waiter队列并且阻塞住自己。
其主干实现代码如下:
```
func (wg *WaitGroup) Wait() {
statep, semap := wg.state()
for {
state := atomic.LoadUint64(statep)
v := int32(state &gt;&gt; 32) // 当前计数值
w := uint32(state) // waiter的数量
if v == 0 {
// 如果计数值为0, 调用这个方法的goroutine不必再等待继续执行它后面的逻辑即可
return
}
// 否则把waiter数量加1。期间可能有并发调用Wait的情况所以最外层使用了一个for循环
if atomic.CompareAndSwapUint64(statep, state, state+1) {
// 阻塞休眠等待
runtime_Semacquire(semap)
// 被唤醒,不再阻塞,返回
return
}
}
}
```
## 使用WaitGroup时的常见错误
在分析WaitGroup的Add、Done和Wait方法的实现的时候为避免干扰我删除了异常检查的代码。但是这些异常检查非常有用。
我们在开发的时候经常会遇见或看到误用WaitGroup的场景究其原因就是没有弄明白这些检查的逻辑。所以接下来我们就通过几个小例子一起学习下在开发时绝对要避免的3个问题。
### 常见问题一:计数器设置为负值
WaitGroup的计数器的值必须大于等于0。我们在更改这个计数值的时候WaitGroup会先做检查如果计数值被设置为负数就会导致panic。
一般情况下,有两种方法会导致计数器设置为负数。
第一种方法是:**调用Add的时候传递一个负数**。如果你能保证当前的计数器加上这个负数后还是大于等于0的话也没有问题否则就会导致panic。
比如下面这段代码计数器的初始值为10当第一次传入-10的时候计数值被设置为0不会有啥问题。但是再紧接着传入-1以后计数值就被设置为负数了程序就会出现panic。
```
func main() {
var wg sync.WaitGroup
wg.Add(10)
wg.Add(-10)//将-10作为参数调用Add计数值被设置为0
wg.Add(-1)//将-1作为参数调用Add如果加上-1计数值就会变为负数。这是不对的所以会触发panic
}
```
第二个方法是:**调用Done方法的次数过多超过了WaitGroup的计数值**。
**使用WaitGroup的正确姿势是预先确定好WaitGroup的计数值然后调用相同次数的Done完成相应的任务**。比如在WaitGroup变量声明之后就立即设置它的计数值或者在goroutine启动之前增加1然后在goroutine中调用Done。
如果你没有遵循这些规则就很可能会导致Done方法调用的次数和计数值不一致进而造成死锁Done调用次数比计数值少或者panicDone调用次数比计数值多
比如下面这个例子中多调用了一次Done方法后会导致计数值为负所以程序运行到这一行会出现panic。
```
func main() {
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done()
}
```
### 常见问题二不期望的Add时机
在使用WaitGroup的时候你一定要遵循的原则就是**等所有的Add方法调用之后再调用Wait**否则就可能导致panic或者不期望的结果。
我们构造这样一个场景只有部分的Add/Done执行完后Wait就返回。我们看一个例子启动四个goroutine每个goroutine内部调用Add(1)然后调用Done()主goroutine调用Wait等待任务完成。
```
func main() {
var wg sync.WaitGroup
go dosomething(100, &amp;wg) // 启动第一个goroutine
go dosomething(110, &amp;wg) // 启动第二个goroutine
go dosomething(120, &amp;wg) // 启动第三个goroutine
go dosomething(130, &amp;wg) // 启动第四个goroutine
wg.Wait() // 主goroutine等待完成
fmt.Println(&quot;Done&quot;)
}
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
duration := millisecs * time.Millisecond
time.Sleep(duration) // 故意sleep一段时间
wg.Add(1)
fmt.Println(&quot;后台执行, duration:&quot;, duration)
wg.Done()
}
```
在这个例子中我们原本设想的是等四个goroutine都执行完毕后输出Done的信息但是它的错误之处在于将WaitGroup.Add方法的调用放在了子gorotuine中。等主goorutine调用Wait的时候因为四个任务goroutine一开始都休眠所以可能WaitGroup的Add方法还没有被调用WaitGroup的计数还是0所以它并没有等待四个子goroutine执行完毕才继续执行而是立刻执行了下一步。
导致这个错误的原因是没有遵循先完成所有的Add之后才Wait。要解决这个问题一个方法是预先设置计数值
```
func main() {
var wg sync.WaitGroup
wg.Add(4) // 预先设定WaitGroup的计数值
go dosomething(100, &amp;wg) // 启动第一个goroutine
go dosomething(110, &amp;wg) // 启动第二个goroutine
go dosomething(120, &amp;wg) // 启动第三个goroutine
go dosomething(130, &amp;wg) // 启动第四个goroutine
wg.Wait() // 主goroutine等待
fmt.Println(&quot;Done&quot;)
}
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
duration := millisecs * time.Millisecond
time.Sleep(duration)
fmt.Println(&quot;后台执行, duration:&quot;, duration)
wg.Done()
}
```
另一种方法是在启动子goroutine之前才调用Add
```
func main() {
var wg sync.WaitGroup
dosomething(100, &amp;wg) // 调用方法把计数值加1并启动任务goroutine
dosomething(110, &amp;wg) // 调用方法把计数值加1并启动任务goroutine
dosomething(120, &amp;wg) // 调用方法把计数值加1并启动任务goroutine
dosomething(130, &amp;wg) // 调用方法把计数值加1并启动任务goroutine
wg.Wait() // 主goroutine等待代码逻辑保证了四次Add(1)都已经执行完了
fmt.Println(&quot;Done&quot;)
}
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
wg.Add(1) // 计数值加1再启动goroutine
go func() {
duration := millisecs * time.Millisecond
time.Sleep(duration)
fmt.Println(&quot;后台执行, duration:&quot;, duration)
wg.Done()
}()
}
```
可见无论是怎么修复都要保证所有的Add方法是在Wait方法之前被调用的。
### 常见问题三前一个Wait还没结束就重用WaitGroup
“前一个Wait还没结束就重用WaitGroup”这一点似乎不太好理解我借用田径比赛的例子和你解释下吧。在田径比赛的百米小组赛中需要把选手分成几组一组选手比赛完之后就可以进行下一组了。为了确保两组比赛时间上没有冲突我们在模型化这个场景的时候可以使用WaitGroup。
WaitGroup等一组比赛的所有选手都跑完后5分钟才开始下一组比赛。下一组比赛还可以使用这个WaitGroup来控制因为**WaitGroup是可以重用的**。只要WaitGroup的计数值恢复到零值的状态那么它就可以被看作是新创建的WaitGroup被重复使用。
但是如果我们在WaitGroup的计数值还没有恢复到零值的时候就重用就会导致程序panic。我们看一个例子初始设置WaitGroup的计数值为1启动一个goroutine先调用Done方法接着就调用Add方法Add方法有可能和主goroutine并发执行。
```
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
time.Sleep(time.Millisecond)
wg.Done() // 计数器减1
wg.Add(1) // 计数值加1
}()
wg.Wait() // 主goroutine等待有可能和第7行并发执行
}
```
在这个例子中第6行虽然让WaitGroup的计数恢复到0但是因为第9行有个waiter在等待如果等待Wait的goroutine刚被唤醒就和Add调用第7行有并发执行的冲突所以就会出现panic。
总结一下WaitGroup虽然可以重用但是是有一个前提的那就是必须等到上一轮的Wait完成之后才能重用WaitGroup执行下一轮的Add/Wait如果你在Wait还没执行完的时候就调用下一轮Add方法就有可能出现panic。
## noCopy辅助vet检查
我们刚刚在学习WaitGroup的数据结构时提到了里面有一个noCopy字段。你还记得它的作用吗其实它就是指示vet工具在做检查的时候这个数据结构不能做值复制使用。更严谨地说是不能在第一次使用之后复制使用( must not be copied after first use)。
你可能会说了为什么要把noCopy字段单独拿出来讲呢一方面把noCopy字段穿插到waitgroup代码中讲解容易干扰我们对WaitGroup整体的理解。另一方面也是非常重要的原因noCopy是一个通用的计数技术其他并发原语中也会用到所以单独介绍有助于你以后在实践中使用这个技术。
我们在[第3讲](https://time.geekbang.org/column/article/296541)学习Mutex的时候用到了vet工具。vet会对实现Locker接口的数据类型做静态检查一旦代码中有复制使用这种数据类型的情况就会发出警告。但是WaitGroup同步原语不就是Add、Done和Wait方法吗vet能检查出来吗
其实是可以的。通过给WaitGroup添加一个noCopy字段我们就可以为WaitGroup实现Locker接口这样vet工具就可以做复制检查了。而且因为noCopy字段是未输出类型所以WaitGroup不会暴露Lock/Unlock方法。
noCopy字段的类型是noCopy它只是一个辅助的、用来帮助vet检查用的类型:
```
type noCopy struct{}
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
```
如果你想要自己定义的数据结构不被复制使用或者说不能通过vet工具检查出复制使用的报警就可以通过嵌入noCopy这个数据类型来实现。
## 流行的Go开发项目中的坑
接下来又到了喝枸杞红枣茶的时间了。你可以稍微休息一下心态放轻松地跟我一起围观下知名项目犯过的错比如copy Waitgroup、Add/Wait并发执行问题、遗漏Add等Bug。
有网友在Go的[issue 28123](https://github.com/golang/go/issues/28123)中提了以下的例子,你能发现这段代码有什么问题吗?
```
type TestStruct struct {
Wait sync.WaitGroup
}
func main() {
w := sync.WaitGroup{}
w.Add(1)
t := &amp;TestStruct{
Wait: w,
}
t.Wait.Done()
fmt.Println(&quot;Finished&quot;)
}
```
这段代码最大的一个问题就是第9行copy了WaitGroup的实例w。虽然这段代码能执行成功但确实是违反了WaitGroup使用之后不要复制的规则。在项目中我们可以通过vet工具检查出这样的错误。
Docker [issue 28161](https://github.com/moby/moby/issues/28161) 和 [issue 27011](https://github.com/moby/moby/issues/27011) 都是因为在重用WaitGroup的时候没等前一次的Wait结束就Add导致的错误。Etcd [issue 6534](https://github.com/etcd-io/etcd/issues/6534) 也是重用WaitGroup的Bug没有等前一个Wait结束就Add。
Kubernetes [issue 59574](https://github.com/kubernetes/kubernetes/pull/59574) 的Bug是忘记Wait之前增加计数了这就属于我们通常认为几乎不可能出现的Bug。
<img src="https://static001.geekbang.org/resource/image/3f/f8/3ff86f54893c23d997113440a3a0e2f8.png" alt="">
即使是开发Go语言的开发者自己在使用WaitGroup的时候也可能会犯错。比如 [issue 12813](https://github.com/golang/go/issues/12813)因为defer的使用Add方法可能在Done之后才执行导致计数负值的panic。
<img src="https://static001.geekbang.org/resource/image/2f/5c/2f69127691a431300478d7d7d1c7bd5c.png" alt="">
## 总结
学完这一讲我们知道了使用WaitGroup容易犯的错是不是有些手脚被束缚的感觉呢其实大可不必只要我们不是特别复杂地使用WaitGroup就不用有啥心理负担。
而关于如何避免错误使用WaitGroup的情况我们只需要尽量保证下面5点就可以了
- 不重用WaitGroup。新建一个WaitGroup不会带来多大的资源开销重用反而更容易出错。
- 保证所有的Add方法调用都在Wait之前。
- 不传递负数给Add方法只通过Done来给计数值减1。
- 不做多余的Done方法调用保证Add的计数值和Done方法调用的数量是一样的。
- 不遗漏Done方法的调用否则会导致Wait hang住无法返回。
这一讲我们详细学习了WaitGroup的相关知识这里我整理了一份关于WaitGroup的知识地图方便你复习。
<img src="https://static001.geekbang.org/resource/image/84/ff/845yyf00c6db85c0yy59867e6de77dff.jpg" alt="">
## 思考题
通常我们可以把WaitGroup的计数值理解为等待要完成的waiter的数量。你可以试着扩展下WaitGroup来查询WaitGroup的当前的计数值吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,333 @@
<audio id="audio" title="07 | Cond条件变量的实现机制及避坑指南" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e3/e8/e3f15684f491aefa4aa9bcaf0ef424e8.mp3"></audio>
你好,我是鸟窝。
在写Go程序之前我曾经写了10多年的Java程序也面试过不少Java程序员。在Java面试中经常被问到的一个知识点就是等待/通知wait/notify机制。面试官经常会这样考察候选人请实现一个限定容量的队列queue当队列满或者空的时候利用等待/通知机制实现阻塞或者唤醒。
在Go中也可以实现一个类似的限定容量的队列而且实现起来也比较简单只要用条件变量Cond并发原语就可以。Cond并发原语相对来说不是那么常用但是在特定的场景使用会事半功倍比如你需要在唤醒一个或者所有的等待者做一些检查操作的时候。
那么今天这一讲我们就学习下Cond这个并发原语。
## Go标准库的Cond
Go标准库提供Cond原语的目的是为等待/通知场景下的并发问题提供支持。Cond通常应用于等待某个条件的一组goroutine等条件变为true的时候其中一个goroutine或者所有的goroutine都会被唤醒执行。
顾名思义Cond是和某个条件相关这个条件需要一组goroutine协作共同完成在条件还没有满足的时候所有等待这个条件的goroutine都会被阻塞住只有这一组goroutine通过协作达到了这个条件等待的goroutine才可能继续进行下去。
那这里等待的条件是什么呢等待的条件可以是某个变量达到了某个阈值或者某个时间点也可以是一组变量分别都达到了某个阈值还可以是某个对象的状态满足了特定的条件。总结来讲等待的条件是一种可以用来计算结果是true还是false的条件。
从开发实践上我们真正使用Cond的场景比较少因为一旦遇到需要使用Cond的场景我们更多地会使用Channel的方式我会在第12和第13讲展开Channel的用法去实现因为那才是更地道的Go语言的写法甚至Go的开发者有个“把Cond从标准库移除”的提议[issue 21165](https://github.com/golang/go/issues/21165)。而有的开发者认为Cond是唯一难以掌握的Go并发原语。至于其中原因我先卖个关子到这一讲的后半部分我再和你解释。
今天这一讲我们就带你仔细地学一学Cond这个并发原语吧。
## Cond的基本用法
标准库中的Cond并发原语初始化的时候需要关联一个Locker接口的实例一般我们使用Mutex或者RWMutex。
我们看一下Cond的实现
```
type Cond
func NeWCond(l Locker) *Cond
func (c *Cond) Broadcast()
func (c *Cond) Signal()
func (c *Cond) Wait()
```
首先Cond关联的Locker实例可以通过c.L访问它内部维护着一个先入先出的等待队列。
然后我们分别看下它的三个方法Broadcast、Signal和Wait方法。
**Signal方法**允许调用者Caller唤醒一个等待此Cond的goroutine。如果此时没有等待的goroutine显然无需通知waiter如果Cond等待队列中有一个或者多个等待的goroutine则需要从等待队列中移除第一个goroutine并把它唤醒。在其他编程语言中比如Java语言中Signal方法也被叫做notify方法。
调用Signal方法时不强求你一定要持有c.L的锁。
**Broadcast方法**允许调用者Caller唤醒所有等待此Cond的goroutine。如果此时没有等待的goroutine显然无需通知waiter如果Cond等待队列中有一个或者多个等待的goroutine则清空所有等待的goroutine并全部唤醒。在其他编程语言中比如Java语言中Broadcast方法也被叫做notifyAll方法。
同样地调用Broadcast方法时也不强求你一定持有c.L的锁。
**Wait方法**会把调用者Caller放入Cond的等待队列中并阻塞直到被Signal或者Broadcast的方法从等待队列中移除并唤醒。
调用Wait方法时必须要持有c.L的锁。
Go实现的sync.Cond的方法名是Wait、Signal和Broadcast这是计算机科学中条件变量的[通用方法名](https://en.wikipedia.org/wiki/Monitor_(synchronization)#Condition_variables_2)。比如C语言中对应的方法名是pthread_cond_wait、pthread_cond_signal和 pthread_cond_broadcast。
知道了Cond提供的三个方法后我们再通过一个百米赛跑开始时的例子来学习下**Cond的使用方法**。10个运动员进入赛场之后需要先做拉伸活动活动筋骨向观众和粉丝招手致敬在自己的赛道上做好准备等所有的运动员都准备好之后裁判员才会打响发令枪。
每个运动员做好准备之后将ready加一表明自己做好准备了同时调用Broadcast方法通知裁判员。因为裁判员只有一个所以这里可以直接替换成Signal方法调用。调用Broadcast方法的时候我们并没有请求c.L锁只是在更改等待变量的时候才使用到了锁。
裁判员会等待运动员都准备好第22行。虽然每个运动员准备好之后都唤醒了裁判员但是裁判员被唤醒之后需要检查等待条件是否满足**运动员都准备好了**)。可以看到,裁判员被唤醒之后一定要检查等待条件,如果条件不满足还是要继续等待。
```
func main() {
c := sync.NewCond(&amp;sync.Mutex{})
var ready int
for i := 0; i &lt; 10; i++ {
go func(i int) {
time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
// 加锁更改等待条件
c.L.Lock()
ready++
c.L.Unlock()
log.Printf(&quot;运动员#%d 已准备就绪\n&quot;, i)
// 广播唤醒所有的等待者
c.Broadcast()
}(i)
}
c.L.Lock()
for ready != 10 {
c.Wait()
log.Println(&quot;裁判员被唤醒一次&quot;)
}
c.L.Unlock()
//所有的运动员是否就绪
log.Println(&quot;所有运动员都准备就绪。比赛开始321, ......&quot;)
}
```
你看Cond的使用其实没那么简单。它的复杂在于这段代码有时候需要加锁有时候可以不加Wait唤醒后需要检查条件条件变量的更改其实是需要原子操作或者互斥锁保护的。所以有的开发者会认为Cond是唯一难以掌握的Go并发原语。
我们继续看看Cond的实现原理。
## Cond的实现原理
其实Cond的实现非常简单或者说复杂的逻辑已经被Locker或者runtime的等待队列实现了。我们直接看看Cond的源码吧。
```
type Cond struct {
noCopy noCopy
// 当观察或者修改等待条件的时候需要加锁
L Locker
// 等待队列
notify notifyList
checker copyChecker
}
func NewCond(l Locker) *Cond {
return &amp;Cond{L: l}
}
func (c *Cond) Wait() {
c.checker.check()
// 增加到等待队列中
t := runtime_notifyListAdd(&amp;c.notify)
c.L.Unlock()
// 阻塞休眠直到被唤醒
runtime_notifyListWait(&amp;c.notify, t)
c.L.Lock()
}
func (c *Cond) Signal() {
c.checker.check()
runtime_notifyListNotifyOne(&amp;c.notify)
}
func (c *Cond) Broadcast() {
c.checker.check()
runtime_notifyListNotifyAll(&amp;c.notify
}
```
这部分源码确实很简单,我来带你学习下其中比较关键的逻辑。
runtime_notifyListXXX是运行时实现的方法实现了一个等待/通知的队列。如果你想深入学习这部分可以再去看看runtime/sema.go代码中。
copyChecker是一个辅助结构可以在运行时检查Cond是否被复制使用。
Signal和Broadcast只涉及到notifyList数据结构不涉及到锁。
Wait把调用者加入到等待队列时会释放锁在被唤醒之后还会请求锁。在阻塞休眠期间调用者是不持有锁的这样能让其他goroutine有机会检查或者更新等待变量。
我们继续看看使用Cond常见的两个错误一个是调用Wait的时候没有加锁另一个是没有检查条件是否满足程序就继续执行了。
## 使用Cond的2个常见错误
我们先看**Cond最常见的使用错误也就是调用Wait的时候没有加锁**。
以前面百米赛跑的程序为例在调用cond.Wait时把前后的Lock/Unlock注释掉如下面的代码中的第20行和第25行
```
func main() {
c := sync.NewCond(&amp;sync.Mutex{})
var ready int
for i := 0; i &lt; 10; i++ {
go func(i int) {
time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
// 加锁更改等待条件
c.L.Lock()
ready++
c.L.Unlock()
log.Printf(&quot;运动员#%d 已准备就绪\n&quot;, i)
// 广播唤醒所有的等待者
c.Broadcast()
}(i)
}
// c.L.Lock()
for ready != 10 {
c.Wait()
log.Println(&quot;裁判员被唤醒一次&quot;)
}
// c.L.Unlock()
//所有的运动员是否就绪
log.Println(&quot;所有运动员都准备就绪。比赛开始321, ......&quot;)
}
```
再运行程序就会报释放未加锁的panic
<img src="https://static001.geekbang.org/resource/image/47/76/4780dca40087277be0d183674bc42c76.jpeg" alt="">
出现这个问题的原因在于cond.Wait方法的实现是把当前调用者加入到notify队列之中后会释放锁如果不释放锁其他Wait的调用者就没有机会加入到notify队列中了然后一直等待等调用者被唤醒之后又会去争抢这把锁。如果调用Wait之前不加锁的话就有可能Unlock一个未加锁的Locker。所以切记**调用cond.Wait方法之前一定要加锁**。
使用Cond的另一个常见错误是只调用了一次Wait没有检查等待条件是否满足结果条件没满足程序就继续执行了。出现这个问题的原因在于误以为Cond的使用就像WaitGroup那样调用一下Wait方法等待那么简单。比如下面的代码中把第21行和第24行注释掉
```
func main() {
c := sync.NewCond(&amp;sync.Mutex{})
var ready int
for i := 0; i &lt; 10; i++ {
go func(i int) {
time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
// 加锁更改等待条件
c.L.Lock()
ready++
c.L.Unlock()
log.Printf(&quot;运动员#%d 已准备就绪\n&quot;, i)
// 广播唤醒所有的等待者
c.Broadcast()
}(i)
}
c.L.Lock()
// for ready != 10 {
c.Wait()
log.Println(&quot;裁判员被唤醒一次&quot;)
// }
c.L.Unlock()
//所有的运动员是否就绪
log.Println(&quot;所有运动员都准备就绪。比赛开始321, ......&quot;)
}
```
运行这个程序,你会发现,可能只有几个运动员准备好之后程序就运行完了,而不是我们期望的所有运动员都准备好才进行下一步。原因在于,每一个运动员准备好之后都会唤醒所有的等待者,也就是这里的裁判员,比如第一个运动员准备好后就唤醒了裁判员,结果这个裁判员傻傻地没做任何检查,以为所有的运动员都准备好了,就继续执行了。
所以,我们一定要**记住**waiter goroutine被唤醒**不等于**等待条件被满足只是有goroutine把它唤醒了而已等待条件有可能已经满足了也有可能不满足我们需要进一步检查。你也可以理解为等待者被唤醒只是得到了一次检查的机会而已。
到这里我们小结下。如果你想在使用Cond的时候避免犯错只要时刻记住调用cond.Wait方法之前一定要加锁以及waiter goroutine被唤醒不等于等待条件被满足这两个知识点。
## 知名项目中Cond的使用
Cond在实际项目中被使用的机会比较少原因总结起来有两个。
第一同样的场景我们会使用其他的并发原语来替代。Go特有的Channel类型有一个应用很广泛的模式就是通知机制这个模式使用起来也特别简单。所以很多情况下我们会使用Channel而不是Cond实现wait/notify机制。
第二对于简单的wait/notify场景比如等待一组goroutine完成之后继续执行余下的代码我们会使用WaitGroup来实现。因为WaitGroup的使用方法更简单而且不容易出错。比如上面百米赛跑的问题就可以很方便地使用WaitGroup来实现。
所以我在这一讲开头提到Cond的使用场景很少。先前的标准库内部有几个地方使用了Cond比如io/pipe.go等后来都被其他的并发原语比如Channel替换了sync.Cond的路越走越窄。但是还是有一批忠实的“粉丝”坚持在使用Cond原因在于Cond有三点特性是Channel无法替代的
- Cond和一个Locker关联可以利用这个Locker对相关的依赖条件更改提供保护。
- Cond可以同时支持Signal和Broadcast方法而Channel只能同时支持其中一种。
- Cond的Broadcast方法可以被重复调用。等待条件再次变成不满足的状态后我们又可以调用Broadcast再次唤醒等待的goroutine。这也是Channel不能支持的Channel被close掉了之后不支持再open。
开源项目中使用sync.Cond的代码少之又少包括标准库原先一些使用Cond的代码也改成使用Channel实现了所以别说找Cond相关的使用Bug了想找到的一个使用的例子都不容易我找了Kubernetes中的一个例子我们一起看看它是如何使用Cond的。
Kubernetes项目中定义了优先级队列 [PriorityQueue](https://github.com/kubernetes/kubernetes/blob/master/pkg/scheduler/internal/queue/scheduling_queue.go) 这样一个数据结构用来实现Pod的调用。它内部有三个Pod的队列即activeQ、podBackoffQ和unschedulableQ其中activeQ就是用来调度的活跃队列heap
Pop方法调用的时候如果这个队列为空并且这个队列没有Close的话会调用Cond的Wait方法等待。
你可以看到调用Wait方法的时候调用者是持有锁的并且被唤醒的时候检查等待条件队列是否为空
```
// 从队列中取出一个元素
func (p *PriorityQueue) Pop() (*framework.QueuedPodInfo, error) {
p.lock.Lock()
defer p.lock.Unlock()
for p.activeQ.Len() == 0 { // 如果队列为空
if p.closed {
return nil, fmt.Errorf(queueClosed)
}
p.cond.Wait() // 等待,直到被唤醒
}
......
return pInfo, err
}
```
当activeQ增加新的元素时会调用条件变量的Boradcast方法通知被Pop阻塞的调用者。
```
// 增加元素到队列中
func (p *PriorityQueue) Add(pod *v1.Pod) error {
p.lock.Lock()
defer p.lock.Unlock()
pInfo := p.newQueuedPodInfo(pod)
if err := p.activeQ.Add(pInfo); err != nil {//增加元素到队列中
klog.Errorf(&quot;Error adding pod %v to the scheduling queue: %v&quot;, nsNameForPod(pod), err)
return err
}
......
p.cond.Broadcast() //通知其它等待的goroutine队列中有元素了
return nil
}
```
这个优先级队列被关闭的时候也会调用Broadcast方法避免被Pop阻塞的调用者永远hang住。
```
func (p *PriorityQueue) Close() {
p.lock.Lock()
defer p.lock.Unlock()
close(p.stop)
p.closed = true
p.cond.Broadcast() //关闭时通知等待的goroutine避免它们永远等待
}
```
你可以思考一下这里为什么使用Cond这个并发原语能不能换成Channel实现呢
## 总结
好了,我们来做个总结。
Cond是为等待/通知场景下的并发问题提供支持的。它提供了条件变量的三个基本方法Signal、Broadcast和Wait为并发的goroutine提供等待/通知机制。
在实践中,处理等待/通知的场景时我们常常会使用Channel替换Cond因为Channel类型使用起来更简洁而且不容易出错。但是对于需要重复调用Broadcast的场景比如上面Kubernetes的例子每次往队列中成功增加了元素后就需要调用Broadcast通知所有的等待者使用Cond就再合适不过了。
使用Cond之所以容易出错就是Wait调用需要加锁以及被唤醒后一定要检查条件是否真的已经满足。你需要牢记这两点。
虽然我们讲到的百米赛跑的例子也可以通过WaitGroup来实现但是本质上WaitGroup和Cond是有区别的WaitGroup是主goroutine等待确定数量的子goroutine完成任务而Cond是等待某个条件满足这个条件的修改可以被任意多的goroutine更新而且Cond的Wait不关心也不知道其他goroutine的数量只关心等待条件。而且Cond还有单个通知的机制也就是Signal方法。
<img src="https://static001.geekbang.org/resource/image/47/5d/477157d2dbe1b7e4511f56c2c9c2105d.jpg" alt="">
## 思考题
1. 一个Cond的waiter被唤醒的时候为什么需要再检查等待条件而不是唤醒后进行下一步
1. 你能否利用Cond实现一个容量有限的queue
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,561 @@
<audio id="audio" title="08 | Once一个简约而不简单的并发原语" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/c3/8c1fe2253cede833bef39b111d17dbc3.mp3"></audio>
你好,我是鸟窝。
这一讲我来讲一个简单的并发原语Once。为什么要学习Once呢我先给你答案**Once可以用来执行且仅仅执行一次动作常常用于单例对象的初始化场景。**
那这节课,我们就从对单例对象进行初始化这件事儿说起。
初始化单例资源有很多方法比如定义package级别的变量这样程序在启动的时候就可以初始化
```
package abc
import time
var startTime = time.Now()
```
或者在init函数中进行初始化
```
package abc
var startTime time.Time
func init() {
startTime = time.Now()
}
```
又或者在main函数开始执行的时候执行一个初始化的函数
```
package abc
var startTime time.Tim
func initApp() {
startTime = time.Now()
}
func main() {
initApp()
}
```
这三种方法都是线程安全的,并且后两种方法还可以根据传入的参数实现定制化的初始化操作。
但是很多时候我们是要延迟进行初始化的,所以有时候单例资源的初始化,我们会使用下面的方法:
```
package main
import (
&quot;net&quot;
&quot;sync&quot;
&quot;time&quot;
)
// 使用互斥锁保证线程(goroutine)安全
var connMu sync.Mutex
var conn net.Conn
func getConn() net.Conn {
connMu.Lock()
defer connMu.Unlock()
// 返回已创建好的连接
if conn != nil {
return conn
}
// 创建连接
conn, _ = net.DialTimeout(&quot;tcp&quot;, &quot;baidu.com:80&quot;, 10*time.Second)
return conn
}
// 使用连接
func main() {
conn := getConn()
if conn == nil {
panic(&quot;conn is nil&quot;)
}
}
```
这种方式虽然实现起来简单,但是有性能问题。一旦连接创建好,每次请求的时候还是得竞争锁才能读取到这个连接,这是比较浪费资源的,因为连接如果创建好之后,其实就不需要锁的保护了。怎么办呢?
这个时候就可以使用这一讲要介绍的Once并发原语了。接下来我会详细介绍Once的使用、实现和易错场景。
# Once的使用场景
**sync.Once只暴露了一个方法Do你可以多次调用Do方法但是只有第一次调用Do方法时f参数才会执行这里的f是一个无参数无返回值的函数。**
```
func (o *Once) Do(f func())
```
因为当且仅当第一次调用Do方法的时候参数f才会执行即使第二次、第三次、第n次调用时f参数的值不一样也不会被执行比如下面的例子虽然f1和f2是不同的函数但是第二个函数f2就不会执行。
```
package main
import (
&quot;fmt&quot;
&quot;sync&quot;
)
func main() {
var once sync.Once
// 第一个初始化函数
f1 := func() {
fmt.Println(&quot;in f1&quot;)
}
once.Do(f1) // 打印出 in f1
// 第二个初始化函数
f2 := func() {
fmt.Println(&quot;in f2&quot;)
}
once.Do(f2) // 无输出
}
```
因为这里的f参数是一个无参数无返回的函数所以你可能会通过闭包的方式引用外面的参数比如
```
var addr = &quot;baidu.com&quot;
var conn net.Conn
var err error
once.Do(func() {
conn, err = net.Dial(&quot;tcp&quot;, addr)
})
```
而且在实际的使用中,绝大多数情况下,你会使用闭包的方式去初始化外部的一个资源。
你看Once的使用场景很明确所以在标准库内部实现中也常常能看到Once的身影。
比如标准库内部[cache](https://github.com/golang/go/blob/f0e97546962736fe4aa73b7c7ed590f0134515e1/src/cmd/go/internal/cache/default.go)的实现上就使用了Once初始化Cache资源包括defaultDir值的获取
```
func Default() *Cache { // 获取默认的Cache
defaultOnce.Do(initDefaultCache) // 初始化cache
return defaultCache
}
// 定义一个全局的cache变量使用Once初始化所以也定义了一个Once变量
var (
defaultOnce sync.Once
defaultCache *Cache
)
func initDefaultCache() { //初始化cache,也就是Once.Do使用的f函数
......
defaultCache = c
}
// 其它一些Once初始化的变量比如defaultDir
var (
defaultDirOnce sync.Once
defaultDir string
defaultDirErr error
)
```
还有一些测试的时候初始化测试的资源([export_windows_test](https://github.com/golang/go/blob/50bd1c4d4eb4fac8ddeb5f063c099daccfb71b26/src/time/export_windows_test.go)
```
// 测试window系统调用时区相关函数
func ForceAusFromTZIForTesting() {
ResetLocalOnceForTest()
// 使用Once执行一次初始化
localOnce.Do(func() { initLocalFromTZI(&amp;aus) })
}
```
除此之外还有保证只调用一次copyenv的envOncestrings包下的Replacertime包中的[测试](https://github.com/golang/go/blob/b71eafbcece175db33acfb205e9090ca99a8f984/src/time/export_test.go#L12)Go拉取库时的[proxy](https://github.com/golang/go/blob/8535008765b4fcd5c7dc3fb2b73a856af4d51f9b/src/cmd/go/internal/modfetch/proxy.go#L103)net.pipecrc64Regexp数不胜数。我给你重点介绍一下很值得我们学习的 math/big/sqrt.go中实现的一个数据结构它通过Once封装了一个只初始化一次的值
```
// 值是3.0或者0.0的一个数据结构
var threeOnce struct {
sync.Once
v *Float
}
// 返回此数据结构的值如果还没有初始化为3.0,则初始化
func three() *Float {
threeOnce.Do(func() { // 使用Once初始化
threeOnce.v = NewFloat(3.0)
})
return threeOnce.v
}
```
它将sync.Once和*Float封装成一个对象提供了只初始化一次的值v。 你看它的three方法的实现虽然每次都调用threeOnce.Do方法但是参数只会被调用一次。
当你使用Once的时候你也可以尝试采用这种结构将值和Once封装成一个新的数据结构提供只初始化一次的值。
总结一下Once并发原语解决的问题和使用场景**Once常常用来初始化单例资源或者并发访问只需初始化一次的共享资源或者在测试的时候初始化一次测试资源**。
了解了Once的使用场景那应该怎样实现一个Once呢
# 如何实现一个Once
很多人认为实现一个Once一样的并发原语很简单只需使用一个flag标记是否初始化过即可最多是用atomic原子操作这个flag比如下面的实现
```
type Once struct {
done uint32
}
func (o *Once) Do(f func()) {
if !atomic.CompareAndSwapUint32(&amp;o.done, 0, 1) {
return
}
f()
}
```
这确实是一种实现方式但是这个实现有一个很大的问题就是如果参数f执行很慢的话后续调用Do方法的goroutine虽然看到done已经设置为执行过了但是获取某些初始化资源的时候可能会得到空的资源因为f还没有执行完。
所以,**一个正确的Once实现要使用一个互斥锁<strong>这样初始化的时候如果有并发的goroutine就会进入**doSlow方法</strong>。互斥锁的机制保证只有一个goroutine进行初始化同时利用**双检查的机制**double-checking再次判断o.done是否为0如果为0则是第一次执行执行完毕后就将o.done设置为1然后释放锁。
即使此时有多个goroutine同时进入了doSlow方法因为双检查的机制后续的goroutine会看到o.done的值为1也不会再次执行f。
这样既保证了并发的goroutine会等待f完成而且还不会多次执行f。
```
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&amp;o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
// 双检查
if o.done == 0 {
defer atomic.StoreUint32(&amp;o.done, 1)
f()
}
}
```
好了到这里我们就了解了Once的使用场景很明确同时呢也感受到Once的实现也是相对简单的。在实践中其实很少会出现错误使用Once的情况但是就像墨菲定律说的凡是可能出错的事就一定会出错。使用Once也有可能出现两种错误场景尽管非常罕见。我这里提前讲给你咱打个预防针。
# 使用Once可能出现的2种错误
## 第一种错误:死锁
你已经知道了Do方法会执行一次f但是如果f中再次调用这个Once的Do方法的话就会导致死锁的情况出现。这还不是无限递归的情况而是的的确确的Lock的递归调用导致的死锁。
```
func main() {
var once sync.Once
once.Do(func() {
once.Do(func() {
fmt.Println(&quot;初始化&quot;)
})
})
}
```
当然想要避免这种情况的出现就不要在f参数中调用当前的这个Once不管是直接的还是间接的。
## 第二种错误:未初始化
如果f方法执行的时候panic或者f执行初始化资源的时候失败了这个时候Once还是会认为初次执行已经成功了即使再次调用Do方法也不会再次执行f。
比如下面的例子由于一些防火墙的原因googleConn并没有被正确的初始化后面如果想当然认为既然执行了Do方法googleConn就已经初始化的话会抛出空指针的错误
```
func main() {
var once sync.Once
var googleConn net.Conn // 到Google网站的一个连接
once.Do(func() {
// 建立到google.com的连接有可能因为网络的原因googleConn并没有建立成功此时它的值为nil
googleConn, _ = net.Dial(&quot;tcp&quot;, &quot;google.com:80&quot;)
})
// 发送http请求
googleConn.Write([]byte(&quot;GET / HTTP/1.1\r\nHost: google.com\r\n Accept: */*\r\n\r\n&quot;))
io.Copy(os.Stdout, googleConn)
}
```
既然执行过Once.Do方法也可能因为函数执行失败的原因未初始化资源并且以后也没机会再次初始化资源那么这种初始化未完成的问题该怎么解决呢
这里我来告诉你一招独家秘笈,我们可以**自己实现一个类似Once的并发原语**既可以返回当前调用Do方法是否正确完成还可以在初始化失败后调用Do方法再次尝试初始化直到初始化成功才不再初始化了。
```
// 一个功能更加强大的Once
type Once struct {
m sync.Mutex
done uint32
}
// 传入的函数f有返回值error如果初始化失败需要返回失败的error
// Do方法会把这个error返回给调用者
func (o *Once) Do(f func() error) error {
if atomic.LoadUint32(&amp;o.done) == 1 { //fast path
return nil
}
return o.slowDo(f)
}
// 如果还没有初始化
func (o *Once) slowDo(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 { // 双检查,还没有初始化
err = f()
if err == nil { // 初始化成功才将标记置为已初始化
atomic.StoreUint32(&amp;o.done, 1)
}
}
return err
}
```
我们所做的改变就是Do方法和参数f函数都会返回error如果f执行失败会把这个错误信息返回。
对slowDo方法也做了调整如果f调用失败我们不会更改done字段的值这样后续degoroutine还会继续调用f。如果f执行成功才会修改done的值为1。
可以说真是一顿操作猛如虎我们使用Once有点得心应手的感觉了。等等还有个问题我们怎么查询是否初始化过呢
目前的Once实现可以保证你调用任意次数的once.Do方法它只会执行这个方法一次。但是有时候我们需要打一个标记。如果初始化后我们就去执行其它的操作标准库的Once并不会告诉你是否初始化完成了只是让你放心大胆地去执行Do方法所以**你还需要一个辅助变量,自己去检查是否初始化过了**比如通过下面的代码中的inited字段
```
type AnimalStore struct {once sync.Once;inited uint32}
func (a *AnimalStore) Init() // 可以被并发调用
a.once.Do(func() {
longOperationSetupDbOpenFilesQueuesEtc()
atomic.StoreUint32(&amp;a.inited, 1)
})
}
func (a *AnimalStore) CountOfCats() (int, error) { // 另外一个goroutine
if atomic.LoadUint32(&amp;a.inited) == 0 { // 初始化后才会执行真正的业务逻辑
return 0, NotYetInitedError
}
//Real operation
}
```
当然通过这段代码我们可以解决这类问题但是如果官方的Once类型有Done这样一个方法的话我们就可以直接使用了。这是有人在Go代码库中提出的一个issue([#41690](https://github.com/golang/go/issues/41690))。对于这类问题,一般都会被建议采用其它类型,或者自己去扩展。我们可以尝试扩展这个并发原语:
```
// Once 是一个扩展的sync.Once类型提供了一个Done方法
type Once struct {
sync.Once
}
// Done 返回此Once是否执行过
// 如果执行过则返回true
// 如果没有执行过或者正在执行返回false
func (o *Once) Done() bool {
return atomic.LoadUint32((*uint32)(unsafe.Pointer(&amp;o.Once))) == 1
}
func main() {
var flag Once
fmt.Println(flag.Done()) //false
flag.Do(func() {
time.Sleep(time.Second)
})
fmt.Println(flag.Done()) //true
}
```
好了到这里关于并发原语Once的内容我讲得就差不多了。最后呢和你分享一个Once的踩坑案例。
其实啊使用Once真的不容易犯错想犯错都很困难因为很少有人会傻傻地在初始化函数f中递归调用f这种死锁的现象几乎不会发生。另外如果函数初始化不成功我们一般会panic或者在使用的时候做检查会及早发现这个问题在初始化函数中加强代码。
所以查看大部分的Go项目几乎找不到Once的错误使用场景不过我还是发现了一个。这个issue先从另外一个需求([go#25955](https://github.com/golang/go/issues/25955))谈起。
# Once的踩坑案例
go#25955有网友提出一个需求希望Once提供一个Reset方法能够将Once重置为初始化的状态。比如下面的例子St通过两个Once控制它的Open/Close状态。但是在Close之后再调用Open的话不会再执行init函数因为Once只会执行一次初始化函数。
```
type St struct {
openOnce *sync.Once
closeOnce *sync.Once
}
func(st *St) Open(){
st.openOnce.Do(func() { ... }) // init
...
}
func(st *St) Close(){
st.closeOnce.Do(func() { ... }) // deinit
...
}
```
所以提交这个Issue的开发者希望Once增加一个Reset方法Reset之后再调用once.Do就又可以初始化了。
Go的核心开发者Ian Lance Taylor给他了一个简单的解决方案。在这个例子中只使用一个ponce *sync.Once 做初始化Reset的时候给ponce这个变量赋值一个新的Once实例即可(ponce = new(sync.Once))。Once的本意就是执行一次所以Reset破坏了这个并发原语的本意。
这个解决方案一点都没问题可以很好地解决这位开发者的需求。Docker较早的版本1.11.2中使用了它们的一个网络库libnetwork这个网络库在使用Once的时候就使用Ian Lance Taylor介绍的方法但是不幸的是它的Reset方法中又改变了Once指针的值导致程序panic了。原始逻辑比较复杂一个简化版可重现的[代码](https://play.golang.org/p/xPULnrVKiY)如下:
```
package main
import (
&quot;fmt&quot;
&quot;sync&quot;
&quot;time&quot;
)
// 一个组合的并发原语
type MuOnce struct {
sync.RWMutex
sync.Once
mtime time.Time
vals []string
}
// 相当于reset方法会将m.Once重新复制一个Once
func (m *MuOnce) refresh() {
m.Lock()
defer m.Unlock()
m.Once = sync.Once{}
m.mtime = time.Now()
m.vals = []string{m.mtime.String()}
}
// 获取某个初始化的值如果超过某个时间会reset Once
func (m *MuOnce) strings() []string {
now := time.Now()
m.RLock()
if now.After(m.mtime) {
defer m.Do(m.refresh) // 使用refresh函数重新初始化
}
vals := m.vals
m.RUnlock()
return vals
}
func main() {
fmt.Println(&quot;Hello, playground&quot;)
m := new(MuOnce)
fmt.Println(m.strings())
fmt.Println(m.strings())
}
```
如果你执行这段代码就会panic:
<img src="https://static001.geekbang.org/resource/image/f3/af/f3401f75a86e1d0c3b257f52696228af.png" alt="">
原因在于第31行执行m.Once.Do方法的时候使用的是m.Once的指针然后调用m.refresh在执行m.refresh的时候Once内部的Mutex首先会加锁可以再翻看一下这一讲的Once的实现原理 但是在refresh中更改了Once指针的值之后结果在执行完refresh释放锁的时候释放的是一个刚初始化未加锁的Mutex所以就panic了。
如果你还不太明白,我再给你简化成一个更简单的例子:
```
package main
import (
&quot;sync&quot;
)
type Once struct {
m sync.Mutex
}
func (o *Once) doSlow() {
o.m.Lock()
defer o.m.Unlock()
// 这里更新的o指针的值!!!!!!!, 会导致上一行Unlock出错
*o = Once{}
}
func main() {
var once Once
once.doSlow()
}
```
doSlow方法就演示了这个错误。Ian Lance Taylor介绍的Reset方法没有错误但是你在使用的时候千万别再初始化函数中Reset这个Once否则势必会导致Unlock一个未加锁的Mutex的错误。
总的来说这还是对Once的实现机制不熟悉又进行复杂使用导致的错误。不过最新版的libnetwork相关的地方已经去掉了Once的使用了。所以我带你一起来看这个案例主要目的还是想巩固一下我们对Once的理解。
# 总结
今天我们一起学习了Once我们常常使用它来实现单例模式。
单例是23种设计模式之一也是常常引起争议的设计模式之一甚至有人把它归为反模式。为什么说它是反模式呢我拿标准库中的单例模式给你介绍下。
因为Go没有immutable类型导致我们声明的全局变量都是可变的别的地方或者第三方库可以随意更改这些变量。比如package io中定义了几个全局变量比如io.EOF
```
var EOF = errors.New(&quot;EOF&quot;)
```
因为它是一个package级别的变量我们可以在程序中偷偷把它改了这会导致一些依赖io.EOF这个变量做判断的代码出错。
```
io.EOF = errors.New(&quot;我们自己定义的EOF&quot;)
```
从我个人的角度来说一些单例全局变量的确很方便比如Buffer池或者连接池所以有时候我们也不要谈虎色变。虽然有人把单例模式称之为反模式但毕竟只能代表一部分开发者的观点否则也不会把它列在23种设计模式中了。
如果你真的担心这个package级别的变量被人修改你可以不把它们暴露出来而是提供一个只读的GetXXX的方法这样别人就不会进行修改了。
而且Once不只应用于单例模式一些变量在也需要在使用的时候做延迟初始化所以也是可以使用Once处理这些场景的。
总而言之Once的应用场景还是很广泛的。**一旦你遇到只需要初始化一次的场景首先想到的就应该是Once并发原语。**
<img src="https://static001.geekbang.org/resource/image/4b/ba/4b1721a63d7bd3f3995eb18cee418fba.jpg" alt="">
# 思考题
<li>
我已经分析了几个并发原语的实现你可能注意到总是有些slowXXXX的方法从XXXX方法中单独抽取出来你明白为什么要这么做吗有什么好处
</li>
<li>
Once在第一次使用之后还能复制给其它变量使用吗
</li>
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,537 @@
<audio id="audio" title="09 | map如何实现线程安全的map类型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/95/aef82c64e4c96520d0cea329aa73cb95.mp3"></audio>
你好,我是鸟窝。
哈希表Hash Table这个数据结构我们已经非常熟悉了。它实现的就是key-value之间的映射关系主要提供的方法包括Add、Lookup、Delete等。因为这种数据结构是一个基础的数据结构每个key都会有一个唯一的索引值通过索引可以很快地找到对应的值所以使用哈希表进行数据的插入和读取都是很快的。Go语言本身就内建了这样一个数据结构也就是**map数据类型**。
今天呢我们就先来学习Go语言内建的这个map类型了解它的基本使用方法和使用陷阱然后再学习如何实现线程安全的map类型最后我还会给你介绍Go标准库中线程安全的sync.Map类型。学完了这节课你可以学会几种可以并发访问的map类型。
## map的基本使用方法
Go内建的map类型如下
```
map[K]V
```
其中,**key类型的K必须是可比较的**comparable也就是可以通过 == 和 !=操作符进行比较value的值和类型无所谓可以是任意的类型或者为nil。
在Go语言中bool、整数、浮点数、复数、字符串、指针、Channel、接口都是可比较的包含可比较元素的struct和数组这俩也是可比较的而slice、map、函数值都是不可比较的。
那么上面这些可比较的数据类型都可以作为map的key吗显然不是。通常情况下我们会选择内建的基本类型比如整数、字符串做key的类型因为这样最方便。
这里有一点需要注意如果使用struct类型做key其实是有坑的因为如果struct的某个字段值修改了查询map时无法获取它add进去的值如下面的例子
```
type mapKey struct {
key int
}
func main() {
var m = make(map[mapKey]string)
var key = mapKey{10}
m[key] = &quot;hello&quot;
fmt.Printf(&quot;m[key]=%s\n&quot;, m[key])
// 修改key的字段的值后再次查询map无法获取刚才add进去的值
key.key = 100
fmt.Printf(&quot;再次查询m[key]=%s\n&quot;, m[key])
}
```
那该怎么办呢如果要使用struct作为key我们要保证struct对象在逻辑上是不可变的这样才会保证map的逻辑没有问题。
以上就是选取key类型的注意点了。接下来我们看一下使用map[key]函数时需要注意的一个知识点。**在Go中map[key]函数返回结果可以是一个值,也可以是两个值**这是容易让人迷惑的地方。原因在于如果获取一个不存在的key对应的值时会返回零值。为了区分真正的零值和key不存在这两种情况可以根据第二个返回值来区分如下面的代码的第6行、第7行
```
func main() {
var m = make(map[string]int)
m[&quot;a&quot;] = 0
fmt.Printf(&quot;a=%d; b=%d\n&quot;, m[&quot;a&quot;], m[&quot;b&quot;])
av, aexisted := m[&quot;a&quot;]
bv, bexisted := m[&quot;b&quot;]
fmt.Printf(&quot;a=%d, existed: %t; b=%d, existed: %t\n&quot;, av, aexisted, bv, bexisted)
}
```
map是无序的所以当遍历一个map对象的时候迭代的元素的顺序是不确定的无法保证两次遍历的顺序是一样的也不能保证和插入的顺序一致。那怎么办呢如果我们想要按照key的顺序获取map的值需要先取出所有的key进行排序然后按照这个排序的key依次获取对应的值。而如果我们想要保证元素有序比如按照元素插入的顺序进行遍历可以使用辅助的数据结构比如[orderedmap](https://github.com/elliotchance/orderedmap),来记录插入顺序。
好了总结下关于map我们需要掌握的内容map的类型是map[key]key类型的K必须是可比较的通常情况下我们会选择内建的基本类型比如整数、字符串做key的类型。如果要使用struct作为key我们要保证struct对象在逻辑上是不可变的。在Go中map[key]函数返回结果可以是一个值也可以是两个值。map是无序的如果我们想要保证遍历map时元素有序可以使用辅助的数据结构比如[orderedmap](https://github.com/elliotchance/orderedmap)。
## 使用map的2种常见错误
那接下来我们来看使用map最常犯的两个错误就是**未初始化**和**并发读写**。
### 常见错误一:未初始化
和slice或者Mutex、RWmutex等struct类型不同map对象必须在使用之前初始化。如果不初始化就直接赋值的话会出现panic异常比如下面的例子m实例还没有初始化就直接进行操作会导致panic第3行:
```
func main() {
var m map[int]int
m[100] = 100
}
```
解决办法就是在第2行初始化这个实例m := make(map[int]int))。
从一个nil的map对象中获取值不会panic而是会得到零值所以下面的代码不会报错:
```
func main() {
var m map[int]int
fmt.Println(m[100])
}
```
这个例子很简单我们可以意识到map的初始化问题。但有时候map作为一个struct字段的时候就很容易忘记初始化了。
```
type Counter struct {
Website string
Start time.Time
PageCounters map[string]int
}
func main() {
var c Counter
c.Website = &quot;baidu.com&quot;
c.PageCounters[&quot;/&quot;]++
}
```
所以,关于初始化这一点,我再强调一下,目前还没有工具可以检查,我们只能记住“**别忘记初始化**”这一条规则。
### 常见错误二:并发读写
对于map类型另一个很容易犯的错误就是并发访问问题。这个易错点相当令人讨厌如果没有注意到并发问题程序在运行的时候就有可能出现并发读写导致的panic。
Go内建的map对象不是线程goroutine安全的并发读写的时候运行时会有检查遇到并发问题就会导致panic。
我们一起看一个并发访问map实例导致panic的例子
```
func main() {
var m = make(map[int]int,10) // 初始化一个map
go func() {
for {
m[1] = 1 //设置key
}
}()
go func() {
for {
_ = m[2] //访问这个map
}
}()
select {}
}
```
虽然这段代码看起来是读写goroutine各自操作不同的元素貌似map也没有扩容的问题但是运行时检测到同时对map对象有并发访问就会直接panic。panic信息会告诉我们代码中哪一行有读写问题根据这个错误信息你就能快速定位出来是哪一个map对象在哪里出的问题了。
<img src="https://static001.geekbang.org/resource/image/82/62/82fb958bb73128cf8afc438de4acc862.png" alt="">
这个错误非常常见是几乎每个人都会踩到的坑。其实不只是我们写代码时容易犯这个错一些知名的项目中也是屡次出现这个问题比如Docker issue 40772在删除map对象的元素时忘记了加锁
<img src="https://static001.geekbang.org/resource/image/60/ce/60642481f9707520045991030d0f00ce.png" alt="">
Docker issue 40772Docker issue 35588、34540、39643等等也都有并发读写map的问题。
除了Docker中Kubernetes的issue 84431、72464、68647、64484、48045、45593、37560等以及TiDB的issue 14960和17494等也出现了这个错误。
这么多人都会踩的坑有啥解决方案吗肯定有那接下来我们就继续来看如何解决内建map的并发读写问题。
## 如何实现线程安全的map类型
避免map并发读写panic的方式之一就是加锁考虑到读写性能可以使用读写锁提供性能。
### 加读写锁扩展map支持并发读写
比较遗憾的是目前Go还没有正式发布泛型特性我们还不能实现一个通用的支持泛型的加锁map。但是将要发布的泛型方案已经可以验证测试了离发布也不远了也许发布之后sync.Map就支持泛型了。
当然了如果没有泛型支持我们也能解决这个问题。我们可以通过interface{}来模拟泛型,但还是要涉及接口和具体类型的转换,比较复杂,还不如将要发布的泛型方案更直接、性能更好。
这里我以一个具体的map类型为例来演示利用读写锁实现线程安全的map[int]int类型
```
type RWMap struct { // 一个读写锁保护的线程安全的map
sync.RWMutex // 读写锁保护下面的map字段
m map[int]int
}
// 新建一个RWMap
func NewRWMap(n int) *RWMap {
return &amp;RWMap{
m: make(map[int]int, n),
}
}
func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值
m.RLock()
defer m.RUnlock()
v, existed := m.m[k] // 在锁的保护下从map中读取
return v, existed
}
func (m *RWMap) Set(k int, v int) { // 设置一个键值对
m.Lock() // 锁保护
defer m.Unlock()
m.m[k] = v
}
func (m *RWMap) Delete(k int) { //删除一个键
m.Lock() // 锁保护
defer m.Unlock()
delete(m.m, k)
}
func (m *RWMap) Len() int { // map的长度
m.RLock() // 锁保护
defer m.RUnlock()
return len(m.m)
}
func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map
m.RLock() //遍历期间一直持有读锁
defer m.RUnlock()
for k, v := range m.m {
if !f(k, v) {
return
}
}
}
```
正如这段代码所示对map对象的操作无非就是增删改查和遍历等几种常见操作。我们可以把这些操作分为读和写两类其中查询和遍历可以看做读操作增加、修改和删除可以看做写操作。如例子所示我们可以通过读写锁对相应的操作进行保护。
### 分片加锁更高效的并发map
虽然使用读写锁可以提供线程安全的map但是在大量并发读写的情况下锁的竞争会非常激烈。我在[第4讲](https://time.geekbang.org/column/article/296793)中提到过,锁是性能下降的万恶之源之一。
在并发编程中我们的一条原则就是尽量减少锁的使用。一些单线程单进程的应用比如Redis等基本上不需要使用锁去解决并发线程访问的问题所以可以取得很高的性能。但是对于Go开发的应用程序来说并发是常用的一个特性在这种情况下我们能做的就是**尽量减少锁的粒度和锁的持有时间**。
你可以优化业务处理的代码,以此来减少锁的持有时间,比如将串行的操作变成并行的子任务执行。不过,这就是另外的故事了,今天我们还是主要讲对同步原语的优化,所以这里我重点讲如何减少锁的粒度。
**减少锁的粒度常用的方法就是分片**Shard将一把锁分成几把锁每个锁控制一个分片。Go比较知名的分片并发map的实现是[orcaman/concurrent-map](https://github.com/orcaman/concurrent-map)。
它默认采用32个分片**GetShard是一个关键的方法能够根据key计算出分片索引**。
```
var SHARD_COUNT = 32
// 分成SHARD_COUNT个分片的map
type ConcurrentMap []*ConcurrentMapShared
// 通过RWMutex保护的线程安全的分片包含一个map
type ConcurrentMapShared struct {
items map[string]interface{}
sync.RWMutex // Read Write mutex, guards access to internal map.
}
// 创建并发map
func New() ConcurrentMap {
m := make(ConcurrentMap, SHARD_COUNT)
for i := 0; i &lt; SHARD_COUNT; i++ {
m[i] = &amp;ConcurrentMapShared{items: make(map[string]interface{})}
}
return m
}
// 根据key计算分片索引
func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
return m[uint(fnv32(key))%uint(SHARD_COUNT)]
}
```
增加或者查询的时候,首先根据分片索引得到分片对象,然后对分片对象加锁进行操作:
```
func (m ConcurrentMap) Set(key string, value interface{}) {
// 根据key计算出对应的分片
shard := m.GetShard(key)
shard.Lock() //对这个分片加锁,执行业务操作
shard.items[key] = value
shard.Unlock()
}
func (m ConcurrentMap) Get(key string) (interface{}, bool) {
// 根据key计算出对应的分片
shard := m.GetShard(key)
shard.RLock()
// 从这个分片读取key的值
val, ok := shard.items[key]
shard.RUnlock()
return val, ok
}
```
当然除了GetShard方法ConcurrentMap还提供了很多其他的方法。这些方法都是通过计算相应的分片实现的目的是保证把锁的粒度限制在分片上。
好了到这里我们就学会了解决map并发panic的两个方法加锁和分片。
**在我个人使用并发map的过程中加锁和分片加锁这两种方案都比较常用如果是追求更高的性能显然是分片加锁更好因为它可以降低锁的粒度进而提高访问此map对象的吞吐。如果并发性能要求不是那么高的场景简单加锁方式更简单。**
接下来我会继续给你介绍sync.Map这是Go官方线程安全map的标准实现。虽然是官方标准反而是不常用的为什么呢一句话来说就是map要解决的场景很难描述很多时候在做抉择时根本就不知道该不该用它。但是呢确实有一些特定的场景我们需要用到sync.Map来实现所以还是很有必要学习这个知识点。具体什么场景呢我慢慢给你道来。
## 应对特殊场景的sync.Map
Go内建的map类型不是线程安全的所以Go 1.9中增加了一个线程安全的map也就是sync.Map。但是我们一定要记住这个sync.Map并不是用来替换内建的map类型的它只能被应用在一些特殊的场景里。
那这些特殊的场景是啥呢?[官方的文档](https://golang.org/pkg/sync/#Map)中指出在以下两个场景中使用sync.Map会比使用map+RWMutex的方式性能要好得多
1. 只会增长的缓存系统中一个key只写入一次而被读很多次
1. 多个goroutine为不相交的键集读、写和重写键值对。
这两个场景说得都比较笼统而且这些场景中还包含了一些特殊的情况。所以官方建议你针对自己的场景做性能评测如果确实能够显著提高性能再使用sync.Map。
这么来看我们能用到sync.Map的场景确实不多。即使是sync.Map的作者Bryan C. Mills也很少使用sync.Map即便是在使用sync.Map的时候也是需要临时查询它的API才能清楚记住它的功能。所以我们可以把sync.Map看成一个生产环境中很少使用的同步原语。
### sync.Map的实现
那sync.Map是怎么实现的呢它是如何解决并发问题提升性能的呢其实sync.Map的实现有几个优化点这里先列出来我们后面慢慢分析。
- 空间换时间。通过冗余的两个数据结构只读的read字段、可写的dirty来减少加锁对性能的影响。对只读字段read的操作不需要加锁。
- 优先从read字段读取、更新、删除因为对read字段的读取不需要锁。
- 动态调整。miss次数多了之后将dirty数据提升为read避免总是从dirty中加锁读取。
- double-checking。加锁之后先还要再检查read字段确定真的不存在才操作dirty字段。
- 延迟删除。删除一个键值只是打标记只有在提升dirty字段为read字段的时候才清理删除的数据。
要理解sync.Map这些优化点我们还是得深入到它的设计和实现上去学习它的处理方式。
我们先看一下map的数据结构
```
type Map struct {
mu Mutex
// 基本上你可以把它看成一个安全的只读的map
// 它包含的元素其实也是通过原子操作更新的但是已删除的entry就需要加锁操作了
read atomic.Value // readOnly
// 包含需要加锁才能访问的元素
// 包括所有在read字段中但未被expunged删除的元素以及新加的元素
dirty map[interface{}]*entry
// 记录从read中读取miss的次数一旦miss数和dirty长度一样了就会把dirty提升为read并把dirty置空
misses int
}
type readOnly struct {
m map[interface{}]*entry
amended bool // 当dirty中包含read没有的数据时为true比如新增一条数据
}
// expunged是用来标识此项已经删掉的指针
// 当map中的一个项目被删除了只是把它的值标记为expunged以后才有机会真正删除此项
var expunged = unsafe.Pointer(new(interface{}))
// entry代表一个值
type entry struct {
p unsafe.Pointer // *interface{}
}
```
如果dirty字段非nil的话map的read字段和dirty字段会包含相同的非expunged的项所以如果通过read字段更改了这个项的值从dirty字段中也会读取到这个项的新值因为本来它们指向的就是同一个地址。
dirty包含重复项目的好处就是一旦miss数达到阈值需要将dirty提升为read的话只需简单地把dirty设置为read对象即可。不好的一点就是当创建新的dirty对象的时候需要逐条遍历read把非expunged的项复制到dirty对象中。
接下来我们就深入到源码去看看sync.map的实现。在看这部分源码的过程中我们只要重点关注Store、Load和Delete这3个核心的方法就可以了。
Store、Load和Delete这三个核心函数的操作都是先从read字段中处理的因为读取read字段的时候不用加锁。
#### Store方法
我们先来看Store方法它是用来设置一个键值对或者更新一个键值对的。
```
func (m *Map) Store(key, value interface{}) {
read, _ := m.read.Load().(readOnly)
// 如果read字段包含这个项说明是更新cas更新项目的值即可
if e, ok := read.m[key]; ok &amp;&amp; e.tryStore(&amp;value) {
return
}
// read中不存在或者cas更新失败就需要加锁访问dirty了
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok { // 双检查看看read是否已经存在了
if e.unexpungeLocked() {
// 此项目先前已经被删除了通过将它的值设置为nil标记为unexpunged
m.dirty[key] = e
}
e.storeLocked(&amp;value) // 更新
} else if e, ok := m.dirty[key]; ok { // 如果dirty中有此项
e.storeLocked(&amp;value) // 直接更新
} else { // 否则就是一个新的key
if !read.amended { //如果dirty为nil
// 需要创建dirty对象并且标记read的amended为true,
// 说明有元素它不包含而dirty包含
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value) //将新值增加到dirty对象中
}
m.mu.Unlock()
}
```
可以看出Store既可以是新增元素也可以是更新元素。如果运气好的话更新的是已存在的未被删除的元素直接更新即可不会用到锁。如果运气不好需要更新重用删除的对象、更新还未提升的dirty中的对象或者新增加元素的时候就会使用到了锁这个时候性能就会下降。
所以从这一点来看sync.Map适合那些只会增长的缓存系统可以进行更新但是不要删除并且不要频繁地增加新元素。
新加的元素需要放入到dirty中如果dirty为nil那么需要从read字段中复制出来一个dirty对象
```
func (m *Map) dirtyLocked() {
if m.dirty != nil { // 如果dirty字段已经存在不需要创建了
return
}
read, _ := m.read.Load().(readOnly) // 获取read字段
m.dirty = make(map[interface{}]*entry, len(read.m))
for k, e := range read.m { // 遍历read字段
if !e.tryExpungeLocked() { // 把非punged的键值对复制到dirty中
m.dirty[k] = e
}
}
}
```
#### Load方法
Load方法用来读取一个key对应的值。它也是从read开始处理一开始并不需要锁。
```
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 首先从read处理
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok &amp;&amp; read.amended { // 如果不存在并且dirty不为nil(有新的元素)
m.mu.Lock()
// 双检查看看read中现在是否存在此key
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok &amp;&amp; read.amended {//依然不存在并且dirty不为nil
e, ok = m.dirty[key]// 从dirty中读取
// 不管dirty中存不存在miss数都加1
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load() //返回读取的对象e既可能是从read中获得的也可能是从dirty中获得的
}
```
如果幸运的话我们从read中读取到了这个key对应的值那么就不需要加锁了性能会非常好。但是如果请求的key不存在或者是新加的就需要加锁从dirty中读取。所以读取不存在的key会因为加锁而导致性能下降读取还没有提升的新值的情况下也会因为加锁性能下降。
其中missLocked增加miss的时候如果miss数等于dirty长度会将dirty提升为read并将dirty置空。
```
func (m *Map) missLocked() {
m.misses++ // misses计数加一
if m.misses &lt; len(m.dirty) { // 如果没达到阈值(dirty字段的长度),返回
return
}
m.read.Store(readOnly{m: m.dirty}) //把dirty字段的内存提升为read字段
m.dirty = nil // 清空dirty
m.misses = 0 // misses数重置为0
}
```
#### Delete方法
sync.map的第3个核心方法是Delete方法。在Go 1.15中欧长坤提供了一个LoadAndDelete的实现[go#issue 33762](https://github.com/golang/go/issues/33762)所以Delete方法的核心改在了对LoadAndDelete中实现了。
同样地Delete方法是先从read操作开始原因我们已经知道了因为不需要锁。
```
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok &amp;&amp; read.amended {
m.mu.Lock()
// 双检查
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok &amp;&amp; read.amended {
e, ok = m.dirty[key]
// 这一行长坤在1.15中实现的时候忘记加上了导致在特殊的场景下有些key总是没有被回收
delete(m.dirty, key)
// miss数加1
m.missLocked()
}
m.mu.Unlock()
}
if ok {
return e.delete()
}
return nil, false
}
func (m *Map) Delete(key interface{}) {
m.LoadAndDelete(key)
}
func (e *entry) delete() (value interface{}, ok bool) {
for {
p := atomic.LoadPointer(&amp;e.p)
if p == nil || p == expunged {
return nil, false
}
if atomic.CompareAndSwapPointer(&amp;e.p, p, nil) {
return *(*interface{})(p), true
}
}
}
```
如果read中不存在那么就需要从dirty中寻找这个项目。最终如果项目存在就删除将它的值标记为nil。如果项目不为nil或者没有被标记为expunged那么还可以把它的值返回。
最后我补充一点sync.map还有一些LoadAndDelete、LoadOrStore、Range等辅助方法但是没有Len这样查询sync.Map的包含项目数量的方法并且官方也不准备提供。如果你想得到sync.Map的项目数量的话你可能不得不通过Range逐个计数。
## 总结
Go内置的map类型使用起来很方便但是它有一个非常致命的缺陷那就是它存在着并发问题所以如果有多个goroutine同时并发访问这个map就会导致程序崩溃。所以Go官方Blog很早就提供了一种加锁的[方法](https://blog.golang.org/maps#TOC_6.)还有后来提供了适用特定场景的线程安全的sync.Map还有第三方实现的分片式的map这些方法都可以应用于并发访问的场景。
这里我给你的建议也是Go开发者给的建议就是通过性能测试看看某种线程安全的map实现是否满足你的需求。
当然还有一些扩展其它功能的map实现比如带有过期功能的[timedmap](https://github.com/zekroTJA/timedmap)、使用红黑树实现的key有序的[treemap](https://godoc.org/github.com/emirpasic/gods/maps/treemap)等,因为和并发问题没有关系,就不详细介绍了。这里我给你提供了链接,你可以自己探索。
<img src="https://static001.geekbang.org/resource/image/a8/03/a80408a137b13f934b0dd6f2b6c5cc03.jpg" alt="">
## 思考题
<li>
为什么sync.Map中的集合核心方法的实现中如果read中项目不存在加锁后还要双检查再检查一次read
</li>
<li>
你看到sync.map元素删除的时候只是把它的值设置为nil那么什么时候这个key才会真正从map对象中删除
</li>
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,472 @@
<audio id="audio" title="10 | Pool性能提升大杀器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/2d/1e4c2e19858848a45b18621yyc3beb2d.mp3"></audio>
你好,我是鸟窝。
Go是一个自动垃圾回收的编程语言采用[三色并发标记算法](https://blog.golang.org/ismmkeynote)标记对象并回收。和其它没有自动垃圾回收的编程语言不同使用Go语言创建对象的时候我们没有回收/释放的心理负担,想用就用,想创建就创建。
但是,**如果你想使用Go开发一个高性能的应用程序的话就必须考虑垃圾回收给性能带来的影响**毕竟Go的自动垃圾回收机制还是有一个STWstop-the-world程序暂停的时间而且大量地创建在堆上的对象也会影响垃圾回收标记的时间。
所以,一般我们做性能优化的时候,会采用对象池的方式,把不用的对象回收起来,避免被垃圾回收掉,这样使用的时候就不必在堆上重新创建了。
不止如此像数据库连接、TCP的长连接这些连接在创建的时候是一个非常耗时的操作。如果每次都创建一个新的连接对象耗时较长很可能整个业务的大部分耗时都花在了创建连接上。
所以,如果我们能把这些连接保存下来,避免每次使用的时候都重新创建,不仅可以大大减少业务的耗时,还能提高应用程序的整体性能。
Go标准库中提供了一个通用的Pool数据结构也就是sync.Pool我们使用它可以创建池化的对象。这节课我会详细给你介绍一下sync.Pool的使用方法、实现原理以及常见的坑帮助你全方位地掌握标准库的Pool。
不过,这个类型也有一些使用起来不太方便的地方,就是**它池化的对象可能会被垃圾回收掉**这对于数据库长连接等场景是不合适的。所以在这一讲中我会专门介绍其它的一些Pool包括TCP连接池、数据库连接池等等。
除此之外,我还会专门介绍一个池的应用场景: Worker Pool或者叫做goroutine pool这也是常用的一种并发模式可以使用有限的goroutine资源去处理大量的业务数据。
# sync.Pool
首先我们来学习下标准库提供的sync.Pool数据类型。
sync.Pool数据类型用来保存一组可独立访问的**临时**对象。请注意这里加粗的“临时”这两个字它说明了sync.Pool这个数据类型的特点也就是说它池化的对象会在未来的某个时候被毫无预兆地移除掉。而且如果没有别的对象引用这个被移除的对象的话这个被移除的对象就会被垃圾回收掉。
因为Pool可以有效地减少新对象的申请从而提高程序性能所以Go内部库也用到了sync.Pool比如fmt包它会使用一个动态大小的buffer池做输出缓存当大量的goroutine并发输出的时候就会创建比较多的buffer并且在不需要的时候回收掉。
有两个知识点你需要记住:
1. sync.Pool本身就是线程安全的多个goroutine可以并发地调用它的方法存取对象
1. sync.Pool不可在使用之后再复制使用。
## sync.Pool的使用方法
知道了sync.Pool这个数据类型的特点接下来我们来学习下它的使用方法。其实这个数据类型不难它只提供了三个对外的方法New、Get和Put。
**1.New**
Pool struct包含一个New字段这个字段的类型是函数 func() interface{}。当调用Pool的Get方法从池中获取元素没有更多的空闲元素可返回时就会调用这个New方法来创建新的元素。如果你没有设置New字段没有更多的空闲元素可返回时Get方法将返回nil表明当前没有可用的元素。
有趣的是New是可变的字段。这就意味着你可以在程序运行的时候改变创建元素的方法。当然很少有人会这么做因为一般我们创建元素的逻辑都是一致的要创建的也是同一类的元素所以你在使用Pool的时候也没必要玩一些“花活”在程序运行时更改New的值。
**2.Get**
如果调用这个方法就会从Pool**取走**一个元素这也就意味着这个元素会从Pool中移除返回给调用者。不过除了返回值是正常实例化的元素Get方法的返回值还可能会是一个nilPool.New字段没有设置又没有空闲元素可以返回所以你在使用的时候可能需要判断。
**3.Put**
这个方法用于将一个元素返还给PoolPool会把这个元素保存到池中并且可以复用。但如果Put一个nil值Pool就会忽略这个值。
好了了解了这几个方法下面我们看看sync.Pool最常用的一个场景buffer池缓冲池
因为byte slice是经常被创建销毁的一类对象使用buffer池可以缓存已经创建的byte slice比如著名的静态网站生成工具Hugo中就包含这样的实现[bufpool](https://github.com/gohugoio/hugo/blob/master/bufferpool/bufpool.go),你可以看一下下面这段代码:
```
var buffers = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func GetBuffer() *bytes.Buffer {
return buffers.Get().(*bytes.Buffer)
}
func PutBuffer(buf *bytes.Buffer) {
buf.Reset()
buffers.Put(buf)
}
```
除了Hugo这段buffer池的代码非常常用。很可能你在阅读其它项目的代码的时候就碰到过或者是你自己实现buffer池的时候也会这么去实现但是请你注意了这段代码是有问题的你一定不要将上面的代码应用到实际的产品中。它可能会有内存泄漏的问题下面我会重点讲这个问题。
## 实现原理
了解了sync.Pool的基本使用方法下面我们就来重点学习下它的实现。
Go 1.13之前的sync.Pool的实现有2大问题
**1.每次GC都会回收创建的对象。**
如果缓存元素数量太多就会导致STW耗时变长缓存元素都被回收后会导致Get命中率下降Get方法不得不新创建很多对象。
**2.底层实现使用了Mutex对这个锁并发请求竞争激烈的时候会导致性能的下降。**
在Go 1.13中sync.Pool做了大量的优化。前几讲中我提到过提高并发程序性能的优化点是尽量不要使用锁如果不得已使用了锁就把锁Go的粒度降到最低。**Go对Pool的优化就是避免使用锁同时将加锁的queue改成lock-free的queue的实现给即将移除的元素再多一次“复活”的机会。**
当前sync.Pool的数据结构如下图所示
<img src="https://static001.geekbang.org/resource/image/f4/96/f4003704663ea081230760098f8af696.jpg" alt="">
Pool最重要的两个字段是 local和victim因为它们两个主要用来存储空闲的元素。弄清楚这两个字段的处理逻辑你就能完全掌握sync.Pool的实现了。下面我们来看看这两个字段的关系。
每次垃圾回收的时候Pool会把victim中的对象移除然后把local的数据给victim这样的话local就会被清空而victim就像一个垃圾分拣站里面的东西可能会被当做垃圾丢弃了但是里面有用的东西也可能被捡回来重新使用。
victim中的元素如果被Get取走那么这个元素就很幸运因为它又“活”过来了。但是如果这个时候Get的并发不是很大元素没有被Get取走那么就会被移除掉因为没有别人引用它的话就会被垃圾回收掉。
下面的代码是垃圾回收时sync.Pool的处理逻辑
```
func poolCleanup() {
// 丢弃当前victim, STW所以不用加锁
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
// 将local复制给victim, 并将原local置为nil
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
oldPools, allPools = allPools, nil
}
```
在这段代码中你需要关注一下local字段因为所有当前主要的空闲可用的元素都存放在local字段中请求元素时也是优先从local字段中查找可用的元素。local字段包含一个poolLocalInternal字段并提供CPU缓存对齐从而避免false sharing。
而poolLocalInternal也包含两个字段private和shared。
- private代表一个缓存的元素而且只能由相应的一个P存取。因为一个P同时只能执行一个goroutine所以不会有并发的问题。
- shared可以由任意的P访问但是只有本地的P才能pushHead/popHead其它P可以popTail相当于只有一个本地的P作为生产者Producer多个P作为消费者Consumer它是使用一个local-free的queue列表实现的。
### Get方法
我们来看看Get方法的具体实现原理。
```
func (p *Pool) Get() interface{} {
// 把当前goroutine固定在当前的P上
l, pid := p.pin()
x := l.private // 优先从local的private字段取快速
l.private = nil
if x == nil {
// 从当前的local.shared弹出一个注意是从head读取并移除
x, _ = l.shared.popHead()
if x == nil { // 如果没有,则去偷一个
x = p.getSlow(pid)
}
}
runtime_procUnpin()
// 如果没有获取到尝试使用New函数生成一个新的
if x == nil &amp;&amp; p.New != nil {
x = p.New()
}
return x
}
```
我来给你解释下这段代码。首先从本地的private字段中获取可用元素因为没有锁获取元素的过程会非常快如果没有获取到就尝试从本地的shared获取一个如果还没有会使用getSlow方法去其它的shared中“偷”一个。最后如果没有获取到就尝试使用New函数创建一个新的。
这里的重点是getSlow方法我们来分析下。看名字也就知道了它的耗时可能比较长。它首先要遍历所有的local尝试从它们的shared弹出一个元素。如果还没找到一个那么就开始对victim下手了。
在vintim中查询可用元素的逻辑还是一样的先从对应的victim的private查找如果查不到就再从其它victim的shared中查找。
下面的代码是getSlow方法的主要逻辑
```
func (p *Pool) getSlow(pid int) interface{} {
size := atomic.LoadUintptr(&amp;p.localSize)
locals := p.local
// 从其它proc中尝试偷取一个元素
for i := 0; i &lt; int(size); i++ {
l := indexLocal(locals, (pid+i+1)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 如果其它proc也没有可用元素那么尝试从vintim中获取
size = atomic.LoadUintptr(&amp;p.victimSize)
if uintptr(pid) &gt;= size {
return nil
}
locals = p.victim
l := indexLocal(locals, pid)
if x := l.private; x != nil { // 同样的逻辑先从vintim中的local private获取
l.private = nil
return x
}
for i := 0; i &lt; int(size); i++ { // 从vintim其它proc尝试偷取
l := indexLocal(locals, (pid+i)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 如果victim中都没有则把这个victim标记为空以后的查找可以快速跳过了
atomic.StoreUintptr(&amp;p.victimSize, 0)
return nil
}
```
这里我没列出pin代码的实现你只需要知道pin方法会将此goroutine固定在当前的P上避免查找元素期间被其它的P执行。固定的好处就是查找元素期间直接得到跟这个P相关的local。有一点需要注意的是pin方法在执行的时候如果跟这个P相关的local还没有创建或者运行时P的数量被修改了的话就会新创建local。
### Put方法
我们来看看Put方法的具体实现原理。
```
func (p *Pool) Put(x interface{}) {
if x == nil { // nil值直接丢弃
return
}
l, _ := p.pin()
if l.private == nil { // 如果本地private没有值直接设置这个值即可
l.private = x
x = nil
}
if x != nil { // 否则加入到本地队列中
l.shared.pushHead(x)
}
runtime_procUnpin()
}
```
Put的逻辑相对简单优先设置本地private如果private字段已经有值了那么就把此元素push到本地队列中。
## sync.Pool的坑
到这里我们就掌握了sync.Pool的使用方法和实现原理接下来我要再和你聊聊容易踩的两个坑分别是内存泄漏和内存浪费。
### 内存泄漏
这节课刚开始的时候我讲到可以使用sync.Pool做buffer池但是如果用刚刚的那种方式做buffer池的话可能会有内存泄漏的风险。为啥这么说呢我们来分析一下。
取出来的bytes.Buffer在使用的时候我们可以往这个元素中增加大量的byte数据这会导致底层的byte slice的容量可能会变得很大。这个时候即使Reset再放回到池子中这些byte slice的容量不会改变所占的空间依然很大。而且因为Pool回收的机制这些大的Buffer可能不被回收而是会一直占用很大的空间这属于内存泄漏的问题。
即使是Go的标准库在内存泄漏这个问题上也栽了几次坑比如 [issue 23199](https://github.com/golang/go/issues/23199)、[@dsnet](https://github.com/dsnet)提供了一个简单的可重现的例子演示了内存泄漏的问题。再比如encoding、json中类似的问题将容量已经变得很大的Buffer再放回Pool中导致内存泄漏。后来在元素放回时增加了检查逻辑改成放回的超过一定大小的buffer就直接丢弃掉不再放到池子中如下所示
<img src="https://static001.geekbang.org/resource/image/e3/9f/e3e23d2f2ab55b64741e14856a58389f.png" alt="">
package fmt中也有这个问题修改方法是一样的超过一定大小的buffer就直接丢弃了
<img src="https://static001.geekbang.org/resource/image/06/62/06c68476cac13a860c470b006718c462.png" alt="">
在使用sync.Pool回收buffer的时候**一定要检查回收的对象的大小。**如果buffer太大就不要回收了否则就太浪费了。
### 内存浪费
除了内存泄漏以外还有一种浪费的情况就是池子中的buffer都比较大但在实际使用的时候很多时候只需要一个小的buffer这也是一种浪费现象。接下来我就讲解一下这种情况的处理方法。
要做到物尽其用尽可能不浪费的话我们可以将buffer池分成几层。首先小于512 byte的元素的buffer占一个池子其次小于1K byte大小的元素占一个池子再次小于4K byte大小的元素占一个池子。这样分成几个池子以后就可以根据需要到所需大小的池子中获取buffer了。
在标准库 [net/http/server.go](https://github.com/golang/go/blob/617f2c3e35cdc8483b950aa3ef18d92965d63197/src/net/http/server.go)中的代码中就提供了2K和4K两个writer的池子。你可以看看下面这段代码
<img src="https://static001.geekbang.org/resource/image/55/35/55086ccba91975a0f65bd35d1192e335.png" alt="">
YouTube开源的知名项目vitess中提供了[bucketpool](https://github.com/vitessio/vitess/blob/master/go/bucketpool/bucketpool.go)的实现它提供了更加通用的多层buffer池。你在使用的时候只需要指定池子的最大和最小尺寸vitess就会自动计算出合适的池子数。而且当你调用Get方法的时候只需要传入你要获取的buffer的大小就可以了。下面这段代码就描述了这个过程你可以看看
<img src="https://static001.geekbang.org/resource/image/c5/08/c5cd474aa53fe57e0722d840a6c7f308.png" alt="">
# 第三方库
除了这种分层的为了节省空间的buffer设计外还有其它的一些第三方的库也会提供buffer池的功能。接下来我带你熟悉几个常用的第三方的库。
1.[bytebufferpool](https://github.com/valyala/bytebufferpool)
这是fasthttp作者valyala提供的一个buffer池基本功能和sync.Pool相同。它的底层也是使用sync.Pool实现的包括会检测最大的buffer超过最大尺寸的buffer就会被丢弃。
valyala一向很擅长挖掘系统的性能这个库也不例外。它提供了校准calibrate用来动态调整创建元素的权重的机制可以“智能”地调整Pool的defaultSize和maxSize。一般来说我们使用buffer size的场景比较固定所用buffer的大小会集中在某个范围里。有了校准的特性bytebufferpool就能够偏重于创建这个范围大小的buffer从而节省空间。
2.[oxtoacart/bpool](https://github.com/oxtoacart/bpool)
这也是比较常用的buffer池它提供了以下几种类型的buffer。
- bpool.BufferPool 提供一个固定元素数量的buffer 池元素类型是bytes.Buffer如果超过这个数量Put的时候就丢弃如果池中的元素都被取光了会新建一个返回。Put回去的时候不会检测buffer的大小。
- bpool.BytesPool提供一个固定元素数量的byte slice池元素类型是byte slice。Put回去的时候不检测slice的大小。
- bpool.SizedBufferPool 提供一个固定元素数量的buffer池如果超过这个数量Put的时候就丢弃如果池中的元素都被取光了会新建一个返回。Put回去的时候会检测buffer的大小超过指定的大小的话就会创建一个新的满足条件的buffer放回去。
bpool最大的特色就是能够保持池子中元素的数量一旦Put的数量多于它的阈值就会自动丢弃而sync.Pool是一个没有限制的池子只要Put就会收进去。
bpool是基于Channel实现的不像sync.Pool为了提高性能而做了很多优化所以在性能上比不过sync.Pool。不过它提供了限制Pool容量的功能所以如果你想控制Pool的容量的话可以考虑这个库。
# 连接池
Pool的另一个很常用的一个场景就是保持TCP的连接。一个TCP的连接创建需要三次握手等过程如果是TLS的还会需要更多的步骤如果加上身份认证等逻辑的话耗时会更长。所以为了避免每次通讯的时候都新创建连接我们一般会建立一个连接的池子预先把连接创建好或者是逐步把连接放在池子中减少连接创建的耗时从而提高系统的性能。
事实上我们很少会使用sync.Pool去池化连接对象原因就在于sync.Pool会无通知地在某个时候就把连接移除垃圾回收掉了而我们的场景是需要长久保持这个连接所以我们一般会使用其它方法来池化连接比如接下来我要讲到的几种需要保持长连接的Pool。
## 标准库中的http client池
标准库的http.Client是一个http client的库可以用它来访问web服务器。为了提高性能这个Client的实现也是通过池的方法来缓存一定数量的连接以便后续重用这些连接。
http.Client实现连接池的代码是在Transport类型中它使用idleConn保存持久化的可重用的长连接
<img src="https://static001.geekbang.org/resource/image/14/ec/141ced98a81466b793b0f90b9652afec.png" alt="">
## TCP连接池
最常用的一个TCP连接池是fatih开发的[fatih/pool](https://github.com/fatih/pool)虽然这个项目已经被fatih归档Archived不再维护了但是因为它相当稳定了我们可以开箱即用。即使你有一些特殊的需求也可以fork它然后自己再做修改。
它的使用套路如下:
```
// 工厂模式,提供创建连接的工厂方法
factory := func() (net.Conn, error) { return net.Dial(&quot;tcp&quot;, &quot;127.0.0.1:4000&quot;) }
// 创建一个tcp池提供初始容量和最大容量以及工厂方法
p, err := pool.NewChannelPool(5, 30, factory)
// 获取一个连接
conn, err := p.Get()
// Close并不会真正关闭这个连接而是把它放回池子所以你不必显式地Put这个对象到池子中
conn.Close()
// 通过调用MarkUnusable, Close的时候就会真正关闭底层的tcp的连接了
if pc, ok := conn.(*pool.PoolConn); ok {
pc.MarkUnusable()
pc.Close()
}
// 关闭池子就会关闭=池子中的所有的tcp连接
p.Close()
// 当前池子中的连接的数量
current := p.Len()
```
虽然我一直在说TCP但是它管理的是更通用的net.Conn不局限于TCP连接。
它通过把net.Conn包装成PoolConn实现了拦截net.Conn的Close方法避免了真正地关闭底层连接而是把这个连接放回到池中
```
type PoolConn struct {
net.Conn
mu sync.RWMutex
c *channelPool
unusable bool
}
//拦截Close
func (p *PoolConn) Close() error {
p.mu.RLock()
defer p.mu.RUnlock()
if p.unusable {
if p.Conn != nil {
return p.Conn.Close()
}
return nil
}
return p.c.put(p.Conn)
}
```
它的Pool是通过Channel实现的空闲的连接放入到Channel中这也是Channel的一个应用场景
```
type channelPool struct {
// 存储连接池的channel
mu sync.RWMutex
conns chan net.Conn
// net.Conn 的产生器
factory Factory
}
```
## 数据库连接池
标准库sql.DB还提供了一个通用的数据库的连接池通过MaxOpenConns和MaxIdleConns控制最大的连接数和最大的idle的连接数。默认的MaxIdleConns是2这个数对于数据库相关的应用来说太小了我们一般都会调整它。
<img src="https://static001.geekbang.org/resource/image/49/15/49c14b5bccb6d6ac7a159eece17a2215.png" alt="">
DB的freeConn保存了idle的连接这样当我们获取数据库连接的时候它就会优先尝试从freeConn获取已有的连接[conn](https://github.com/golang/go/blob/4fc3896e7933e31822caa50e024d4e139befc75f/src/database/sql/sql.go#L1196))。
<img src="https://static001.geekbang.org/resource/image/d0/b5/d043yyd649a216fe37885yy4e03af3b5.png" alt="">
## Memcached Client连接池
Brad Fitzpatrick是知名缓存库Memcached的原作者前Go团队成员。[gomemcache](https://github.com/bradfitz/gomemcache)是他使用Go开发的Memchaced的客户端其中也用了连接池的方式池化Memcached的连接。接下来让我们看看它的连接池的实现。
gomemcache Client有一个freeconn的字段用来保存空闲的连接。当一个请求使用完之后它会调用putFreeConn放回到池子中请求的时候调用getFreeConn优先查询freeConn中是否有可用的连接。它采用Mutex+Slice实现Pool
```
// 放回一个待重用的连接
func (c *Client) putFreeConn(addr net.Addr, cn *conn) {
c.lk.Lock()
defer c.lk.Unlock()
if c.freeconn == nil { // 如果对象为空创建一个map对象
c.freeconn = make(map[string][]*conn)
}
freelist := c.freeconn[addr.String()] //得到此地址的连接列表
if len(freelist) &gt;= c.maxIdleConns() {//如果连接已满,关闭,不再放入
cn.nc.Close()
return
}
c.freeconn[addr.String()] = append(freelist, cn) // 加入到空闲列表中
}
// 得到一个空闲连接
func (c *Client) getFreeConn(addr net.Addr) (cn *conn, ok bool) {
c.lk.Lock()
defer c.lk.Unlock()
if c.freeconn == nil {
return nil, false
}
freelist, ok := c.freeconn[addr.String()]
if !ok || len(freelist) == 0 { // 没有此地址的空闲列表,或者列表为空
return nil, false
}
cn = freelist[len(freelist)-1] // 取出尾部的空闲连接
c.freeconn[addr.String()] = freelist[:len(freelist)-1]
return cn, true
}
```
# Worker Pool
最后我再讲一个Pool应用得非常广泛的场景。
你已经知道goroutine是一个很轻量级的“纤程”在一个服务器上可以创建十几万甚至几十万的goroutine。但是“可以”和“合适”之间还是有区别的你会在应用中让几十万的goroutine一直跑吗基本上是不会的。
一个goroutine初始的栈大小是2048个字节并且在需要的时候可以扩展到1GB具体的内容你可以课下看看代码中的配置[不同的架构最大数会不同](https://github.com/golang/go/blob/f296b7a6f045325a230f77e9bda1470b1270f817/src/runtime/proc.go#L120)所以大量的goroutine还是很耗资源的。同时大量的goroutine对于调度和垃圾回收的耗时还是会有影响的因此goroutine并不是越多越好。
有的时候我们就会创建一个Worker Pool来减少goroutine的使用。比如我们实现一个TCP服务器如果每一个连接都要由一个独立的goroutine去处理的话在大量连接的情况下就会创建大量的goroutine这个时候我们就可以创建一个固定数量的goroutineWorker由这一组Worker去处理连接比如fasthttp中的[Worker Pool](https://github.com/valyala/fasthttp/blob/9f11af296864153ee45341d3f2fe0f5178fd6210/workerpool.go#L16)。
Worker的实现也是五花八门的
- 有些是在后台默默执行的,不需要等待返回结果;
- 有些需要等待一批任务执行完;
- 有些Worker Pool的生命周期和程序一样长
- 有些只是临时使用执行完毕后Pool就销毁了。
大部分的Worker Pool都是通过Channel来缓存任务的因为Channel能够比较方便地实现并发的保护有的是多个Worker共享同一个任务Channel有些是每个Worker都有一个独立的Channel。
综合下来精挑细选我给你推荐三款易用的Worker Pool这三个Worker Pool 的API设计简单也比较相似易于和项目集成而且提供的功能也是我们常用的功能。
- [gammazero/workerpool](https://godoc.org/github.com/gammazero/workerpool)gammazero/workerpool可以无限制地提交任务提供了更便利的Submit和SubmitWait方法提交任务还可以提供当前的worker数和任务数以及关闭Pool的功能。
- [ivpusic/grpool](https://godoc.org/github.com/ivpusic/grpool)grpool创建Pool的时候需要提供Worker的数量和等待执行的任务的最大数量任务的提交是直接往Channel放入任务。
- [dpaks/goworkers](https://godoc.org/github.com/dpaks/goworkers)dpaks/goworkers提供了更便利的Submit方法提交任务以及Worker数、任务数等查询方法、关闭Pool的方法。它的任务的执行结果需要在ResultChan和ErrChan中去获取没有提供阻塞的方法但是它可以在初始化的时候设置Worker的数量和任务数。
类似的Worker Pool的实现非常多比如还有[panjf2000/ants](https://github.com/panjf2000/ants)、[Jeffail/tunny](https://github.com/Jeffail/tunny) 、[benmanns/goworker](https://github.com/benmanns/goworker)、[go-playground/pool](https://github.com/go-playground/pool)、[Sherifabdlnaby/gpool](https://github.com/Sherifabdlnaby/gpool)等第三方库。[pond](https://github.com/alitto/pond)也是一个非常不错的Worker Pool关注度目前不是很高但是功能非常齐全。
其实你也可以自己去开发自己的Worker Pool但是对于我这种“懒惰”的人来说只要满足我的实际需求我还是倾向于从这个几个常用的库中选择一个来使用。所以我建议你也从常用的库中进行选择。
# 总结
Pool是一个通用的概念也是解决对象重用和预先分配的一个常用的优化手段。即使你自己没在项目中直接使用过但肯定在使用其它库的时候就享受到应用Pool的好处了比如数据库的访问、http API的请求等等。
我们一般不会在程序一开始的时候就开始考虑优化而是等项目开发到一个阶段或者快结束的时候才全面地考虑程序中的优化点而Pool就是常用的一个优化手段。如果你发现程序中有一种GC耗时特别高有大量的相同类型的临时对象不断地被创建销毁这时你就可以考虑看看是不是可以通过池化的手段重用这些对象。
另外在分布式系统或者微服务框架中可能会有大量的并发Client请求如果Client的耗时占比很大你也可以考虑池化Client以便重用。
如果你发现系统中的goroutine数量非常多程序的内存资源占用比较大而且整体系统的耗时和GC也比较高我建议你看看是否能够通过Worker Pool解决大量goroutine的问题从而降低这些指标。
<img src="https://static001.geekbang.org/resource/image/58/aa/58358f16bcee0281b55299f0386e17aa.jpg" alt="">
# 思考题
在标准库net/rpc包中Server端需要解析大量客户端的请求[Request](https://github.com/golang/go/blob/master/src/net/rpc/server.go#L171)这些短暂使用的Request是可以重用的。请你检查相关的代码看看Go开发者都使用了什么样的方式来重用这些对象。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,301 @@
<audio id="audio" title="11 | Context信息穿透上下文" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/09/712bcc5b3a4f70d9fd4b341933229509.mp3"></audio>
你好,我是鸟窝。
在这节课正式开始之前,我想先带你看一个工作中的场景。
假设有一天你进入办公室,突然同事们都围住你,然后大喊“小王小王你最帅”,此时你可能一头雾水,只能尴尬地笑笑。为啥呢?因为你缺少上下文的信息,不知道之前发生了什么。
但是如果同事告诉你由于你业绩突出一天之内就把云服务化的主要架构写好了因此被评为9月份的工作之星总经理还特意给你发1万元的奖金那么你心里就很清楚了原来同事恭喜你是因为你的工作被表扬了还获得了奖金。同事告诉你的这些前因后果就是上下文信息他把上下文传递给你你接收后就可以获取之前不了解的信息。
你看上下文Context就是这么重要。在我们的开发场景中上下文也是不可或缺的缺少了它我们就不能获取完整的程序信息。那到底啥是上下文呢其实这就是指在API之间或者方法调用之间所传递的除了业务参数之外的额外信息。
比如服务端接收到客户端的HTTP请求之后可以把客户端的IP地址和端口、客户端的身份信息、请求接收的时间、Trace ID等信息放入到上下文中这个上下文可以在后端的方法调用中传递后端的业务方法除了利用正常的参数做一些业务处理如订单处理之外还可以从上下文读取到消息请求的时间、Trace ID等信息把服务处理的时间推送到Trace服务中。Trace服务可以把同一Trace ID的不同方法的调用顺序和调用时间展示成流程图方便跟踪。
不过Go标准库中的Context功能还不止于此它还提供了超时Timeout和取消Cancel的机制下面就让我一一道来。
# Context的来历
在学习Context的功能之前呢我先带你了解下它的来历。毕竟知道了它的来龙去脉我们才能应用得更加得心应手一些。
Go在1.7的版本中才正式把Context加入到标准库中。在这之前很多Web框架在定义自己的handler时都会传递一个自定义的Context把客户端的信息和客户端的请求信息放入到Context中。Go最初提供了golang.org/x/net/context库用来提供上下文信息最终还是在Go1.7中把此库提升到标准库context包中。
为啥呢这是因为在Go1.7之前有很多库都依赖golang.org/x/net/context中的Context实现这就导致Go 1.7发布之后出现了标准库Context和golang.org/x/net/context并存的状况。新的代码使用标准库Context的时候没有办法使用这个标准库的Context去调用旧有的使用x/net/context实现的方法。
所以在Go1.9中还专门实现了一个叫做type alias的新特性然后把x/net/context中的Context定义成标准库Context的别名以解决新旧Context类型冲突问题你可以看一下下面这段代码
```
// +build go1.9
package context
import &quot;context&quot;
type Context = context.Context
type CancelFunc = context.CancelFunc
```
Go标准库的Context不仅提供了上下文传递的信息还提供了cancel、timeout等其它信息这些信息貌似和context这个包名没关系但是还是得到了广泛的应用。所以你看context包中的Context不仅仅传递上下文信息还有timeout等其它功能是不是“名不副实”呢
其实啊这也是这个Context的一个问题比较容易误导人Go布道师Dave Cheney还专门写了一篇文章讲述这个问题[Context isnt for cancellation](https://dave.cheney.net/2017/08/20/context-isnt-for-cancellation)。
同时也有一些批评者针对Context提出了批评[Context should go away for Go 2](https://faiface.github.io/post/context-should-go-away-go2/)这篇文章把Context比作病毒病毒会传染结果把所有的方法都传染上了病毒加上Context参数绝对是视觉污染。
Go的开发者也注意到了“关于Context存在一些争议”这件事儿所以Go核心开发者Ian Lance Taylor专门开了一个[issue 28342](https://github.com/golang/go/issues/28342)用来记录当前的Context的问题
- Context包名导致使用的时候重复ctx context.Context
- Context.WithValue可以接受任何类型的值非类型安全
- Context包名容易误导人实际上Context最主要的功能是取消goroutine的执行
- Context漫天飞函数污染。
尽管有很多的争议但是在很多场景下使用Context其实会很方便所以现在它已经在Go生态圈中传播开来了包括很多的Web应用框架都切换成了标准库的Context。标准库中的database/sql、os/exec、net、net/http等包中都使用到了Context。而且如果我们遇到了下面的一些场景也可以考虑使用Context
- 上下文信息传递 request-scoped比如处理http请求、在请求处理链路上传递信息
- 控制子goroutine的运行
- 超时控制的方法调用;
- 可以取消的方法调用。
所以我们需要掌握Context的具体用法这样才能在不影响主要业务流程实现的时候实现一些通用的信息传递或者是能够和其它goroutine协同工作提供timeout、cancel等机制。
# Context基本使用方法
首先我们来学习一下Context接口包含哪些方法这些方法都是干什么用的。
包context定义了Context接口Context的具体实现包括4个方法分别是Deadline、Done、Err和Value如下所示
```
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() &lt;-chan struct{}
Err() error
Value(key interface{}) interface{}
}
```
下面我来具体解释下这4个方法。
**Deadline**方法会返回这个Context被取消的截止日期。如果没有设置截止日期ok的值是false。后续每次调用这个对象的Deadline方法时都会返回和第一次调用相同的结果。
**Done**方法返回一个Channel对象。在Context被取消时此Channel会被close如果没被取消可能会返回nil。后续的Done调用总是返回相同的结果。当Done被close的时候你可以通过ctx.Err获取错误信息。Done这个方法名其实起得并不好因为名字太过笼统不能明确反映Done被close的原因因为cancel、timeout、deadline都可能导致Done被close不过目前还没有一个更合适的方法名称。
关于Done方法你必须要记住的知识点就是如果Done没有被closeErr方法返回nil如果Done被closeErr方法会返回Done被close的原因。
**Value**返回此ctx中和指定的key相关联的value。
Context中实现了2个常用的生成顶层Context的方法。
- context.Background()返回一个非nil的、空的Context没有任何值不会被cancel不会超时没有截止日期。一般用在主函数、初始化、测试以及创建根Context的时候。
- context.TODO()返回一个非nil的、空的Context没有任何值不会被cancel不会超时没有截止日期。当你不清楚是否该用Context或者目前还不知道要传递一些什么上下文信息的时候就可以使用这个方法。
官方文档是这么讲的你可能会觉得像没说一样因为界限并不是很明显。其实你根本不用费脑子去考虑可以直接使用context.Background。事实上它们两个底层的实现是一模一样的
```
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
```
在使用Context的时候有一些约定俗成的规则。
1. 一般函数使用Context的时候会把这个参数放在第一个参数的位置。
1. 从来不把nil当做Context类型的参数值可以使用context.Background()创建一个空的上下文对象也不要使用nil。
1. Context只用来临时做函数之间的上下文透传不能持久化Context或者把Context长久保存。把Context持久化到数据库、本地文件或者全局变量、缓存中都是错误的用法。
1. key的类型不应该是字符串类型或者其它内建类型否则容易在包之间使用Context时候产生冲突。使用WithValue时key的类型应该是自己定义的类型。
1. 常常使用struct{}作为底层类型定义key的类型。对于exported key的静态类型常常是接口或者指针。这样可以尽量减少内存分配。
其实官方的文档也是比较搞笑的文档中强调key的类型不要使用string结果接下来的例子中就是用string类型作为key的类型。你自己把握住这个要点就好如果你能保证别人使用你的Context时不会和你定义的key冲突那么key的类型就比较随意因为你自己保证了不同包的key不会冲突否则建议你尽量采用保守的unexported的类型。
# 创建特殊用途Context的方法
接下来我会介绍标准库中几种创建特殊用途Context的方法WithValue、WithCancel、WithTimeout和WithDeadline包括它们的功能以及实现方式。
## WithValue
WithValue基于parent Context生成一个新的Context保存了一个key-value键值对。它常常用来传递上下文。
WithValue方法其实是创建了一个类型为valueCtx的Context它的类型定义如下
```
type valueCtx struct {
Context
key, val interface{}
}
```
它持有一个key-value键值对还持有parent的Context。它覆盖了Value方法优先从自己的存储中检查这个key不存在的话会从parent中继续检查。
Go标准库实现的Context还实现了链式查找。如果不存在还会向parent Context去查找如果parent还是valueCtx的话还是遵循相同的原则valueCtx会嵌入parent所以还是会查找parent的Value方法的。
```
ctx = context.TODO()
ctx = context.WithValue(ctx, &quot;key1&quot;, &quot;0001&quot;)
ctx = context.WithValue(ctx, &quot;key2&quot;, &quot;0001&quot;)
ctx = context.WithValue(ctx, &quot;key3&quot;, &quot;0001&quot;)
ctx = context.WithValue(ctx, &quot;key4&quot;, &quot;0004&quot;)
fmt.Println(ctx.Value(&quot;key1&quot;))
```
<img src="https://static001.geekbang.org/resource/image/03/fe/035a1b8e090184c1feba1ef194ec53fe.jpg" alt="">
## WithCancel
WithCancel 方法返回parent的副本只是副本中的Done Channel是新建的对象它的类型是cancelCtx。
我们常常在一些需要主动取消长时间的任务时创建这种类型的Context然后把这个Context传给长时间执行任务的goroutine。当需要中止任务时我们就可以cancel这个Context这样长时间执行任务的goroutine就可以通过检查这个Context知道Context已经被取消了。
WithCancel返回值中的第二个值是一个cancel函数。其实这个返回值的名称cancel和类型Cancel也非常迷惑人。
记住不是只有你想中途放弃才去调用cancel只要你的任务正常完成了就需要调用cancel这样这个Context才能释放它的资源通知它的children 处理cancel从它的parent中把自己移除甚至释放相关的goroutine。很多同学在使用这个方法的时候都会忘记调用cancel切记切记而且一定尽早释放。
我们来看下WithCancel方法的实现代码
```
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &amp;c)// 把c朝上传播
return &amp;c, func() { c.cancel(true, Canceled) }
}
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
```
代码中调用的propagateCancel方法会顺着parent路径往上找直到找到一个cancelCtx或者为nil。如果不为空就把自己加入到这个cancelCtx的child以便这个cancelCtx被取消的时候通知自己。如果为空会新起一个goroutine由它来监听parent的Done是否已关闭。
当这个cancelCtx的cancel函数被调用的时候或者parent的Done被close的时候这个cancelCtx的Done才会被close。
cancel是向下传递的如果一个WithCancel生成的Context被cancel时如果它的子Context也有可能是孙或者更低依赖子的类型也是cancelCtx类型的就会被cancel但是不会向上传递。parent Context不会因为子Context被cancel而cancel。
cancelCtx被取消时它的Err字段就是下面这个Canceled错误
```
var Canceled = errors.New(&quot;context canceled&quot;)
```
## WithTimeout
WithTimeout其实是和WithDeadline一样只不过一个参数是超时时间一个参数是截止时间。超时时间加上当前时间其实就是截止时间因此WithTimeout的实现是
```
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
// 当前时间+timeout就是deadline
return WithDeadline(parent, time.Now().Add(timeout))
}
```
## WithDeadline
WithDeadline会返回一个parent的副本并且设置了一个不晚于参数d的截止时间类型为timerCtx或者是cancelCtx
如果它的截止时间晚于parent的截止时间那么就以parent的截止时间为准并返回一个类型为cancelCtx的Context因为parent的截止时间到了就会取消这个cancelCtx。
如果当前时间已经超过了截止时间就直接返回一个已经被cancel的timerCtx。否则就会启动一个定时器到截止时间取消这个timerCtx。
综合起来timerCtx的Done被Close掉主要是由下面的某个事件触发的
- 截止时间到了;
- cancel函数被调用
- parent的Done被close。
下面的代码是WithDeadline方法的实现
```
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 如果parent的截止时间更早直接返回一个cancelCtx即可
if cur, ok := parent.Deadline(); ok &amp;&amp; cur.Before(d) {
return WithCancel(parent)
}
c := &amp;timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c) // 同cancelCtx的处理逻辑
dur := time.Until(d)
if dur &lt;= 0 { //当前时间已经超过了截止时间直接cancel
c.cancel(true, DeadlineExceeded)
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
// 设置一个定时器,到截止时间后取消
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
```
和cancelCtx一样WithDeadlineWithTimeout返回的cancel一定要调用并且要尽可能早地被调用这样才能尽早释放资源不要单纯地依赖截止时间被动取消。正确的使用姿势是啥呢我们来看一个例子。
```
func slowOperationWithTimeout(ctx context.Context) (Result, error) {
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel() // 一旦慢操作完成就立马调用cancel
return slowOperation(ctx)
}
```
# 总结
我们经常使用Context来取消一个goroutine的运行这是Context最常用的场景之一Context也被称为goroutine生命周期范围goroutine-scoped的Context把Context传递给goroutine。但是goroutine需要尝试检查Context的Done是否关闭了
```
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer func() {
fmt.Println(&quot;goroutine exit&quot;)
}()
for {
select {
case &lt;-ctx.Done():
return
default:
time.Sleep(time.Second)
}
}
}()
time.Sleep(time.Second)
cancel()
time.Sleep(2 * time.Second)
}
```
如果你要为Context实现一个带超时功能的调用比如访问远程的一个微服务超时并不意味着你会通知远程微服务已经取消了这次调用大概率的实现只是避免客户端的长时间等待远程的服务器依然还执行着你的请求。
所以有时候Context并不会减少对服务器的请求负担。如果在Context被cancel的时候你能关闭和服务器的连接中断和数据库服务器的通讯、停止对本地文件的读写那么这样的超时处理同时能减少对服务调用的压力但是这依赖于你对超时的底层处理机制。
<img src="https://static001.geekbang.org/resource/image/2d/2b/2dcbb1ca54c31b4f3e987b602a38e82b.jpg" alt="">
# 思考题
使用WithCancel和WithValue写一个级联的使用Context的例子验证一下parent Context被cancel后子conext是否也立刻被cancel了。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,81 @@
<audio id="audio" title="开篇词 | 想吃透Go并发编程你得这样学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d5/ca/d5ed65cd1940e3e47618b07e924b9dca.mp3"></audio>
你好我是晁岳攀网名鸟窝。之前我在微博研发平台架构中心担任资深架构师同时也是微服务框架rpcx的作者欢迎来到“Go并发编程实战课”。
## 并发编程为什么选Go
为什么要学Go并发呢我想先和你聊聊我和Go结缘的经历。
作为一位老程序员我在清华同方、摩托罗拉、Comcast等公司一直使用Java做项目开发。但是后来我毅然抛弃了十几年的Java编程经验投入到了Go语言的怀抱为什么呢
一句话我被Go的简单高效所打动。它不仅部署方便自带完善的工具链特别是Go在处理并发场景上表现出的独特性能更是让我着迷。
我们知道Java语言的编码非常繁琐为了应用设计模式而做了大量的冗长设计而Go就不一样了。它提供了便利的并发编程方式简简单单的Go语句就可以创建多个goroutine执行并发任务。而且Go还提供了独特的Channel类型很容易实现goroutine之间的数据交流。所以Go并发编程入门很容易即使是初学者要写一个使用goroutine异步输出“Hello World”的例子也可以不费吹灰之力。
不过和其他语言相比Go微服务治理框架的发展还是比较晚的。当阿里出品的Java微服务框架Dubbo被广泛应用时Go生态圈还没有微服务框架。
于是四五年前为了填补Go生态圈微服务化的缺失我就用Go开发了一个微服务的框架rpcx。它既有类似标准rpc库的易用特点又包含了非常丰富的服务治理的功能。而且根据[benchmark测试](https://colobu.com/2020/01/21/benchmark-2019-spring-of-popular-rpc-frameworks/)rpcx有着数一数二的性能很多互联网企业比如马蜂窝、百度等都在使用。
在微博的四年时间里我使用Go参与开发多个基础架构系统并负责中国版权链、微博下一代的Redis集群系统、数据库资源云等系统的设计和开发工作。在多年的实战中我遇见过各种各样的并发难题积累了大量的高并发高吞吐的服务器开发经验也梳理了一整套并发编程的知识体系。
2019年astaxie谢孟军邀请我在Gopher China大会上做一个关于Go并发编程的分享。我准备了一份120页的[PPT](https://myslide.cn/slides/23014#)全面地介绍了Go并发编程的基础内容包括基本并发原语、扩展并发原语和Channel等。会后现场的观众都说干货满满希望我能提供无删改版的PPT。
后来在Go爱好者的强烈要求下我又在滴滴举办了一场Go并发编程的培训详细地分享了我的并发编程心得和经验包括各种并发原语的基本用法和实现机制。
结合我自己的开发经验,以及这些年的技术分享经历,我真切地感受到了这一点:**Go并发编程的重要性不容置疑。只要是使用Go开发的大型应用程序并发是必然要采用的技术。**
但同时我也了解到很多人想要学习Go并发编程却不知道该从何学起也不知该如何精进。
## 学习Go并发编程有哪些困难
那学习Go并发会有哪些困难呢我总结了一下主要是有5大问题。
1. 在面对并发难题时,感觉无从下手,不知道**该用什么并发原语来解决问题**。
1. 如果多个并发原语都可以解决问题,那么,**究竟哪个是最优解呢**比如说是用互斥锁还是用Channel。
1. **不知道如何编排并发任务**。并发编程不像是传统的串行编程,程序的运行存在着很大的不确定性。这个时候,就会面临一个问题,**怎么才能让相应的任务按照你设想的流程运行呢**
1. 有时候按照正常理解的并发方式去实现的程序结果莫名其妙就panic或者死锁了**排查起来非常困难**。
1. **已知的并发原语都不能解决并发问题**,程序写起来异常复杂,而且代码混乱,容易出错。
每一位刚入门Go的程序员在深入学习Go语言的时候尤其是面对Go并发编程的时候都会遇到这些问题。那么具体该怎么学呢
## 怎么提升Go并发编程能力
学习这件事儿,最怕的就是不成体系,即使知识点之间是彼此独立的,也必定存在着联系。我们要做的,就是找出逻辑关系,拎出知识线。我认为,**关于Go并发编程有两条主线分别是知识主线和学习主线**。具体是啥意思呢?可以看下面的这张知识地图。
<img src="https://static001.geekbang.org/resource/image/81/e3/81fa1cfd8d39632d871baeedf4081ce3.jpg" alt="">
从图中可以看到在知识主线层面这门课程的核心内容设计了5个模块
- **基本并发原语**在这部分我会介绍Mutex、RWMutex、Waitgroup、Cond、Pool、Context等标准库中的并发原语这些都是传统的并发原语在其它语言中也很常见是我们在并发编程中常用的类型。
- **原子操作:**在这部分我会介绍Go标准库中提供的原子操作。原子操作是其它并发原语的基础学会了你就可以自己创造新的并发原语。
- **Channel**Channel类型是Go语言独特的类型因为比较新所以难以掌握。但是别怕我会带你全方位地学习Channel类型你不仅能掌握它的基本用法而且还能掌握它的处理场景和应用模式避免踩坑。
- **扩展并发原语**目前来看Go开发组不准备在标准库中扩充并发原语了但是还有一些并发原语应用广泛比如信号量、SingleFlight、循环栅栏、ErrGroup等。掌握了它们就可以在处理一些并发问题时取得事半功倍的效果。
- **分布式并发原语**分布式并发原语是应对大规模的应用程序中并发问题的并发类型。我主要会介绍使用etcd实现的一些分布式并发原语比如Leader选举、分布式互斥锁、分布式读写锁、分布式队列等在处理分布式场景的并发问题时特别有用。
<img src="https://static001.geekbang.org/resource/image/7a/de/7af6e5216dd0cc449878b3949a3e81de.jpg" alt="">
沿着这条知识主线,我会带你建立起一个丰富的并发原语库。你可以把并发问题当成一个强大的敌人,而这些并发原语,就是我们的武器。每一种并发原语都有它的用处,你只有知道足够多的并发原语,才能灵活地应对各种场景。
那具体怎么掌握这些武器呢课程的每一个模块都是独立的它们之间没有任何依赖问题你可以结合自己的实际情况有重点地进行学习。如果你对Channel类型不是太熟悉就可以先看Channel这个模块的内容如果你已经非常熟悉标准库的并发原语了就可以看看扩展并发原语和分布式并发原语的内容。
同时,在学习主线层面,主要是**四大步骤包括基础用法、实现原理、易错场景、知名项目中的Bug**。每一个模块,我都会带着你按照这四个步骤来学习,目的就是带你熟知每一种并发原语的实现机制和适用场景。
Go中有一个大的方向就是任务编排用Channel共享资源保护用传统并发原语。在刚开始学习时你可以基于这个原则去选择相应的并发原语这是没错的。但是如果你想要在Go并发编程的道路上向前走就不能局限于这个原则。
实际上针对同一种场景也许存在很多并发原语都适用的情况但是一定是有最合适的那一个。所以你必须非常清楚每种并发原语的实现机制和适用场景千万不要被网上的一些文章误导万事皆用Channel。
而且你还可以深入学习下Go并发原语的源代码。你会发现很多独到的设计比如Mutex为了公平性考量的设计、sync.Map为提升性能做的设计以及很多并发原语的异常状况的处理方式。尤其是这些异常状况常常是并发编程中程序panic的原因。
所以,如果你能深入了解这些并发原语的实现,不但会提高你的编程能力,还能让你避免在开发中踩并发问题的坑。这个时候,你就达到精通的程度了。
如果没有做过大型并发项目你可能还不太清楚并发原语的重要性。那么我建议你先阅读一下课程中介绍的知名项目中犯的错这也是这门课里我特别设计的一部分内容。通过理解这些Go大牛们犯的错误以及解决方案你就可以积累一套避坑指南和应对之道。
有了这两条线的学习我们就从广度和深度上掌握了Go并发编程的知识点。这些是不是就足够了呢我们还可以更进一步你要有野心能够创造出自己需要的并发原语。
这里的创造有两层含义。第一层是对既有的并发原语进行组合使用两个、三个或者更多的并发原语去解决问题。比如说我们可以通过信号量和WaitGroup组合成一个新的并发原语这个并发原语可以使用有限个goroutine并发处理子任务。第二层含义是“无中生有”根据已经掌握的并发原语的设计经验创造出合适的新的并发原语以应对一些特殊的并发问题。比如说标准库中并没有信号量你可以自己创造出这个类型。
达到了这一层那就不得了了可以说你对Go并发原语的掌握已经出神入化了。那想要达到这个程度是不是很难呢确实不容易不过我相信如果你仔细学习了我们课程里的每一节课心里牢牢地锚定3个目标建立起一个丰富的并发原语库熟知每一种并发原语的实现机制和适用场景能够创造出自己需要的并发原语。达到了这3个目标你就可以轻松地应对各种并发问题了。甚至可以说你几乎能站在Go并发编程的顶端成为大牛中的一员。
最后我想说的是Go并发编程的世界确实纷繁复杂涉及到的内容非常多。你可以把它看作是一个江湖如果你想拥有极强的作战力就要拥有足够多的武器并且修炼内功。这门课就是你的修炼山洞我准备了应有尽有的宝藏等待着你来挖掘。
修炼的过程中,最好有人和你并肩而行,共同成长。欢迎你把这门课分享给你的朋友或同事,和他/她一起提升并发编程的功力。

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。
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,77 @@
<audio id="audio" title="结束语 | 再聊Go并发编程的价值和精进之路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/36/bc939097fbe69423d28d680e6d055336.mp3"></audio>
你好,我是鸟窝。很高兴和你一起度过了一个多月的时间,到了和你说再见的时候了。
在过去的这些年里我一直在研究Go并发编程时间越久越觉得掌握Go并发原语是一件很有意思的事情。
很多刚开始学习并发原语的同学给我留言说:“**使用Go写并发程序很容易啊为啥要学这么多并发原语呢”**
如果你也有这样的疑问,我的答案就是在这节课的封面图中写的那句话:“**并发原语,初识时简单,深交时复杂,熟识时又觉简单。**”这是我的真实体会。
如果你处于刚开始接触并发原语的阶段,你可能会觉得:“这挺好理解的呀,我一看就会了。”但是随着学习的不断深入,你会看到各种复杂的用法,各种潜在的坑,这些东西打破了初印象,你会陷入到“千头万绪”的境地。只要你不畏困难,持续学习,最后你就可以轻松地使用这些并发原语了。如果说最初的“简单”是“初生牛犊不怕虎”的“简单”,那么“熟识”后的“简单”,就是“拨云见雾”的“简单”。这也是,我在这门课里想要带你达到的状态。
总之,**使用Go写并发程序很容易使用Go写好并发程序很不容易。**
遗憾的是很多人都没有意识并发编程的复杂性甚至还没有意识到并发编程错误带来的严重后果。所以我想跟你分享关于并发编程Bug的两个小故事。
第一个故事是我刚刚看到的澳大利亚交易所ASX的新系统在上线后崩溃的故事。
11月16 日中午ASX发布声明说当天将休市会在次日的正常时间重新开放。官方给出的关闭原因是“局限于单个交易指令中交易多种证券组合交易的软件问题导致了市场数据不准确。”
虽然我并没有看到这个Bug的细节但是从官方提供的关闭原因中我们可以简单地推断出是“单个指令中交易多种证券的问题”大概率是一个并发问题的Bug。虽然经过一天的排查和修复第二天这个交易所就恢复上线了。但是耽误一天的时间损失也是非常大的。
类似的软件Bug尤其是并发问题的Bug即使经过很长时间的测试也不一定能被触发和发现。可是一旦出现就可能是一个一级的Bug。
如果看完这个故事,你还没有意识到并发编程的复杂性和并发问题的危害,我再给你讲一个故事。
1997年7月NASA 的 Mars Pathfinder火星探路者在降落火星表面后不久就因并发软件中的一个缺陷受到了威胁。这是在飞行前的测试中发现的但因为它只发生在某些没有预料到的重载条件下所以被给予了较低的优先级。
但是,飞船开始采集气象数据的时候,它所使用的 vxWorks 操作系统就出现了问题不断地重启。这是经典的优先级反转的并发Bug。
幸好工程师上传了一小段 C 语言程序给飞船,在运行的时候,将优先级继承的互斥标志从 false 改成了 true才成功地解决了这个Bug。
这次人为的忽视,险些酿成惨剧。所以,学好并发编程,是我们的重要责任。
那么该怎么在编写Go程序时避免并发编程的Bug呢在[开篇词](https://time.geekbang.org/column/article/294849)里,我讲到了“两大主线”,现在学完了所有内容之后,你会发现,其实可以抽象成“三部曲”:
1. 全面地掌握Go并发编程的知识不遗漏任何的知识点
1. 熟悉每一个并发原语的功能和实现,在面对并发场景时,能够高效地选出最适合的并发原语;
1. 多看看别人踩的坑,避免自己再掉进相同的坑里。
在前面的课程中,我讲的所有内容,都是为了帮助你轻松地完成这三个目标。在课程的最后,我还想再给你多交代几句。
学完这门课并不代表你已经掌握了Go并发编程的知识。Go并发编程的知识广、内容深现在你再回顾前面的知识可能已经遗忘了一大半了。即使你现在记得很清楚等过一段时间再提到这些知识点你也可能答不上来。
所以,学完这门课并不是一件一劳永逸的事情,你要在空闲的时候多复习下前面的内容。怎么复习呢?你可能也注意到了,每讲完一个并发原语,课程里都有一张知识导图,这些图既可以帮助你梳理知识主线,也可以帮助你高效地复习。
你可以对照着图中的核心要点,去回顾我们学习的重要内容,如果感觉有些地方比较陌生了,就及时回去复习下。另外,你也可以做一些摘录,并且写上你自己的收获和思考。**学习过不等于能输出**,你一定要记住这句话。
另外这门课的核心是讲Go并发原语的知识并没有涉及到Go并发模型和调度的事情。这不是说我认为这部分内容不重要而是很多大牛已经把这些内容写得很清楚、很明白了。如果你对这方面的知识还不太熟悉可以搜索关键字“golang gpm schedule”你会看到很多资料。你读几篇就明白了。如果要推荐的话我建议你重点读一读欧长坤的 [《Go语言原本》的 并发调度](https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/),这一篇的逻辑非常顺畅,能看出非常多的经验。
当然,我还想再给你推荐一些补充资料,如果你还有余力,可以再扩展一下知识面。
首先是一本书名字是“Concurrency in Go”。这是第一本全面介绍Go并发编程的图书。书中介绍了并发编程的背景知识、常见的原语和并发模式。我印象最深的就是书里对Channel的介绍比如Channel是粘合goroutine的胶水而select是粘合Channel的胶水。这样形象的说法可以帮助你快速地学到精髓。
除此之外Go官方博客列出的一些技术分享比如[Go Concurrency Patterns](https://www.youtube.com/watch?v=f6kdp27TYZs)、[Advanced Go Concurrency Patterns](https://www.youtube.com/watch?v=QDDwwePbDtw),都是不错的阅读材料,我建议你好好读一读。
好了,关于结课后的学习方法,我就说到这里。在这节课的最后,我特别想再和你分享我自己的两个心得。
**第一,开放的心态,可以拓展你的人生边界。**
我始终认为,一个人衰老的标志,不是指他的容貌经历了太多岁月的刻画,而是他的内心封闭了,不再接收新的知识、新的事物。
在一些技术交流会上我听到一些开发者说Go并发编程很简单有什么可学的遇到这种不是技术讨论的话题我一般只会说“你说得对。”
我当然认同我们应该把核心精力用在眼下有价值的事情上,在自己擅长的领域里深耕,但是我更相信,开放心会让你的人生与众不同。如果你碰见了新技术的发展,即使不需要深入地学习,也要尽量花时间去了解一下,也许这些新的东西,就是你人生的转折点。
我之前就是一直使用Java、Scala后来才开始了解Go但是很显然Go给我的人生带来了不一样的东西。如果不是深入研究Go我就没有机会开设这么一门课了。
**第二,无数人想要你的注意力,但只有你能决定你把它放在哪里。**
我们总说这个时代是信息爆炸的时代,其实,信息爆炸就意味着千万的信息发送者想要占用你的注意力。你一定要保持谨慎,不要毫无感知地把你的时间扔给无价值、无意义的信息。
如果说上一条是让你延伸注意力的触角,那么这一条,就是让你收缩注意力的触角,但这两者并不矛盾,因为侧重点不同。“延伸”还是“收缩”,取决于你自己想要拥有的人生的样子,只有你能决定。我能做的,就是提醒你,要开放,也要谨慎。
虽然很舍不得但还是要跟你说再见了。在课程的最后我给你准备一份结课问卷希望你花1分钟时间点击下面的图片填写一下。如果你的建议被采纳我将会给你赠送一个护腕垫或者价值99元的课程阅码。期待你的畅所欲言。
[<img src="https://static001.geekbang.org/resource/image/2e/9b/2eab2acf71e5183e59ea9a10e08eee9b.jpg" alt="">](https://jinshuju.net/f/UQheYe)