mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 15:43:44 +08:00
del
This commit is contained in:
341
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/01 | Mutex:如何解决资源并发访问问题?.md
Normal file
341
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/01 | Mutex:如何解决资源并发访问问题?.md
Normal 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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var count = 0
|
||||
// 使用WaitGroup等待10个goroutine完成
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// 对变量count执行10次加1
|
||||
for j := 0; j < 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 "".count(SB), AX
|
||||
LEAQ 1(AX), CX
|
||||
MOVQ CX, "".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)或者运行(run)Go代码的时候,加上**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, "".j+8(SP)
|
||||
0x0068 00104 (counter.go:16) PCDATA $0, $1
|
||||
0x0068 00104 (counter.go:16) MOVQ "".&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 "".&count+128(SP), AX
|
||||
0x0081 00129 (counter.go:16) MOVQ (AX), CX
|
||||
0x0084 00132 (counter.go:16) MOVQ CX, ""..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 ""..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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
|
||||
func main() {
|
||||
// 互斥锁保护计数器
|
||||
var mu sync.Mutex
|
||||
// 计数器的值
|
||||
var count = 0
|
||||
|
||||
// 辅助变量,用来确认所有的goroutine都完成
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(10)
|
||||
|
||||
// 启动10个gourontine
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// 累加10万次
|
||||
for j := 0; j < 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 < 10; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 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 < 10; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// 执行10万次累加
|
||||
for j := 0; j < 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呢?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
555
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/02 | Mutex:庖丁解牛看实现.md
Normal file
555
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/02 | Mutex:庖丁解牛看实现.md
Normal 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,就可以通过CAS(compare-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("unreached")
|
||||
}
|
||||
|
||||
// 请求锁
|
||||
func (m *Mutex) Lock() {
|
||||
if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
|
||||
return
|
||||
}
|
||||
semacquire(&m.sema) // 否则阻塞等待
|
||||
}
|
||||
|
||||
func (m *Mutex) Unlock() {
|
||||
if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
|
||||
return
|
||||
}
|
||||
semrelease(&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 < 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 < 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 << 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(&m.state, 0, mutexLocked) {
|
||||
return
|
||||
}
|
||||
|
||||
awoke := false
|
||||
for {
|
||||
old := m.state
|
||||
new := old | mutexLocked // 新状态加锁
|
||||
if old&mutexLocked != 0 {
|
||||
new = old + 1<<mutexWaiterShift //等待者数量加一
|
||||
}
|
||||
if awoke {
|
||||
// goroutine是被唤醒的,
|
||||
// 新状态清除唤醒标志
|
||||
new &^= mutexWoken
|
||||
}
|
||||
if atomic.CompareAndSwapInt32(&m.state, old, new) {//设置新状态
|
||||
if old&mutexLocked == 0 { // 锁原状态未加锁
|
||||
break
|
||||
}
|
||||
runtime.Semacquire(&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(&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(&m.state, -mutexLocked) //去掉锁标志
|
||||
if (new+mutexLocked)&mutexLocked == 0 { //本来就没有加锁
|
||||
panic("sync: unlock of unlocked mutex")
|
||||
}
|
||||
|
||||
old := new
|
||||
for {
|
||||
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 { // 没有等待者,或者有唤醒的waiter,或者锁原来已加锁
|
||||
return
|
||||
}
|
||||
new = (old - 1<<mutexWaiterShift) | mutexWoken // 新状态,准备唤醒goroutine,并设置唤醒标志
|
||||
if atomic.CompareAndSwapInt32(&m.state, old, new) {
|
||||
runtime.Semrelease(&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(&m.state, 0, mutexLocked) {
|
||||
return
|
||||
}
|
||||
|
||||
awoke := false
|
||||
iter := 0
|
||||
for { // 不管是新来的请求锁的goroutine, 还是被唤醒的goroutine,都不断尝试请求锁
|
||||
old := m.state // 先保存当前锁的状态
|
||||
new := old | mutexLocked // 新状态设置加锁标志
|
||||
if old&mutexLocked != 0 { // 锁还没被释放
|
||||
if runtime_canSpin(iter) { // 还可以自旋
|
||||
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
|
||||
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
|
||||
awoke = true
|
||||
}
|
||||
runtime_doSpin()
|
||||
iter++
|
||||
continue // 自旋,再次尝试请求锁
|
||||
}
|
||||
new = old + 1<<mutexWaiterShift
|
||||
}
|
||||
if awoke { // 唤醒状态
|
||||
if new&mutexWoken == 0 {
|
||||
panic("sync: inconsistent mutex state")
|
||||
}
|
||||
new &^= mutexWoken // 新状态清除唤醒标记
|
||||
}
|
||||
if atomic.CompareAndSwapInt32(&m.state, old, new) {
|
||||
if old&mutexLocked == 0 { // 旧状态锁已释放,新状态成功持有了锁,直接返回
|
||||
break
|
||||
}
|
||||
runtime_Semacquire(&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 << iota // mutex is locked
|
||||
mutexWoken
|
||||
mutexStarving // 从state字段中分出一个饥饿标记
|
||||
mutexWaiterShift = iota
|
||||
|
||||
starvationThresholdNs = 1e6
|
||||
)
|
||||
|
||||
func (m *Mutex) Lock() {
|
||||
// Fast path: 幸运之路,一下就获取到了锁
|
||||
if atomic.CompareAndSwapInt32(&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&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
|
||||
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
|
||||
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
|
||||
awoke = true
|
||||
}
|
||||
runtime_doSpin()
|
||||
iter++
|
||||
old = m.state // 再次获取锁的状态,之后会检查是否锁被释放了
|
||||
continue
|
||||
}
|
||||
new := old
|
||||
if old&mutexStarving == 0 {
|
||||
new |= mutexLocked // 非饥饿状态,加锁
|
||||
}
|
||||
if old&(mutexLocked|mutexStarving) != 0 {
|
||||
new += 1 << mutexWaiterShift // waiter数量加1
|
||||
}
|
||||
if starving && old&mutexLocked != 0 {
|
||||
new |= mutexStarving // 设置饥饿状态
|
||||
}
|
||||
if awoke {
|
||||
if new&mutexWoken == 0 {
|
||||
throw("sync: inconsistent mutex state")
|
||||
}
|
||||
new &^= mutexWoken // 新状态清除唤醒标记
|
||||
}
|
||||
// 成功设置新状态
|
||||
if atomic.CompareAndSwapInt32(&m.state, old, new) {
|
||||
// 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回
|
||||
if old&(mutexLocked|mutexStarving) == 0 {
|
||||
break // locked the mutex with CAS
|
||||
}
|
||||
// 处理饥饿状态
|
||||
|
||||
// 如果以前就在队列里面,加入到队列头
|
||||
queueLifo := waitStartTime != 0
|
||||
if waitStartTime == 0 {
|
||||
waitStartTime = runtime_nanotime()
|
||||
}
|
||||
// 阻塞等待
|
||||
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
|
||||
// 唤醒之后检查锁是否应该处于饥饿状态
|
||||
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
|
||||
old = m.state
|
||||
// 如果锁已经处于饥饿状态,直接抢到锁,返回
|
||||
if old&mutexStarving != 0 {
|
||||
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
|
||||
throw("sync: inconsistent mutex state")
|
||||
}
|
||||
// 有点绕,加锁并且将waiter数减1
|
||||
delta := int32(mutexLocked - 1<<mutexWaiterShift)
|
||||
if !starving || old>>mutexWaiterShift == 1 {
|
||||
delta -= mutexStarving // 最后一个waiter或者已经不饥饿了,清除饥饿标记
|
||||
}
|
||||
atomic.AddInt32(&m.state, delta)
|
||||
break
|
||||
}
|
||||
awoke = true
|
||||
iter = 0
|
||||
} else {
|
||||
old = m.state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mutex) Unlock() {
|
||||
// Fast path: drop lock bit.
|
||||
new := atomic.AddInt32(&m.state, -mutexLocked)
|
||||
if new != 0 {
|
||||
m.unlockSlow(new)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mutex) unlockSlow(new int32) {
|
||||
if (new+mutexLocked)&mutexLocked == 0 {
|
||||
throw("sync: unlock of unlocked mutex")
|
||||
}
|
||||
if new&mutexStarving == 0 {
|
||||
old := new
|
||||
for {
|
||||
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
|
||||
return
|
||||
}
|
||||
new = (old - 1<<mutexWaiterShift) | mutexWoken
|
||||
if atomic.CompareAndSwapInt32(&m.state, old, new) {
|
||||
runtime_Semrelease(&m.sema, false, 1)
|
||||
return
|
||||
}
|
||||
old = m.state
|
||||
}
|
||||
} else {
|
||||
runtime_Semrelease(&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毫秒。
|
||||
|
||||
正常模式拥有更好的性能,因为即使有等待抢锁的waiter,goroutine也可以连续多次获取到锁。
|
||||
|
||||
饥饿模式是对公平性和性能的一种平衡,它避免了某些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数减1,mutexWoken标志设置上,通过CAS更新state的值(第115行到第119行)。
|
||||
|
||||
# 总结
|
||||
|
||||
“罗马不是一天建成的”,Mutex的设计也是从简单设计到复杂处理逐渐演变的。初版的Mutex设计非常简洁,充分展示了Go创始者的简单、简洁的设计哲学。但是,随着大家的使用,逐渐暴露出一些缺陷,为了弥补这些缺陷,Mutex不得不越来越复杂。
|
||||
|
||||
有一点值得我们学习的,同时也体现了Go创始者的哲学,就是他们强调GO语言和标准库的稳定性,新版本要向下兼容,用新的版本总能编译老的代码。Go语言从出生到现在已经10多年了,这个Mutex对外的接口却没有变化,依然向下兼容,即使现在Go出了两个版本,每个版本也会向下兼容,保持Go语言的稳定性,你也能领悟他们软件开发和设计的思想。
|
||||
|
||||
还有一点,你也可以观察到,为了一个程序20%的特性,你可能需要添加80%的代码,这也是程序越来越复杂的原因。所以,最开始的时候,如果能够有一个清晰而且易于扩展的设计,未来增加新特性时,也会更加方便。
|
||||
|
||||
# 思考题
|
||||
|
||||
最后,给你留两个小问题:
|
||||
|
||||
1. 目前Mutex的state字段有几个意义,这几个意义分别是由哪些字段表示的?
|
||||
1. 等待一个Mutex的goroutine数最大是多少?是否能满足现实的需求?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
469
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/03|Mutex:4种易错场景大盘点.md
Normal file
469
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/03|Mutex:4种易错场景大盘点.md
Normal file
@@ -0,0 +1,469 @@
|
||||
<audio id="audio" title="03|Mutex:4种易错场景大盘点" 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("hello world!")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行的时候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("in foo")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第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, "Lock", nullary),
|
||||
types.NewFunc(token.NoPos, nil, "Unlock", 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("in foo")
|
||||
l.Lock()
|
||||
bar(l)
|
||||
l.Unlock()
|
||||
}
|
||||
|
||||
|
||||
func bar(l sync.Locker) {
|
||||
l.Lock()
|
||||
fmt.Println("in bar")
|
||||
l.Unlock()
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
l := &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]), "goroutine "))[0]
|
||||
id, err := strconv.Atoi(idField)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("cannot get goroutine id: %v", 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(&m.owner) == gid {
|
||||
m.recursion++
|
||||
return
|
||||
}
|
||||
m.Mutex.Lock()
|
||||
// 获得锁的goroutine第一次调用,记录下它的goroutine id,调用次数加1
|
||||
atomic.StoreInt64(&m.owner, gid)
|
||||
m.recursion = 1
|
||||
}
|
||||
|
||||
func (m *RecursiveMutex) Unlock() {
|
||||
gid := goid.Get()
|
||||
// 非持有锁的goroutine尝试释放锁,错误的使用
|
||||
if atomic.LoadInt64(&m.owner) != gid {
|
||||
panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
|
||||
}
|
||||
// 调用次数减1
|
||||
m.recursion--
|
||||
if m.recursion != 0 { // 如果这个goroutine还没有完全释放,则直接返回
|
||||
return
|
||||
}
|
||||
// 此goroutine最后一次调用,需要释放锁
|
||||
atomic.StoreInt64(&m.owner, -1)
|
||||
m.Mutex.Unlock()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面这段代码你可以拿来即用。我们一起来看下这个实现,真是非常巧妙,它相当于给Mutex打一个补丁,解决了记录锁的持有者的问题。可以看到,我们用owner字段,记录当前锁的拥有者goroutine的id;recursion 是辅助字段,用于记录重入的次数。
|
||||
|
||||
有一点,我要提醒你一句,尽管拥有者可以多次调用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(&m.token) == token { //如果传入的token和持有锁的token一致,说明是递归调用
|
||||
m.recursion++
|
||||
return
|
||||
}
|
||||
m.Mutex.Lock() // 传入的token不一致,说明不是递归调用
|
||||
// 抢到锁之后记录这个token
|
||||
atomic.StoreInt64(&m.token, token)
|
||||
m.recursion = 1
|
||||
}
|
||||
|
||||
// 释放锁
|
||||
func (m *TokenRecursiveMutex) Unlock(token int64) {
|
||||
if atomic.LoadInt64(&m.token) != token { // 释放其它token持有的锁
|
||||
panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
|
||||
}
|
||||
m.recursion-- // 当前持有这个锁的token释放锁
|
||||
if m.recursion != 0 { // 还没有回退到最初的递归调用
|
||||
return
|
||||
}
|
||||
atomic.StoreInt64(&m.token, 0) // 没有递归调用了,释放锁
|
||||
m.Mutex.Unlock()
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
## 死锁
|
||||
|
||||
接下来,我们来看第四种错误场景:死锁。
|
||||
|
||||
我先解释下什么是死锁。两个或两个以上的进程(或线程,goroutine)在执行过程中,因争夺共享资源而处于一种互相等待的状态,如果没有外部干涉,它们都将无法推进下去,此时,我们称系统处于死锁状态或系统产生了死锁。
|
||||
|
||||
我们来分析一下死锁产生的必要条件。如果你想避免死锁,只要破坏这四个条件中的一个或者几个,就可以了。
|
||||
|
||||
1. **互斥**: 至少一个资源是被排他性独享的,其他线程必须处于等待状态,直到资源被释放。
|
||||
1. **持有和等待**:goroutine持有一个资源,并且还在请求其它goroutine持有的资源,也就是咱们常说的“吃着碗里,看着锅里”的意思。
|
||||
1. **不可剥夺**:资源只能由持有它的goroutine来释放。
|
||||
1. **环路等待**:一般来说,存在一组等待进程,P={P1,P2,…,PN},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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
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("成功完成")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序没有办法运行成功,因为派出所的处理和物业的处理是一个环路等待的死结。
|
||||
|
||||
<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。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
249
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/04| Mutex:骇客编程,如何拓展额外功能?.md
Normal file
249
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/04| Mutex:骇客编程,如何拓展额外功能?.md
Normal 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 << iota // 加锁标识位置
|
||||
mutexWoken // 唤醒标识位置
|
||||
mutexStarving // 锁饥饿标识位置
|
||||
mutexWaiterShift = iota // 标识waiter的起始bit位置
|
||||
)
|
||||
|
||||
// 扩展一个Mutex结构
|
||||
type Mutex struct {
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
// 尝试获取锁
|
||||
func (m *Mutex) TryLock() bool {
|
||||
// 如果能成功抢到锁
|
||||
if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), 0, mutexLocked) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果处于唤醒、加锁或者饥饿状态,这次请求就不参与竞争了,返回false
|
||||
old := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
|
||||
if old&(mutexLocked|mutexStarving|mutexWoken) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 尝试在竞争的状态下请求锁
|
||||
new := old | mutexLocked
|
||||
return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&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("got the lock")
|
||||
// do something
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// 没有获取到
|
||||
fmt.Println("can't get the lock")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
# 获取等待者的数量等指标
|
||||
|
||||
接下来,我想和你聊聊怎么获取等待者数量等指标。
|
||||
|
||||
第二讲中,我们已经学习了Mutex的结构。先来回顾一下Mutex的数据结构,如下面的代码所示。它包含两个字段,state和sema。前四个字节(int32)就是state字段。
|
||||
|
||||
```
|
||||
type Mutex struct {
|
||||
state int32
|
||||
sema uint32
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Mutex结构中的state字段有很多个含义,通过state字段,你可以知道锁是否已经被某个goroutine持有、当前是否处于饥饿状态、是否有等待的goroutine被唤醒、等待者的数量等信息。但是,state这个字段并没有暴露出来,所以,我们需要想办法获取到这个字段,并进行解析。
|
||||
|
||||
怎么获取未暴露的字段呢?很简单,我们可以通过unsafe的方式实现。我来举一个例子,你一看就明白了。
|
||||
|
||||
```
|
||||
const (
|
||||
mutexLocked = 1 << 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(&m.Mutex)))
|
||||
v = v >> mutexWaiterShift //得到等待者的数值
|
||||
v = v + (v & 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(&m.Mutex)))
|
||||
return state&mutexLocked == mutexLocked
|
||||
}
|
||||
|
||||
// 是否有等待者被唤醒
|
||||
func (m *Mutex) IsWoken() bool {
|
||||
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
|
||||
return state&mutexWoken == mutexWoken
|
||||
}
|
||||
|
||||
// 锁是否处于饥饿状态
|
||||
func (m *Mutex) IsStarving() bool {
|
||||
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
|
||||
return state&mutexStarving == mutexStarving
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以写一个程序测试一下,比如,在1000个goroutine并发访问的情况下,我们可以把锁的状态信息输出出来:
|
||||
|
||||
```
|
||||
func count() {
|
||||
var mu Mutex
|
||||
for i := 0; i < 1000; i++ { // 启动1000个goroutine
|
||||
go func() {
|
||||
mu.Lock()
|
||||
time.Sleep(time.Second)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
// 输出锁的信息
|
||||
fmt.Printf("waitings: %d, isLocked: %t, woken: %t, starving: %t\n", 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 &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机制吗?会有什么问题吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
362
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/05| RWMutex:读写锁的实现原理及避坑指南.md
Normal file
362
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/05| RWMutex:读写锁的实现原理及避坑指南.md
Normal 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 < 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 << 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(&rw.readerCount, 1) < 0 {
|
||||
// rw.readerCount是负值的时候,意味着此时有writer等待请求锁,因为writer优先级高,所以把后来的reader阻塞休眠
|
||||
runtime_SemacquireMutex(&rw.readerSem, false, 0)
|
||||
}
|
||||
}
|
||||
func (rw *RWMutex) RUnlock() {
|
||||
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
|
||||
rw.rUnlockSlow(r) // 有等待的writer
|
||||
}
|
||||
}
|
||||
func (rw *RWMutex) rUnlockSlow(r int32) {
|
||||
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
|
||||
// 最后一个reader了,writer终于有机会获得锁了
|
||||
runtime_Semrelease(&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(>=0)修改为负数(readerCount-rwmutexMaxReaders),让这个字段保持两个含义(既保存了reader的数量,又表示当前有writer)。
|
||||
|
||||
我们来看下下面的代码。第5行,还会记录当前活跃的reader数量,所谓活跃的reader,就是指持有读锁还没有释放的那些reader。
|
||||
|
||||
```
|
||||
func (rw *RWMutex) Lock() {
|
||||
// 首先解决其他writer竞争问题
|
||||
rw.w.Lock()
|
||||
// 反转readerCount,告诉reader有writer竞争锁
|
||||
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
|
||||
// 如果当前有reader持有锁,那么需要等待
|
||||
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
|
||||
runtime_SemacquireMutex(&rw.writerSem, false, 0)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果readerCount不是0,就说明当前有持有读锁的reader,RWMutex需要把这个当前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(&rw.readerCount, rwmutexMaxReaders)
|
||||
|
||||
// 唤醒阻塞的reader们
|
||||
for i := 0; i < int(r); i++ {
|
||||
runtime_Semrelease(&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("in foo")
|
||||
l.Lock()
|
||||
bar(l)
|
||||
l.Unlock()
|
||||
}
|
||||
|
||||
func bar(l *sync.RWMutex) {
|
||||
l.Lock()
|
||||
fmt.Println("in bar")
|
||||
l.Unlock()
|
||||
}
|
||||
|
||||
func main() {
|
||||
l := &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 -> 活跃的reader依赖新来的reader -> 新来的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("Lock")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Unlock()
|
||||
fmt.Println("Unlock")
|
||||
}()
|
||||
|
||||
go func() {
|
||||
factorial(&mu, 10) // 计算10的阶乘, 10!
|
||||
}()
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
// 递归调用计算阶乘
|
||||
func factorial(m *sync.RWMutex, n int) int {
|
||||
if n < 1 { // 阶乘退出条件
|
||||
return 0
|
||||
}
|
||||
fmt.Println("RLock")
|
||||
m.RLock()
|
||||
defer func() {
|
||||
fmt.Println("RUnlock")
|
||||
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 -> 活跃的reader依赖新来的reader -> 新来的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的数量等方法。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
442
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/06 | WaitGroup:协同等待,任务编排利器.md
Normal file
442
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/06 | WaitGroup:协同等待,任务编排利器.md
Normal 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、Pthread(POSIX线程)中的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 < 10; i++ { // 启动10个goroutine执行加1任务
|
||||
go worker(&counter, &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(&wg.state1))%8 == 0 {
|
||||
// 如果地址是64bit对齐的,数组前两个元素做state,后一个元素做信号量
|
||||
return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
|
||||
} else {
|
||||
// 如果地址是32bit对齐的,数组后两个元素用来做state,它可以用来做64bit的原子操作,第一个元素32bit用来做信号量
|
||||
return (*uint64)(unsafe.Pointer(&wg.state1[1])), &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)<<32)
|
||||
v := int32(state >> 32) // 当前计数值
|
||||
w := uint32(state) // waiter count
|
||||
|
||||
if v > 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 >> 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调用次数比计数值少)或者panic(Done调用次数比计数值多)。
|
||||
|
||||
比如下面这个例子中,多调用了一次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, &wg) // 启动第一个goroutine
|
||||
go dosomething(110, &wg) // 启动第二个goroutine
|
||||
go dosomething(120, &wg) // 启动第三个goroutine
|
||||
go dosomething(130, &wg) // 启动第四个goroutine
|
||||
|
||||
wg.Wait() // 主goroutine等待完成
|
||||
fmt.Println("Done")
|
||||
}
|
||||
|
||||
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
|
||||
duration := millisecs * time.Millisecond
|
||||
time.Sleep(duration) // 故意sleep一段时间
|
||||
|
||||
wg.Add(1)
|
||||
fmt.Println("后台执行, duration:", 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, &wg) // 启动第一个goroutine
|
||||
go dosomething(110, &wg) // 启动第二个goroutine
|
||||
go dosomething(120, &wg) // 启动第三个goroutine
|
||||
go dosomething(130, &wg) // 启动第四个goroutine
|
||||
|
||||
wg.Wait() // 主goroutine等待
|
||||
fmt.Println("Done")
|
||||
}
|
||||
|
||||
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
|
||||
duration := millisecs * time.Millisecond
|
||||
time.Sleep(duration)
|
||||
|
||||
fmt.Println("后台执行, duration:", duration)
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
另一种方法是在启动子goroutine之前才调用Add:
|
||||
|
||||
```
|
||||
func main() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
dosomething(100, &wg) // 调用方法,把计数值加1,并启动任务goroutine
|
||||
dosomething(110, &wg) // 调用方法,把计数值加1,并启动任务goroutine
|
||||
dosomething(120, &wg) // 调用方法,把计数值加1,并启动任务goroutine
|
||||
dosomething(130, &wg) // 调用方法,把计数值加1,并启动任务goroutine
|
||||
|
||||
wg.Wait() // 主goroutine等待,代码逻辑保证了四次Add(1)都已经执行完了
|
||||
fmt.Println("Done")
|
||||
}
|
||||
|
||||
func dosomething(millisecs time.Duration, wg *sync.WaitGroup) {
|
||||
wg.Add(1) // 计数值加1,再启动goroutine
|
||||
|
||||
go func() {
|
||||
duration := millisecs * time.Millisecond
|
||||
time.Sleep(duration)
|
||||
fmt.Println("后台执行, duration:", 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 := &TestStruct{
|
||||
Wait: w,
|
||||
}
|
||||
|
||||
t.Wait.Done()
|
||||
fmt.Println("Finished")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码最大的一个问题,就是第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的当前的计数值吗?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
333
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/07 | Cond:条件变量的实现机制及避坑指南.md
Normal file
333
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/07 | Cond:条件变量的实现机制及避坑指南.md
Normal 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(&sync.Mutex{})
|
||||
var ready int
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(i int) {
|
||||
time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
|
||||
|
||||
// 加锁更改等待条件
|
||||
c.L.Lock()
|
||||
ready++
|
||||
c.L.Unlock()
|
||||
|
||||
log.Printf("运动员#%d 已准备就绪\n", i)
|
||||
// 广播唤醒所有的等待者
|
||||
c.Broadcast()
|
||||
}(i)
|
||||
}
|
||||
|
||||
c.L.Lock()
|
||||
for ready != 10 {
|
||||
c.Wait()
|
||||
log.Println("裁判员被唤醒一次")
|
||||
}
|
||||
c.L.Unlock()
|
||||
|
||||
//所有的运动员是否就绪
|
||||
log.Println("所有运动员都准备就绪。比赛开始,3,2,1, ......")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你看,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 &Cond{L: l}
|
||||
}
|
||||
|
||||
func (c *Cond) Wait() {
|
||||
c.checker.check()
|
||||
// 增加到等待队列中
|
||||
t := runtime_notifyListAdd(&c.notify)
|
||||
c.L.Unlock()
|
||||
// 阻塞休眠直到被唤醒
|
||||
runtime_notifyListWait(&c.notify, t)
|
||||
c.L.Lock()
|
||||
}
|
||||
|
||||
func (c *Cond) Signal() {
|
||||
c.checker.check()
|
||||
runtime_notifyListNotifyOne(&c.notify)
|
||||
}
|
||||
|
||||
func (c *Cond) Broadcast() {
|
||||
c.checker.check()
|
||||
runtime_notifyListNotifyAll(&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(&sync.Mutex{})
|
||||
var ready int
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(i int) {
|
||||
time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
|
||||
|
||||
// 加锁更改等待条件
|
||||
c.L.Lock()
|
||||
ready++
|
||||
c.L.Unlock()
|
||||
|
||||
log.Printf("运动员#%d 已准备就绪\n", i)
|
||||
// 广播唤醒所有的等待者
|
||||
c.Broadcast()
|
||||
}(i)
|
||||
}
|
||||
|
||||
// c.L.Lock()
|
||||
for ready != 10 {
|
||||
c.Wait()
|
||||
log.Println("裁判员被唤醒一次")
|
||||
}
|
||||
// c.L.Unlock()
|
||||
|
||||
//所有的运动员是否就绪
|
||||
log.Println("所有运动员都准备就绪。比赛开始,3,2,1, ......")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再运行程序,就会报释放未加锁的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(&sync.Mutex{})
|
||||
var ready int
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(i int) {
|
||||
time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)
|
||||
|
||||
// 加锁更改等待条件
|
||||
c.L.Lock()
|
||||
ready++
|
||||
c.L.Unlock()
|
||||
|
||||
log.Printf("运动员#%d 已准备就绪\n", i)
|
||||
// 广播唤醒所有的等待者
|
||||
c.Broadcast()
|
||||
}(i)
|
||||
}
|
||||
|
||||
c.L.Lock()
|
||||
// for ready != 10 {
|
||||
c.Wait()
|
||||
log.Println("裁判员被唤醒一次")
|
||||
// }
|
||||
c.L.Unlock()
|
||||
|
||||
//所有的运动员是否就绪
|
||||
log.Println("所有运动员都准备就绪。比赛开始,3,2,1, ......")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行这个程序,你会发现,可能只有几个运动员准备好之后程序就运行完了,而不是我们期望的所有运动员都准备好才进行下一步。原因在于,每一个运动员准备好之后都会唤醒所有的等待者,也就是这里的裁判员,比如第一个运动员准备好后就唤醒了裁判员,结果这个裁判员傻傻地没做任何检查,以为所有的运动员都准备好了,就继续执行了。
|
||||
|
||||
所以,我们一定要**记住**,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("Error adding pod %v to the scheduling queue: %v", 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?
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
561
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/08 | Once:一个简约而不简单的并发原语.md
Normal file
561
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/08 | Once:一个简约而不简单的并发原语.md
Normal 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 (
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 使用互斥锁保证线程(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("tcp", "baidu.com:80", 10*time.Second)
|
||||
return conn
|
||||
}
|
||||
|
||||
// 使用连接
|
||||
func main() {
|
||||
conn := getConn()
|
||||
if conn == nil {
|
||||
panic("conn is nil")
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种方式虽然实现起来简单,但是有性能问题。一旦连接创建好,每次请求的时候还是得竞争锁才能读取到这个连接,这是比较浪费资源的,因为连接如果创建好之后,其实就不需要锁的保护了。怎么办呢?
|
||||
|
||||
这个时候就可以使用这一讲要介绍的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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var once sync.Once
|
||||
|
||||
// 第一个初始化函数
|
||||
f1 := func() {
|
||||
fmt.Println("in f1")
|
||||
}
|
||||
once.Do(f1) // 打印出 in f1
|
||||
|
||||
// 第二个初始化函数
|
||||
f2 := func() {
|
||||
fmt.Println("in f2")
|
||||
}
|
||||
once.Do(f2) // 无输出
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因为这里的f参数是一个无参数无返回的函数,所以你可能会通过闭包的方式引用外面的参数,比如:
|
||||
|
||||
```
|
||||
var addr = "baidu.com"
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
once.Do(func() {
|
||||
conn, err = net.Dial("tcp", 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(&aus) })
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除此之外,还有保证只调用一次copyenv的envOnce,strings包下的Replacer,time包中的[测试](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.pipe,crc64,Regexp,…,数不胜数。我给你重点介绍一下很值得我们学习的 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(&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(&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(&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("初始化")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,想要避免这种情况的出现,就不要在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("tcp", "google.com:80")
|
||||
})
|
||||
// 发送http请求
|
||||
googleConn.Write([]byte("GET / HTTP/1.1\r\nHost: google.com\r\n Accept: */*\r\n\r\n"))
|
||||
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(&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(&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(&a.inited, 1)
|
||||
})
|
||||
}
|
||||
func (a *AnimalStore) CountOfCats() (int, error) { // 另外一个goroutine
|
||||
if atomic.LoadUint32(&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(&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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 一个组合的并发原语
|
||||
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("Hello, playground")
|
||||
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 (
|
||||
"sync"
|
||||
)
|
||||
|
||||
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("EOF")
|
||||
|
||||
```
|
||||
|
||||
因为它是一个package级别的变量,我们可以在程序中偷偷把它改了,这会导致一些依赖io.EOF这个变量做判断的代码出错。
|
||||
|
||||
```
|
||||
io.EOF = errors.New("我们自己定义的EOF")
|
||||
|
||||
```
|
||||
|
||||
从我个人的角度来说,一些单例(全局变量)的确很方便,比如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>
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
537
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/09 | map:如何实现线程安全的map类型?.md
Normal file
537
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/09 | map:如何实现线程安全的map类型?.md
Normal 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] = "hello"
|
||||
fmt.Printf("m[key]=%s\n", m[key])
|
||||
|
||||
|
||||
// 修改key的字段的值后再次查询map,无法获取刚才add进去的值
|
||||
key.key = 100
|
||||
fmt.Printf("再次查询m[key]=%s\n", 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["a"] = 0
|
||||
fmt.Printf("a=%d; b=%d\n", m["a"], m["b"])
|
||||
|
||||
av, aexisted := m["a"]
|
||||
bv, bexisted := m["b"]
|
||||
fmt.Printf("a=%d, existed: %t; b=%d, existed: %t\n", 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 = "baidu.com"
|
||||
|
||||
|
||||
c.PageCounters["/"]++
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以,关于初始化这一点,我再强调一下,目前还没有工具可以检查,我们只能记住“**别忘记初始化**”这一条规则。
|
||||
|
||||
### 常见错误二:并发读写
|
||||
|
||||
对于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 40772,Docker 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 &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 < SHARD_COUNT; i++ {
|
||||
m[i] = &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 && e.tryStore(&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(&value) // 更新
|
||||
} else if e, ok := m.dirty[key]; ok { // 如果dirty中有此项
|
||||
e.storeLocked(&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 && read.amended { // 如果不存在并且dirty不为nil(有新的元素)
|
||||
m.mu.Lock()
|
||||
// 双检查,看看read中现在是否存在此key
|
||||
read, _ = m.read.Load().(readOnly)
|
||||
e, ok = read.m[key]
|
||||
if !ok && 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 < 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 && read.amended {
|
||||
m.mu.Lock()
|
||||
// 双检查
|
||||
read, _ = m.read.Load().(readOnly)
|
||||
e, ok = read.m[key]
|
||||
if !ok && 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(&e.p)
|
||||
if p == nil || p == expunged {
|
||||
return nil, false
|
||||
}
|
||||
if atomic.CompareAndSwapPointer(&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>
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
472
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/10 | Pool:性能提升大杀器.md
Normal file
472
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/10 | Pool:性能提升大杀器.md
Normal 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的自动垃圾回收机制还是有一个STW(stop-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方法的返回值还可能会是一个nil(Pool.New字段没有设置,又没有空闲元素可以返回),所以你在使用的时候,可能需要判断。
|
||||
|
||||
**3.Put**
|
||||
|
||||
这个方法用于将一个元素返还给Pool,Pool会把这个元素保存到池中,并且可以复用。但如果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 && 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(&p.localSize)
|
||||
locals := p.local
|
||||
// 从其它proc中尝试偷取一个元素
|
||||
for i := 0; i < 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(&p.victimSize)
|
||||
if uintptr(pid) >= 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 < 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(&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("tcp", "127.0.0.1:4000") }
|
||||
|
||||
// 创建一个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) >= 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,这个时候,我们就可以创建一个固定数量的goroutine(Worker),由这一组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开发者都使用了什么样的方式来重用这些对象。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
301
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/11 | Context:信息穿透上下文.md
Normal file
301
极客时间专栏/geek/Go 并发编程实战课/基本并发原语/11 | Context:信息穿透上下文.md
Normal 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 "context"
|
||||
|
||||
type Context = context.Context
|
||||
type CancelFunc = context.CancelFunc
|
||||
|
||||
```
|
||||
|
||||
Go标准库的Context不仅提供了上下文传递的信息,还提供了cancel、timeout等其它信息,这些信息貌似和context这个包名没关系,但是还是得到了广泛的应用。所以,你看,context包中的Context不仅仅传递上下文信息,还有timeout等其它功能,是不是“名不副实”呢?
|
||||
|
||||
其实啊,这也是这个Context的一个问题,比较容易误导人,Go布道师Dave Cheney还专门写了一篇文章讲述这个问题:[Context isn’t 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() <-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没有被close,Err方法返回nil;如果Done被close,Err方法会返回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, "key1", "0001")
|
||||
ctx = context.WithValue(ctx, "key2", "0001")
|
||||
ctx = context.WithValue(ctx, "key3", "0001")
|
||||
ctx = context.WithValue(ctx, "key4", "0004")
|
||||
|
||||
fmt.Println(ctx.Value("key1"))
|
||||
|
||||
```
|
||||
|
||||
<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, &c)// 把c朝上传播
|
||||
return &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("context canceled")
|
||||
|
||||
```
|
||||
|
||||
## 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 && cur.Before(d) {
|
||||
return WithCancel(parent)
|
||||
}
|
||||
c := &timerCtx{
|
||||
cancelCtx: newCancelCtx(parent),
|
||||
deadline: d,
|
||||
}
|
||||
propagateCancel(parent, c) // 同cancelCtx的处理逻辑
|
||||
dur := time.Until(d)
|
||||
if dur <= 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一样,WithDeadline(WithTimeout)返回的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("goroutine exit")
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-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了。
|
||||
|
||||
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。
|
||||
Reference in New Issue
Block a user