This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

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