├── 1.基本并发原语 ├── 1.01:Mutex如何解决资源并发访问问题 │ ├── 01.00-Mutex基本简介和应用.md │ ├── 01.01.unLock.go │ ├── 01.02.Lock.go │ ├── 01.03.MutexStruct.go │ └── 01.think思考题.md ├── 1.02:Mutex的底层实现 │ ├── 02.00-Mutex的实现.md │ └── 02.think思考题.md ├── 1.03:Mutex:4种易错场景大盘点 │ ├── 03.00-Mutex:4种易错场景大盘点.md │ ├── 03.01.没有lock.go │ ├── 03.02.复制了带状态的Mutex.go │ ├── 03.03.重入的情况.go │ ├── 03.04.解析goroutineID.go │ ├── 03.05.实现可重入锁.go │ ├── 03.06.token方式可重入锁.go │ └── 03.07.死锁问题.go ├── 1.04:Mutex:骇客编程,如何拓展额外功能? │ ├── 04.00-Mutex:骇客编程,如何拓展额外功能?.md │ ├── 04.01.基于Mutex实现的TryLock.go │ ├── 04.02.获取state字段的信息.go │ └── 04.03.并发安全的队列.go ├── 1.05:RWMutex:读写锁的实现原理及避坑指南 │ ├── 05.00-RWMutex:读写锁的实现原理及避坑指南.md │ ├── 05.01.计数器示例使用读写锁.go │ └── 05.02.阶乘依赖循环‘.go ├── 1.06:WaitGroup:协同等待,任务编排利器 │ ├── 06.00-WaitGroup:协同等待,任务编排利器.md │ ├── 06.01.等待队列计数器.go │ ├── 06.02.add方法调用负数.go │ ├── 06.03.错误重用WaitGroup.go │ └── 06.think思考题.go ├── 1.07:Cond:条件变量的实现机制及避坑指南 │ ├── 07.00-Cond:条件变量的实现机制及避坑指南.md │ ├── 07.01.裁判运动员例子.go │ └── 07.02.容量有限的queue.go ├── 1.08:Once:一个简约而不简单的并发原语 │ ├── 08.00-Once:一个简约而不简单的并发原语.md │ └── 08.01.直到成功的Once.go ├── 1.09:map:如何实现线程安全的map类型? │ ├── 09.00-map:如何实现线程安全的map类型?.md │ ├── 09.01.初始化map示例.go │ ├── 09.03.并发读写自带map.go │ └── 09.03.读写锁并发安全map.go ├── 1.10:Pool:性能提升大杀器 │ └── 10.00-Pool:性能提升大杀器.md └── 1.11:Context:信息穿透上下文 │ ├── 11.00-Context:信息穿透上下文.md │ ├── 11.01.WithValue用法.go │ └── 11.02.思考题.go ├── 2.原子操作 └── 2.01:atomic:要保证原子操作,一定要使用这几种方法 │ ├── 2.00-atomic:要保证原子操作,一定要使用这几种方法.md │ ├── 2.01.test.go │ ├── 2.02.配置变更.go │ └── 2.03.LKQueue.go ├── 3.Channel ├── 3.01:Channel:另辟蹊径,解决并发问题 │ ├── 03.00-Channel:另辟蹊径,解决并发问题.md │ └── 03.01.思考题.go ├── 3.02:Channel:透过代码看典型的应用模式 │ ├── 02.01.反射动态处理chan.go │ └── 04.00-Channel:透过代码看典型的应用模式.md └── 3.03:内存模型:Go如何保证并发读写的顺序? │ └── 03.00-内存模型:Go如何保证并发读写的顺序?.md ├── README.md └── go.mod /1.基本并发原语/1.01:Mutex如何解决资源并发访问问题/01.00-Mutex基本简介和应用.md: -------------------------------------------------------------------------------- 1 | # Mutex的基本简介和应用 2 | ## 前言 3 | 并发:说白了就是系统一次运行多个程序,或者一个程序运行多个任务的过程。CPU通过切换线程对公共资源持有的时间片,去调度不同任务的异步执行。 4 | 从程序的层面来说:就是多个线程(协程)的同时运行。运行过程中免不了对公共资源进行操作。这个公共资源可以是程序,可以是文件,可以是数据库。对于这一类的资源一般被称为**临界区** 5 | 如果很多线程(或者协程)同步访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以使用互斥锁,限定临界区只能同时由一个线程持有。当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。 6 | 来个通俗点的解释就是上厕所大号,只有一个坑,但是这里是公共厕所,为了防止你在doing something的时候被人访问,你就会把门锁上,等到你出去了就会把门开起来,等下一个人进来。最经典的互斥锁并发理论(当然了,不能这样子对面试官讲的) 7 | ![get_lock](https://s1.ax1x.com/2020/10/13/0h8uq0.jpg "协程根据锁获取资源") 8 | 根据上图所示互斥锁就很好地解决了资源竞争问题,有人也把互斥锁叫做排它锁。那在 Go 标准库中,它提供了 Mutex 来实现互斥锁这个功能。在很多地方也有把它叫做同步原语。go语言中的sync包就是主要负责实现这一块儿的地方。 9 | 具体什么场景适合使用同步原语呢? 10 | - 共享资源:并发地读写共享资源,会出现数据竞争(data race)的问题,所以需要 Mutex、RWMutex 这样的并发原语来保护。 11 | - 任务编排:需要 goroutine 按照一定的规律执行,而 goroutine 之间有相互等待或者依赖的顺序关系,我们常常使用 WaitGroup 或者 Channel 来实现。 12 | - 消息传递:信息交流以及不同的 goroutine 之间的线程安全的数据交流,常常使用 Channel 来实现。 13 | 接下来从互斥锁开始说。 14 | ## Mutex 的基本使用方法 15 | 在go语言的sync包中Mutex实现了Locker接口,我们来先看下Locker接口的代码 16 | ``` go 17 | // A Locker represents an object that can be locked and unlocked. 18 | type Locker interface { 19 | Lock() 20 | Unlock() 21 | } 22 | ``` 23 | 可以看到,Go 定义的锁接口的方法集很简单,就是请求锁(Lock)和释放锁(Unlock)这两个方法,秉承了 Go 语言一贯的简洁风格。 24 | 但是我们一般会直接使用具体的同步原语,而不是通过接口。 25 | 接下来我们直接看Mutex:**互斥锁 Mutex 就提供两个方法 Lock 和 Unlock:进入临界区之前调用 Lock 方法,退出临界区的时候调用 Unlock 方法:** 26 | ``` go 27 | // A Locker represents an object that can be locked and unlocked. 28 | func(m *Mutex)Lock() 29 | func(m *Mutex)Unlock() 30 | ``` 31 | **当一个 goroutine 通过调用 Lock 方法获得了这个锁的拥有权后, 其它请求锁的 goroutine 就会阻塞在 Lock 方法的调用上,直到锁被释放并且自己获取到了这个锁的拥有权。**(跟上述是不是很像,doing something) 32 | 至于为什么要加锁,以i++来说,线程在处理这一个的时候,会先从内存复制一个值,取完了进行加一放回去,你放的快别人拿的时候看到的就是你改过的值,你放的慢你就把别人的值给覆盖了。 33 | 举个例子吧: 34 | ``` go 35 | package main 36 | 37 | import ( 38 | "fmt" 39 | "sync" 40 | ) 41 | 42 | func main() { 43 | for i := 0; i < 10; i++ {//简单for循环 44 | fmt.Printf("第%d次输出: ",i) 45 | UnLock() 46 | } 47 | 48 | } 49 | 50 | func UnLock() { 51 | var count = 0 52 | // 使用WaitGroup等待10个goroutine完成 53 | var wg sync.WaitGroup 54 | wg.Add(10) 55 | for i := 0; i < 10; i++ { 56 | go func() { 57 | defer wg.Done() 58 | // 对变量count执行10次加1 59 | for j := 0; j < 100000; j++ { 60 | count++ 61 | } 62 | }() 63 | } 64 | // 等待10个goroutine完成 65 | wg.Wait() 66 | fmt.Println(count) 67 | } 68 | 69 | ``` 70 | 在unLock函数中,10个协程同时对一个进行 10 万次的加 1 操作,我们期望的最后计数的结果是 10 * 100000 = 1000000 (一百万)。但是实际的结果确实这样的: 71 | 72 | ![运行结果](https://s1.ax1x.com/2020/10/14/05TCjO.png "未加锁的运行结果") 73 | 10次的输出都没有一次是正确答案,可见没有加锁的并发读写是多么的不安全。这个问题,有经验的开发人员还是比较容易发现的,但是,很多时候,并发问题隐藏得非常深,即使是有经验的人,也不太容易发现或者 Debug 出来。针对这个问题,Go 提供了一个检测并发访问共享资源是否有问题的工具: race detector,它可以帮助我们自动发现程序有没有 data race 的问题。 74 | Go race detector 是基于 Google 的 C/C++ sanitizers 技术实现的,编译器通过探测所有的内存访问,加入代码能监视对这些内存地址的访问(读还是写)。在代码运行的时候,race detector 就能监控到对共享变量的非同步访问,出现 race 的时候,就会打印出警告信息。例如:go run -race counter.go 75 | 既然这个例子存在 data race 问题,我们就要想办法来解决它。这个时候,我们的主角 Mutex 就要登场了,它可以轻松地消除掉 data race。 76 | 77 | 我们知道,这里的共享资源是 count 变量,临界区是 count++,只要在临界区前面获取锁,在离开临界区的时候释放锁,就能完美地解决 data race 的问题了。 78 | ```go 79 | package main 80 | 81 | import ( 82 | "fmt" 83 | "sync" 84 | ) 85 | 86 | /** 87 | 使用Mutex情况下10个goroutine进行对同一个变量的计数 88 | */ 89 | func main() { 90 | for i := 0; i < 10; i++ { //简单for循环 91 | fmt.Printf("第%d次输出: ", i) 92 | Lock() 93 | } 94 | } 95 | 96 | //用Mutex处理过的方法 97 | func Lock() { 98 | // 互斥锁保护计数器 99 | var mu sync.Mutex 100 | // 计数器的值 101 | var count = 0 102 | 103 | // 辅助变量,用来确认所有的goroutine都完成 104 | var wg sync.WaitGroup 105 | wg.Add(10) 106 | 107 | // 启动10个gourontine 108 | for i := 0; i < 10; i++ { 109 | go func() { 110 | defer wg.Done() 111 | // 累加10万次 112 | for j := 0; j < 100000; j++ { 113 | mu.Lock() 114 | count++ 115 | mu.Unlock() 116 | } 117 | }() 118 | } 119 | wg.Wait() 120 | fmt.Println(count) 121 | } 122 | 123 | ``` 124 | 这段代码的运行结果就如下图所示了 125 | ![运行结果](https://s3.ax1x.com/2020/11/23/DJJLi4.png "加锁的运行结果") 126 | 这样子就完美的解决了之前的问题了,其实就是加锁访问,放锁,给其他人访问罢了。在很多的使用情况下,都会直接把Mutex 会嵌入到其它 struct 中使用,如下代码所示: 127 | ```go 128 | type Counter struct { 129 | mu sync.Mutex 130 | Count uint64 131 | } 132 | 133 | ``` 134 | 135 | 如果嵌入的 struct 有多个字段,我们一般会把 Mutex 放在要控制的字段上面,然后使用空格把字段分隔开来。即使你不这样做,代码也可以正常编译,只不过,用这种风格去写的话,逻辑会更清晰,也更易于维护。甚至,你还可以把获取锁、释放锁、计数加一的逻辑封装成一个方法, 136 | ```go 137 | package main 138 | 139 | import ( 140 | "fmt" 141 | "sync" 142 | ) 143 | 144 | /** 145 | 有时候,我们还可以采用嵌入字段的方式。通过嵌入字段,你可以在这个 struct 上直接调用 Lock/Unlock 方法。 146 | 如果嵌入的 struct 有多个字段,我们一般会把 Mutex 放在要控制的字段上面,然后使用空格把字段分隔开来。 147 | 甚至,你还可以把获取锁、释放锁、计数加一的逻辑封装成一个方法,对外不需要暴露锁等逻辑 148 | */ 149 | 150 | func main() { 151 | // 封装好的计数器 152 | var counter Counter 153 | 154 | var wg sync.WaitGroup 155 | wg.Add(10) 156 | 157 | // 启动10个goroutine 158 | for i := 0; i < 10; i++ { 159 | go func() { 160 | defer wg.Done() 161 | // 执行10万次累加 162 | for j := 0; j < 100000; j++ { 163 | counter.Incr() // 受到锁保护的方法 164 | } 165 | }() 166 | } 167 | wg.Wait() 168 | fmt.Println(counter.Count()) 169 | } 170 | 171 | // 线程安全的计数器类型 172 | type Counter struct { 173 | CounterType int 174 | Name string 175 | 176 | mu sync.Mutex 177 | count uint64 178 | } 179 | 180 | // 加1的方法,内部使用互斥锁保护 181 | func (c *Counter) Incr() { 182 | c.mu.Lock() 183 | c.count++ 184 | c.mu.Unlock() 185 | } 186 | 187 | // 得到计数器的值,也需要锁保护 188 | func (c *Counter) Count() uint64 { 189 | c.mu.Lock() 190 | defer c.mu.Unlock() 191 | return c.count 192 | } 193 | 194 | 195 | ``` 196 | 197 | 198 | 那么问题就来了,如果在Mutex门外有多个大哥(goroutine)要准备开门呢?也就是Mutex 已经被一个 goroutine 获取了锁,其它等待中的 goroutine 们只能一直等待。那么,等这个锁释放后,等待中的 goroutine 中哪一个会优先获取 Mutex 呢? 199 | 200 | - 等待的goroutine们是以FIFO排队的: 201 | 1)当Mutex处于正常模式时,若此时没有新goroutine与队头goroutine竞争,则队头goroutine获得。若有新goroutine竞争大概率新goroutine获得。 202 | 2)当队头goroutine竞争锁失败1ms后,它会将Mutex调整为饥饿模式。进入饥饿模式后,锁的所有权会直接从解锁goroutine移交给队头goroutine,此时新来的goroutine直接放入队尾。 203 | 3)当一个goroutine获取锁后,如果发现自己满足下列条件中的任何一个#1它是队列中最后一个#2它等待锁的时间少于1ms,则将锁切换回正常模式 -------------------------------------------------------------------------------- /1.基本并发原语/1.01:Mutex如何解决资源并发访问问题/01.01.unLock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | /** 9 | 在没有加锁的情况下10个goroutine进行对同一个变量的计数 10 | */ 11 | func main() { 12 | for i := 0; i < 10; i++ { //简单for循环 13 | fmt.Printf("第%d次输出: ", i) 14 | UnLock() 15 | } 16 | 17 | } 18 | 19 | func UnLock() { 20 | var count = 0 21 | // 使用WaitGroup等待10个goroutine完成 22 | var wg sync.WaitGroup 23 | wg.Add(10) 24 | for i := 0; i < 10; i++ { 25 | go func() { 26 | defer wg.Done() 27 | // 对变量count执行10次加1 28 | for j := 0; j < 100000; j++ { 29 | count++ 30 | } 31 | }() 32 | } 33 | // 等待10个goroutine完成 34 | wg.Wait() 35 | fmt.Println(count) 36 | } 37 | -------------------------------------------------------------------------------- /1.基本并发原语/1.01:Mutex如何解决资源并发访问问题/01.02.Lock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | /** 9 | 使用Mutex情况下10个goroutine进行对同一个变量的计数 10 | */ 11 | func main() { 12 | for i := 0; i < 10; i++ { //简单for循环 13 | fmt.Printf("第%d次输出: ", i) 14 | Lock() 15 | } 16 | } 17 | 18 | //用Mutex处理过的方法 19 | func Lock() { 20 | // 互斥锁保护计数器 21 | var mu sync.Mutex 22 | // 计数器的值 23 | var count = 0 24 | 25 | // 辅助变量,用来确认所有的goroutine都完成 26 | var wg sync.WaitGroup 27 | wg.Add(10) 28 | 29 | // 启动10个gourontine 30 | for i := 0; i < 10; i++ { 31 | go func() { 32 | defer wg.Done() 33 | // 累加10万次 34 | for j := 0; j < 100000; j++ { 35 | mu.Lock() 36 | count++ 37 | mu.Unlock() 38 | } 39 | }() 40 | } 41 | wg.Wait() 42 | fmt.Println(count) 43 | } 44 | -------------------------------------------------------------------------------- /1.基本并发原语/1.01:Mutex如何解决资源并发访问问题/01.03.MutexStruct.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | /** 9 | 有时候,我们还可以采用嵌入字段的方式。通过嵌入字段,你可以在这个 struct 上直接调用 Lock/Unlock 方法。 10 | 如果嵌入的 struct 有多个字段,我们一般会把 Mutex 放在要控制的字段上面,然后使用空格把字段分隔开来。 11 | 甚至,你还可以把获取锁、释放锁、计数加一的逻辑封装成一个方法,对外不需要暴露锁等逻辑 12 | */ 13 | 14 | func main() { 15 | // 封装好的计数器 16 | var counter Counter 17 | 18 | var wg sync.WaitGroup 19 | wg.Add(10) 20 | 21 | // 启动10个goroutine 22 | for i := 0; i < 10; i++ { 23 | go func() { 24 | defer wg.Done() 25 | // 执行10万次累加 26 | for j := 0; j < 100000; j++ { 27 | counter.Incr() // 受到锁保护的方法 28 | } 29 | }() 30 | } 31 | wg.Wait() 32 | fmt.Println(counter.Count()) 33 | } 34 | 35 | // 线程安全的计数器类型 36 | type Counter struct { 37 | CounterType int 38 | Name string 39 | 40 | mu sync.Mutex 41 | count uint64 42 | } 43 | 44 | // 加1的方法,内部使用互斥锁保护 45 | func (c *Counter) Incr() { 46 | c.mu.Lock() 47 | c.count++ 48 | c.mu.Unlock() 49 | } 50 | 51 | // 得到计数器的值,也需要锁保护 52 | func (c *Counter) Count() uint64 { 53 | c.mu.Lock() 54 | defer c.mu.Unlock() 55 | return c.count 56 | } 57 | -------------------------------------------------------------------------------- /1.基本并发原语/1.01:Mutex如何解决资源并发访问问题/01.think思考题.md: -------------------------------------------------------------------------------- 1 | # 思考题 2 | - 你已经知道,如果 Mutex 已经被一个 goroutine 获取了锁,其它等待中的 goroutine 们只能一直等待。那么,等这个锁释放后,等待中的 goroutine 中哪一个会优先获取 Mutex 呢? 3 | 答: 4 | 等待的goroutine们是以FIFO排队的 5 | 1)当Mutex处于正常模式时,若此时没有新goroutine与队头goroutine竞争,则队头goroutine获得。若有新goroutine竞争大概率新goroutine获得。 6 | 2)当队头goroutine竞争锁失败1ms后,它会将Mutex调整为饥饿模式。进入饥饿模式后,锁的所有权会直接从解锁goroutine移交给队头goroutine,此时新来的goroutine直接放入队尾。 7 | 8 | 3)当一个goroutine获取锁后,如果发现自己满足下列条件中的任何一个#1它是队列中最后一个#2它等待锁的时间少于1ms,则将锁切换回正常模式 -------------------------------------------------------------------------------- /1.基本并发原语/1.02:Mutex的底层实现/02.00-Mutex的实现.md: -------------------------------------------------------------------------------- 1 | # 探索 Mutex 的实现及演进之路 2 | Mutex 的架构演进分成了四个阶段,下面给你画了一张图来说明。 3 | - “初版”的 Mutex 使用一个 flag 来表示锁是否被持有,实现比较简单; 4 | - 后来照顾到新来的 goroutine,所以会让新的 goroutine 也尽可能地先获取到锁,这是第二个阶段,我把它叫作“给新人机会”; 5 | - 接下来就是第三阶段“多给些机会”,照顾新来的和被唤醒的 goroutine;但是这样会带来饥饿问题, 6 | - 所以目前又加入了饥饿的解决方案,也就是第四阶段“解决饥饿”。 7 | 8 | ![四个阶段](https://static001.geekbang.org/resource/image/c2/35/c28531b47ff7f220d5bc3c9650180835.jpg "Mutex 的架构演进四个阶段图") 9 | 10 | 11 | 接下来就慢慢的体会一下吧! 12 | 13 | ## 初版的互斥锁 14 | 假如我们来设计一个锁的状态的时候,很快就会想到使用flag去表示锁的状态,被一个goroutine持有,状态就为1,其他的goroutine就等待,如果flag为0,就可以通过 CAS(compare-and-swap,或者 compare-and-set)将这个 flag 设置为 1,标识锁被当前的这个 goroutine 持有了。 15 | 16 | 画外音:那么CAS是什么呢? 17 | - 之前在学java并发的时候有看过一点点 :CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。反正就是我知道你flag为0,我来了,诶?你的flag怎么是1?那我走了,再见~ 18 | 19 | 回归正传:在08版的实现如下: 20 | 21 | ```go 22 | 23 | // CAS操作,当时还没有抽象出atomic包 24 | func cas(val *int32, old, new int32) bool 25 | func semacquire(*int32) 26 | func semrelease(*int32) 27 | // 互斥锁的结构,包含两个字段 28 | type Mutex struct { 29 | key int32 // 锁是否被持有的标识 30 | sema int32 // 信号量专用,用以阻塞/唤醒goroutine 31 | } 32 | 33 | // 保证成功在val上增加delta的值 34 | func xadd(val *int32, delta int32) (new int32) { 35 | for { 36 | v := *val 37 | if cas(val, v, v+delta) { 38 | return v + delta 39 | } 40 | } 41 | panic("unreached") 42 | } 43 | 44 | // 请求锁 45 | func (m *Mutex) Lock() { 46 | if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁 47 | return 48 | } 49 | semacquire(&m.sema) // 否则阻塞等待 50 | } 51 | 52 | func (m *Mutex) Unlock() { 53 | if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者 54 | return 55 | } 56 | semrelease(&m.sema) // 唤醒其它阻塞的goroutine 57 | } 58 | ``` 59 | 现在来看代码绝对有变,但是道理就是这么个道理,死人不喘气,活人气吁吁,老祖宗的哲学还是一样的,我们照看不误: 60 | 61 | Mutex 结构体包含两个字段: 62 | - 字段 key:是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有; 63 | - 字段 sema:是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。 64 | ![](https://static001.geekbang.org/resource/image/82/25/825e23e1af96e78f3773e0b45de38e25.jpg "初版Mutex 结构体") 65 | 66 | 调用Lock请求加锁的时候呢,就通过执行 CAS 操作也就是xdd方法进行原子性的加锁。xadd 方法通过循环执行 CAS 操作直到成功,保证对 key 加 1 的操作成功完成。如果比较幸运,锁没有被别的 goroutine 持有,那么,Lock 方法成功地将 key 设置为 1,这个 goroutine 就持有了这个锁;如果锁已经被别的 goroutine 持有了,那么,当前的 goroutine 会把 key 加 1,而且还会调用 semacquire 方法,使用信号量将自己休眠,等锁释放的时候,信号量会将它唤醒。 67 | 68 | 持有锁的 goroutine 调用 Unlock 释放锁时,它会将 key 减 1。如果当前没有其它等待这个锁的 goroutine,这个方法就返回了。但是,如果还有等待此锁的其它 goroutine,那么,它会调用 semrelease 方法,利用信号量唤醒等待锁的其它 goroutine 中的一个。 69 | 70 | 那么,其实初版的Mutex 利用 CAS 原子操作,对 key 这个标志量进行设置。key 不仅仅标识了锁是否被 goroutine 所持有,还记录了当前持有和等待获取锁的 goroutine 的数量。 71 | 72 | **注意了:Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今。** 73 | 74 | 这就有点蛋疼了,举个栗子,你在上厕所把门锁了,此刻一个排队的大哥把你门开了与你共舞,卧槽,这是真刺激,为啥还有点小激动呢?后面会发生什么那真是意想不到啊^~^ 75 | 所以我们一定要有道德约束,谁上的锁自己才要去解锁,这种刺激的事情尽量别去做。之前在java中要不然就用同步块synchronized 进行同步方法定义,要不然就是在某地开启Lock,if符合条件后进行unlock操作,有点子麻烦哦。特大喜讯特大喜讯,go语言支持defer啦!defer会在方法返回时执行,让你的解锁永不遗忘! 76 | ```go 77 | func (f *Foo) Bar() { 78 | f.mu.Lock() 79 | defer f.mu.Unlock() 80 | 81 | 82 | if f.count < 1000 { 83 | f.count += 3 84 | return 85 | } 86 | 87 | 88 | f.count++ 89 | return 90 | } 91 | ``` 92 | 这成双成对比翼双飞的lock和Unlock看起来是多么的舒服呀,但是有一点记好了,defer虽好,不要贪杯哦! 93 | 如果临界区只是方法中的一部分,为了尽快释放锁,还是应该第一时间调用 Unlock,而不是一直等到方法返回时才释放。 94 | 95 | 初版的 Mutex 实现之后,Go 开发组又对 Mutex 做了一些微调,比如把字段类型变成了 uint32 类型;调用 Unlock 方法会做检查;使用 atomic 包的同步原语执行原子操作等等,这些小的改动,都不是核心功能,你简单知道就行了,我就不详细介绍了。 96 | 97 | 但是,初版的 Mutex 实现有一个问题:请求锁的 goroutine 会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用 CPU 时间片的 goroutine 的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。 98 | 99 | ## 第二阶段-给新人机会 100 | Go 开发者在 2011 年 6 月 30 日的 commit 中对 Mutex 做了一次大的调整,调整后的 Mutex 实现如下: 101 | 102 | ```go 103 | 104 | type Mutex struct { 105 | state int32//这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。 106 | sema uint32// 信号量专用,用以阻塞/唤醒goroutine 107 | } 108 | 109 | 110 | const ( 111 | mutexLocked = 1 << iota // mutex is locked 112 | mutexWoken 113 | mutexWaiterShift = iota 114 | ) 115 | 116 | ``` 117 | 118 | 虽然还是两个字段,但是state的含义已经改变了 119 | 120 | ![state字段解析图](https://static001.geekbang.org/resource/image/4c/15/4c4a3dd2310059821f41af7b84925615.jpg "state字段解析图") 121 | 122 | state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个字段被分成了三部分,代表三个数据。 123 | 124 | 随之而来的他的Lock方法也变得复杂了,复杂之处不仅仅在于对字段 state 的操作难以理解,而且代码逻辑也变得相当复杂: 125 | ```go 126 | 127 | func (m *Mutex) Lock() { 128 | // Fast path: 幸运case,能够直接获取到锁 129 | if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { 130 | return 131 | } 132 | 133 | awoke := false 134 | for { 135 | old := m.state 136 | new := old | mutexLocked // 新状态加锁 137 | if old&mutexLocked != 0 { 138 | new = old + 1<>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 { // 没有等待者,或者有唤醒的waiter,或者锁原来已加锁 184 | return 185 | } 186 | new = (old - 1<>mutexWaiterShift != 0 && 229 | atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { 230 | awoke = true 231 | } 232 | runtime_doSpin() 233 | iter++ 234 | continue // 自旋,再次尝试请求锁 235 | } 236 | new = old + 1<>mutexWaiterShift != 0 && 307 | atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { 308 | awoke = true 309 | } 310 | runtime_doSpin() 311 | iter++ 312 | old = m.state // 再次获取锁的状态,之后会检查是否锁被释放了 313 | continue 314 | } 315 | new := old 316 | if old&mutexStarving == 0 { 317 | new |= mutexLocked // 非饥饿状态,加锁 318 | } 319 | if old&(mutexLocked|mutexStarving) != 0 { 320 | new += 1 << mutexWaiterShift // waiter数量加1 321 | } 322 | if starving && old&mutexLocked != 0 { 323 | new |= mutexStarving // 设置饥饿状态 324 | } 325 | if awoke { 326 | if new&mutexWoken == 0 { 327 | throw("sync: inconsistent mutex state") 328 | } 329 | new &^= mutexWoken // 新状态清除唤醒标记 330 | } 331 | // 成功设置新状态 332 | if atomic.CompareAndSwapInt32(&m.state, old, new) { 333 | // 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回 334 | if old&(mutexLocked|mutexStarving) == 0 { 335 | break // locked the mutex with CAS 336 | } 337 | // 处理饥饿状态 338 | 339 | // 如果以前就在队列里面,加入到队列头 340 | queueLifo := waitStartTime != 0 341 | if waitStartTime == 0 { 342 | waitStartTime = runtime_nanotime() 343 | } 344 | // 阻塞等待 345 | runtime_SemacquireMutex(&m.sema, queueLifo, 1) 346 | // 唤醒之后检查锁是否应该处于饥饿状态 347 | starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs 348 | old = m.state 349 | // 如果锁已经处于饥饿状态,直接抢到锁,返回 350 | if old&mutexStarving != 0 { 351 | if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 { 352 | throw("sync: inconsistent mutex state") 353 | } 354 | // 有点绕,加锁并且将waiter数减1 355 | delta := int32(mutexLocked - 1<>mutexWaiterShift == 1 { 357 | delta -= mutexStarving // 最后一个waiter或者已经不饥饿了,清除饥饿标记 358 | } 359 | atomic.AddInt32(&m.state, delta) 360 | break 361 | } 362 | awoke = true 363 | iter = 0 364 | } else { 365 | old = m.state 366 | } 367 | } 368 | } 369 | 370 | func (m *Mutex) Unlock() { 371 | // Fast path: drop lock bit. 372 | new := atomic.AddInt32(&m.state, -mutexLocked) 373 | if new != 0 { 374 | m.unlockSlow(new) 375 | } 376 | } 377 | 378 | func (m *Mutex) unlockSlow(new int32) { 379 | if (new+mutexLocked)&mutexLocked == 0 { 380 | throw("sync: unlock of unlocked mutex") 381 | } 382 | if new&mutexStarving == 0 { 383 | old := new 384 | for { 385 | if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 { 386 | return 387 | } 388 | new = (old - 1<> mutexWaiterShift //得到等待者的数值 138 | v = v + (v & mutexLocked) //再加上锁持有者的数量,0或者1 139 | return int(v) 140 | } 141 | ~~~ 142 | 这个例子的第 14 行通过 unsafe 操作,我们可以得到 state 字段的值。第 15 行我们右移三位(这里的常量 mutexWaiterShift 的值为 3),就得到了当前等待者的数量。如果当前的锁已经被其他 goroutine 持有,那么,我们就稍微调整一下这个值,加上一个 1(第 16 行),你基本上可以把它看作是当前持有和等待这把锁的 goroutine 的总数。 143 | 144 | state 这个字段的第一位是用来标记锁是否被持有,第二位用来标记是否已经唤醒了一个等待者,第三位标记锁是否处于饥饿状态,通过分析这个 state 字段我们就可以得到这些状态信息。我们可以为这些状态提供查询的方法,这样就可以实时地知道锁的状态了。 145 | ~~~go 146 | // 锁是否被持有 147 | func (m *Mutex) IsLocked() bool { 148 | state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 149 | return state&mutexLocked == mutexLocked 150 | } 151 | 152 | // 是否有等待者被唤醒 153 | func (m *Mutex) IsWoken() bool { 154 | state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 155 | return state&mutexWoken == mutexWoken 156 | } 157 | 158 | // 锁是否处于饥饿状态 159 | func (m *Mutex) IsStarving() bool { 160 | state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 161 | return state&mutexStarving == mutexStarving 162 | } 163 | ~~~ 164 | 165 | 我们可以写一个程序测试一下,比如,在 1000 个 goroutine 并发访问的情况下,我们可以把锁的状态信息输出出来 166 | 167 | ~~~go 168 | func count() { 169 | var mu Mutex 170 | for i := 0; i < 1000; i++ { // 启动1000个goroutine 171 | go func() { 172 | mu.Lock() 173 | time.Sleep(time.Second) 174 | mu.Unlock() 175 | }() 176 | } 177 | 178 | time.Sleep(time.Second) 179 | // 输出锁的信息 180 | fmt.Printf("waitings: %d, isLocked: %t, woken: %t, starving: %t\n", mu.Count(), mu.IsLocked(), mu.IsWoken(), mu.IsStarving()) 181 | } 182 | ~~~ 183 | 有一点你需要注意一下,在获取 state 字段的时候,并没有通过 Lock 获取这把锁,所以获取的这个 state 的值是一个瞬态的值,可能在你解析出这个字段之后,锁的状态已经发生了变化。不过没关系,因为你查看的就是调用的那一时刻的锁的状态。 184 | 185 | ## 使用 Mutex 实现一个线程安全的队列 186 | 187 | 因为 Mutex 经常会和其他非线程安全(对于 Go 来说,我们其实指的是 goroutine 安全)的数据结构一起,组合成一个线程安全的数据结构。新数据结构的业务逻辑由原来的数据结构提供,而 **Mutex 提供了锁的机制,来保证线程安全**。 188 | 189 | 比如队列,我们可以通过 Slice 来实现,但是通过 Slice 实现的队列不是线程安全的,出队(Dequeue)和入队(Enqueue)会有 data race 的问题。这个时候,Mutex 就要隆重出场了,通过它,我们可以在出队和入队的时候加上锁的保护。 190 | 191 | 其实呢就是在处理改动的时候将队列锁上嘛,太悲观了,但是也造成了现在的安全局面。 192 | ~~~go 193 | type SliceQueue struct { 194 | data []interface{} 195 | mu sync.Mutex 196 | } 197 | 198 | func NewSliceQueue(n int) (q *SliceQueue) { 199 | return &SliceQueue{data: make([]interface{}, 0, n)} 200 | } 201 | 202 | // Enqueue 把值放在队尾 203 | func (q *SliceQueue) Enqueue(v interface{}) { 204 | q.mu.Lock() 205 | q.data = append(q.data, v) 206 | q.mu.Unlock() 207 | } 208 | 209 | // Dequeue 移去队头并返回 210 | func (q *SliceQueue) Dequeue() interface{} { 211 | q.mu.Lock() 212 | if len(q.data) == 0 { 213 | q.mu.Unlock() 214 | return nil 215 | } 216 | v := q.data[0] 217 | q.data = q.data[1:] 218 | q.mu.Unlock() 219 | return v 220 | } 221 | ~~~ 222 | 223 | ## 总结 224 | 225 | Mutex 是 package sync 的基石,其他的一些同步原语也是基于它实现的,所以,我们“隆重”地用了四讲来深度学习它。学到后面,你一定能感受到,多花些时间来完全掌握 Mutex 是值得的。 226 | 227 | 今天这一讲我和你分享了几个 Mutex 的拓展功能,这些方法是不是给你带来了一种“骇客”的编程体验呢,通过 Hacker 的方式,我们真的可以让 Mutex 变得更强大。 228 | 229 | 我们学习了基于 Mutex 实现 TryLock,通过 unsafe 的方式读取到 Mutex 内部的 state 字段,这样,我们就解决了开篇列举的问题,一是不希望锁的 goroutine 继续等待,一是想监控锁。 230 | 231 | 另外,使用 Mutex 组合成更丰富的数据结构是我们常见的场景,今天我们就实现了一个线程安全的队列,未来我们还会讲到实现线程安全的 map 对象 232 | 233 | 到这里,Mutex 我们就系统学习完了,最后给你总结了一张 Mutex 知识地图,帮你复习一下。 234 | 235 | ![mutex知识结构](https://static001.geekbang.org/resource/image/5a/0b/5ayy6cd9ec9fe0bcc13113302056ac0b.jpg "mutex知识结构") 236 | 237 | ## 思考题 238 | 你可以为 Mutex 获取锁时加上 Timeout 机制吗?会有什么问题吗? 239 | 240 | 241 | 优秀回答:最简单直接的是采用channel实现,用select监听锁和timeout两个channel,不在今天的讨论范围内。 242 | 243 | 1. 用for循环+TryLock实现: 244 | 先记录开始的时间,用for循环判断是否超时:没有超时则反复尝试tryLock,直到获取成功;如果超时直接返回失败。 245 | 246 | 问题:高频的CAS自旋操作,如果失败的太多,会消耗大量的CPU。 247 | 248 | 2. 优化1:TryLock的fast的拆分 249 | TryLock的抢占实现分为两部分,一个是fast path,另一个是竞争状态下的,后者的cas操作很多。我会考虑减少slow方法的频率,比如调用n次fast path失败后,再调用一次整个Trylock。 250 | 251 | 3. 优化2:借鉴TCP重试机制 252 | for循环中的重试增加休眠时间,每次失败将休眠时间乘以一个系数(如1.5),直到达到上限(如10ms),减少自旋带来的性能损耗 -------------------------------------------------------------------------------- /1.基本并发原语/1.04:Mutex:骇客编程,如何拓展额外功能?/04.01.基于Mutex实现的TryLock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | "unsafe" 10 | ) 11 | 12 | // 复制Mutex定义的常量 13 | const ( 14 | mutexLocked = 1 << iota // 加锁标识位置 15 | mutexWoken // 唤醒标识位置 16 | mutexStarving // 锁饥饿标识位置 17 | mutexWaiterShift = iota // 标识waiter的起始bit位置 18 | ) 19 | 20 | // 扩展一个Mutex结构 21 | type Mutex struct { 22 | sync.Mutex 23 | } 24 | 25 | // 尝试获取锁 26 | func (m *Mutex) TryLock() bool { 27 | // 如果能成功抢到锁 28 | if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), 0, mutexLocked) { 29 | return true 30 | } 31 | 32 | // 如果处于唤醒、加锁或者饥饿状态,这次请求就不参与竞争了,返回false 33 | old := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 34 | if old&(mutexLocked|mutexStarving|mutexWoken) != 0 { 35 | return false 36 | } 37 | 38 | // 尝试在竞争的状态下请求锁 39 | new := old | mutexLocked 40 | return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), old, new) 41 | } 42 | 43 | /* 44 | 这个测试程序的工作机制是这样子的:程序运行时会启动一个 goroutine 持有这把我们自己实现的锁, 45 | 经过随机的时间才释放。主 goroutine 会尝试获取这把锁。 46 | 如果前一个 goroutine 一秒内释放了这把锁,那么,主 goroutine 47 | 就有可能获取到这把锁了,输出“got the lock”, 48 | 否则没有获取到也不会被阻塞,会直接输出“can't get the lock”。 49 | */ 50 | func try() { 51 | var mu Mutex 52 | go func() { // 启动一个goroutine持有一段时间的锁 53 | mu.Lock() 54 | time.Sleep(time.Duration(rand.Intn(3)) * time.Second) 55 | mu.Unlock() 56 | }() 57 | 58 | time.Sleep(time.Second) 59 | 60 | ok := mu.TryLock() // 尝试获取到锁 61 | if ok { // 获取成功 62 | fmt.Println("got the lock") 63 | // do something 64 | mu.Unlock() 65 | 66 | } else { 67 | // 没有获取到 68 | fmt.Println("can't get the lock") 69 | } 70 | time.Sleep(time.Second) 71 | ok = mu.TryLock() // 尝试获取到锁 72 | if ok { // 获取成功 73 | fmt.Println("got the lock") 74 | // do something 75 | mu.Unlock() 76 | 77 | } else { 78 | // 没有获取到 79 | fmt.Println("can't get the lock") 80 | } 81 | 82 | } 83 | 84 | func main() { 85 | try() 86 | } 87 | -------------------------------------------------------------------------------- /1.基本并发原语/1.04:Mutex:骇客编程,如何拓展额外功能?/04.02.获取state字段的信息.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "sync/atomic" 7 | "time" 8 | "unsafe" 9 | ) 10 | 11 | const ( //能运行,无视报错即可 12 | mutexLocked1 = 1 << iota // mutex is locked 13 | mutexWoken1 14 | mutexStarving1 15 | mutexWaiterShift1 = iota 16 | ) 17 | 18 | type MyMutex struct { 19 | sync.Mutex 20 | } 21 | 22 | //等待者的数量 23 | func (m *MyMutex) Count() int { 24 | // 获取state字段的值 25 | v := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 26 | v = v >> mutexWaiterShift1 //得到等待者的数值 27 | v = v + (v & mutexLocked1) //再加上锁持有者的数量,0或者1 28 | return int(v) 29 | } 30 | 31 | // 锁是否被持有 32 | func (m *MyMutex) IsLocked() bool { 33 | state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 34 | return state&mutexLocked1 == mutexLocked1 35 | } 36 | 37 | // 是否有等待者被唤醒 38 | func (m *MyMutex) IsWoken() bool { 39 | state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 40 | return state&mutexWoken1 == mutexWoken1 41 | } 42 | 43 | // 锁是否处于饥饿状态 44 | func (m *MyMutex) IsStarving() bool { 45 | state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))) 46 | return state&mutexStarving1 == mutexStarving1 47 | } 48 | 49 | func count() { 50 | var mu MyMutex 51 | for i := 0; i < 1000; i++ { // 启动1000个goroutine 52 | go func() { 53 | mu.Lock() 54 | time.Sleep(time.Second) 55 | mu.Unlock() 56 | }() 57 | } 58 | 59 | time.Sleep(time.Second) 60 | // 输出锁的信息 61 | fmt.Printf("waitings: %d, isLocked: %t, woken: %t, starving: %t\n", mu.Count(), mu.IsLocked(), mu.IsWoken(), mu.IsStarving()) 62 | } 63 | 64 | func main() { 65 | count() 66 | } 67 | -------------------------------------------------------------------------------- /1.基本并发原语/1.04:Mutex:骇客编程,如何拓展额外功能?/04.03.并发安全的队列.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sync" 4 | 5 | type SliceQueue struct { 6 | data []interface{} 7 | mu sync.Mutex 8 | } 9 | 10 | func NewSliceQueue(n int) (q *SliceQueue) { 11 | return &SliceQueue{data: make([]interface{}, 0, n)} 12 | } 13 | 14 | // Enqueue 把值放在队尾 15 | func (q *SliceQueue) Enqueue(v interface{}) { 16 | q.mu.Lock() 17 | q.data = append(q.data, v) 18 | q.mu.Unlock() 19 | } 20 | 21 | // Dequeue 移去队头并返回 22 | func (q *SliceQueue) Dequeue() interface{} { 23 | q.mu.Lock() 24 | if len(q.data) == 0 { 25 | q.mu.Unlock() 26 | return nil 27 | } 28 | v := q.data[0] 29 | q.data = q.data[1:] 30 | q.mu.Unlock() 31 | return v 32 | } 33 | -------------------------------------------------------------------------------- /1.基本并发原语/1.05:RWMutex:读写锁的实现原理及避坑指南/05.00-RWMutex:读写锁的实现原理及避坑指南.md: -------------------------------------------------------------------------------- 1 | # RWMutex:读写锁的实现原理及避坑指南 2 | 前面讲的Mutex虽然可以解决很多并发问题,但是随之而来的也会带来的性能问题,本来可以进行很多并行的事情,但是加了锁就变成了串行了,那岂不是乖乖排队,就很蛋疼了。假如只是一堆人想看看坑长什么样子,那总不至于锁门看吧,当然了,你要是想do something的话就得不让那些人看了,就要锁了。这就要开始区分读写操作了。 3 | 4 | 来个官方点的回答呢就是:如果某个读操作的 goroutine 持有了锁,在这种情况下,其它读操作的 goroutine 就不必一直傻傻地等待了,而是可以并发地访问共享变量,这样我们就可以将**串行的读变成并行读**,提高读操作的性能。当写操作的 goroutine 持有锁的时候,它就是一个排外锁,其它的写操作和读操作的 goroutine,需要阻塞等待持有这个锁的 goroutine 释放锁。 5 | 6 | 这一类并发读写问题叫作readers-writers 问题(读写问题),意思就是,同时可能有多个读或者多个写,但是只要有一个线程在执行写操作,其它的线程都不能执行读写操作。 7 | 8 | **Go 标准库中的 RWMutex(读写锁)就是用来解决这类 readers-writers 问题的。**所以,我们就一起来学习 RWMutex。我会给你介绍读写锁的使用场景、实现原理以及容易掉入的坑,你一定要记住这些陷阱,避免在实际的开发中犯相同的错误。俺也会搞点生动的例子让人便于理解。 9 | 10 | ## 什么是 RWMutex? 11 | 读写锁 RWMutex。标准库中的 RWMutex 是一个 reader/writer 互斥锁。RWMutex 在某一时刻只能由任意数量的 reader 持有,或者是只被单个的 writer 持有。 12 | 13 | RWMutex 的方法也很少,总共有 5 个。 14 | - **Lock/Unlock:写操作时调用的方法**。如果锁已经被 reader 或者 writer 持有,那么,Lock 方法会一直阻塞,直到能获取到锁;Unlock 则是配对的释放锁的方法。 15 | - **RLock/RUnlock:读操作时调用的方法**。如果锁已经被 writer 持有的话,RLock 方法会一直阻塞,直到能获取到锁,否则就直接返回;而 RUnlock 是 reader 释放锁的方法。 16 | - **RLocker:这个方法的作用是为读操作返回一个 Locker 接口的对象。**它的 Lock 方法会调用 RWMutex 的 RLock 方法,它的 Unlock 方法会调用 RWMutex 的 RUnlock 方法 17 | 18 | RWMutex 的零值是未加锁的状态,所以,当你使用 RWMutex 的时候,无论是声明变量,还是嵌入到其它 struct 中,都不必显式地初始化。 19 | 20 | 那就用一段代码展示一下吧:计数器的 count++操作是写操作,而获取 count 的值是读操作,这个场景非常适合读写锁,因为读操作可以并行执行,写操作时只允许一个线程执行,这正是 readers-writers 问题。 21 | ~~~go 22 | func main() { 23 | var counter Counter 24 | for i := 0; i < 10; i++ { // 10个reader 25 | go func() { 26 | for { 27 | fmt.Println(counter.Count()) // 计数器读操作 28 | time.Sleep(time.Millisecond) 29 | } 30 | }() 31 | } 32 | 33 | for { // 一个writer 34 | counter.Incr() // 计数器写操作 35 | time.Sleep(time.Second) 36 | } 37 | } 38 | // 一个线程安全的计数器 39 | type Counter struct { 40 | mu sync.RWMutex 41 | count uint64 42 | } 43 | 44 | // 使用写锁保护 45 | func (c *Counter) Incr() { 46 | c.mu.Lock() 47 | c.count++ 48 | c.mu.Unlock() 49 | } 50 | 51 | // 使用读锁保护 52 | func (c *Counter) Count() uint64 { 53 | c.mu.RLock() 54 | defer c.mu.RUnlock() 55 | return c.count 56 | } 57 | ~~~ 58 | 59 | 可以看到,Incr 方法会修改计数器的值,是一个写操作,我们使用 Lock/Unlock 进行保护。Count 方法会读取当前计数器的值,是一个读操作,我们使用 RLock/RUnlock 方法进行保护。 60 | 61 | Incr 方法每秒才调用一次,所以,writer 竞争锁的频次是比较低的,而 10 个 goroutine 每毫秒都要执行一次查询,通过读写锁,可以极大提升计数器的性能,因为在读取的时候,可以并发进行。如果使用 Mutex,性能就不会像读写锁这么好。因为多个 reader 并发读的时候,使用互斥锁导致了 reader 要排队读的情况,没有 RWMutex 并发读的性能好。 62 | 63 | 64 | 如果你遇到可以明确区分 reader 和 writer goroutine 的场景,且有**大量的并发读、少量的并发写**,并且有强烈的性能需求,你就可以考虑使用读写锁 RWMutex 替换 Mutex。 65 | 66 | ## RWMutex 的实现原理 67 | 68 | 先讲个概念吧! 69 | readers-writers 问题一般有三类,基于对读和写操作的优先级,读写锁的设计和实现也分成三类。 70 | - **Read-preferring**:读优先的设计可以提供很高的并发性,但是,在竞争激烈的情况下可能会导致写饥饿。这是因为,如果有大量的读,这种设计会导致只有所有的读都释放了锁之后,写才可能获取到锁。 71 | - **Write-preferring**:写优先的设计意味着,如果已经有一个 writer 在等待请求锁的话,它会阻止新来的请求锁的 reader 获取到锁,所以优先保障 writer。当然,如果有一些 reader 已经请求了锁的话,新请求的 writer 也会等待已经存在的 reader 都释放锁之后才能获取。所以,写优先级设计中的优先权是针对新来的请求而言的。这种设计主要避免了 writer 的饥饿问题。 72 | - **不指定优先级**:这种设计比较简单,不区分 reader 和 writer 优先级,某些场景下这种不指定优先级的设计反而更有效,因为第一类优先级会导致写饥饿,第二类优先级可能会导致读饥饿,这种不指定优先级的访问不再区分读写,大家都是同一个优先级,解决了饥饿的问题。 73 | 74 | **Go 标准库中的 RWMutex 设计是 Write-preferring 方案。一个正在阻塞的 Lock 调用会排除新的 reader 请求到锁。** 75 | 76 | 接下来看下源码里的实现 77 | ~~~go 78 | type RWMutex struct { 79 | w Mutex // 互斥锁解决多个writer的竞争 80 | writerSem uint32 // writer信号量 81 | readerSem uint32 // reader信号量 82 | readerCount int32 // reader的数量 83 | readerWait int32 // writer等待完成的reader的数量 84 | } 85 | const rwmutexMaxReaders = 1 << 30 86 | ~~~ 87 | 这几个字段的解释如下: 88 | - 字段 w:为 writer 的竞争锁而设计; 89 | - 字段 readerCount:记录当前 reader 的数量(以及是否有 writer 竞争锁); 90 | - readerWait:记录 writer 请求锁时需要等待 read 完成的 reader 的数量; 91 | - writerSem 和 readerSem:都是为了阻塞设计的信号量。 92 | 93 | 94 | 这里的常量 rwmutexMaxReaders,定义了最大的 reader 数量。 95 | 96 | 好了,知道了 RWMutex 的设计方案和具体字段,下面我来解释(复制)一下具体的方法实现。 97 | 98 | ## RLock/RUnlock 的实现 99 | 100 | 首先,我们看一下移除了 race 等无关紧要的代码后的 RLock 和 RUnlock 方法: 101 | 102 | ~~~go 103 | func (rw *RWMutex) RLock() { 104 | if atomic.AddInt32(&rw.readerCount, 1) < 0 { 105 | // rw.readerCount是负值的时候,意味着此时有writer等待请求锁,因为writer优先级高,所以把后来的reader阻塞休眠 106 | runtime_SemacquireMutex(&rw.readerSem, false, 0) 107 | } 108 | } 109 | func (rw *RWMutex) RUnlock() { 110 | if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 { 111 | rw.rUnlockSlow(r) // 有等待的writer 112 | } 113 | } 114 | func (rw *RWMutex) rUnlockSlow(r int32) { 115 | if atomic.AddInt32(&rw.readerWait, -1) == 0 { 116 | // 最后一个reader了,writer终于有机会获得锁了 117 | runtime_Semrelease(&rw.writerSem, false, 1) 118 | } 119 | } 120 | ~~~ 121 | 122 | 第二行的atomic.AddInt32是对reader 计数加 1。你可能比较困惑的是,readerCount 怎么还可能为负数呢?其实,这是因为,readerCount 这个字段有双重含义: 123 | - 没有 writer 竞争或持有锁时,readerCount 和我们正常理解的 reader 的计数是一样的; 124 | - 但是,如果有 writer 竞争锁或者持有锁时,那么,readerCount 不仅仅承担着 reader 的计数功能,还能够标识当前是否有 writer 竞争或持有锁,在这种情况下,请求锁的 reader 的处理进入第 4 行,阻塞等待锁的释放。 125 | 126 | 调用 RUnlock 的时候,我们需要将 Reader 的计数减去 1(第 8 行),因为 reader 的数量减少了一个。但是,第 8 行的 AddInt32 的返回值还有另外一个含义。如果它是负值,就表示当前有 writer 竞争锁,在这种情况下,还会调用 rUnlockSlow 方法,检查是不是 reader 都释放读锁了,如果读锁都释放了,那么可以唤醒请求写锁的 writer 了。 127 | 128 | 当一个或者多个 reader 持有锁的时候,竞争锁的 writer 会等待这些 reader 释放完,才可能持有这把锁。打个比方,在房地产行业中有条规矩叫做“**买卖不破租赁**”,意思是说,就算房东把房子卖了,新业主也不能把当前的租户赶走,而是要等到租约结束后,才能接管房子(蛋壳除外!!!)。这和 RWMutex 的设计是一样的。当 writer 请求锁的时候,是无法改变既有的 reader 持有锁的现实的,也不会强制这些 reader 释放锁,它的优先权只是限定后来的 reader 不要和它抢。 129 | 130 | 所以,rUnlockSlow 将持有锁的 reader 计数减少 1 的时候,会检查既有的 reader 是不是都已经释放了锁,如果都释放了锁,就会唤醒 writer,让 writer 持有锁。 131 | 132 | ### Lock 133 | 134 | RWMutex 是一个多 writer 多 reader 的读写锁,所以同时可能有多个 writer 和 reader。那么,为了避免 writer 之间的竞争,RWMutex 就会使用一个 Mutex 来保证 writer 的互斥。 135 | 136 | 一旦一个 writer 获得了内部的互斥锁,就会反转 readerCount 字段,把它从原来的正整数 readerCount(>=0) 修改为负数(readerCount-rwmutexMaxReaders),让这个字段保持两个含义(既保存了 reader 的数量,又表示当前有 writer)。 137 | 138 | 我们来看下下面的代码。第 5 行,还会记录当前活跃的 reader 数量,所谓活跃的 reader,就是指持有读锁还没有释放的那些 reader。 139 | 140 | ~~~go 141 | func (rw *RWMutex) Lock() { 142 | // 首先解决其他writer竞争问题 143 | rw.w.Lock() 144 | // 反转readerCount,告诉reader有writer竞争锁 145 | r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders 146 | // 如果当前有reader持有锁,那么需要等待 147 | if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { 148 | runtime_SemacquireMutex(&rw.writerSem, false, 0) 149 | } 150 | } 151 | ~~~ 152 | 153 | 如果 readerCount 不是 0,就说明当前有持有读锁的 reader,RWMutex 需要把这个当前 readerCount 赋值给 readerWait 字段保存下来(第 7 行), 同时,这个 writer 进入阻塞等待状态(第 8 行)。 154 | 155 | 每当一个 reader 释放读锁的时候(调用 RUnlock 方法时),readerWait 字段就减 1,直到所有的活跃的 reader 都释放了读锁,才会唤醒这个 writer。 156 | 157 | ## Unlock 158 | 当一个 writer 释放锁的时候,它会再次反转 readerCount 字段。可以肯定的是,因为当前锁由 writer 持有,所以,readerCount 字段是反转过的,并且减去了 159 | 160 | rwmutexMaxReaders 这个常数,变成了负数。所以,这里的反转方法就是给它增加 rwmutexMaxReaders 这个常数值。 161 | 162 | 既然 writer 要释放锁了,那么就需要唤醒之后新来的 reader,不必再阻塞它们了,让它们开开心心地继续执行就好了 163 | 164 | 在 RWMutex 的 Unlock 返回之前,需要把内部的互斥锁释放。释放完毕后,其他的 writer 才可以继续竞争这把锁。 165 | 166 | ~~~go 167 | func (rw *RWMutex) Unlock() { 168 | // 告诉reader没有活跃的writer了 169 | r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) 170 | 171 | // 唤醒阻塞的reader们 172 | for i := 0; i < int(r); i++ { 173 | runtime_Semrelease(&rw.readerSem, false, 0) 174 | } 175 | // 释放内部的互斥锁 176 | rw.w.Unlock() 177 | } 178 | ~~~ 179 | 180 | 在这段代码中,没有race 的处理和异常情况的检查,总体看来还是比较简单的。这里有几个重点,我要再提醒你一下。首先,你要理解 readerCount 这个字段的含义以及反转方式。其次,你还要注意字段的更改和内部互斥锁的顺序关系。在 Lock 方法中,是先获取内部互斥锁,才会修改的其他字段;而在 Unlock 方法中,是先修改的其他字段,才会释放内部互斥锁,这样才能保证字段的修改也受到互斥锁的保护。 181 | 182 | 好了,到这里我们就完整学习了 RWMutex 的概念和实现原理。RWMutex 的应用场景非常明确,就是解决 readers-writers 问题。学完了今天的内容,之后当你遇到这类问题时,要优先想到 RWMutex。另外,Go 并发原语代码实现的质量都很高,非常精炼和高效,所以,你可以通过看它们的实现原理,学习一些编程的技巧。当然,还有非常重要的一点就是要知道 reader 或者 writer 请求锁的时候,既有的 reader/writer 和后续请求锁的 reader/writer 之间的(释放锁 / 请求锁)顺序关系 183 | 184 | ## RWMutex 的 3 个踩坑点 185 | ### 坑点 1:不可复制 186 | 187 | 前面刚刚说过,RWMutex 是由一个互斥锁和四个辅助字段组成的。我们很容易想到,互斥锁是不可复制的,再加上四个有状态的字段,RWMutex 就更加不能复制使用了。 188 | 189 | 不能复制的原因和互斥锁一样。一旦读写锁被使用,它的字段就会记录它当前的一些状态。这个时候你去复制这把锁,就会把它的状态也给复制过来。但是,原来的锁在释放的时候,并不会修改你复制出来的这个读写锁,这就会导致复制出来的读写锁的状态不对,可能永远无法释放锁。 190 | 191 | 那该怎么办呢?其实,解决方案也和互斥锁一样。你可以借助 vet 工具,在变量赋值、函数传参、函数返回值、遍历数据、struct 初始化等时,检查是否有读写锁隐式复制的情景。 192 | 193 | ### 坑点 2:重入导致死锁 194 | 195 | 读写锁因为重入(或递归调用)导致死锁的情况更多。 196 | 197 | 我先介绍第一种情况。因为读写锁内部基于互斥锁实现对 writer 的并发访问,而互斥锁本身是有重入问题的,所以,writer 重入调用 Lock 的时候,就会出现死锁的现象,这个问题,我们在学习互斥锁的时候已经了解过了。 198 | 199 | ~~~go 200 | func foo(l *sync.RWMutex) { 201 | fmt.Println("in foo") 202 | l.Lock() 203 | bar(l) 204 | l.Unlock() 205 | } 206 | 207 | func bar(l *sync.RWMutex) { 208 | l.Lock() 209 | fmt.Println("in bar") 210 | l.Unlock() 211 | } 212 | 213 | func main() { 214 | l := &sync.RWMutex{} 215 | foo(l) 216 | } 217 | ~~~ 218 | 219 | 运行这个程序,你就会得到死锁的错误输出,在 Go 运行的时候,很容易就能检测出来。 220 | 第二种死锁的场景有点隐蔽。我们知道,有活跃 reader 的时候,writer 会等待,如果我们在 reader 的读操作时调用 writer 的写操作(它会调用 Lock 方法),那么,这个 reader 和 writer 就会形成互相依赖的死锁状态。Reader 想等待 writer 完成后再释放锁,而 writer 需要这个 reader 释放锁之后,才能不阻塞地继续执行。这是一个读写锁常见的死锁场景。 221 | 222 | 第三种死锁的场景更加隐蔽。 223 | 224 | 当一个 writer 请求锁的时候,如果已经有一些活跃的 reader,它会等待这些活跃的 reader 完成,才有可能获取到锁,但是,如果之后活跃的 reader 再依赖新的 reader 的话,这些新的 reader 就会等待 writer 释放锁之后才能继续执行,这就形成了一个环形依赖: **writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer。** 225 | ![依赖循环](https://static001.geekbang.org/resource/image/c1/35/c18e897967d29e2d5273b88afe626035.jpg) 226 | 这个死锁相当隐蔽,原因在于它和 RWMutex 的设计和实现有关。啥意思呢?我们来看一个计算阶乘 (n!) 的例子: 227 | 228 | ~~~go 229 | func main() { 230 | var mu sync.RWMutex 231 | 232 | // writer,稍微等待,然后制造一个调用Lock的场景 233 | go func() { 234 | time.Sleep(200 * time.Millisecond) 235 | mu.Lock() 236 | fmt.Println("Lock") 237 | time.Sleep(100 * time.Millisecond) 238 | mu.Unlock() 239 | fmt.Println("Unlock") 240 | }() 241 | 242 | go func() { 243 | factorial(&mu, 10) // 计算10的阶乘, 10! 244 | }() 245 | 246 | select {} 247 | } 248 | 249 | // 递归调用计算阶乘 250 | func factorial(m *sync.RWMutex, n int) int { 251 | if n < 1 { // 阶乘退出条件 252 | return 0 253 | } 254 | fmt.Println("RLock") 255 | m.RLock() 256 | defer func() { 257 | fmt.Println("RUnlock") 258 | m.RUnlock() 259 | }() 260 | time.Sleep(100 * time.Millisecond) 261 | return factorial(m, n-1) * n // 递归调用 262 | } 263 | ~~~ 264 | 265 | factoria 方法是一个递归计算阶乘的方法,我们用它来模拟 reader。为了更容易地制造出死锁场景,我在这里加上了 sleep 的调用,延缓逻辑的执行。这个方法会调用读锁(第 27 行),在第 33 行递归地调用此方法,每次调用都会产生一次读锁的调用,所以可以不断地产生读锁的调用,而且必须等到新请求的读锁释放,这个读锁才能释放。 266 | 267 | 同时,我们使用另一个 goroutine 去调用 Lock 方法,来实现 writer,这个 writer 会等待 200 毫秒后才会调用 Lock,这样在调用 Lock 的时候,factoria 方法还在执行中不断调用 RLock。 268 | 269 | 270 | 这两个 goroutine 互相持有锁并等待,谁也不会退让一步,满足了“writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer”的死锁条件,所以就导致了死锁的产生。 271 | 272 | 所以,使用读写锁最需要注意的一点就是尽量避免重入,重入带来的死锁非常隐蔽,而且难以诊断。 273 | ### 坑点 3:释放未加锁的 RWMutex 274 | 275 | 和互斥锁一样,Lock 和 Unlock 的调用总是成对出现的,RLock 和 RUnlock 的调用也必须成对出现。Lock 和 RLock 多余的调用会导致锁没有被释放,可能会出现死锁,而 Unlock 和 RUnlock 多余的调用会导致 panic。在生产环境中出现 panic 是大忌,你总不希望半夜爬起来处理生产环境程序崩溃的问题吧?所以,在使用读写锁的时候,一定要注意,**不遗漏不多余**。 276 | 277 | 278 | ## 总结 279 | 280 | 在开发过程中,一开始考虑共享资源并发访问问题的时候,我们就会想到互斥锁 Mutex。因为刚开始的时候,我们还并不太了解并发的情况,所以,就会使用最简单的同步原语来解决问题。等到系统成熟,真正到了需要性能优化的时候,我们就能静下心来分析并发场景的可能性,这个时候,我们就要考虑将 Mutex 修改为 RWMutex,来压榨系统的性能。 281 | 282 | 当然,如果一开始你的场景就非常明确了,比如我就要实现一个线程安全的 map,那么,一开始你就可以考虑使用读写锁。 283 | 284 | 正如我在前面提到的,如果你能意识到你要解决的问题是一个 readers-writers 问题,那么你就可以毫不犹豫地选择 RWMutex,不用考虑其它选择。那在使用 RWMutex 时,最需要注意的一点就是尽量避免重入,重入带来的死锁非常隐蔽,而且难以诊断。 285 | 286 | 另外我们也可以扩展 RWMutex,不过实现方法和互斥锁 Mutex 差不多,在技术上是一样的,都是通过 unsafe 来实现,我就不再具体讲了。课下你可以参照我们上节课学习的方法,实现一个扩展的 RWMutex。 287 | 288 | 这一讲我们系统学习了读写锁的相关知识,这里提供给你一个知识地图,帮助你复习本节课的知识。 289 | 290 | ![读写锁知识图谱](https://static001.geekbang.org/resource/image/69/42/695b9aa6027b5d3a61e92cbcbba10042.jpg) -------------------------------------------------------------------------------- /1.基本并发原语/1.05:RWMutex:读写锁的实现原理及避坑指南/05.01.计数器示例使用读写锁.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | func main() { 10 | var counter Counter 11 | for i := 0; i < 10; i++ { // 10个reader 12 | go func() { 13 | for { 14 | fmt.Println(counter.Count()) // 计数器读操作 15 | time.Sleep(time.Millisecond) 16 | } 17 | }() 18 | } 19 | 20 | for { // 一个writer 21 | counter.Incr() // 计数器写操作 22 | time.Sleep(time.Second) 23 | } 24 | } 25 | 26 | // 一个线程安全的计数器 27 | type Counter struct { 28 | mu sync.RWMutex 29 | count uint64 30 | } 31 | 32 | // 使用写锁保护 33 | func (c *Counter) Incr() { 34 | c.mu.Lock() 35 | c.count++ 36 | c.mu.Unlock() 37 | } 38 | 39 | // 使用读锁保护 40 | func (c *Counter) Count() uint64 { 41 | c.mu.RLock() 42 | defer c.mu.RUnlock() 43 | return c.count 44 | } 45 | -------------------------------------------------------------------------------- /1.基本并发原语/1.05:RWMutex:读写锁的实现原理及避坑指南/05.02.阶乘依赖循环‘.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | func main() { 10 | var mu sync.RWMutex 11 | 12 | // writer,稍微等待,然后制造一个调用Lock的场景 13 | go func() { 14 | time.Sleep(200 * time.Millisecond) 15 | mu.Lock() 16 | fmt.Println("Lock") 17 | time.Sleep(100 * time.Millisecond) 18 | mu.Unlock() 19 | fmt.Println("Unlock") 20 | }() 21 | 22 | go func() { 23 | factorial(&mu, 10) // 计算10的阶乘, 10! 24 | }() 25 | 26 | select {} 27 | } 28 | 29 | // 递归调用计算阶乘 30 | func factorial(m *sync.RWMutex, n int) int { 31 | if n < 1 { // 阶乘退出条件 32 | return 0 33 | } 34 | fmt.Println("RLock") 35 | m.RLock() 36 | defer func() { 37 | fmt.Println("RUnlock") 38 | m.RUnlock() 39 | }() 40 | time.Sleep(100 * time.Millisecond) 41 | return factorial(m, n-1) * n // 递归调用 42 | } 43 | -------------------------------------------------------------------------------- /1.基本并发原语/1.06:WaitGroup:协同等待,任务编排利器/06.00-WaitGroup:协同等待,任务编排利器.md: -------------------------------------------------------------------------------- 1 | # WaitGroup:协同等待,任务编排利器 2 | 3 | WaitGroup,顾名思义,等待队列,其实WaitGroup 很简单,就是 package sync 用来做任务编排的一个并发原语。它要解决的就是并发 - 等待的问题:现在有一个 goroutine A 在检查点(checkpoint)等待一组 goroutine 全部完成,如果在执行任务的这些 goroutine 还没全部完成,那么 goroutine A 就会阻塞在检查点,直到所有 goroutine 都完成后才能继续执行。你在上厕所前是不是得等阿姨扫完厕所,厕所里放完纸这一系列的事情完了之后才可以进行上厕所的操作,不然你就只能等着。 4 | 5 | 来个正经人的场景: 6 | 7 | 比如,我们要完成一个大的任务,需要使用并行的 goroutine 执行三个小任务,只有这三个小任务都完成,我们才能去执行后面的任务。如果通过轮询的方式定时询问三个小任务是否完成,会存在两个问题:一是,性能比较低,因为三个小任务可能早就完成了,却要等很长时间才被轮询到;二是,会有很多无谓的轮询,空耗 CPU 资源。 8 | 9 | 那么,这个时候使用 WaitGroup 并发原语就比较有效了,它可以阻塞等待的 goroutine。等到三个小任务都完成了,再即时唤醒它们。 10 | 11 | 其实,很多操作系统和编程语言都提供了类似的并发原语。比如,Linux 中的 barrier、Pthread(POSIX 线程)中的 barrier、C++ 中的 std::barrier、Java 中的 CyclicBarrier 和 CountDownLatch 等。由此可见,这个并发原语还是一个非常基础的并发类型。所以,我们要认真掌握今天的内容,这样就可以举一反三,轻松应对其他场景下的需求了。 12 | 13 | 开始正题: 14 | ## WaitGroup 的基本用法 15 | 16 | Go 标准库中的 WaitGroup 提供了三个方法,保持了 Go 简洁的风格 17 | 18 | ~~~go 19 | //用来设置 WaitGroup 的计数值 20 | func (wg *WaitGroup) Add(delta int) 21 | //用来将 WaitGroup 的计数值减 1,其实就是调用了 Add(-1); 22 | func (wg *WaitGroup) Done() 23 | //调用这个方法的 goroutine 会一直阻塞,直到 WaitGroup 的计数值变为 0。 24 | func (wg *WaitGroup) Wait() 25 | ~~~ 26 | 27 | 我们分别看下这三个方法: 28 | - Add,用来设置 WaitGroup 的计数值; 29 | - Done,用来将 WaitGroup 的计数值减 1,其实就是调用了 Add(-1); 30 | - Wait,调用这个方法的 goroutine 会一直阻塞,直到 WaitGroup 的计数值变为 0。 31 | 32 | 接下来,我们通过一个使用 WaitGroup 的例子,来看下 Add、Done、Wait 方法的基本用法。 33 | 34 | 在这个例子中,我们使用了以前实现的计数器 struct。我们启动了 10 个 worker,分别对计数值加一,10 个 worker 都完成后,我们期望输出计数器的值。 35 | ~~~go 36 | // 线程安全的计数器 37 | type Counter struct { 38 | mu sync.Mutex 39 | count uint64 40 | } 41 | // 对计数值加一 42 | func (c *Counter) Incr() { 43 | c.mu.Lock() 44 | c.count++ 45 | c.mu.Unlock() 46 | } 47 | // 获取当前的计数值 48 | func (c *Counter) Count() uint64 { 49 | c.mu.Lock() 50 | defer c.mu.Unlock() 51 | return c.count 52 | } 53 | // sleep 1秒,然后计数值加1 54 | func worker(c *Counter, wg *sync.WaitGroup) { 55 | defer wg.Done() 56 | time.Sleep(time.Second) 57 | c.Incr() 58 | } 59 | 60 | func main() { 61 | var counter Counter 62 | 63 | var wg sync.WaitGroup 64 | wg.Add(10) // WaitGroup的值设置为10 65 | 66 | for i := 0; i < 10; i++ { // 启动10个goroutine执行加1任务 67 | go worker(&counter, &wg) 68 | } 69 | // 检查点,等待goroutine都完成任务 70 | wg.Wait() 71 | // 输出当前计数器的值 72 | fmt.Println(counter.Count()) 73 | } 74 | ~~~ 75 | - 声明了一个 WaitGroup 变量,初始值为零。 76 | - 把 WaitGroup 变量的计数值设置为 10。因为我们需要编排 10 个 goroutine(worker) 去执行任务,并且等待 goroutine 完成。 77 | - 调用 Wait 方法阻塞等待。 78 | - 启动了 goroutine,并把我们定义的 WaitGroup 指针当作参数传递进去。 79 | - goroutine 完成后,需要调用 Done 方法,把 WaitGroup 的计数值减 1。等 10 个 goroutine 都调用了 Done 方法后,WaitGroup 的计数值降为 0,这时,主 goroutine 就不再阻塞,会继续执行,然后输出计数值。 80 | 81 | 这就是我们使用 WaitGroup 编排这类任务的常用方式。而“这类任务”指的就是,需要启动多个 goroutine 执行任务,主 goroutine 需要等待子 goroutine 都完成后才继续执行 82 | 83 | 熟悉了 WaitGroup 的基本用法后,我们再看看它具体是如何实现的吧。 84 | 85 | ## WaitGroup 的实现 86 | 87 | 首先,我们看看 WaitGroup 的数据结构。它包括了一个 noCopy 的辅助字段,一个 state1 记录 WaitGroup 状态的数组。 88 | 89 | - noCopy 的辅助字段,主要就是辅助 vet 工具检查是否通过 copy 赋值这个 WaitGroup 实例。我会在后面和你详细分析这个字段; 90 | - state1,一个具有复合意义的字段,包含 WaitGroup 的计数、阻塞在检查点的 waiter 数和信号量。 91 | 92 | WaitGroup 的数据结构定义以及 state 信息的获取方法如下: 93 | 94 | ~~~go 95 | type WaitGroup struct { 96 | // 避免复制使用的一个技巧,可以告诉vet工具违反了复制使用的规则 97 | noCopy noCopy 98 | // 64bit(8bytes)的值分成两段,高32bit是计数值,低32bit是waiter的计数 99 | // 另外32bit是用作信号量的 100 | // 因为64bit值的原子操作需要64bit对齐,但是32bit编译器不支持,所以数组中的元素在不同的架构中不一样,具体处理看下面的方法 101 | // 总之,会找到对齐的那64bit作为state,其余的32bit做信号量 102 | state1 [3]uint32 103 | } 104 | 105 | 106 | // 得到state的地址和信号量的地址 107 | func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { 108 | if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { 109 | // 如果地址是64bit对齐的,数组前两个元素做state,后一个元素做信号量 110 | return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] 111 | } else { 112 | // 如果地址是32bit对齐的,数组后两个元素用来做state,它可以用来做64bit的原子操作,第一个元素32bit用来做信号量 113 | return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0] 114 | } 115 | } 116 | ~~~ 117 | 118 | 因为对 64 位整数的原子操作要求整数的地址是 64 位对齐的,所以针对 64 位和 32 位环境的 state 字段的组成是不一样的。 119 | 120 | 在 64 位环境下,state1 的第一个元素是 waiter 数,第二个元素是 WaitGroup 的计数值,第三个元素是信号量。 121 | ![64位图解](https://static001.geekbang.org/resource/image/71/ea/71b5fyy6284140986d04c0b6f87aedea.jpg) 122 | 123 | 在 32 位环境下,如果 state1 不是 64 位对齐的地址,那么 state1 的第一个元素是信号量,后两个元素分别是 waiter 数和计数值。 124 | ![32位图解](https://static001.geekbang.org/resource/image/22/ac/22c40ac54cfeb53669a6ae39020c23ac.jpg) 125 | 126 | 接下来开始看源码时间看一下 Add、Done 和 Wait 这三个方法的实现。又是将rece检查和异常检测去掉的版本,这些等下再说,先直接看功能实现吧: 127 | 128 | 我先为你梳理下 **Add 方法**的逻辑。Add 方法主要操作的是 state 的计数部分。你可以为计数值增加一个 delta 值,内部通过原子操作把这个值加到计数值上。需要注意的是,这个 delta 也可以是个负数,相当于为计数值减去一个值,Done 方法内部其实就是通过 Add(-1) 实现的。 129 | 130 | 它的实现代码如下: 131 | ~~~go 132 | func (wg *WaitGroup) Add(delta int) { 133 | statep, semap := wg.state() 134 | // 高32bit是计数值v,所以把delta左移32,增加到计数上 135 | state := atomic.AddUint64(statep, uint64(delta)<<32) 136 | v := int32(state >> 32) // 当前计数值 137 | w := uint32(state) // waiter count 138 | 139 | if v > 0 || w == 0 { 140 | return 141 | } 142 | 143 | // 如果计数值v为0并且waiter的数量w不为0,那么state的值就是waiter的数量 144 | // 将waiter的数量设置为0,因为计数值v也是0,所以它们俩的组合*statep直接设置为0即可。此时需要并唤醒所有的waiter 145 | *statep = 0 146 | for ; w != 0; w-- { 147 | runtime_Semrelease(semap, false, 0) 148 | } 149 | } 150 | 151 | 152 | // Done方法实际就是计数器减1 153 | func (wg *WaitGroup) Done() { 154 | wg.Add(-1) 155 | } 156 | ~~~ 157 | 158 | 159 | Wait 方法的实现逻辑是:不断检查 state 的值。如果其中的计数值变为了 0,那么说明所有的任务已完成,调用者不必再等待,直接返回。如果计数值大于 0,说明此时还有任务没完成,那么调用者就变成了等待者,需要加入 waiter 队列,并且阻塞住自己。 160 | 161 | ~~~go 162 | func (wg *WaitGroup) Wait() { 163 | statep, semap := wg.state() 164 | 165 | for { 166 | state := atomic.LoadUint64(statep) 167 | v := int32(state >> 32) // 当前计数值 168 | w := uint32(state) // waiter的数量 169 | if v == 0 { 170 | // 如果计数值为0, 调用这个方法的goroutine不必再等待,继续执行它后面的逻辑即可 171 | return 172 | } 173 | // 否则把waiter数量加1。期间可能有并发调用Wait的情况,所以最外层使用了一个for循环 174 | if atomic.CompareAndSwapUint64(statep, state, state+1) { 175 | // 阻塞休眠等待 176 | runtime_Semacquire(semap) 177 | // 被唤醒,不再阻塞,返回 178 | return 179 | } 180 | } 181 | } 182 | ~~~ 183 | 184 | ## 使用 WaitGroup 时的常见错误 185 | 186 | 在分析 WaitGroup 的 Add、Done 和 Wait 方法的实现的时候,为避免干扰,我删除了异常检查的代码。但是,这些异常检查非常有用。 187 | 188 | 我们在开发的时候,经常会遇见或看到误用 WaitGroup 的场景,究其原因就是没有弄明白这些检查的逻辑。所以接下来,我们就通过几个小例子,一起学习下在开发时绝对要避免的 3 个问题。 189 | 190 | ### 常见问题一:计数器设置为负值 191 | 192 | WaitGroup 的计数器的值必须大于等于 0。我们在更改这个计数值的时候,WaitGroup 会先做检查,如果计数值被设置为负数,就会导致 panic 193 | 194 | 一般情况下,有两种方法会导致计数器设置为负数。 195 | 196 | 197 | 第一种方法是:调用 **Add 的时候传递一个负数**。如果你能保证当前的计数器加上这个负数后还是大于等于 0 的话,也没有问题,否则就会导致 panic。 198 | 199 | 比如下面这段代码,计数器的初始值为 10,当第一次传入 -10 的时候,计数值被设置为 0,不会有啥问题。但是,再紧接着传入 -1 以后,计数值就被设置为负数了,程序就会出现 panic。 200 | ~~~go 201 | func main() { 202 | var wg sync.WaitGroup 203 | wg.Add(10) 204 | 205 | wg.Add(-10)//将-10作为参数调用Add,计数值被设置为0 206 | 207 | wg.Add(-1)//将-1作为参数调用Add,如果加上-1计数值就会变为负数。这是不对的,所以会触发panic 208 | } 209 | ~~~ 210 | 211 | 第二个方法是:**调用 Done 方法的次数过多,超过了 WaitGroup 的计数值。** 212 | 213 | **使用 WaitGroup 的正确姿势是,预先确定好 WaitGroup 的计数值,然后调用相同次数的 Done 完成相应的任务。**比如,在 WaitGroup 变量声明之后,就立即设置它的计数值,或者在 goroutine 启动之前增加 1,然后在 goroutine 中调用 Done。 214 | 215 | 216 | 如果你没有遵循这些规则,就很可能会导致 Done 方法调用的次数和计数值不一致,进而造成死锁(Done 调用次数比计数值少)或者 panic(Done 调用次数比计数值多)。 217 | 218 | 比如下面这个例子中,多调用了一次 Done 方法后,会导致计数值为负,所以程序运行到这一行会出现 panic。 219 | 220 | ~~~go 221 | func main() { 222 | var wg sync.WaitGroup 223 | wg.Add(1) 224 | 225 | wg.Done() 226 | 227 | wg.Done() 228 | } 229 | ~~~ 230 | 231 | ### 常见问题二:不期望的 Add 时机 232 | 233 | 在使用 WaitGroup 的时候,你一定要遵循的原则就是,**等所有的 Add 方法调用之后再调用 Wait**,否则就可能导致 panic 或者不期望的结果。 234 | 235 | 我们构造这样一个场景:只有部分的 Add/Done 执行完后,Wait 就返回。我们看一个例子:启动四个 goroutine,每个 goroutine 内部调用 Add(1) 然后调用 Done(),主 goroutine 调用 Wait 等待任务完成。 236 | 237 | ~~~go 238 | func main() { 239 | var wg sync.WaitGroup 240 | go dosomething(100, &wg) // 启动第一个goroutine 241 | go dosomething(110, &wg) // 启动第二个goroutine 242 | go dosomething(120, &wg) // 启动第三个goroutine 243 | go dosomething(130, &wg) // 启动第四个goroutine 244 | 245 | wg.Wait() // 主goroutine等待完成 246 | fmt.Println("Done") 247 | } 248 | 249 | func dosomething(millisecs time.Duration, wg *sync.WaitGroup) { 250 | duration := millisecs * time.Millisecond 251 | time.Sleep(duration) // 故意sleep一段时间 252 | 253 | wg.Add(1) 254 | fmt.Println("后台执行, duration:", duration) 255 | wg.Done() 256 | } 257 | ~~~ 258 | 在这个例子中,我们原本设想的是,等四个 goroutine 都执行完毕后输出 Done 的信息,但是它的错误之处在于,将 WaitGroup.Add 方法的调用放在了子 gorotuine 中。等主 goorutine 调用 Wait 的时候,因为四个任务 goroutine 一开始都休眠,所以可能 WaitGroup 的 Add 方法还没有被调用,WaitGroup 的计数还是 0,所以它并没有等待四个子 goroutine 执行完毕才继续执行,而是立刻执行了下一步。 259 | 260 | 看着好像没有问题是吧,其实在启动协程的时候具有滞后性,可能主线程跑完了协程还没启动呢,所以就会导致没有跑Add直接跑的Wait就会GG了。所以可以这么处理: 261 | 262 | 预先设置计数值: 263 | ~~~go 264 | func main() { 265 | var wg sync.WaitGroup 266 | wg.Add(4) // 预先设定WaitGroup的计数值 267 | 268 | go dosomething(100, &wg) // 启动第一个goroutine 269 | go dosomething(110, &wg) // 启动第二个goroutine 270 | go dosomething(120, &wg) // 启动第三个goroutine 271 | go dosomething(130, &wg) // 启动第四个goroutine 272 | 273 | wg.Wait() // 主goroutine等待 274 | fmt.Println("Done") 275 | } 276 | 277 | func dosomething(millisecs time.Duration, wg *sync.WaitGroup) { 278 | duration := millisecs * time.Millisecond 279 | time.Sleep(duration) 280 | 281 | fmt.Println("后台执行, duration:", duration) 282 | wg.Done() 283 | } 284 | ~~~ 285 | 286 | 是在启动子 goroutine 之前才调用 Add: 287 | ~~~go 288 | func main() { 289 | var wg sync.WaitGroup 290 | 291 | dosomething(100, &wg) // 调用方法,把计数值加1,并启动任务goroutine 292 | dosomething(110, &wg) // 调用方法,把计数值加1,并启动任务goroutine 293 | dosomething(120, &wg) // 调用方法,把计数值加1,并启动任务goroutine 294 | dosomething(130, &wg) // 调用方法,把计数值加1,并启动任务goroutine 295 | 296 | wg.Wait() // 主goroutine等待,代码逻辑保证了四次Add(1)都已经执行完了 297 | fmt.Println("Done") 298 | } 299 | 300 | func dosomething(millisecs time.Duration, wg *sync.WaitGroup) { 301 | wg.Add(1) // 计数值加1,再启动goroutine 302 | 303 | go func() { 304 | duration := millisecs * time.Millisecond 305 | time.Sleep(duration) 306 | fmt.Println("后台执行, duration:", duration) 307 | wg.Done() 308 | }() 309 | } 310 | ~~~ 311 | 可见,无论是怎么修复,都要保证所有的 Add 方法是在 Wait 方法之前被调用的。 312 | 313 | ### 常见问题三:前一个 Wait 还没结束就重用 WaitGroup 314 | 315 | “前一个 Wait 还没结束就重用 WaitGroup”这一点似乎不太好理解,我借用田径比赛的例子和你解释下吧。在田径比赛的百米小组赛中,需要把选手分成几组,一组选手比赛完之后,就可以进行下一组了。为了确保两组比赛时间上没有冲突,我们在模型化这个场景的时候,可以使用 WaitGroup。“前一个 Wait 还没结束就重用 WaitGroup”这一点似乎不太好理解,我借用田径比赛的例子和你解释下吧。在田径比赛的百米小组赛中,需要把选手分成几组,一组选手比赛完之后,就可以进行下一组了。为了确保两组比赛时间上没有冲突,我们在模型化这个场景的时候,可以使用 WaitGroup。 316 | 317 | WaitGroup 等一组比赛的所有选手都跑完后 5 分钟,才开始下一组比赛。下一组比赛还可以使用这个 WaitGroup 来控制,因为 WaitGroup 是可以重用的。只要 WaitGroup 的计数值恢复到零值的状态,那么它就可以被看作是新创建的 WaitGroup,被重复使用。 318 | 319 | 但是,如果我们在 WaitGroup 的计数值还没有恢复到零值的时候就重用,就会导致程序 panic。我们看一个例子,初始设置 WaitGroup 的计数值为 1,启动一个 goroutine 先调用 Done 方法,接着就调用 Add 方法,Add 方法有可能和主 goroutine 并发执行。 320 | ~~~go 321 | func main() { 322 | var wg sync.WaitGroup 323 | wg.Add(1) 324 | go func() { 325 | time.Sleep(time.Millisecond) 326 | wg.Done() // 计数器减1 327 | wg.Add(1) // 计数值加1 328 | }() 329 | wg.Wait() // 主goroutine等待,有可能和第7行并发执行 330 | } 331 | ~~~ 332 | 在这个例子中,虽然在goroutine中让 WaitGroup 的计数恢复到 0,但是因为主协程 waiter 在等待,如果等待 Wait 的 goroutine,刚被唤醒就和 Add 调用(go的协程)有并发执行的冲突,所以就会出现 panic。 333 | 334 | 总结一下:WaitGroup 虽然可以重用,但是是有一个前提的,那就是必须等到上一轮的 Wait 完成之后,才能重用 WaitGroup 执行下一轮的 Add/Wait,如果你在 Wait 还没执行完的时候就调用下一轮 Add 方法,就有可能出现 panic。 335 | 336 | ### noCopy:辅助 vet 检查 337 | 之前有提到了里面有一个 noCopy 字段。其实,它就是指示 vet 工具在做检查的时候,这个数据结构不能做值复制使用。更严谨地说,是不能在第一次使用之后复制使用 ( must not be copied after first use)。noCopy 是一个通用的计数技术,其他并发原语中也会用到,所以单独介绍有助于你以后在实践中使用这个技术。 338 | 339 | 之前在学习 Mutex 的时候用到了 vet 工具。vet 会对实现 Locker 接口的数据类型做静态检查,一旦代码中有复制使用这种数据类型的情况,就会发出警告。但是,WaitGroup 同步原语不就是 Add、Done 和 Wait 方法吗?vet 能检查出来吗? 340 | 341 | 其实是可以的。通过给 WaitGroup 添加一个 noCopy 字段,我们就可以为 WaitGroup 实现 Locker 接口,这样 vet 工具就可以做复制检查了。而且因为 noCopy 字段是未输出类型,所以 WaitGroup 不会暴露 Lock/Unlock 方法。 342 | 343 | noCopy 字段的类型是 noCopy,它只是一个辅助的、用来帮助 vet 检查用的类型: 344 | 345 | ~~~go 346 | type noCopy struct{} 347 | 348 | // Lock is a no-op used by -copylocks checker from `go vet`. 349 | func (*noCopy) Lock() {} 350 | func (*noCopy) Unlock() {} 351 | ~~~ 352 | 353 | 如果你想要自己定义的数据结构不被复制使用,或者说,不能通过 vet 工具检查出复制使用的报警,就可以通过嵌入 noCopy 这个数据类型来实现。 354 | 355 | ## 总结 356 | 我们知道了使用 WaitGroup 容易犯的错,是不是有些手脚被束缚的感觉呢?其实大可不必,只要我们不是特别复杂地使用 WaitGroup,就不用有啥心理负担。 357 | 358 | 而关于如何避免错误使用 WaitGroup 的情况,我们只需要尽量保证下面 5 点就可以了: 359 | 360 | - 不重用 WaitGroup。新建一个 WaitGroup 不会带来多大的资源开销,重用反而更容易出错。 361 | - 保证所有的 Add 方法调用都在 Wait 之前。 362 | - 不传递负数给 Add 方法,只通过 Done 来给计数值减 1。 363 | - 不做多余的 Done 方法调用,保证 Add 的计数值和 Done 方法调用的数量是一样的。 364 | - 不遗漏 Done 方法的调用,否则会导致 Wait hang 住无法返回。 365 | 366 | 这一讲我们详细学习了 WaitGroup 的相关知识,这里我整理(复制)了一份关于 WaitGroup 的知识地图,方便你复习。 367 | ![WaitGroup](https://static001.geekbang.org/resource/image/84/ff/845yyf00c6db85c0yy59867e6de77dff.jpg) 368 | 369 | ## 思考题 370 | 371 | 通常我们可以把 WaitGroup 的计数值,理解为等待要完成的 waiter 的数量。你可以试着扩展下 WaitGroup,来查询 WaitGroup 的当前的计数值吗? -------------------------------------------------------------------------------- /1.基本并发原语/1.06:WaitGroup:协同等待,任务编排利器/06.01.等待队列计数器.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // 线程安全的计数器 10 | type Counter struct { 11 | mu sync.Mutex 12 | count uint64 13 | } 14 | 15 | // 对计数值加一 16 | func (c *Counter) Incr() { 17 | c.mu.Lock() 18 | c.count++ 19 | c.mu.Unlock() 20 | } 21 | 22 | // 获取当前的计数值 23 | func (c *Counter) Count() uint64 { 24 | c.mu.Lock() 25 | defer c.mu.Unlock() 26 | return c.count 27 | } 28 | 29 | // sleep 1秒,然后计数值加1 30 | func worker(c *Counter, wg *sync.WaitGroup) { 31 | defer wg.Done() 32 | time.Sleep(time.Second) 33 | c.Incr() 34 | } 35 | 36 | func main() { 37 | var counter Counter 38 | 39 | var wg sync.WaitGroup 40 | wg.Add(10) // WaitGroup的值设置为10 41 | 42 | for i := 0; i < 10; i++ { // 启动10个goroutine执行加1任务 43 | go worker(&counter, &wg) 44 | } 45 | // 检查点,等待goroutine都完成任务 46 | wg.Wait() 47 | // 输出当前计数器的值 48 | fmt.Println(counter.Count()) 49 | } 50 | -------------------------------------------------------------------------------- /1.基本并发原语/1.06:WaitGroup:协同等待,任务编排利器/06.02.add方法调用负数.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sync" 4 | 5 | func main() { 6 | var wg sync.WaitGroup 7 | wg.Add(10) 8 | 9 | wg.Add(-10) //将-10作为参数调用Add,计数值被设置为0 10 | 11 | wg.Add(-1) //将-1作为参数调用Add,如果加上-1计数值就会变为负数。这是不对的,所以会触发panic 12 | } 13 | -------------------------------------------------------------------------------- /1.基本并发原语/1.06:WaitGroup:协同等待,任务编排利器/06.03.错误重用WaitGroup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | func main() { 9 | var wg sync.WaitGroup 10 | wg.Add(1) 11 | go func() { 12 | time.Sleep(time.Millisecond) 13 | wg.Done() // 计数器减1 14 | wg.Add(1) // 计数值加1 15 | }() 16 | wg.Wait() // 主goroutine等待,有可能和第7行并发执行 17 | } 18 | -------------------------------------------------------------------------------- /1.基本并发原语/1.06:WaitGroup:协同等待,任务编排利器/06.think思考题.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "unsafe" 6 | ) 7 | 8 | /* 9 | 注意点: 10 | 1. 这里用了一个函数来实现,更常见的可以自己封一个类。用函数实现时注意用指针传递wg 11 | 2. 返回的两个值分别是state和wait,state是要完成的waiter计数值(即等待多少个goroutine完成);wait是指有多少个sync.Wait在等待(和前面的waiter不是一个概念)。 12 | */ 13 | func getStateAndWait(wgp *sync.WaitGroup) (uint32, uint32) { 14 | var statep *uint64 15 | if uintptr(unsafe.Pointer(wgp))%8 == 0 { 16 | statep = (*uint64)(unsafe.Pointer(wgp)) 17 | } else { 18 | statep = (*uint64)(unsafe.Pointer(uintptr(unsafe.Pointer(wgp)) + unsafe.Sizeof(uint32(0)))) 19 | } 20 | return uint32(*statep >> 32), uint32(*statep) 21 | } 22 | -------------------------------------------------------------------------------- /1.基本并发原语/1.07:Cond:条件变量的实现机制及避坑指南/07.00-Cond:条件变量的实现机制及避坑指南.md: -------------------------------------------------------------------------------- 1 | # Cond:条件变量的实现机制及避坑指南 2 | 3 | 在 Java 面试中,经常被问到的一个知识点就是等待 / 通知(wait/notify)机制。面试官经常会这样考察候选人:请实现一个限定容量的队列(queue),当队列满或者空的时候,利用等待 / 通知机制实现阻塞或者唤醒。 4 | 5 | 在 Go 中,也可以实现一个类似的限定容量的队列,而且实现起来也比较简单,只要用条件变量(Cond)并发原语就可以。Cond 并发原语相对来说不是那么常用,但是在特定的场景使用会事半功倍,比如你需要在唤醒一个或者所有的等待者做一些检查操作的时候。 6 | 7 | 那么今天这一讲,我们就学习下 Cond 这个并发原语。 8 | 9 | ## Go 标准库的 Cond 10 | 11 | Go 标准库提供 Cond 原语的目的是,为等待 / 通知场景下的并发问题提供支持。Cond 通常应用于等待某个条件的一组 goroutine,等条件变为 true 的时候,其中一个 goroutine 或者所有的 goroutine 都会被唤醒执行。 12 | 13 | 顾名思义,Cond 是和某个条件相关,这个条件需要一组 goroutine 协作共同完成,在条件还没有满足的时候,所有等待这个条件的 goroutine 都会被阻塞住,只有这一组 goroutine 通过协作达到了这个条件,等待的 goroutine 才可能继续进行下去。 14 | 15 | 那这里等待的条件是什么呢?等待的条件,可以是某个变量达到了某个阈值或者某个时间点,也可以是一组变量分别都达到了某个阈值,还可以是某个对象的状态满足了特定的条件。总结来讲,等待的条件是一种可以用来计算结果是 true 还是 false 的条件。 16 | 17 | 从开发实践上,我们真正使用 Cond 的场景比较少,因为一旦遇到需要使用 Cond 的场景,我们更多地会使用 Channel 的方式(我会在第 12 和第 13 讲展开 Channel 的用法)去实现,因为那才是更地道的 Go 语言的写法,甚至 Go 的开发者有个“把 Cond 从标准库移除”的提议([issue 21165](https://github.com/golang/go/issues/21165))。而有的开发者认为,Cond 是唯一难以掌握的 Go 并发原语。至于其中原因,我先卖个关子,到这一讲的后半部分我再和你解释。 18 | 19 | 20 | 今天,这一讲我们就带你仔细地学一学 Cond 这个并发原语吧。 21 | 22 | ## Cond 的基本用法 23 | 24 | 标准库中的 Cond 并发原语初始化的时候,需要关联一个 Locker 接口的实例,一般我们使用 Mutex 或者 RWMutex。 25 | 26 | 我们看一下 Cond 的实现: 27 | ~~~go 28 | type Cond 29 | func NeWCond(l Locker) *Cond 30 | func (c *Cond) Broadcast() 31 | func (c *Cond) Signal() 32 | func (c *Cond) Wait() 33 | ~~~ 34 | 35 | 首先,Cond 关联的 Locker 实例可以通过 c.L 访问,它内部维护着一个先入先出的等待队列。 36 | 37 | 然后,我们分别看下它的三个方法 Broadcast、Signal 和 Wait 方法。 38 | 39 | **Signal 方法**,允许调用者 Caller 唤醒一个等待此 Cond 的 goroutine。如果此时没有等待的 goroutine,显然无需通知 waiter;如果 Cond 等待队列中有一个或者多个等待的 goroutine,则需要从等待队列中移除第一个 goroutine 并把它唤醒。在其他编程语言中,比如 Java 语言中,Signal 方法也被叫做 notify 方法。 40 | 41 | 调用 Signal 方法时,不强求你一定要持有 c.L 的锁。 42 | 43 | **Broadcast 方法**,允许调用者 Caller 唤醒所有等待此 Cond 的 goroutine。如果此时没有等待的 goroutine,显然无需通知 waiter;如果 Cond 等待队列中有一个或者多个等待的 goroutine,则清空所有等待的 goroutine,并全部唤醒。在其他编程语言中,比如 Java 语言中,Broadcast 方法也被叫做 notifyAll 方法。 44 | 45 | 同样地,调用 Broadcast 方法时,也不强求你一定持有 c.L 的锁。 46 | 47 | **Wait 方法**,会把调用者 Caller 放入 Cond 的等待队列中并阻塞,直到被 Signal 或者 Broadcast 的方法从等待队列中移除并唤醒。 48 | 49 | 调用 Wait 方法时必须要持有 c.L 的锁。 50 | 51 | Go 实现的 sync.Cond 的方法名是 Wait、Signal 和 Broadcast,这是计算机科学中条件变量的通用方法名。比如,C 语言中对应的方法名是 pthread_cond_wait、pthread_cond_signal 和 pthread_cond_broadcast。 52 | 53 | 知道了 Cond 提供的三个方法后,我们再通过一个百米赛跑开始时的例子,来学习下** Cond 的使用方法**。10 个运动员进入赛场之后需要先做拉伸活动活动筋骨,向观众和粉丝招手致敬,在自己的赛道上做好准备;等所有的运动员都准备好之后,裁判员才会打响发令枪。 54 | 55 | 每个运动员做好准备之后,将 ready 加一,表明自己做好准备了,同时调用 Broadcast 方法通知裁判员。因为裁判员只有一个,所以这里可以直接替换成 Signal 方法调用。调用 Broadcast 方法的时候,我们并没有请求 c.L 锁,只是在更改等待变量的时候才使用到了锁。 56 | 57 | 裁判员会等待运动员都准备好(c.wait)。虽然每个运动员准备好之后都唤醒了裁判员,但是裁判员被唤醒之后需要检查等待条件是否满足(**运动员都准备好了**)。可以看到,裁判员被唤醒之后一定要检查等待条件,如果条件不满足还是要继续等待。 58 | ~~~go 59 | func main() { 60 | c := sync.NewCond(&sync.Mutex{}) 61 | var ready int 62 | 63 | for i := 0; i < 10; i++ { 64 | go func(i int) { 65 | time.Sleep(time.Duration(rand.Int63n(10)) * time.Second) 66 | 67 | // 加锁更改等待条件 68 | c.L.Lock() 69 | ready++ 70 | c.L.Unlock() 71 | 72 | log.Printf("运动员#%d 已准备就绪\n", i) 73 | // 广播唤醒所有的等待者 74 | c.Broadcast() 75 | }(i) 76 | } 77 | 78 | c.L.Lock() 79 | for ready != 10 { 80 | c.Wait() 81 | log.Println("裁判员被唤醒一次") 82 | } 83 | c.L.Unlock() 84 | 85 | //所有的运动员是否就绪 86 | log.Println("所有运动员都准备就绪。比赛开始,3,2,1, ......") 87 | } 88 | ~~~ 89 | 90 | 你看,Cond 的使用其实没那么简单。它的复杂在于:一,这段代码有时候需要加锁,有时候可以不加;二,Wait 唤醒后需要检查条件;三,条件变量的更改,其实是需要原子操作或者互斥锁保护的。所以,有的开发者会认为,Cond 是唯一难以掌握的 Go 并发原语 91 | 92 | 我们继续看看 Cond 的实现原理。 93 | 94 | ## Cond 的实现原理 95 | 其实,Cond 的实现非常简单,或者说复杂的逻辑已经被 Locker 或者 runtime 的等待队列实现了。我们直接看看 Cond 的源码吧。 96 | ~~~go 97 | type Cond struct { 98 | noCopy noCopy 99 | 100 | // 当观察或者修改等待条件的时候需要加锁 101 | L Locker 102 | 103 | // 等待队列 104 | notify notifyList 105 | checker copyChecker 106 | } 107 | 108 | func NewCond(l Locker) *Cond { 109 | return &Cond{L: l} 110 | } 111 | 112 | func (c *Cond) Wait() { 113 | c.checker.check() 114 | // 增加到等待队列中 115 | t := runtime_notifyListAdd(&c.notify) 116 | c.L.Unlock() 117 | // 阻塞休眠直到被唤醒 118 | runtime_notifyListWait(&c.notify, t) 119 | c.L.Lock() 120 | } 121 | 122 | func (c *Cond) Signal() { 123 | c.checker.check() 124 | runtime_notifyListNotifyOne(&c.notify) 125 | } 126 | 127 | func (c *Cond) Broadcast() { 128 | c.checker.check() 129 | runtime_notifyListNotifyAll(&c.notify) 130 | } 131 | ~~~ 132 | 133 | 这部分源码确实很简单,我来带你学习下其中比较关键的逻辑。 134 | 135 | runtime_notifyListXXX 是运行时实现的方法,实现了一个等待 / 通知的队列。如果你想深入学习这部分,可以再去看看 runtime/sema.go 代码中 136 | 137 | copyChecker 是一个辅助结构,可以在运行时检查 Cond 是否被复制使用。 138 | 139 | Signal 和 Broadcast 只涉及到 notifyList 数据结构,不涉及到锁。 140 | 141 | Wait 把调用者加入到等待队列时会释放锁,在被唤醒之后还会请求锁。在阻塞休眠期间,调用者是不持有锁的,这样能让其他 goroutine 有机会检查或者更新等待变量。 142 | 143 | 我们继续看看使用 Cond 常见的两个错误,一个是调用 Wait 的时候没有加锁,另一个是没有检查条件是否满足程序就继续执行了。 144 | 145 | ## 使用 Cond 的 2 个常见错误 146 | 147 | 我们先看 Cond 最常见的使用错误,**也就是调用 Wait 的时候没有加锁。** 148 | 149 | 以前面百米赛跑的程序为例,在调用 cond.Wait 时,把前后的 Lock/Unlock 注释掉,再运行程序,就会报释放未加锁的 panic. 150 | 151 | 出现这个问题的原因在于,cond.Wait 方法的实现是,把当前调用者加入到 notify 队列之中后会释放锁(如果不释放锁,其他 Wait 的调用者就没有机会加入到 notify 队列中了),然后一直等待;等调用者被唤醒之后,又会去争抢这把锁。如果调用 Wait 之前不加锁的话,就有可能 Unlock 一个未加锁的 Locker。所以切记,**调用 cond.Wait 方法之前一定要加锁。** 152 | 153 | 使用 Cond 的另一个常见错误是,只调用了一次 Wait,没有检查等待条件是否满足,结果条件没满足,程序就继续执行了。出现这个问题的原因在于,误以为 Cond 的使用,就像 WaitGroup 那样调用一下 Wait 方法等待那么简单。比如下面的代码中,把第 for ready != 10注释掉. 154 | 155 | 运行这个程序,你会发现,可能只有几个运动员准备好之后程序就运行完了,而不是我们期望的所有运动员都准备好才进行下一步。原因在于,每一个运动员准备好之后都会唤醒所有的等待者,也就是这里的裁判员,比如第一个运动员准备好后就唤醒了裁判员,结果这个裁判员傻傻地没做任何检查,以为所有的运动员都准备好了,就继续执行了。 156 | 157 | 所以,我们一定要记住,waiter goroutine 被唤醒不等于等待条件被满足,只是有 goroutine 把它唤醒了而已,等待条件有可能已经满足了,也有可能不满足,我们需要进一步检查。你也可以理解为,等待者被唤醒,只是得到了一次检查的机会而已。 158 | 159 | 到这里,我们小结下。如果你想在使用 Cond 的时候避免犯错,只要时刻记住调用 cond.Wait 方法之前一定要加锁,以及 waiter goroutine 被唤醒不等于等待条件被满足这两个知识点。 160 | 161 | ## Cond 的使用 说明 162 | Cond 在实际项目中被使用的机会比较少,原因总结起来有两个。 163 | 164 | 第一,同样的场景我们会使用其他的并发原语来替代。Go 特有的 Channel 类型,有一个应用很广泛的模式就是通知机制,这个模式使用起来也特别简单。所以很多情况下,我们会使用 Channel 而不是 Cond 实现 wait/notify 机制。 165 | 166 | 第二,对于简单的 wait/notify 场景,比如等待一组 goroutine 完成之后继续执行余下的代码,我们会使用 WaitGroup 来实现。因为 WaitGroup 的使用方法更简单,而且不容易出错。比如,上面百米赛跑的问题,就可以很方便地使用 WaitGroup 来实现。 167 | 168 | 所以,我在这一讲开头提到,Cond 的使用场景很少。先前的标准库内部有几个地方使用了 Cond,比如 io/pipe.go 等,后来都被其他的并发原语(比如 Channel)替换了,sync.Cond 的路越走越窄。但是,还是有一批忠实的“粉丝”坚持在使用 Cond,原因在于 Cond 有三点特性是 Channel 无法替代的: 169 | 170 | - Cond 和一个 Locker 关联,可以利用这个 Locker 对相关的依赖条件更改提供保护。 171 | - Cond 可以同时支持 Signal 和 Broadcast 方法,而 Channel 只能同时支持其中一种。 172 | - Cond 的 Broadcast 方法可以被重复调用。等待条件再次变成不满足的状态后,我们又可以调用 Broadcast 再次唤醒等待的 goroutine。这也是 Channel 不能支持的,Channel 被 close 掉了之后不支持再 open。 173 | 174 | ## 总结 175 | 176 | Cond 是为等待 / 通知场景下的并发问题提供支持的。它提供了条件变量的三个基本方法 Signal、Broadcast 和 Wait,为并发的 goroutine 提供等待 / 通知机制。 177 | 178 | 在实践中,处理等待 / 通知的场景时,我们常常会使用 Channel 替换 Cond,因为 Channel 类型使用起来更简洁,而且不容易出错。但是对于需要重复调用 Broadcast 的场景,比如上面 Kubernetes 的例子,每次往队列中成功增加了元素后就需要调用 Broadcast 通知所有的等待者,使用 Cond 就再合适不过了。 179 | 180 | 使用 Cond 之所以容易出错,就是 Wait 调用需要加锁,以及被唤醒后一定要检查条件是否真的已经满足。你需要牢记这两点。 181 | 182 | 虽然我们讲到的百米赛跑的例子,也可以通过 WaitGroup 来实现,但是本质上 WaitGroup 和 Cond 是有区别的:WaitGroup 是主 goroutine 等待确定数量的子 goroutine 完成任务;而 Cond 是等待某个条件满足,这个条件的修改可以被任意多的 goroutine 更新,而且 Cond 的 Wait 不关心也不知道其他 goroutine 的数量,只关心等待条件。而且 Cond 还有单个通知的机制,也就是 Signal 方法。 183 | 184 | ![结构图](https://static001.geekbang.org/resource/image/47/5d/477157d2dbe1b7e4511f56c2c9c2105d.jpg) 185 | 186 | ## 思考题 187 | 188 | 1. 一个 Cond 的 waiter 被唤醒的时候,为什么需要再检查等待条件,而不是唤醒后进行下一步? 189 | - 假如只是唤醒,不检查条件直接进行下一步,那体现不出来条件变量的条件了 190 | 2. 你能否利用 Cond 实现一个容量有限的 queue? -------------------------------------------------------------------------------- /1.基本并发原语/1.07:Cond:条件变量的实现机制及避坑指南/07.01.裁判运动员例子.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | func main() { 11 | //初始化并带上锁 12 | c := sync.NewCond(&sync.Mutex{}) 13 | var ready int 14 | 15 | for i := 0; i < 10; i++ { 16 | go func(i int) { 17 | time.Sleep(time.Duration(rand.Int63n(10)) * time.Second) 18 | 19 | // 加锁更改等待条件 20 | c.L.Lock() 21 | ready++ 22 | c.L.Unlock() 23 | 24 | log.Printf("运动员#%d 已准备就绪\n", i) 25 | // 广播唤醒所有的等待者 26 | c.Broadcast() 27 | }(i) 28 | } 29 | 30 | c.L.Lock() 31 | for ready != 10 { //在全都准备之前不做处理 32 | c.Wait() 33 | log.Println("裁判员被唤醒一次") 34 | } 35 | c.L.Unlock() 36 | 37 | //所有的运动员是否就绪 38 | log.Println("所有运动员都准备就绪。比赛开始,3,2,1, ......") 39 | } 40 | -------------------------------------------------------------------------------- /1.基本并发原语/1.07:Cond:条件变量的实现机制及避坑指南/07.02.容量有限的queue.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | ) 8 | 9 | type Queue struct { 10 | cond *sync.Cond 11 | data []interface{} 12 | capc int 13 | logs []string 14 | } 15 | 16 | func NewQueue(capacity int) *Queue { 17 | return &Queue{cond: &sync.Cond{L: &sync.Mutex{}}, data: make([]interface{}, 0), capc: capacity, logs: make([]string, 0)} 18 | } 19 | 20 | func (q *Queue) Enqueue(d interface{}) { 21 | q.cond.L.Lock() 22 | defer q.cond.L.Unlock() 23 | 24 | for len(q.data) == q.capc { 25 | q.cond.Wait() 26 | } 27 | // FIFO入队 28 | q.data = append(q.data, d) 29 | // 记录操作日志 30 | q.logs = append(q.logs, fmt.Sprintf("En %v\n", d)) 31 | // 通知其他waiter进行Dequeue或Enqueue操作 32 | q.cond.Broadcast() 33 | 34 | } 35 | 36 | func (q *Queue) Dequeue() (d interface{}) { 37 | q.cond.L.Lock() 38 | defer q.cond.L.Unlock() 39 | 40 | for len(q.data) == 0 { 41 | q.cond.Wait() 42 | } 43 | // FIFO出队 44 | d = q.data[0] 45 | q.data = q.data[1:] 46 | // 记录操作日志 47 | q.logs = append(q.logs, fmt.Sprintf("De %v\n", d)) 48 | // 通知其他waiter进行Dequeue或Enqueue操作 49 | q.cond.Broadcast() 50 | return 51 | } 52 | 53 | func (q *Queue) Len() int { 54 | q.cond.L.Lock() 55 | defer q.cond.L.Unlock() 56 | return len(q.data) 57 | } 58 | 59 | func (q *Queue) String() string { 60 | var b strings.Builder 61 | for _, log := range q.logs { 62 | //fmt.Fprint(&b, log) 63 | b.WriteString(log) 64 | } 65 | return b.String() 66 | } 67 | -------------------------------------------------------------------------------- /1.基本并发原语/1.08:Once:一个简约而不简单的并发原语/08.00-Once:一个简约而不简单的并发原语.md: -------------------------------------------------------------------------------- 1 | # Once:一个简约而不简单的并发原语 2 | 3 | 单例模式:管他什么双加锁校验单例,什么懒汉,饿汉,这里直接用Once梭哈就行了! 4 | 5 | **Once 可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。** 6 | 7 | 我们就从对单例对象进行初始化这件事儿说起。 8 | 9 | 初始化单例资源有很多方法,比如定义 package 级别的变量,这样程序在启动的时候就可以初始化: 10 | 11 | ~~~go 12 | package abc 13 | import time 14 | 15 | var startTime = time.Now() 16 | ~~~ 17 | 18 | 或者在 init 函数中进行初始化: 19 | 20 | ~~~go 21 | package abc 22 | var startTime time.Time 23 | 24 | func init() { 25 | startTime = time.Now() 26 | } 27 | 28 | ~~~ 29 | 30 | 又或者在 main 函数开始执行的时候,执行一个初始化的函数: 31 | ~~~go 32 | package abc 33 | 34 | var startTime time.Tim 35 | 36 | func initApp() { 37 | startTime = time.Now() 38 | } 39 | func main() { 40 | initApp() 41 | } 42 | ~~~ 43 | 这三种方法都是线程安全的,并且后两种方法还可以根据传入的参数实现定制化的初始化操作。 44 | 45 | 但是很多时候我们是要延迟进行初始化的(就像要使用连接的时候才进行连接),所以有时候单例资源的初始化,我们会使用下面的方法: 46 | ~~~go 47 | package main 48 | 49 | import ( 50 | "net" 51 | "sync" 52 | "time" 53 | ) 54 | 55 | // 使用互斥锁保证线程(goroutine)安全 56 | var connMu sync.Mutex 57 | var conn net.Conn 58 | 59 | func getConn() net.Conn { 60 | connMu.Lock() 61 | defer connMu.Unlock() 62 | 63 | // 返回已创建好的连接 64 | if conn != nil { 65 | return conn 66 | } 67 | 68 | // 创建连接 69 | conn, _ = net.DialTimeout("tcp", "baidu.com:80", 10*time.Second) 70 | return conn 71 | } 72 | 73 | // 使用连接 74 | func main() { 75 | conn := getConn() 76 | if conn == nil { 77 | panic("conn is nil") 78 | } 79 | } 80 | ~~~ 81 | 82 | 这种方式虽然实现起来简单,但是有性能问题。一旦连接创建好,每次请求的时候还是得竞争锁才能读取到这个连接,这是比较浪费资源的,因为连接如果创建好之后,其实就不需要锁的保护了。怎么办呢? 83 | 84 | 这个时候就可以使用这一讲要介绍的 Once 并发原语了。接下来我会详细介绍 Once 的使用、实现和易错场景。 85 | 86 | ## Once 的使用场景 87 | 88 | **sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值的函数。** 89 | 90 | ~~~~go 91 | func (o *Once) Do(f func()) 92 | ~~~~ 93 | 因为当且仅当第一次调用 Do 方法的时候参数 f 才会执行,即使第二次、第三次、第 n 次调用时 f 参数的值不一样,也不会被执行,比如下面的例子,虽然 f1 和 f2 是不同的函数,但是第二个函数 f2 就不会执行。 94 | ~~~go 95 | package main 96 | 97 | import ( 98 | "fmt" 99 | "sync" 100 | ) 101 | func main() { 102 | var once sync.Once 103 | 104 | // 第一个初始化函数 105 | f1 := func() { 106 | fmt.Println("in f1") 107 | } 108 | once.Do(f1) // 打印出 in f1 109 | 110 | // 第二个初始化函数 111 | f2 := func() { 112 | fmt.Println("in f2") 113 | } 114 | once.Do(f2) // 无输出 115 | } 116 | ~~~ 117 | 118 | 因为这里的 f 参数是一个无参数无返回的函数,所以你可能会通过闭包的方式引用外面的参数,比如: 119 | ~~~go 120 | var addr = "baidu.com" 121 | 122 | var conn net.Conn 123 | var err error 124 | 125 | once.Do(func() { 126 | conn, err = net.Dial("tcp", addr) 127 | }) 128 | ~~~ 129 | 而且在实际的使用中,绝大多数情况下,你会使用闭包的方式去初始化外部的一个资源。 130 | 131 | 你看,Once 的使用场景很明确,所以,在标准库内部实现中也常常能看到 Once 的身影。 132 | 133 | 比如标准库内部[cache](https://github.com/golang/go/blob/f0e97546962736fe4aa73b7c7ed590f0134515e1/src/cmd/go/internal/cache/default.go)的实现上,就使用了 Once 初始化 Cache 资源,包括 defaultDir 值的获取: 134 | 135 | ~~~go 136 | func Default() *Cache { // 获取默认的Cache 137 | defaultOnce.Do(initDefaultCache) // 初始化cache 138 | return defaultCache 139 | } 140 | 141 | // 定义一个全局的cache变量,使用Once初始化,所以也定义了一个Once变量 142 | var ( 143 | defaultOnce sync.Once 144 | defaultCache *Cache 145 | ) 146 | 147 | func initDefaultCache() { //初始化cache,也就是Once.Do使用的f函数 148 | ...... 149 | defaultCache = c 150 | } 151 | 152 | // 其它一些Once初始化的变量,比如defaultDir 153 | var ( 154 | defaultDirOnce sync.Once 155 | defaultDir string 156 | defaultDirErr error 157 | ) 158 | ~~~ 159 | 还有一些测试的时候初始化测试的资源:([export_windows_test](https://github.com/golang/go/blob/50bd1c4d4eb4fac8ddeb5f063c099daccfb71b26/src/time/export_windows_test.go)) 160 | ~~~go 161 | // 测试window系统调用时区相关函数 162 | func ForceAusFromTZIForTesting() { 163 | ResetLocalOnceForTest() 164 | // 使用Once执行一次初始化 165 | localOnce.Do(func() { initLocalFromTZI(&aus) }) 166 | } 167 | ~~~ 168 | 除此之外,还有保证只调用一次 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 封装了一个只初始化一次的值: 169 | ~~~go 170 | // 值是3.0或者0.0的一个数据结构 171 | var threeOnce struct { 172 | sync.Once 173 | v *Float 174 | } 175 | 176 | // 返回此数据结构的值,如果还没有初始化为3.0,则初始化 177 | func three() *Float { 178 | threeOnce.Do(func() { // 使用Once初始化 179 | threeOnce.v = NewFloat(3.0) 180 | }) 181 | return threeOnce.v 182 | } 183 | ~~~ 184 | 185 | 它将 sync.Once 和 *Float 封装成一个对象,提供了只初始化一次的值 v。 你看它的 three 方法的实现,虽然每次都调用 threeOnce.Do 方法,但是参数只会被调用一次。 186 | 187 | 当你使用 Once 的时候,你也可以尝试采用这种结构,将值和 Once 封装成一个新的数据结构,提供只初始化一次的值 188 | 189 | 总结一下 Once 并发原语解决的问题和使用场景:**Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。** 190 | 191 | 了解了 Once 的使用场景,那应该怎样实现一个 Once 呢? 192 | ## 如何实现一个 Once? 193 | 很多人认为实现一个 Once 一样的并发原语很简单,只需使用一个 flag 标记是否初始化过即可,最多是用 atomic 原子操作这个 flag,比如下面的实现 194 | ~~~go 195 | type Once struct { 196 | done uint32 197 | } 198 | 199 | func (o *Once) Do(f func()) { 200 | if !atomic.CompareAndSwapUint32(&o.done, 0, 1) { 201 | return 202 | } 203 | f() 204 | } 205 | ~~~ 206 | 这确实是一种实现方式,但是,这个实现有一个很大的问题,就是如果参数 f 执行很慢的话,后续调用 Do 方法的 goroutine 虽然看到 done 已经设置为执行过了,但是获取某些初始化资源的时候可能会得到空的资源,因为 f 还没有执行完。 207 | 208 | 所以,**一个正确的 Once 实现要使用一个互斥锁,这样初始化的时候如果有并发的 goroutine,就会进入doSlow 方法。**互斥锁的机制保证只有一个 goroutine 进行初始化,同时利用**双检查**的机制(double-checking),再次判断 o.done 是否为 0,如果为 0,则是第一次执行,执行完毕后,就将 o.done 设置为 1,然后释放锁。 209 | 210 | 即使此时有多个 goroutine 同时进入了 doSlow 方法,因为双检查的机制,后续的 goroutine 会看到 o.done 的值为 1,也不会再次执行 f。 211 | 212 | 这样既保证了并发的 goroutine 会等待 f 完成,而且还不会多次执行 f。 213 | 214 | ~~~go 215 | type Once struct { 216 | done uint32 217 | m Mutex 218 | } 219 | 220 | func (o *Once) Do(f func()) { 221 | if atomic.LoadUint32(&o.done) == 0 { 222 | o.doSlow(f) 223 | } 224 | } 225 | 226 | 227 | func (o *Once) doSlow(f func()) { 228 | o.m.Lock() 229 | defer o.m.Unlock() 230 | // 双检查 231 | if o.done == 0 { 232 | defer atomic.StoreUint32(&o.done, 1) 233 | f() 234 | } 235 | } 236 | ~~~ 237 | 238 | 好了,到这里我们就了解了 Once 的使用场景,很明确,同时呢,也感受到 Once 的实现也是相对简单的。在实践中,其实很少会出现错误使用 Once 的情况,但是就像墨菲定律说的,凡是可能出错的事就一定会出错。使用 Once 也有可能出现两种错误场景,尽管非常罕见。我这里提前讲给你,咱打个预防针。 239 | 240 | ## 使用 Once 可能出现的 2 种错误 241 | ### 第一种错误:死锁 242 | 243 | 你已经知道了 Do 方法会执行一次 f,但是如果 f 中再次调用这个 Once 的 Do 方法的话,就会导致死锁的情况出现。这还不是无限递归的情况,而是的的确确的 Lock 的递归调用导致的死锁。 244 | 245 | ~~~go 246 | func main() { 247 | var once sync.Once 248 | once.Do(func() { 249 | once.Do(func() { 250 | fmt.Println("初始化") 251 | }) 252 | }) 253 | } 254 | ~~~ 255 | 当然,想要避免这种情况的出现,就不要在 f 参数中调用当前的这个 Once,不管是直接的还是间接的。 256 | 257 | ### 第二种错误:未初始化 258 | 259 | 如果 f 方法执行的时候 panic,或者 f 执行初始化资源的时候失败了,这个时候,Once 还是会认为初次执行已经成功了,即使再次调用 Do 方法,也不会再次执行 f。 260 | 261 | 比如下面的例子,由于一些防火墙的原因,googleConn 并没有被正确的初始化,后面如果想当然认为既然执行了 Do 方法 googleConn 就已经初始化的话,会抛出空指针的错误: 262 | ~~~go 263 | func main() { 264 | var once sync.Once 265 | var googleConn net.Conn // 到Google网站的一个连接 266 | 267 | once.Do(func() { 268 | // 建立到google.com的连接,有可能因为网络的原因,googleConn并没有建立成功,此时它的值为nil 269 | googleConn, _ = net.Dial("tcp", "google.com:80") 270 | }) 271 | // 发送http请求 272 | googleConn.Write([]byte("GET / HTTP/1.1\r\nHost: google.com\r\n Accept: */*\r\n\r\n")) 273 | io.Copy(os.Stdout, googleConn) 274 | } 275 | ~~~ 276 | 既然执行过 Once.Do 方法也可能因为函数执行失败的原因未初始化资源,并且以后也没机会再次初始化资源,那么这种初始化未完成的问题该怎么解决呢? 277 | 278 | 这里我来告诉你一招独家秘笈,我们可以自己实现一个类似 Once 的并发原语,既可以返回当前调用 Do 方法是否正确完成,还可以在初始化失败后调用 Do 方法再次尝试初始化,直到初始化成功才不再初始化了。 279 | ~~~go 280 | // 一个功能更加强大的Once 281 | type Once struct { 282 | m sync.Mutex 283 | done uint32 284 | } 285 | // 传入的函数f有返回值error,如果初始化失败,需要返回失败的error 286 | // Do方法会把这个error返回给调用者 287 | func (o *Once) Do(f func() error) error { 288 | if atomic.LoadUint32(&o.done) == 1 { //fast path 289 | return nil 290 | } 291 | return o.slowDo(f) 292 | } 293 | // 如果还没有初始化 294 | func (o *Once) slowDo(f func() error) error { 295 | o.m.Lock() 296 | defer o.m.Unlock() 297 | var err error 298 | if o.done == 0 { // 双检查,还没有初始化 299 | err = f() 300 | if err == nil { // 初始化成功才将标记置为已初始化 301 | atomic.StoreUint32(&o.done, 1) 302 | } 303 | } 304 | return err 305 | } 306 | ~~~ 307 | 我们所做的改变就是 Do 方法和参数 f 函数都会返回 error,如果 f 执行失败,会把这个错误信息返回。 308 | 309 | 对 slowDo 方法也做了调整,如果 f 调用失败,我们不会更改 done 字段的值,这样后续 degoroutine 还会继续调用 f。如果 f 执行成功,才会修改 done 的值为 1。 310 | 311 | 可以说,真是一顿操作猛如虎,我们使用 Once 有点得心应手的感觉了。等等,还有个问题,我们怎么查询是否初始化过呢? 312 | 313 | 目前的 Once 实现可以保证你调用任意次数的 once.Do 方法,它只会执行这个方法一次。但是,有时候我们需要打一个标记。如果初始化后我们就去执行其它的操作,标准库的 Once 并不会告诉你是否初始化完成了,只是让你放心大胆地去执行 Do 方法,所以,你还需要一个辅助变量,自己去检查是否初始化过了,比如通过下面的代码中的 inited 字段: 314 | 315 | ~~~go 316 | type AnimalStore struct {once sync.Once;inited uint32} 317 | func (a *AnimalStore) Init() // 可以被并发调用 318 | a.once.Do(func() { 319 | longOperationSetupDbOpenFilesQueuesEtc() 320 | atomic.StoreUint32(&a.inited, 1) 321 | }) 322 | } 323 | func (a *AnimalStore) CountOfCats() (int, error) { // 另外一个goroutine 324 | if atomic.LoadUint32(&a.inited) == 0 { // 初始化后才会执行真正的业务逻辑 325 | return 0, NotYetInitedError 326 | } 327 | //Real operation 328 | } 329 | ~~~ 330 | 331 | 当然,通过这段代码,我们可以解决这类问题,但是,如果官方的 Once 类型有 Done 这样一个方法的话,我们就可以直接使用了。这是有人在 Go 代码库中提出的一个 issue([#41690](https://github.com/golang/go/issues/41690))。对于这类问题,一般都会被建议采用其它类型,或者自己去扩展。我们可以尝试扩展这个并发原语: 332 | 333 | ~~~go 334 | // Once 是一个扩展的sync.Once类型,提供了一个Done方法 335 | type Once struct { 336 | sync.Once 337 | } 338 | 339 | // Done 返回此Once是否执行过 340 | // 如果执行过则返回true 341 | // 如果没有执行过或者正在执行,返回false 342 | func (o *Once) Done() bool { 343 | return atomic.LoadUint32((*uint32)(unsafe.Pointer(&o.Once))) == 1 344 | } 345 | 346 | func main() { 347 | var flag Once 348 | fmt.Println(flag.Done()) //false 349 | 350 | flag.Do(func() { 351 | time.Sleep(time.Second) 352 | }) 353 | 354 | fmt.Println(flag.Done()) //true 355 | } 356 | ~~~ 357 | 358 | ## 总结 359 | 单例是 23 种设计模式之一,也是常常引起争议的设计模式之一,甚至有人把它归为反模式。为什么说它是反模式呢,我拿标准库中的单例模式给你介绍下。 360 | 361 | 因为 Go 没有 immutable 类型,导致我们声明的全局变量都是可变的,别的地方或者第三方库可以随意更改这些变量。比如 package io 中定义了几个全局变量,比如 io.EOF: 362 | 363 | ~~~go 364 | var EOF = errors.New("EOF") 365 | ~~~ 366 | 因为它是一个 package 级别的变量,我们可以在程序中偷偷把它改了,这会导致一些依赖 io.EOF 这个变量做判断的代码出错。 367 | 368 | ~~~go 369 | io.EOF = errors.New("我们自己定义的EOF") 370 | ~~~ 371 | 从我个人的角度来说,一些单例(全局变量)的确很方便,比如 Buffer 池或者连接池,所以有时候我们也不要谈虎色变。虽然有人把单例模式称之为反模式,但毕竟只能代表一部分开发者的观点,否则也不会把它列在 23 种设计模式中了。 372 | 373 | 如果你真的担心这个 package 级别的变量被人修改,你可以不把它们暴露出来,而是提供一个只读的 GetXXX 的方法,这样别人就不会进行修改了。 374 | 375 | 而且,Once 不只应用于单例模式,一些变量在也需要在使用的时候做延迟初始化,所以也是可以使用 Once 处理这些场景的。 376 | 377 | 378 | 总而言之,Once 的应用场景还是很广泛的。**一旦你遇到只需要初始化一次的场景,首先想到的就应该是 Once 并发原语。** 379 | ![Once总结图](https://static001.geekbang.org/resource/image/4b/ba/4b1721a63d7bd3f3995eb18cee418fba.jpg) 380 | 381 | ## 思考题 382 | 1. 我已经分析了几个并发原语的实现,你可能注意到总是有些 slowXXXX 的方法,从 XXXX 方法中单独抽取出来,你明白为什么要这么做吗,有什么好处? 383 | - 分离固定内容和非固定内容,使得固定的内容能被内联调用,从而优化执行过程。 384 | 2. Once 在第一次使用之后,还能复制给其它变量使用吗? 385 | - Once被拷贝的过程中内部的已执行状态不会改变,所以Once不能通过拷贝多次执行。 -------------------------------------------------------------------------------- /1.基本并发原语/1.08:Once:一个简约而不简单的并发原语/08.01.直到成功的Once.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | ) 7 | 8 | // 一个功能更加强大的Once 9 | type Once struct { 10 | m sync.Mutex 11 | done uint32 12 | } 13 | 14 | // 传入的函数f有返回值error,如果初始化失败,需要返回失败的error 15 | // Do方法会把这个error返回给调用者 16 | func (o *Once) Do(f func() error) error { 17 | if atomic.LoadUint32(&o.done) == 1 { //fast path 18 | return nil 19 | } 20 | return o.slowDo(f) 21 | } 22 | 23 | // 如果还没有初始化 24 | func (o *Once) slowDo(f func() error) error { 25 | o.m.Lock() 26 | defer o.m.Unlock() 27 | var err error 28 | if o.done == 0 { // 双检查,还没有初始化 29 | err = f() 30 | if err == nil { // 初始化成功才将标记置为已初始化 31 | atomic.StoreUint32(&o.done, 1) 32 | } 33 | } 34 | return err 35 | } 36 | -------------------------------------------------------------------------------- /1.基本并发原语/1.09:map:如何实现线程安全的map类型?/09.00-map:如何实现线程安全的map类型?.md: -------------------------------------------------------------------------------- 1 | # map:如何实现线程安全的map类型? 2 | 哈希表(Hash Table)这个数据结构,我们已经非常熟悉了。它实现的就是 key-value 之间的映射关系,主要提供的方法包括 Add、Lookup、Delete 等。因为这种数据结构是一个基础的数据结构,每个 key 都会有一个唯一的索引值,通过索引可以很快地找到对应的值,所以使用哈希表进行数据的插入和读取都是很快的。Go 语言本身就内建了这样一个数据结构,也就是** map 数据类型**。 3 | 4 | ## map 的基本使用方法 5 | 6 | Go 内建的 map 类型如下: 7 | ~~~go 8 | map[K]V 9 | ~~~ 10 | 其中,**key 类型的 K 必须是可比较的(comparable)**,也就是可以通过 == 和 != 操作符进行比较;value 的值和类型无所谓,可以是任意的类型,或者为 nil。 11 | 12 | 在 Go 语言中,bool、整数、浮点数、复数、字符串、指针、Channel、接口都是可比较的,包含可比较元素的 struct 和数组,这俩也是可比较的,而 slice、map、函数值都是不可比较的。 13 | 14 | 那么,上面这些可比较的数据类型都可以作为 map 的 key 吗?显然不是。通常情况下,我们会选择内建的基本类型,比如整数、字符串做 key 的类型,因为这样最方便。 15 | 16 | 这里有一点需要注意,如果使用 struct 类型做 key 其实是有坑的,因为如果 struct 的某个字段值修改了,查询 map 时无法获取它 add 进去的值,如下面的例子: 17 | ~~~go 18 | type mapKey struct { 19 | key int 20 | } 21 | 22 | func main() { 23 | var m = make(map[mapKey]string) 24 | var key = mapKey{10} 25 | 26 | 27 | m[key] = "hello" 28 | fmt.Printf("m[key]=%s\n", m[key]) 29 | 30 | 31 | // 修改key的字段的值后再次查询map,无法获取刚才add进去的值 32 | key.key = 100 33 | fmt.Printf("再次查询m[key]=%s\n", m[key]) 34 | } 35 | ~~~ 36 | 37 | 那该怎么办呢?如果要使用 struct 作为 key,我们要保证 struct 对象在逻辑上是不可变的,这样才会保证 map 的逻辑没有问题。 38 | 39 | 以上就是选取 key 类型的注意点了。接下来,我们看一下使用 map[key]函数时需要注意的一个知识点。**在 Go 中,map[key]函数返回结果可以是一个值,也可以是两个值,**这是容易让人迷惑的地方。原因在于,如果获取一个不存在的 key 对应的值时,会返回零值。为了区分真正的零值和 key 不存在这两种情况,可以根据第二个返回值来区分,如下面的代码的第 6 行、第 7 行: 40 | ~~~go 41 | func main() { 42 | var m = make(map[string]int) 43 | m["a"] = 0 44 | fmt.Printf("a=%d; b=%d\n", m["a"], m["b"]) 45 | 46 | av, aexisted := m["a"] 47 | bv, bexisted := m["b"] 48 | fmt.Printf("a=%d, existed: %t; b=%d, existed: %t\n", av, aexisted, bv, bexisted) 49 | } 50 | ~~~ 51 | 52 | map 是无序的,所以当遍历一个 map 对象的时候,迭代的元素的顺序是不确定的,无法保证两次遍历的顺序是一样的,也不能保证和插入的顺序一致。那怎么办呢?如果我们想要按照 key 的顺序获取 map 的值,需要先取出所有的 key 进行排序,然后按照这个排序的 key 依次获取对应的值。而如果我们想要保证元素有序,比如按照元素插入的顺序进行遍历,可以使用辅助的数据结构,比如[orderedmap](https://github.com/elliotchance/orderedmap),来记录插入顺序。 53 | 54 | 好了,总结下关于 map 我们需要掌握的内容:map 的类型是 map[key],key 类型的 K 必须是可比较的,通常情况下,我们会选择内建的基本类型,比如整数、字符串做 key 的类型。如果要使用 struct 作为 key,我们要保证 struct 对象在逻辑上是不可变的。在 Go 中,map[key]函数返回结果可以是一个值,也可以是两个值。map 是无序的,如果我们想要保证遍历 map 时元素有序,可以使用辅助的数据结构,比如[orderedmap](https://github.com/elliotchance/orderedmap)。 55 | 56 | ## 使用 map 的 2 种常见错误 57 | 58 | 那接下来,我们来看使用 map 最常犯的两个错误,就是**未初始化**和**并发读写**。 59 | 60 | ### 常见错误一:未初始化 61 | 和 slice 或者 Mutex、RWmutex 等 struct 类型不同,map 对象必须在使用之前初始化。如果不初始化就直接赋值的话,会出现 panic 异常,比如下面的例子,m 实例还没有初始化就直接进行操作会导致 panic 62 | ~~~go 63 | func main() { 64 | var m map[int]int 65 | m[100] = 100 66 | } 67 | ~~~ 68 | 解决办法就是在第 2 行初始化这个实例(m := make(map[int]int))。 69 | 70 | 从一个 nil 的 map 对象中获取值不会 panic,而是会得到零值,所以下面的代码不会报错: 71 | 72 | ~~~go 73 | func main() { 74 | var m map[int]int 75 | fmt.Println(m[100]) 76 | } 77 | ~~~ 78 | 79 | 这个例子很简单,我们可以意识到 map 的初始化问题。但有时候 map 作为一个 struct 字段的时候,就很容易忘记初始化了。 80 | ~~~go 81 | type Counter struct { 82 | Website string 83 | Start time.Time 84 | PageCounters map[string]int 85 | } 86 | 87 | func main() { 88 | var c Counter 89 | c.Website = "baidu.com" 90 | 91 | 92 | c.PageCounters["/"]++ 93 | } 94 | ~~~ 95 | 96 | 所以,关于初始化这一点,我再强调一下,目前还没有工具可以检查,我们只能记住“**别忘记初始化**”这一条规则。 97 | 98 | ### 常见错误二:并发读写 99 | 100 | 对于 map 类型,另一个很容易犯的错误就是并发访问问题。这个易错点,相当令人讨厌,如果没有注意到并发问题,程序在运行的时候就有可能出现并发读写导致的 panic。 101 | 102 | Go 内建的 map 对象不是线程(goroutine)安全的,并发读写的时候运行时会有检查,遇到并发问题就会导致 panic。 103 | 104 | 我们一起看一个并发访问 map 实例导致 panic 的例子: 105 | ~~~go 106 | func main() { 107 | var m = make(map[int]int,10) // 初始化一个map 108 | go func() { 109 | for { 110 | m[1] = 1 //设置key 111 | } 112 | }() 113 | 114 | go func() { 115 | for { 116 | _ = m[2] //访问这个map 117 | } 118 | }() 119 | select {} 120 | } 121 | ~~~ 122 | 123 | 虽然这段代码看起来是读写 goroutine 各自操作不同的元素,貌似 map 也没有扩容的问题,但是运行时检测到同时对 map 对象有并发访问,就会直接 panic 124 | 125 | 126 | 这个错误非常常见,是几乎每个人都会踩到的坑。 127 | 128 | ## 如何实现线程安全的 map 类型? 129 | 130 | 避免 map 并发读写 panic 的方式之一就是加锁,考虑到读写性能,可以使用读写锁提供性能 131 | 132 | ### 加读写锁:扩展 map,支持并发读写 133 | 134 | 比较遗憾的是,目前 Go 还没有正式发布泛型特性,我们还不能实现一个通用的支持泛型的加锁 map。但是,将要发布的泛型方案已经可以验证测试了,离发布也不远了,也许发布之后 sync.Map 就支持泛型了。 135 | 136 | 137 | 当然了,如果没有泛型支持,我们也能解决这个问题。我们可以通过 interface{}来模拟泛型,但还是要涉及接口和具体类型的转换,比较复杂,还不如将要发布的泛型方案更直接、性能更好。 138 | 139 | 140 | 这里我以一个具体的 map 类型为例,来演示利用读写锁实现线程安全的 map[int]int 类型: 141 | ~~~go 142 | type RWMap struct { // 一个读写锁保护的线程安全的map 143 | sync.RWMutex // 读写锁保护下面的map字段 144 | m map[int]int 145 | } 146 | // 新建一个RWMap 147 | func NewRWMap(n int) *RWMap { 148 | return &RWMap{ 149 | m: make(map[int]int, n), 150 | } 151 | } 152 | func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值 153 | m.RLock() 154 | defer m.RUnlock() 155 | v, existed := m.m[k] // 在锁的保护下从map中读取 156 | return v, existed 157 | } 158 | 159 | func (m *RWMap) Set(k int, v int) { // 设置一个键值对 160 | m.Lock() // 锁保护 161 | defer m.Unlock() 162 | m.m[k] = v 163 | } 164 | 165 | func (m *RWMap) Delete(k int) { //删除一个键 166 | m.Lock() // 锁保护 167 | defer m.Unlock() 168 | delete(m.m, k) 169 | } 170 | 171 | func (m *RWMap) Len() int { // map的长度 172 | m.RLock() // 锁保护 173 | defer m.RUnlock() 174 | return len(m.m) 175 | } 176 | 177 | func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map 178 | m.RLock() //遍历期间一直持有读锁 179 | defer m.RUnlock() 180 | 181 | for k, v := range m.m { 182 | if !f(k, v) { 183 | return 184 | } 185 | } 186 | } 187 | ~~~ 188 | 正如这段代码所示,对 map 对象的操作,无非就是增删改查和遍历等几种常见操作。我们可以把这些操作分为读和写两类,其中,查询和遍历可以看做读操作,增加、修改和删除可以看做写操作。如例子所示,我们可以通过读写锁对相应的操作进行保护。 189 | 190 | ### 分片加锁:更高效的并发 map 191 | 192 | 虽然使用读写锁可以提供线程安全的 map,但是在大量并发读写的情况下,锁的竞争会非常激烈 193 | 194 | 在并发编程中,我们的一条原则就是尽量减少锁的使用。一些单线程单进程的应用(比如 Redis 等),基本上不需要使用锁去解决并发线程访问的问题,所以可以取得很高的性能。但是对于 Go 开发的应用程序来说,并发是常用的一个特性,在这种情况下,我们能做的就是,**尽量减少锁的粒度和锁的持有时间**。 195 | 196 | 你可以优化业务处理的代码,以此来减少锁的持有时间,比如将串行的操作变成并行的子任务执行。不过,这就是另外的故事了,今天我们还是主要讲对同步原语的优化,所以这里我重点讲如何减少锁的粒度。 197 | 198 | 减少锁的粒度常用的方法就是分片(Shard),将一把锁分成几把锁,每个锁控制一个分片。Go 比较知名的分片并发 map 的实现是[orcaman/concurrent-map](https://github.com/orcaman/concurrent-map)。 199 | 200 | 它默认采用 32 个分片,**GetShard 是一个关键的方法,能够根据 key 计算出分片索引**。 201 | 202 | ~~~go 203 | var SHARD_COUNT = 32 204 | 205 | // 分成SHARD_COUNT个分片的map 206 | type ConcurrentMap []*ConcurrentMapShared 207 | 208 | // 通过RWMutex保护的线程安全的分片,包含一个map 209 | type ConcurrentMapShared struct { 210 | items map[string]interface{} 211 | sync.RWMutex // Read Write mutex, guards access to internal map. 212 | } 213 | 214 | // 创建并发map 215 | func New() ConcurrentMap { 216 | m := make(ConcurrentMap, SHARD_COUNT) 217 | for i := 0; i < SHARD_COUNT; i++ { 218 | m[i] = &ConcurrentMapShared{items: make(map[string]interface{})} 219 | } 220 | return m 221 | } 222 | 223 | 224 | // 根据key计算分片索引 225 | func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared { 226 | return m[uint(fnv32(key))%uint(SHARD_COUNT)] 227 | } 228 | ~~~ 229 | 增加或者查询的时候,首先根据分片索引得到分片对象,然后对分片对象加锁进行操作: 230 | ~~~go 231 | func (m ConcurrentMap) Set(key string, value interface{}) { 232 | // 根据key计算出对应的分片 233 | shard := m.GetShard(key) 234 | shard.Lock() //对这个分片加锁,执行业务操作 235 | shard.items[key] = value 236 | shard.Unlock() 237 | } 238 | 239 | func (m ConcurrentMap) Get(key string) (interface{}, bool) { 240 | // 根据key计算出对应的分片 241 | shard := m.GetShard(key) 242 | shard.RLock() 243 | // 从这个分片读取key的值 244 | val, ok := shard.items[key] 245 | shard.RUnlock() 246 | return val, ok 247 | } 248 | ~~~ 249 | 当然,除了 GetShard 方法,ConcurrentMap 还提供了很多其他的方法。这些方法都是通过计算相应的分片实现的,目的是保证把锁的粒度限制在分片上。 250 | 251 | 好了,到这里我们就学会了解决 map 并发 panic 的两个方法:加锁和分片。 252 | 253 | **加锁和分片加锁这两种方案都比较常用,如果是追求更高的性能,显然是分片加锁更好,因为它可以降低锁的粒度,进而提高访问此 map 对象的吞吐。如果并发性能要求不是那么高的场景,简单加锁方式更简单。** 254 | 255 | 256 | 接下来,我会继续给你介绍 sync.Map,这是 Go 官方线程安全 map 的标准实现。虽然是官方标准,反而是不常用的,为什么呢?一句话来说就是 map 要解决的场景很难描述,很多时候在做抉择时根本就不知道该不该用它。但是呢,确实有一些特定的场景,我们需要用到 sync.Map 来实现,所以还是很有必要学习这个知识点。具体什么场景呢,我慢慢给你道来。 257 | 258 | ### 应对特殊场景的 sync.Map 259 | 260 | Go 内建的 map 类型不是线程安全的,所以 Go 1.9 中增加了一个线程安全的 map,也就是 sync.Map。但是,我们一定要记住,这个 sync.Map 并不是用来替换内建的 map 类型的,它只能被应用在一些特殊的场景里。 261 | 262 | 那这些特殊的场景是啥呢?[官方的文档](https://golang.org/pkg/sync/#Map)中指出,在以下两个场景中使用 sync.Map,会比使用 map+RWMutex 的方式,性能要好得多: 263 | 264 | 1. 只会增长的缓存系统中,一个 key 只写入一次而被读很多次 265 | 2. 多个 goroutine 为不相交的键集读、写和重写键值对。 266 | 267 | 这两个场景说得都比较笼统,而且,这些场景中还包含了一些特殊的情况。所以,官方建议你针对自己的场景做性能评测,如果确实能够显著提高性能,再使用 sync.Map。 268 | 269 | 这么来看,我们能用到 sync.Map 的场景确实不多。即使是 sync.Map 的作者 Bryan C. Mills,也很少使用 sync.Map,即便是在使用 sync.Map 的时候,也是需要临时查询它的 API,才能清楚记住它的功能。所以,我们可以把 sync.Map 看成一个生产环境中很少使用的同步原语。 270 | 271 | ### sync.Map 的实现 272 | 273 | 那 sync.Map 是怎么实现的呢?它是如何解决并发问题提升性能的呢?其实 sync.Map 的实现有几个优化点,这里先列出来,我们后面慢慢分析。 274 | 275 | - 空间换时间。通过冗余的两个数据结构(只读的 read 字段、可写的 dirty),来减少加锁对性能的影响。对只读字段(read)的操作不需要加锁。 276 | - 优先从 read 字段读取、更新、删除,因为对 read 字段的读取不需要锁。 277 | - 动态调整。miss 次数多了之后,将 dirty 数据提升为 read,避免总是从 dirty 中加锁读取。 278 | - double-checking。加锁之后先还要再检查 read 字段,确定真的不存在才操作 dirty 字段。 279 | - 延迟删除。删除一个键值只是打标记,只有在提升 dirty 字段为 read 字段的时候才清理删除的数据。 280 | 281 | 要理解 sync.Map 这些优化点,我们还是得深入到它的设计和实现上,去学习它的处理方式。 282 | 283 | 284 | 我们先看一下 map 的数据结构: 285 | 286 | ~~~go 287 | type Map struct { 288 | mu Mutex 289 | // 基本上你可以把它看成一个安全的只读的map 290 | // 它包含的元素其实也是通过原子操作更新的,但是已删除的entry就需要加锁操作了 291 | read atomic.Value // readOnly 292 | 293 | // 包含需要加锁才能访问的元素 294 | // 包括所有在read字段中但未被expunged(删除)的元素以及新加的元素 295 | dirty map[interface{}]*entry 296 | 297 | // 记录从read中读取miss的次数,一旦miss数和dirty长度一样了,就会把dirty提升为read,并把dirty置空 298 | misses int 299 | } 300 | 301 | type readOnly struct { 302 | m map[interface{}]*entry 303 | amended bool // 当dirty中包含read没有的数据时为true,比如新增一条数据 304 | } 305 | 306 | // expunged是用来标识此项已经删掉的指针 307 | // 当map中的一个项目被删除了,只是把它的值标记为expunged,以后才有机会真正删除此项 308 | var expunged = unsafe.Pointer(new(interface{})) 309 | 310 | // entry代表一个值 311 | type entry struct { 312 | p unsafe.Pointer // *interface{} 313 | } 314 | ~~~ 315 | 316 | 如果 dirty 字段非 nil 的话,map 的 read 字段和 dirty 字段会包含相同的非 expunged 的项,所以如果通过 read 字段更改了这个项的值,从 dirty 字段中也会读取到这个项的新值,因为本来它们指向的就是同一个地址。 317 | 318 | dirty 包含重复项目的好处就是,一旦 miss 数达到阈值需要将 dirty 提升为 read 的话,只需简单地把 dirty 设置为 read 对象即可。不好的一点就是,当创建新的 dirty 对象的时候,需要逐条遍历 read,把非 expunged 的项复制到 dirty 对象中。 319 | 320 | 接下来,我们就深入到源码去看看 sync.map 的实现。在看这部分源码的过程中,我们只要重点关注 Store、Load 和 Delete 这 3 个核心的方法就可以了。 321 | 322 | Store、Load 和 Delete 这三个核心函数的操作都是先从 read 字段中处理的,因为读取 read 字段的时候不用加锁。 323 | 324 | **Store 方法** 325 | 我们先来看 Store 方法,它是用来设置一个键值对,或者更新一个键值对的。 326 | 327 | ~~~go 328 | func (m *Map) Store(key, value interface{}) { 329 | read, _ := m.read.Load().(readOnly) 330 | // 如果read字段包含这个项,说明是更新,cas更新项目的值即可 331 | if e, ok := read.m[key]; ok && e.tryStore(&value) { 332 | return 333 | } 334 | 335 | // read中不存在,或者cas更新失败,就需要加锁访问dirty了 336 | m.mu.Lock() 337 | read, _ = m.read.Load().(readOnly) 338 | if e, ok := read.m[key]; ok { // 双检查,看看read是否已经存在了 339 | if e.unexpungeLocked() { 340 | // 此项目先前已经被删除了,通过将它的值设置为nil,标记为unexpunged 341 | m.dirty[key] = e 342 | } 343 | e.storeLocked(&value) // 更新 344 | } else if e, ok := m.dirty[key]; ok { // 如果dirty中有此项 345 | e.storeLocked(&value) // 直接更新 346 | } else { // 否则就是一个新的key 347 | if !read.amended { //如果dirty为nil 348 | // 需要创建dirty对象,并且标记read的amended为true, 349 | // 说明有元素它不包含而dirty包含 350 | m.dirtyLocked() 351 | m.read.Store(readOnly{m: read.m, amended: true}) 352 | } 353 | m.dirty[key] = newEntry(value) //将新值增加到dirty对象中 354 | } 355 | m.mu.Unlock() 356 | } 357 | ~~~ 358 | 359 | 可以看出,Store 既可以是新增元素,也可以是更新元素。如果运气好的话,更新的是已存在的未被删除的元素,直接更新即可,不会用到锁。如果运气不好,需要更新(重用)删除的对象、更新还未提升的 dirty 中的对象,或者新增加元素的时候就会使用到了锁,这个时候,性能就会下降。 360 | 361 | 所以从这一点来看,sync.Map 适合那些只会增长的缓存系统,可以进行更新,但是不要删除,并且不要频繁地增加新元素。 362 | 新加的元素需要放入到 dirty 中,如果 dirty 为 nil,那么需要从 read 字段中复制出来一个 dirty 对象: 363 | 364 | ~~~go 365 | func (m *Map) dirtyLocked() { 366 | if m.dirty != nil { // 如果dirty字段已经存在,不需要创建了 367 | return 368 | } 369 | 370 | read, _ := m.read.Load().(readOnly) // 获取read字段 371 | m.dirty = make(map[interface{}]*entry, len(read.m)) 372 | for k, e := range read.m { // 遍历read字段 373 | if !e.tryExpungeLocked() { // 把非punged的键值对复制到dirty中 374 | m.dirty[k] = e 375 | } 376 | } 377 | } 378 | ~~~ 379 | 380 | **Load 方法** 381 | 382 | Load 方法用来读取一个 key 对应的值。它也是从 read 开始处理,一开始并不需要锁。 383 | 384 | ~~~go 385 | 386 | func (m *Map) Load(key interface{}) (value interface{}, ok bool) { 387 | // 首先从read处理 388 | read, _ := m.read.Load().(readOnly) 389 | e, ok := read.m[key] 390 | if !ok && read.amended { // 如果不存在并且dirty不为nil(有新的元素) 391 | m.mu.Lock() 392 | // 双检查,看看read中现在是否存在此key 393 | read, _ = m.read.Load().(readOnly) 394 | e, ok = read.m[key] 395 | if !ok && read.amended {//依然不存在,并且dirty不为nil 396 | e, ok = m.dirty[key]// 从dirty中读取 397 | // 不管dirty中存不存在,miss数都加1 398 | m.missLocked() 399 | } 400 | m.mu.Unlock() 401 | } 402 | if !ok { 403 | return nil, false 404 | } 405 | return e.load() //返回读取的对象,e既可能是从read中获得的,也可能是从dirty中获得的 406 | } 407 | ~~~ 408 | 409 | 如果幸运的话,我们从 read 中读取到了这个 key 对应的值,那么就不需要加锁了,性能会非常好。但是,如果请求的 key 不存在或者是新加的,就需要加锁从 dirty 中读取。所以,读取不存在的 key 会因为加锁而导致性能下降,读取还没有提升的新值的情况下也会因为加锁性能下降。 410 | 411 | 其中,missLocked 增加 miss 的时候,如果 miss 数等于 dirty 长度,会将 dirty 提升为 read,并将 dirty 置空。 412 | ~~~go 413 | func (m *Map) missLocked() { 414 | m.misses++ // misses计数加一 415 | if m.misses < len(m.dirty) { // 如果没达到阈值(dirty字段的长度),返回 416 | return 417 | } 418 | m.read.Store(readOnly{m: m.dirty}) //把dirty字段的内存提升为read字段 419 | m.dirty = nil // 清空dirty 420 | m.misses = 0 // misses数重置为0 421 | } 422 | ~~~ 423 | **Delete 方法** 424 | 425 | sync.map 的第 3 个核心方法是 Delete 方法。在 Go 1.15 中欧长坤提供了一个 LoadAndDelete 的实现([go#issue 33762](https://github.com/golang/go/issues/33762)),所以 Delete 方法的核心改在了对 LoadAndDelete 中实现了。 426 | 427 | 同样地,Delete 方法是先从 read 操作开始,原因我们已经知道了,因为不需要锁。 428 | 429 | ~~~go 430 | func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) { 431 | read, _ := m.read.Load().(readOnly) 432 | e, ok := read.m[key] 433 | if !ok && read.amended { 434 | m.mu.Lock() 435 | // 双检查 436 | read, _ = m.read.Load().(readOnly) 437 | e, ok = read.m[key] 438 | if !ok && read.amended { 439 | e, ok = m.dirty[key] 440 | // 这一行长坤在1.15中实现的时候忘记加上了,导致在特殊的场景下有些key总是没有被回收 441 | delete(m.dirty, key) 442 | // miss数加1 443 | m.missLocked() 444 | } 445 | m.mu.Unlock() 446 | } 447 | if ok { 448 | return e.delete() 449 | } 450 | return nil, false 451 | } 452 | 453 | func (m *Map) Delete(key interface{}) { 454 | m.LoadAndDelete(key) 455 | } 456 | func (e *entry) delete() (value interface{}, ok bool) { 457 | for { 458 | p := atomic.LoadPointer(&e.p) 459 | if p == nil || p == expunged { 460 | return nil, false 461 | } 462 | if atomic.CompareAndSwapPointer(&e.p, p, nil) { 463 | return *(*interface{})(p), true 464 | } 465 | } 466 | } 467 | ~~~ 468 | 469 | 如果 read 中不存在,那么就需要从 dirty 中寻找这个项目。最终,如果项目存在就删除(将它的值标记为 nil)。如果项目不为 nil 或者没有被标记为 expunged,那么还可以把它的值返回。 470 | 471 | 最后,我补充一点,sync.map 还有一些 LoadAndDelete、LoadOrStore、Range 等辅助方法,但是没有 Len 这样查询 sync.Map 的包含项目数量的方法,并且官方也不准备提供。如果你想得到 sync.Map 的项目数量的话,你可能不得不通过 Range 逐个计数。 472 | 473 | ## 总结 474 | 475 | Go 内置的 map 类型使用起来很方便,但是它有一个非常致命的缺陷,那就是它存在着并发问题,所以如果有多个 goroutine 同时并发访问这个 map,就会导致程序崩溃。所以 Go 官方 Blog 很早就提供了一种[加锁的方法](https://blog.golang.org/maps#TOC_6.),还有后来提供了适用特定场景的线程安全的 sync.Map,还有第三方实现的分片式的 map,这些方法都可以应用于并发访问的场景。 476 | 477 | 这里我给你的建议,也是 Go 开发者给的建议,就是通过性能测试,看看某种线程安全的 map 实现是否满足你的需求。 478 | 479 | 当然还有一些扩展其它功能的 map 实现,比如带有过期功能的[timedmap](https://github.com/zekroTJA/timedmap)、使用红黑树实现的 key 有序的[treemap](https://godoc.org/github.com/emirpasic/gods/maps/treemap)等,因为和并发问题没有关系,就不详细介绍了。这里我给你提供了链接,你可以自己探索。 480 | 481 | ![map结构图](https://static001.geekbang.org/resource/image/a8/03/a80408a137b13f934b0dd6f2b6c5cc03.jpg) 482 | 483 | ## 思考题 484 | 485 | 1. 为什么 sync.Map 中的集合核心方法的实现中,如果 read 中项目不存在,加锁后还要双检查,再检查一次 read? 486 | - 第一次先用CAS快速尝试,失败后进行加锁,然后进行第二次CAS检查,再进行修改; 487 | - 在高并发的情况下,存在多个goroutine在修改同一个Key,第一次CAS都失败了,在竞争锁;如果不进行第二次CAS检查就直接修改,这个Key就会被多次修改; 488 | 2. 你看到 sync.map 元素删除的时候只是把它的值设置为 nil,那么什么时候这个 key 才会真正从 map 对象中删除? 489 | - 真正删除key的操作是在数据从read往dirty迁移的过程中(往dirty写数据时,发现dirty没有数据,就会触发迁移),只迁移没有被标记为删除的KV -------------------------------------------------------------------------------- /1.基本并发原语/1.09:map:如何实现线程安全的map类型?/09.01.初始化map示例.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | //未初始化 9 | func main1() { 10 | var m map[int]int 11 | m[100] = 100 12 | } 13 | 14 | //取0值 15 | func main2() { 16 | var m map[int]int 17 | fmt.Println(m[100]) 18 | } 19 | 20 | //结构体包含忘记初始化 21 | type Counter struct { 22 | Website string 23 | Start time.Time 24 | PageCounters map[string]int 25 | } 26 | 27 | //结构体包含忘记初始化 28 | func main3() { 29 | var c Counter 30 | c.Website = "baidu.com" 31 | 32 | c.PageCounters["/"]++ 33 | } 34 | 35 | func main() { 36 | //main1() 37 | main2() 38 | main3() 39 | } 40 | -------------------------------------------------------------------------------- /1.基本并发原语/1.09:map:如何实现线程安全的map类型?/09.03.并发读写自带map.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | var m = make(map[int]int, 10) // 初始化一个map 5 | go func() { 6 | for { 7 | m[1] = 1 //设置key 8 | } 9 | }() 10 | 11 | go func() { 12 | for { 13 | _ = m[2] //访问这个map 14 | } 15 | }() 16 | select {} 17 | } 18 | -------------------------------------------------------------------------------- /1.基本并发原语/1.09:map:如何实现线程安全的map类型?/09.03.读写锁并发安全map.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sync" 4 | 5 | //可以在适当的时候将int换为intface 6 | type RWMap struct { // 一个读写锁保护的线程安全的map 7 | sync.RWMutex // 读写锁保护下面的map字段 8 | m map[int]int 9 | } 10 | 11 | // 新建一个RWMap 12 | func NewRWMap(n int) *RWMap { 13 | return &RWMap{ 14 | m: make(map[int]int, n), 15 | } 16 | } 17 | func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值 18 | m.RLock() 19 | defer m.RUnlock() 20 | v, existed := m.m[k] // 在锁的保护下从map中读取 21 | return v, existed 22 | } 23 | 24 | func (m *RWMap) Set(k int, v int) { // 设置一个键值对 25 | m.Lock() // 锁保护 26 | defer m.Unlock() 27 | m.m[k] = v 28 | } 29 | 30 | func (m *RWMap) Delete(k int) { //删除一个键 31 | m.Lock() // 锁保护 32 | defer m.Unlock() 33 | delete(m.m, k) 34 | } 35 | 36 | func (m *RWMap) Len() int { // map的长度 37 | m.RLock() // 锁保护 38 | defer m.RUnlock() 39 | return len(m.m) 40 | } 41 | 42 | func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map 43 | m.RLock() //遍历期间一直持有读锁 44 | defer m.RUnlock() 45 | 46 | for k, v := range m.m { 47 | if !f(k, v) { 48 | return 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /1.基本并发原语/1.10:Pool:性能提升大杀器/10.00-Pool:性能提升大杀器.md: -------------------------------------------------------------------------------- 1 | # Pool:性能提升大杀器 2 | Go 是一个自动垃圾回收的编程语言,采用[三色并发标记算法](https://studygolang.com/articles/22104?fr=sidebar)标记对象并回收。和其它没有自动垃圾回收的编程语言不同,使用 Go 语言创建对象的时候,我们没有回收 / 释放的心理负担,想用就用,想创建就创建 3 | 4 | 但是,如果你想使用 Go 开发一个高性能的应用程序的话,就必须考虑垃圾回收给性能带来的影响,毕竟,Go 的自动垃圾回收机制还是有一个 STW(stop-the-world,程序暂停)的时间,而且,大量地创建在堆上的对象,也会影响垃圾回收标记的时间。 5 | 6 | 所以,一般我们做性能优化的时候,会采用对象池的方式,把不用的对象回收起来,避免被垃圾回收掉,这样使用的时候就不必在堆上重新创建了。 7 | 8 | 不止如此,像数据库连接、TCP 的长连接,这些连接在创建的时候是一个非常耗时的操作。如果每次都创建一个新的连接对象,耗时较长,很可能整个业务的大部分耗时都花在了创建连接上。 9 | 10 | 所以,如果我们能把这些连接保存下来,避免每次使用的时候都重新创建,不仅可以大大减少业务的耗时,还能提高应用程序的整体性能。 11 | 12 | Go 标准库中提供了一个通用的 Pool 数据结构,也就是 sync.Pool,我们使用它可以创建池化的对象。这节课我会详细给你介绍一下 sync.Pool 的使用方法、实现原理以及常见的坑,帮助你全方位地掌握标准库的 Pool。 13 | 14 | 不过,这个类型也有一些使用起来不太方便的地方,就是它池化的对象可能会被垃圾回收掉,**这对于数据库长连接等场景是不合适的**。所以在这一讲中,我会专门介绍其它的一些 Pool,包括 TCP 连接池、数据库连接池等等。 15 | 16 | 除此之外,我还会专门介绍一个池的应用场景: Worker Pool,或者叫做 goroutine pool,这也是常用的一种并发模式,可以使用有限的 goroutine 资源去处理大量的业务数据。 17 | 18 | ## sync.Pool 19 | 20 | 学习下标准库提供的 sync.Pool 数据类型 21 | 22 | sync.Pool 数据类型用来保存一组可独立访问的**临时**对象。请注意这里加粗的“临时”这两个字,它说明了 sync.Pool 这个数据类型的特点,也就是说,它池化的对象会在未来的某个时候被毫无预兆地移除掉。而且,如果没有别的对象引用这个被移除的对象的话,这个被移除的对象就会被垃圾回收掉。 23 | 24 | 25 | 因为 Pool 可以有效地减少新对象的申请,从而提高程序性能,所以 Go 内部库也用到了 sync.Pool,比如 fmt 包,它会使用一个动态大小的 buffer 池做输出缓存,当大量的 goroutine 并发输出的时候,就会创建比较多的 buffer,并且在不需要的时候回收掉 26 | 27 | 有两个知识点你需要记住: 28 | 1. sync.Pool 本身就是线程安全的,多个 goroutine 可以并发地调用它的方法存取对象; 29 | 2. sync.Pool 不可在使用之后再复制使用。 30 | 31 | ## sync.Pool 的使用方法 32 | 33 | 知道了 sync.Pool 这个数据类型的特点,接下来,我们来学习下它的使用方法。其实,这个数据类型不难,它只提供了三个对外的方法:New、Get 和 Put。 34 | 35 | **1.New** 36 | 37 | Pool struct 包含一个 New 字段,这个字段的类型是函数 func() interface{}。当调用 Pool 的 Get 方法从池中获取元素,没有更多的空闲元素可返回时,就会调用这个 New 方法来创建新的元素。如果你没有设置 New 字段,没有更多的空闲元素可返回时,Get 方法将返回 nil,表明当前没有可用的元素。 38 | 39 | 有趣的是,New 是可变的字段。这就意味着,你可以在程序运行的时候改变创建元素的方法。当然,很少有人会这么做,因为一般我们创建元素的逻辑都是一致的,要创建的也是同一类的元素,所以你在使用 Pool 的时候也没必要玩一些“花活”,在程序运行时更改 New 的值。 40 | 41 | **2.Get** 42 | 43 | 如果调用这个方法,就会从 Pool**取走**一个元素,这也就意味着,这个元素会从 Pool 中移除,返回给调用者。不过,除了返回值是正常实例化的元素,Get 方法的返回值还可能会是一个 nil(Pool.New 字段没有设置,又没有空闲元素可以返回),所以你在使用的时候,可能需要判断。 44 | 45 | 46 | **3.Put** 47 | 48 | 这个方法用于将一个元素返还给 Pool,Pool 会把这个元素保存到池中,并且可以复用。但如果 Put 一个 nil 值,Pool 就会忽略这个值。 49 | 50 | 好了,了解了这几个方法,下面我们看看 sync.Pool 最常用的一个场景:buffer 池(缓冲池)。 51 | 52 | 因为 byte slice 是经常被创建销毁的一类对象,使用 buffer 池可以缓存已经创建的 byte slice,比如,著名的静态网站生成工具 Hugo 中,就包含这样的实现bufpool,你可以看一下下面这段代码: 53 | ~~~go 54 | var buffers = sync.Pool{ 55 | New: func() interface{} { 56 | return new(bytes.Buffer) 57 | }, 58 | } 59 | 60 | func GetBuffer() *bytes.Buffer { 61 | return buffers.Get().(*bytes.Buffer) 62 | } 63 | 64 | func PutBuffer(buf *bytes.Buffer) { 65 | buf.Reset() 66 | buffers.Put(buf) 67 | } 68 | ~~~ 69 | 70 | 除了 Hugo,这段 buffer 池的代码非常常用。很可能你在阅读其它项目的代码的时候就碰到过,或者是你自己实现 buffer 池的时候也会这么去实现,但是请你注意了,这段代码是有问题的,你一定不要将上面的代码应用到实际的产品中。它可能会有内存泄漏的问题,下面我会重点讲这个问题。 71 | 72 | ## 实现原理 73 | 74 | 了解了 sync.Pool 的基本使用方法,下面我们就来重点学习下它的实现。 75 | 76 | Go 1.13 之前的 sync.Pool 的实现有 2 大问题: 77 | 78 | 1. 每次 GC 都会回收创建的对象。 79 | 80 | 81 | 如果缓存元素数量太多,就会导致 STW 耗时变长;缓存元素都被回收后,会导致 Get 命中率下降,Get 方法不得不新创建很多对象。 82 | 1. 底层实现使用了 Mutex,对这个锁并发请求竞争激烈的时候,会导致性能的下降。 83 | 84 | 在 Go 1.13 中,sync.Pool 做了大量的优化。前几讲中我提到过,提高并发程序性能的优化点是尽量不要使用锁,如果不得已使用了锁,就把锁 Go 的粒度降到最低。Go 对 Pool 的优化就是避免使用锁,同时将加锁的 queue 改成 lock-free 的 queue 的实现,给即将移除的元素再多一次“复活”的机会。 85 | 86 | 当前,sync.Pool 的数据结构如下图所示: 87 | ![sync.Pool 的数据结构](https://static001.geekbang.org/resource/image/f4/96/f4003704663ea081230760098f8af696.jpg) 88 | 89 | Pool 最重要的两个字段是 local 和 victim,因为它们两个主要用来存储空闲的元素。弄清楚这两个字段的处理逻辑,你就能完全掌握 sync.Pool 的实现了。下面我们来看看这两个字段的关系。 90 | 91 | 每次垃圾回收的时候,Pool 会把 victim 中的对象移除,然后把 local 的数据给 victim,这样的话,local 就会被清空,而 victim 就像一个垃圾分拣站,里面的东西可能会被当做垃圾丢弃了,但是里面有用的东西也可能被捡回来重新使用。 92 | 93 | victim 中的元素如果被 Get 取走,那么这个元素就很幸运,因为它又“活”过来了。但是,如果这个时候 Get 的并发不是很大,元素没有被 Get 取走,那么就会被移除掉,因为没有别人引用它的话,就会被垃圾回收掉。 94 | 95 | 下面的代码是垃圾回收时 sync.Pool 的处理逻辑: 96 | 97 | ~~~go 98 | func poolCleanup() { 99 | // 丢弃当前victim, STW所以不用加锁 100 | for _, p := range oldPools { 101 | p.victim = nil 102 | p.victimSize = 0 103 | } 104 | 105 | // 将local复制给victim, 并将原local置为nil 106 | for _, p := range allPools { 107 | p.victim = p.local 108 | p.victimSize = p.localSize 109 | p.local = nil 110 | p.localSize = 0 111 | } 112 | 113 | oldPools, allPools = allPools, nil 114 | } 115 | ~~~ 116 | 117 | 在这段代码中,你需要关注一下 local 字段,因为所有当前主要的空闲可用的元素都存放在 local 字段中,请求元素时也是优先从 local 字段中查找可用的元素。local 字段包含一个 poolLocalInternal 字段,并提供 CPU 缓存对齐,从而避免 false sharing。 118 | 119 | 而 poolLocalInternal 也包含两个字段:private 和 shared。 120 | - private,代表一个缓存的元素,而且只能由相应的一个 P 存取。因为一个 P 同时只能执行一个 goroutine,所以不会有并发的问题。 121 | - shared,可以由任意的 P 访问,但是只有本地的 P 才能 pushHead/popHead,其它 P 可以 popTail,相当于只有一个本地的 P 作为生产者(Producer),多个 P 作为消费者(Consumer),它是使用一个 local-free 的 queue 列表实现的。 122 | 123 | ### Get 方法 124 | 125 | 我们来看看 Get 方法的具体实现原理。 126 | ~~~go 127 | func (p *Pool) Get() interface{} { 128 | // 把当前goroutine固定在当前的P上 129 | l, pid := p.pin() 130 | x := l.private // 优先从local的private字段取,快速 131 | l.private = nil 132 | if x == nil { 133 | // 从当前的local.shared弹出一个,注意是从head读取并移除 134 | x, _ = l.shared.popHead() 135 | if x == nil { // 如果没有,则去偷一个 136 | x = p.getSlow(pid) 137 | } 138 | } 139 | runtime_procUnpin() 140 | // 如果没有获取到,尝试使用New函数生成一个新的 141 | if x == nil && p.New != nil { 142 | x = p.New() 143 | } 144 | return x 145 | } 146 | ~~~ 147 | 148 | 我来给你解释下这段代码。首先,从本地的 private 字段中获取可用元素,因为没有锁,获取元素的过程会非常快,如果没有获取到,就尝试从本地的 shared 获取一个,如果还没有,会使用 getSlow 方法去其它的 shared 中“偷”一个。最后,如果没有获取到,就尝试使用 New 函数创建一个新的。 149 | 150 | 这里的重点是 getSlow 方法,我们来分析下。看名字也就知道了,它的耗时可能比较长。它首先要遍历所有的 local,尝试从它们的 shared 弹出一个元素。如果还没找到一个,那么,就开始对 victim 下手了。 151 | 152 | 在 vintim 中查询可用元素的逻辑还是一样的,先从对应的 victim 的 private 查找,如果查不到,就再从其它 victim 的 shared 中查找。 153 | 154 | 下面的代码是 getSlow 方法的主要逻辑: 155 | ~~~go 156 | func (p *Pool) getSlow(pid int) interface{} { 157 | 158 | size := atomic.LoadUintptr(&p.localSize) 159 | locals := p.local 160 | // 从其它proc中尝试偷取一个元素 161 | for i := 0; i < int(size); i++ { 162 | l := indexLocal(locals, (pid+i+1)%int(size)) 163 | if x, _ := l.shared.popTail(); x != nil { 164 | return x 165 | } 166 | } 167 | 168 | // 如果其它proc也没有可用元素,那么尝试从vintim中获取 169 | size = atomic.LoadUintptr(&p.victimSize) 170 | if uintptr(pid) >= size { 171 | return nil 172 | } 173 | locals = p.victim 174 | l := indexLocal(locals, pid) 175 | if x := l.private; x != nil { // 同样的逻辑,先从vintim中的local private获取 176 | l.private = nil 177 | return x 178 | } 179 | for i := 0; i < int(size); i++ { // 从vintim其它proc尝试偷取 180 | l := indexLocal(locals, (pid+i)%int(size)) 181 | if x, _ := l.shared.popTail(); x != nil { 182 | return x 183 | } 184 | } 185 | 186 | // 如果victim中都没有,则把这个victim标记为空,以后的查找可以快速跳过了 187 | atomic.StoreUintptr(&p.victimSize, 0) 188 | 189 | return nil 190 | } 191 | ~~~ 192 | 193 | 这里我没列出 pin 代码的实现,你只需要知道,pin 方法会将此 goroutine 固定在当前的 P 上,避免查找元素期间被其它的 P 执行。固定的好处就是查找元素期间直接得到跟这个 P 相关的 local。有一点需要注意的是,pin 方法在执行的时候,如果跟这个 P 相关的 local 还没有创建,或者运行时 P 的数量被修改了的话,就会新创建 local。 194 | 195 | ### Put 方法 196 | 197 | 我们来看看 Put 方法的具体实现原理。 198 | 199 | ~~~go 200 | func (p *Pool) Put(x interface{}) { 201 | if x == nil { // nil值直接丢弃 202 | return 203 | } 204 | l, _ := p.pin() 205 | if l.private == nil { // 如果本地private没有值,直接设置这个值即可 206 | l.private = x 207 | x = nil 208 | } 209 | if x != nil { // 否则加入到本地队列中 210 | l.shared.pushHead(x) 211 | } 212 | runtime_procUnpin() 213 | } 214 | ~~~ 215 | 216 | Put 的逻辑相对简单,优先设置本地 private,如果 private 字段已经有值了,那么就把此元素 push 到本地队列中。 217 | 218 | ## sync.Pool 的坑 219 | 220 | 到这里,我们就掌握了 sync.Pool 的使用方法和实现原理,接下来,我要再和你聊聊容易踩的两个坑,分别是内存泄漏和内存浪费。 221 | 222 | ### 内存泄漏 223 | 224 | 这节课刚开始的时候,我讲到,可以使用 sync.Pool 做 buffer 池,但是,如果用刚刚的那种方式做 buffer 池的话,可能会有内存泄漏的风险。为啥这么说呢?我们来分析一下。 225 | 226 | 取出来的 bytes.Buffer 在使用的时候,我们可以往这个元素中增加大量的 byte 数据,这会导致底层的 byte slice 的容量可能会变得很大。这个时候,即使 Reset 再放回到池子中,这些 byte slice 的容量不会改变,所占的空间依然很大。而且,因为 Pool 回收的机制,这些大的 Buffer 可能不被回收,而是会一直占用很大的空间,这属于内存泄漏的问题。 227 | 228 | 即使是 Go 的标准库,在内存泄漏这个问题上也栽了几次坑,比如 issue 23199、@dsnet提供了一个简单的可重现的例子,演示了内存泄漏的问题。再比如 encoding、json 中类似的问题:将容量已经变得很大的 Buffer 再放回 Pool 中,导致内存泄漏。后来在元素放回时,增加了检查逻辑,改成放回的超过一定大小的 buffer,就直接丢弃掉,不再放到池子中,如下所示: 229 | ![修改的逻辑](https://static001.geekbang.org/resource/image/e3/9f/e3e23d2f2ab55b64741e14856a58389f.png) 230 | 231 | package fmt 中也有这个问题,修改方法是一样的,超过一定大小的 buffer,就直接丢弃了: 232 | ![修改的逻辑](https://static001.geekbang.org/resource/image/06/62/06c68476cac13a860c470b006718c462.png) 233 | 234 | 在使用 sync.Pool 回收 buffer 的时候,一定要检查回收的对象的大小。如果 buffer 太大,就不要回收了,否则就太浪费了。 235 | 236 | ### 内存浪费 237 | 238 | 除了内存泄漏以外,还有一种浪费的情况,就是池子中的 buffer 都比较大,但在实际使用的时候,很多时候只需要一个小的 buffer,这也是一种浪费现象。接下来,我就讲解一下这种情况的处理方法。 239 | 240 | 要做到物尽其用,尽可能不浪费的话,我们可以将 buffer 池分成几层。首先,小于 512 byte 的元素的 buffer 占一个池子;其次,小于 1K byte 大小的元素占一个池子;再次,小于 4K byte 大小的元素占一个池子。这样分成几个池子以后,就可以根据需要,到所需大小的池子中获取 buffer 了。 241 | 242 | 在标准库 [net/http/server.go](https://github.com/golang/go/blob/617f2c3e35cdc8483b950aa3ef18d92965d63197/src/net/http/server.go)中的代码中,就提供了 2K 和 4K 两个 writer 的池子。你可以看看下面这段代码: 243 | 244 | ![daima](https://static001.geekbang.org/resource/image/55/35/55086ccba91975a0f65bd35d1192e335.png) 245 | 246 | 247 | YouTube 开源的知名项目 vitess 中提供了[bucketpool](https://github.com/vitessio/vitess/blob/master/go/bucketpool/bucketpool.go)的实现,它提供了更加通用的多层 buffer 池。你在使用的时候,只需要指定池子的最大和最小尺寸,vitess 就会自动计算出合适的池子数。而且,当你调用 Get 方法的时候,只需要传入你要获取的 buffer 的大小,就可以了。下面这段代码就描述了这个过程,你可以看看: 248 | ![](https://static001.geekbang.org/resource/image/c5/08/c5cd474aa53fe57e0722d840a6c7f308.png) 249 | 250 | 251 | ## 第三方库 252 | 除了这种分层的为了节省空间的 buffer 设计外,还有其它的一些第三方的库也会提供 buffer 池的功能。接下来我带你熟悉几个常用的第三方的库。 253 | 254 | 1.bytebufferpool 255 | 这是 fasthttp 作者 valyala 提供的一个 buffer 池,基本功能和 sync.Pool 相同。它的底层也是使用 sync.Pool 实现的,包括会检测最大的 buffer,超过最大尺寸的 buffer,就会被丢弃。 256 | 257 | valyala 一向很擅长挖掘系统的性能,这个库也不例外。它提供了校准(calibrate,用来动态调整创建元素的权重)的机制,可以“智能”地调整 Pool 的 defaultSize 和 maxSize。一般来说,我们使用 buffer size 的场景比较固定,所用 buffer 的大小会集中在某个范围里。有了校准的特性,bytebufferpool 就能够偏重于创建这个范围大小的 buffer,从而节省空间。 258 | 259 | 260 | 2.oxtoacart/bpool 261 | 262 | 这也是比较常用的 buffer 池,它提供了以下几种类型的 buffer。 263 | 264 | - bpool.BufferPool: 提供一个固定元素数量的 buffer 池,元素类型是 bytes.Buffer,如果超过这个数量,Put 的时候就丢弃,如果池中的元素都被取光了,会新建一个返回。Put 回去的时候,不会检测 buffer 的大小。 265 | - bpool.BytesPool:提供一个固定元素数量的 byte slice 池,元素类型是 byte slice。Put 回去的时候不检测 slice 的大小。 266 | - bpool.SizedBufferPool: 提供一个固定元素数量的 buffer 池,如果超过这个数量,Put 的时候就丢弃,如果池中的元素都被取光了,会新建一个返回。Put 回去的时候,会检测 buffer 的大小,超过指定的大小的话,就会创建一个新的满足条件的 buffer 放回去。 267 | 268 | bpool 最大的特色就是能够保持池子中元素的数量,一旦 Put 的数量多于它的阈值,就会自动丢弃,而 sync.Pool 是一个没有限制的池子,只要 Put 就会收进去。 269 | 270 | bpool 是基于 Channel 实现的,不像 sync.Pool 为了提高性能而做了很多优化,所以,在性能上比不过 sync.Pool。不过,它提供了限制 Pool 容量的功能,所以,如果你想控制 Pool 的容量的话,可以考虑这个库。 271 | 272 | ### 连接池 273 | 274 | Pool 的另一个很常用的一个场景就是保持 TCP 的连接。一个 TCP 的连接创建,需要三次握手等过程,如果是 TLS 的,还会需要更多的步骤,如果加上身份认证等逻辑的话,耗时会更长。所以,为了避免每次通讯的时候都新创建连接,我们一般会建立一个连接的池子,预先把连接创建好,或者是逐步把连接放在池子中,减少连接创建的耗时,从而提高系统的性能。 275 | 276 | 事实上,我们很少会使用 sync.Pool 去池化连接对象,原因就在于,sync.Pool 会无通知地在某个时候就把连接移除垃圾回收掉了,而我们的场景是需要长久保持这个连接,所以,我们一般会使用其它方法来池化连接,比如接下来我要讲到的几种需要保持长连接的 Pool。 277 | 278 | ### 标准库中的 http client 池 279 | 280 | 标准库的 http.Client 是一个 http client 的库,可以用它来访问 web 服务器。为了提高性能,这个 Client 的实现也是通过池的方法来缓存一定数量的连接,以便后续重用这些连接。 281 | 282 | http.Client 实现连接池的代码是在 Transport 类型中,它使用 idleConn 保存持久化的可重用的长连接: 283 | 284 | ![](https://static001.geekbang.org/resource/image/14/ec/141ced98a81466b793b0f90b9652afec.png) 285 | 286 | ### TCP 连接池 287 | 288 | 最常用的一个 TCP 连接池是 fatih 开发的[fatih/pool](https://github.com/fatih/pool),虽然这个项目已经被 fatih 归档(Archived),不再维护了,但是因为它相当稳定了,我们可以开箱即用。即使你有一些特殊的需求,也可以 fork 它,然后自己再做修改。 289 | 290 | 它的使用套路如下: 291 | ~~~go 292 | // 工厂模式,提供创建连接的工厂方法 293 | factory := func() (net.Conn, error) { return net.Dial("tcp", "127.0.0.1:4000") } 294 | 295 | // 创建一个tcp池,提供初始容量和最大容量以及工厂方法 296 | p, err := pool.NewChannelPool(5, 30, factory) 297 | 298 | // 获取一个连接 299 | conn, err := p.Get() 300 | 301 | // Close并不会真正关闭这个连接,而是把它放回池子,所以你不必显式地Put这个对象到池子中 302 | conn.Close() 303 | 304 | // 通过调用MarkUnusable, Close的时候就会真正关闭底层的tcp的连接了 305 | if pc, ok := conn.(*pool.PoolConn); ok { 306 | pc.MarkUnusable() 307 | pc.Close() 308 | } 309 | 310 | // 关闭池子就会关闭=池子中的所有的tcp连接 311 | p.Close() 312 | 313 | // 当前池子中的连接的数量 314 | current := p.Len() 315 | ~~~ 316 | 317 | 虽然我一直在说 TCP,但是它管理的是更通用的 net.Conn,不局限于 TCP 连接。 318 | 319 | 它通过把 net.Conn 包装成 PoolConn,实现了拦截 net.Conn 的 Close 方法,避免了真正地关闭底层连接,而是把这个连接放回到池中: 320 | 321 | ~~~go 322 | type PoolConn struct { 323 | net.Conn 324 | mu sync.RWMutex 325 | c *channelPool 326 | unusable bool 327 | } 328 | 329 | //拦截Close 330 | func (p *PoolConn) Close() error { 331 | p.mu.RLock() 332 | defer p.mu.RUnlock() 333 | 334 | if p.unusable { 335 | if p.Conn != nil { 336 | return p.Conn.Close() 337 | } 338 | return nil 339 | } 340 | return p.c.put(p.Conn) 341 | } 342 | ~~~ 343 | 344 | 它的 Pool 是通过 Channel 实现的,空闲的连接放入到 Channel 中,这也是 Channel 的一个应用场景: 345 | ~~~go 346 | type channelPool struct { 347 | // 存储连接池的channel 348 | mu sync.RWMutex 349 | conns chan net.Conn 350 | 351 | 352 | // net.Conn 的产生器 353 | factory Factory 354 | } 355 | ~~~ 356 | 357 | ### 数据库连接池 358 | 359 | 标准库 sql.DB 还提供了一个通用的数据库的连接池,通过 MaxOpenConns 和 MaxIdleConns 控制最大的连接数和最大的 idle 的连接数。默认的 MaxIdleConns 是 2,这个数对于数据库相关的应用来说太小了,我们一般都会调整它。 360 | 361 | ![标准库 sql.DB](https://static001.geekbang.org/resource/image/49/15/49c14b5bccb6d6ac7a159eece17a2215.png) 362 | 363 | DB 的 freeConn 保存了 idle 的连接,这样,当我们获取数据库连接的时候,它就会优先尝试从 freeConn 获取已有的连接([conn](https://github.com/golang/go/blob/4fc3896e7933e31822caa50e024d4e139befc75f/src/database/sql/sql.go#L1196))。 364 | 365 | ### Memcached Client 连接池 366 | 367 | Brad Fitzpatrick 是知名缓存库 Memcached 的原作者,前 Go 团队成员。[gomemcache](https://github.com/bradfitz/gomemcache)是他使用 Go 开发的 Memchaced 的客户端,其中也用了连接池的方式池化 Memcached 的连接。接下来让我们看看它的连接池的实现。 368 | 369 | gomemcache Client 有一个 freeconn 的字段,用来保存空闲的连接。当一个请求使用完之后,它会调用 putFreeConn 放回到池子中,请求的时候,调用 getFreeConn 优先查询 freeConn 中是否有可用的连接。它采用 Mutex+Slice 实现 Pool: 370 | 371 | ~~~go 372 | // 放回一个待重用的连接 373 | func (c *Client) putFreeConn(addr net.Addr, cn *conn) { 374 | c.lk.Lock() 375 | defer c.lk.Unlock() 376 | if c.freeconn == nil { // 如果对象为空,创建一个map对象 377 | c.freeconn = make(map[string][]*conn) 378 | } 379 | freelist := c.freeconn[addr.String()] //得到此地址的连接列表 380 | if len(freelist) >= c.maxIdleConns() {//如果连接已满,关闭,不再放入 381 | cn.nc.Close() 382 | return 383 | } 384 | c.freeconn[addr.String()] = append(freelist, cn) // 加入到空闲列表中 385 | } 386 | 387 | // 得到一个空闲连接 388 | func (c *Client) getFreeConn(addr net.Addr) (cn *conn, ok bool) { 389 | c.lk.Lock() 390 | defer c.lk.Unlock() 391 | if c.freeconn == nil { 392 | return nil, false 393 | } 394 | freelist, ok := c.freeconn[addr.String()] 395 | if !ok || len(freelist) == 0 { // 没有此地址的空闲列表,或者列表为空 396 | return nil, false 397 | } 398 | cn = freelist[len(freelist)-1] // 取出尾部的空闲连接 399 | c.freeconn[addr.String()] = freelist[:len(freelist)-1] 400 | return cn, true 401 | } 402 | ~~~ 403 | 404 | ### Worker Pool 405 | 406 | 最后,我再讲一个 Pool 应用得非常广泛的场景。 407 | 408 | 你已经知道,goroutine 是一个很轻量级的“纤程”,在一个服务器上可以创建十几万甚至几十万的 goroutine。但是“可以”和“合适”之间还是有区别的,你会在应用中让几十万的 goroutine 一直跑吗?基本上是不会的。 409 | 410 | 一个 goroutine 初始的栈大小是 2048 个字节,并且在需要的时候可以扩展到 1GB(具体的内容你可以课下看看代码中的配置:[不同的架构最大数会不同](https://github.com/golang/go/blob/f296b7a6f045325a230f77e9bda1470b1270f817/src/runtime/proc.go#L120)),所以,大量的 goroutine 还是很耗资源的。同时,大量的 goroutine 对于调度和垃圾回收的耗时还是会有影响的,因此,goroutine 并不是越多越好。 411 | 412 | 有的时候,我们就会创建一个 Worker Pool 来减少 goroutine 的使用。比如,我们实现一个 TCP 服务器,如果每一个连接都要由一个独立的 goroutine 去处理的话,在大量连接的情况下,就会创建大量的 goroutine,这个时候,我们就可以创建一个固定数量的 goroutine(Worker),由这一组 Worker 去处理连接,比如 fasthttp 中的[Worker Pool](https://github.com/valyala/fasthttp/blob/9f11af296864153ee45341d3f2fe0f5178fd6210/workerpool.go#L16)。 413 | 414 | Worker 的实现也是五花八门的: 415 | 416 | - 有些是在后台默默执行的,不需要等待返回结果; 417 | - 有些需要等待一批任务执行完; 418 | - 有些 Worker Pool 的生命周期和程序一样长; 419 | - 有些只是临时使用,执行完毕后,Pool 就销毁了。 420 | 421 | 大部分的 Worker Pool 都是通过 Channel 来缓存任务的,因为 Channel 能够比较方便地实现并发的保护,有的是多个 Worker 共享同一个任务 Channel,有些是每个 Worker 都有一个独立的 Channel。 422 | 423 | 综合下来,精挑细选,我给你推荐三款易用的 Worker Pool,这三个 Worker Pool 的 API 设计简单,也比较相似,易于和项目集成,而且提供的功能也是我们常用的功能。 424 | 425 | - [gammazero/workerpool](https://godoc.org/github.com/gammazero/workerpool):gammazero/workerpool 可以无限制地提交任务,提供了更便利的 Submit 和 SubmitWait 方法提交任务,还可以提供当前的 worker 数和任务数以及关闭 Pool 的功能。 426 | - [ivpusic/grpool](https://godoc.org/github.com/ivpusic/grpool):grpool 创建 Pool 的时候需要提供 Worker 的数量和等待执行的任务的最大数量,任务的提交是直接往 Channel 放入任务。 427 | - [dpaks/goworkers](https://godoc.org/github.com/dpaks/goworkers):dpaks/goworkers 提供了更便利的 Submi 方法提交任务以及 Worker 数、任务数等查询方法、关闭 Pool 的方法。它的任务的执行结果需要在 ResultChan 和 ErrChan 中去获取,没有提供阻塞的方法,但是它可以在初始化的时候设置 Worker 的数量和任务数。 428 | 429 | 类似的 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也是一个非常不错的 Worker Pool,关注度目前不是很高,但是功能非常齐全。 430 | 431 | 其实,你也可以自己去开发自己的 Worker Pool,但是,对于我这种“懒惰”的人来说,只要满足我的实际需求,我还是倾向于从这个几个常用的库中选择一个来使用。所以,我建议你也从常用的库中进行选择。 432 | 433 | ## 总结 434 | 435 | Pool 是一个通用的概念,也是解决对象重用和预先分配的一个常用的优化手段。即使你自己没在项目中直接使用过,但肯定在使用其它库的时候,就享受到应用 Pool 的好处了,比如数据库的访问、http API 的请求等等。 436 | 437 | 我们一般不会在程序一开始的时候就开始考虑优化,而是等项目开发到一个阶段,或者快结束的时候,才全面地考虑程序中的优化点,而 Pool 就是常用的一个优化手段。如果你发现程序中有一种 GC 耗时特别高,有大量的相同类型的临时对象,不断地被创建销毁,这时,你就可以考虑看看,是不是可以通过池化的手段重用这些对象。 438 | 439 | 另外,在分布式系统或者微服务框架中,可能会有大量的并发 Client 请求,如果 Client 的耗时占比很大,你也可以考虑池化 Client,以便重用。 440 | 441 | 如果你发现系统中的 goroutine 数量非常多,程序的内存资源占用比较大,而且整体系统的耗时和 GC 也比较高,我建议你看看,是否能够通过 Worker Pool 解决大量 goroutine 的问题,从而降低这些指标。 442 | 443 | 444 | ![](https://static001.geekbang.org/resource/image/58/aa/58358f16bcee0281b55299f0386e17aa.jpg) 445 | 446 | ## 思考题 447 | 在标准库 net/rpc 包中,Server 端需要解析大量客户端的请求(Request),这些短暂使用的 Request 是可以重用的。请你检查相关的代码,看看 Go 开发者都使用了什么样的方式来重用这些对象。 448 | 449 | ~~~go 450 | func (server *Server) freeRequest(req *Request) { 451 | server.reqLock.Lock() 452 | // 将req放在freeReq的头部,指向原先的链表头 453 | // 至于为什么放在头部、而不是尾部,我觉得是放在尾部需要遍历完这个链表(增加时间复杂度)、或者要额外维护一个尾部Request的指针(增加空间复杂度),权衡下放在头部更方便 454 | req.next = server.freeReq 455 | server.freeReq = req 456 | server.reqLock.Unlock() 457 | } 458 | 459 | func (server *Server) getRequest() *Request { 460 | server.reqLock.Lock() 461 | // freeReq是一个链表,保存空闲的Request 462 | req := server.freeReq 463 | if req == nil { 464 | // 初始状态:freeReq为空时,在heap上重新分配一个对象 465 | req = new(Request) 466 | } else { 467 | server.freeReq = req.next 468 | // 复用的关键在这里,这里并不是新建一个对象 new(Request) 469 | // 这里的思想类似于Reset,将原先有数据的Request设置为空 470 | *req = Request{} 471 | } 472 | server.reqLock.Unlock() 473 | return req 474 | } 475 | 476 | ~~~ -------------------------------------------------------------------------------- /1.基本并发原语/1.11:Context:信息穿透上下文/11.00-Context:信息穿透上下文.md: -------------------------------------------------------------------------------- 1 | # Context:信息穿透上下文 2 | 3 | 重要的东西来了!!!什么是上下文,举个例子,我冲向了厕所,但是过了一个小时还没出来。大家是不是就特别奇怪我怎么还没出来呢?如果这时候一个人跟你说,我是没带纸所以还没出来,你是不是就懂了。 4 | 5 | 你看,上下文(Context)就是这么重要。在我们的开发场景中,上下文也是不可或缺的,缺少了它,我们就不能获取完整的程序信息。 6 | 7 | 那到底啥是上下文呢?其实,这就是指,**在 API 之间或者方法调用之间,所传递的除了业务参数之外的额外信息。** 8 | 9 | 通常,在一些简单场景下使用 channel 和 WaitGroup 已经足够了,但是当面临一些复杂多变的网络(比如超时)并发场景下 channel 和 WaitGroup 显得有些力不从心了。 比如一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,这些 goroutine 又可能会开启其他的 goroutine,比如数据库和RPC服务。 所以我们需要一种可以跟踪 goroutine 的方案,才可以达到控制他们的目的,这就是Go语言为我们提供的 Context,称之为上下文非常贴切,它就是goroutine 的上下文。 它是包括一个程序的运行环境、现场和快照等。每个程序要运行时,都需要知道当前程序的运行状态,通常Go 将这些封装在一个 Context 里,再将它传给要执行的 goroutine 。 10 | 11 | 比如,服务端接收到客户端的 HTTP 请求之后,可以把客户端的 IP 地址和端口、客户端的身份信息、请求接收的时间、Trace ID 等信息放入到上下文中,这个上下文可以在后端的方法调用中传递,后端的业务方法除了利用正常的参数做一些业务处理(如订单处理)之外,还可以从上下文读取到消息请求的时间、Trace ID 等信息,把服务处理的时间推送到 Trace 服务中。Trace 服务可以把同一 Trace ID 的不同方法的调用顺序和调用时间展示成流程图,方便跟踪。 12 | 13 | 14 | 不过,Go 标准库中的 Context 功能还不止于此,它还提供了超时(Timeout)和取消(Cancel)的机制,下面就让我一一道来。 15 | 16 | 17 | ## Context 基本使用方法 18 | 19 | 首先,我们来学习一下 Context 接口包含哪些方法,这些方法都是干什么用的。、 20 | 21 | 包 context 定义了 Context 接口,Context 的具体实现包括 4 个方法,分别是 Deadline、Done、Err 和 Value,如下所示: 22 | ~~~go 23 | type Context interface { 24 | Deadline() (deadline time.Time, ok bool) 25 | Done() <-chan struct{} 26 | Err() error 27 | Value(key interface{}) interface{} 28 | } 29 | ~~~ 30 | 31 | **Deadline** 方法会返回这个 Context 被取消的截止日期。如果没有设置截止日期,ok 的值是 false。后续每次调用这个对象的 Deadline 方法时,都会返回和第一次调用相同的结果。 32 | 33 | **Done** 方法返回一个 Channel 对象。在 Context 被取消时,此 Channel 会被 close,如果没被取消,可能会返回 nil。后续的 Done 调用总是返回相同的结果。当 Done 被 close 的时候,你可以通过 ctx.Err 获取错误信息。Done 这个方法名其实起得并不好,因为名字太过笼统,不能明确反映 Done 被 close 的原因,因为 cancel、timeout、deadline 都可能导致 Done 被 close,不过,目前还没有一个更合适的方法名 34 | 35 | 关于 Done 方法,你必须要记住的知识点就是:如果 Done 没有被 close,Err 方法返回 nil;如果 Done 被 close,Err 方法会返回 Done 被 close 的原因。 36 | 37 | **Value** 返回此 ctx 中和指定的 key 相关联的 value。 38 | 39 | Context 中实现了 2 个常用的生成顶层 Context 的方法。 40 | 41 | - context.Background():返回一个非 nil 的、空的 Context,没有任何值,不会被 cancel,不会超时,没有截止日期。一般用在主函数、初始化、测试以及创建根 Context 的时候。 42 | - context.TODO():返回一个非 nil 的、空的 Context,没有任何值,不会被 cancel,不会超时,没有截止日期。当你不清楚是否该用 Context,或者目前还不知道要传递一些什么上下文信息的时候,就可以使用这个方法。 43 | 44 | 45 | 官方文档是这么讲的,你可能会觉得像没说一样,因为界限并不是很明显。其实,你根本不用费脑子去考虑,可以直接使用 context.Background。事实上,它们两个底层的实现是一模一样的: 46 | ~~~go 47 | var ( 48 | background = new(emptyCtx) 49 | todo = new(emptyCtx) 50 | ) 51 | 52 | func Background() Context { 53 | return background 54 | } 55 | 56 | func TODO() Context { 57 | return todo 58 | } 59 | ~~~ 60 | 61 | 在使用 Context 的时候,有一些约定俗成的规则。 62 | 1. 一般函数使用 Context 的时候,会把这个参数放在第一个参数的位置。 63 | 2. 从来不把 nil 当做 Context 类型的参数值,可以使用 context.Background() 创建一个空的上下文对象,也不要使用 nil。 64 | 3. Context 只用来临时做函数之间的上下文透传,不能持久化 Context 或者把 Context 长久保存。把 Context 持久化到数据库、本地文件或者全局变量、缓存中都是错误的用法。 65 | 4. key 的类型不应该是字符串类型或者其它内建类型,否则容易在包之间使用 Context 时候产生冲突(虽然官方例子用的string,太淦了!)。使用 WithValue 时,key 的类型应该是自己定义的类型。 66 | 5. 常常使用 struct{}作为底层类型定义 key 的类型。对于 exported key 的静态类型,常常是接口或者指针。这样可以尽量减少内存分配。 67 | 68 | 如果你能保证别人使用你的 Context 时不会和你定义的 key 冲突,那么 key 的类型就比较随意,因为你自己保证了不同包的 key 不会冲突,否则建议你尽量采用保守的 unexported 的类型。 69 | 70 | ## 创建特殊用途 Context 的方法 71 | 72 | 接下来,我会介绍标准库中几种创建特殊用途 Context 的方法:WithValue、WithCancel、WithTimeout 和 WithDeadline,包括它们的功能以及实现方式。 73 | 74 | ### WithValue 75 | 76 | WithValue 基于 parent Context 生成一个新的 Context,保存了一个 key-value 键值对。它常常用来传递上下文。 77 | 78 | WithValue 方法其实是创建了一个类型为 valueCtx 的 Context,它的类型定义如下: 79 | 80 | ~~~go 81 | type valueCtx struct { 82 | Context 83 | key, val interface{} 84 | } 85 | ~~~ 86 | 87 | 它持有一个 key-value 键值对,还持有 parent 的 Context。它覆盖了 Value 方法,优先从自己的存储中检查这个 key,不存在的话会从 parent 中继续检查。 88 | 89 | Go 标准库实现的 Context 还实现了链式查找。如果不存在,还会向 parent Context 去查找,如果 parent 还是 valueCtx 的话,还是遵循相同的原则:valueCtx 会嵌入 parent,所以还是会查找 parent 的 Value 方法的。 90 | 91 | ~~~go 92 | ctx = context.TODO() 93 | ctx = context.WithValue(ctx, "key1", "0001") 94 | ctx = context.WithValue(ctx, "key2", "0001") 95 | ctx = context.WithValue(ctx, "key3", "0001") 96 | ctx = context.WithValue(ctx, "key4", "0004") 97 | 98 | fmt.Println(ctx.Value("key1")) 99 | ~~~ 100 | ![查找路径](https://static001.geekbang.org/resource/image/03/fe/035a1b8e090184c1feba1ef194ec53fe.jpg) 101 | 102 | ### WithCancel 103 | 104 | WithCancel 方法返回 parent 的副本,只是副本中的 Done Channel 是新建的对象,它的类型是 cancelCtx。 105 | 106 | 我们常常在一些需要主动取消长时间的任务时,创建这种类型的 Context,然后把这个 Context 传给长时间执行任务的 goroutine。当需要中止任务时,我们就可以 cancel 这个 Context,这样长时间执行任务的 goroutine,就可以通过检查这个 Context,知道 Context 已经被取消了。 107 | 108 | WithCancel 返回值中的第二个值是一个 cancel 函数。其实,这个返回值的名称(cancel)和类型(Cancel)也非常迷惑人。 109 | 110 | 记住,不是只有你想中途放弃,才去调用 cancel,**只要你的任务正常完成了,就需要调用 cancel**,这样,这个 Context 才能释放它的资源(通知它的 children 处理 cancel,从它的 parent 中把自己移除,甚至释放相关的 goroutine)。很多同学在使用这个方法的时候,都会忘记调用 cancel,切记切记,而且一定尽早释放。 111 | 112 | 我们来看下 WithCancel 方法的实现代码: 113 | ~~~go 114 | func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { 115 | c := newCancelCtx(parent) 116 | propagateCancel(parent, &c)// 把c朝上传播 117 | return &c, func() { c.cancel(true, Canceled) } 118 | } 119 | 120 | // newCancelCtx returns an initialized cancelCtx. 121 | func newCancelCtx(parent Context) cancelCtx { 122 | return cancelCtx{Context: parent} 123 | } 124 | ~~~ 125 | 代码中调用的 propagateCancel 方法会顺着 parent 路径往上找,直到找到一个 cancelCtx,或者为 nil。如果不为空,就把自己加入到这个 cancelCtx 的 child,以便这个 cancelCtx 被取消的时候通知自己。如果为空,会新起一个 goroutine,由它来监听 parent 的 Done 是否已关闭。 126 | 127 | 当这个 cancelCtx 的 cancel 函数被调用的时候,或者 parent 的 Done 被 close 的时候,这个 cancelCtx 的 Done 才会被 close。 128 | 129 | cancel 是向下传递的,如果一个 WithCancel 生成的 Context 被 cancel 时,如果它的子 Context(也有可能是孙,或者更低,依赖子的类型)也是 cancelCtx 类型的,就会被 cancel,但是不会向上传递。parent Context 不会因为子 Context 被 cancel 而 cancel。 130 | 131 | cancelCtx 被取消时,它的 Err 字段就是下面这个 Canceled 错误: 132 | 133 | ~~~go 134 | var Canceled = errors.New("context canceled") 135 | ~~~ 136 | 137 | ### WithTimeout 138 | 139 | WithTimeout 其实是和 WithDeadline 一样,只不过一个参数是超时时间,一个参数是截止时间。超时时间加上当前时间,其实就是截止时间,因此,WithTimeout 的实现是: 140 | ~~~go 141 | func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { 142 | // 当前时间+timeout就是deadline 143 | return WithDeadline(parent, time.Now().Add(timeout)) 144 | } 145 | ~~~ 146 | 147 | ### WithDeadline 148 | 149 | WithDeadline 会返回一个 parent 的副本,并且设置了一个不晚于参数 d 的截止时间,类型为 timerCtx(或者是 cancelCtx)。 150 | 151 | 如果它的截止时间晚于 parent 的截止时间,那么就以 parent 的截止时间为准,并返回一个类型为 cancelCtx 的 Context,因为 parent 的截止时间到了,就会取消这个 cancelCtx。 152 | 153 | 如果当前时间已经超过了截止时间,就直接返回一个已经被 cancel 的 timerCtx。否则就会启动一个定时器,到截止时间取消这个 timerCtx。 154 | 155 | 综合起来,timerCtx 的 Done 被 Close 掉,主要是由下面的某个事件触发的: 156 | 157 | - 截止时间到了; 158 | - cancel 函数被调用; 159 | - parent 的 Done 被 close。 160 | 161 | 下面的代码是 WithDeadline 方法的实现: 162 | 163 | ~~~go 164 | func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { 165 | // 如果parent的截止时间更早,直接返回一个cancelCtx即可 166 | if cur, ok := parent.Deadline(); ok && cur.Before(d) { 167 | return WithCancel(parent) 168 | } 169 | c := &timerCtx{ 170 | cancelCtx: newCancelCtx(parent), 171 | deadline: d, 172 | } 173 | propagateCancel(parent, c) // 同cancelCtx的处理逻辑 174 | dur := time.Until(d) 175 | if dur <= 0 { //当前时间已经超过了截止时间,直接cancel 176 | c.cancel(true, DeadlineExceeded) 177 | return c, func() { c.cancel(false, Canceled) } 178 | } 179 | c.mu.Lock() 180 | defer c.mu.Unlock() 181 | if c.err == nil { 182 | // 设置一个定时器,到截止时间后取消 183 | c.timer = time.AfterFunc(dur, func() { 184 | c.cancel(true, DeadlineExceeded) 185 | }) 186 | } 187 | return c, func() { c.cancel(true, Canceled) } 188 | } 189 | ~~~ 190 | 191 | 和 cancelCtx 一样,WithDeadline(WithTimeout)**返回的 cancel 一定要调用**,并且要尽可能早地被调用,这样才能尽早释放资源,不要单纯地依赖截止时间被动取消。正确的使用姿势是啥呢?我们来看一个例子。 192 | ~~~go 193 | func slowOperationWithTimeout(ctx context.Context) (Result, error) { 194 | ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) 195 | defer cancel() // 一旦慢操作完成就立马调用cancel 196 | return slowOperation(ctx) 197 | } 198 | ~~~ 199 | ### 总结 200 | 201 | 我们经常使用 Context 来取消一个 goroutine 的运行,这是 Context 最常用的场景之一,Context 也被称为 goroutine 生命周期范围(goroutine-scoped)的 Context,把 Context 传递给 goroutine。但是,goroutine 需要尝试检查 Context 的 Done 是否关闭了: 202 | 203 | ~~~go 204 | func main() { 205 | ctx, cancel := context.WithCancel(context.Background()) 206 | 207 | go func() { 208 | defer func() { 209 | fmt.Println("goroutine exit") 210 | }() 211 | 212 | for { 213 | select { 214 | case <-ctx.Done(): 215 | return 216 | default: 217 | time.Sleep(time.Second) 218 | } 219 | } 220 | }() 221 | 222 | time.Sleep(time.Second) 223 | cancel() 224 | time.Sleep(2 * time.Second) 225 | } 226 | ~~~ 227 | 228 | 如果你要为 Context 实现一个带超时功能的调用,比如访问远程的一个微服务,超时并不意味着你会通知远程微服务已经取消了这次调用,大概率的实现只是避免客户端的长时间等待,远程的服务器依然还执行着你的请求。 229 | 230 | 所以,有时候,Context 并不会减少对服务器的请求负担。如果在 Context 被 cancel 的时候,你能关闭和服务器的连接,中断和数据库服务器的通讯、停止对本地文件的读写,那么,这样的超时处理,同时能减少对服务调用的压力,但是这依赖于你对超时的底层处理机制。 231 | 232 | ![](https://static001.geekbang.org/resource/image/2d/2b/2dcbb1ca54c31b4f3e987b602a38e82b.jpg) 233 | 234 | ## 思考题 235 | 236 | 使用 WithCancel 和 WithValue 写一个级联的使用 Context 的例子,验证一下 parent Context 被 cancel 后,子 conext 是否也立刻被 cancel 了。 237 | ```go 238 | package main 239 | 240 | import ( 241 | "context" 242 | "fmt" 243 | "time" 244 | ) 245 | 246 | func main() { 247 | parent := context.Background() 248 | ctx, cancel := context.WithCancel(parent) 249 | child := context.WithValue(ctx, "name", "wuqq") 250 | go func() { 251 | for { 252 | select { 253 | case <-child.Done(): 254 | fmt.Println("it's over") 255 | return 256 | default: 257 | res := child.Value("name") 258 | fmt.Println("name:", res) 259 | time.Sleep(1 * time.Second) 260 | } 261 | } 262 | }() 263 | go func() { 264 | time.Sleep(3 * time.Second) 265 | cancel() 266 | }() 267 | 268 | time.Sleep(5 * time.Second) 269 | } 270 | 271 | 272 | ``` -------------------------------------------------------------------------------- /1.基本并发原语/1.11:Context:信息穿透上下文/11.01.WithValue用法.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func main() { 9 | ctx := context.TODO() 10 | ctx = context.WithValue(ctx, "key1", "0001") 11 | ctx = context.WithValue(ctx, "key2", "0001") 12 | ctx = context.WithValue(ctx, "key3", "0001") 13 | ctx = context.WithValue(ctx, "key4", "0004") 14 | 15 | fmt.Println(ctx.Value("key1")) 16 | } 17 | -------------------------------------------------------------------------------- /1.基本并发原语/1.11:Context:信息穿透上下文/11.02.思考题.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | func main() { 10 | parent := context.Background() 11 | ctx, cancel := context.WithCancel(parent) 12 | child := context.WithValue(ctx, "name", "wuqq") 13 | go func() { 14 | for { 15 | select { 16 | case <-child.Done(): 17 | fmt.Println("it's over") 18 | return 19 | default: 20 | res := child.Value("name") 21 | fmt.Println("name:", res) 22 | time.Sleep(1 * time.Second) 23 | } 24 | } 25 | }() 26 | go func() { 27 | time.Sleep(3 * time.Second) 28 | cancel() 29 | }() 30 | 31 | time.Sleep(5 * time.Second) 32 | } 33 | -------------------------------------------------------------------------------- /2.原子操作/2.01:atomic:要保证原子操作,一定要使用这几种方法/2.00-atomic:要保证原子操作,一定要使用这几种方法.md: -------------------------------------------------------------------------------- 1 | # atomic:保证原子操作 2 | 3 | 之前在看锁实现,once等代码的时候是不是发现有个大宝贝一直在被使用中,那就是atomic 包。 4 | 5 | atomic包可以用来实现一些原子操作,那么为什么要了解原子操作呢?当然是为了优化(面试咯),咳咳,这就开始这个知识点了! 6 | 7 | ## 原子操作的基础知识 8 | 9 | sync/atomic 实现了同步算法底层的原子的内存操作原语,我们把它叫做原子操作原语,它提供了一些实现原子操作的方法。 10 | 11 | 原子操作是什么呢?就是要不搞完了,要不搞失败了,别人看不见你的中间搞了什么。就像一个最小的粒子 - 原子一样,不可分割。(别杠还有夸克,说了我也不听!!) 12 | 13 | CPU 提供了基础的原子操作,不过,不同架构的系统的原子操作是不一样的。 14 | 15 | 对于单核处理器的机子来说呢,如果一个操作是由一个 CPU 指令来实现的,那么它就是原子操作,比如它的 XCHG 和 INC 等指令。如果操作是基于多条指令来实现的,那么,执行的过程中可能会被中断,并执行上下文切换,这样的话,原子性的保证就被打破了,因为这个时候,操作可能只执行了一半。 16 | 17 | 在多处理器多核系统中,原子操作的实现就比较复杂了。 18 | 19 | 由于 cache 的存在,单个核上的单个指令进行原子操作的时候,你要确保其它处理器或者核不访问此原子操作的地址,或者是确保其它处理器或者核总是访问原子操作之后的最新的值。x86 架构中提供了指令前缀 LOCK,LOCK 保证了指令(比如 LOCK CMPXCHG op1、op2)不会受其它处理器或 CPU 核的影响,有些指令(比如 XCHG)本身就提供 Lock 的机制。不同的 CPU 架构提供的原子操作指令的方式也是不同的,比如对于多核的 MIPS 和 ARM,提供了 LL/SC(Load Link/Store Conditional)指令,可以帮助实现原子操作(ARMLL/SC 指令 LDREX 和 STREX)。 20 | 21 | 简单来说呢,就是单核系统会有一条指令进行操作,多核系统会有锁来保证原子性,是不是很熟悉,就是你想的那样,都是并行变串行啦。 22 | 23 | **因为不同的 CPU 架构甚至不同的版本提供的原子操作的指令是不同的,所以,要用一种编程语言实现支持不同架构的原子操作是相当有难度的**。但是呢?我们写crud的,有语言帮我们搞定了,就不用管汇编层的事情了。 24 | 25 | Go 提供了一个通用的原子操作的 API,将更底层的不同的架构下的实现封装成 atomic 包,提供了修改类型的原子操作([atomic read-modify-write](https://preshing.com/20150402/you-can-do-any-kind-of-atomic-read-modify-write-operation/),RMW)和加载存储类型的原子操作([Load 和 Store](https://preshing.com/20130618/atomic-vs-non-atomic-operations/))的 API,稍后我会一一介绍。 26 | 27 | 有些代码看似原子,但是在不同平台并不是: 28 | ~~~go 29 | const x int64 = 1 + 1<<33 30 | 31 | func main() { 32 | var i = x//将一个 64 位的值赋值给变量 i 33 | _ = i 34 | } 35 | ~~~ 36 | 37 | 如果你使用 GOARCH=386 的架构去编译这段代码,那么,` _ = i`其实是被拆成了两个指令,分别操作低 32 位和高 32 位(使用 GOARCH=386 go tool compile -N -l test.go;GOARCH=386 go tool objdump -gnu test.o 反编译试试): 38 | 39 | ![暂时偷的图](https://static001.geekbang.org/resource/image/45/62/4563ac42f379d1500d191377db16a162.png) 40 | 41 | 如果 GOARCH=amd64 的架构去编译这段代码,那么,第 5 行其中的赋值操作其实是一条指令: 42 | 43 | ![偷的第二章图](https://static001.geekbang.org/resource/image/6e/66/6e20a0f44d95d78c1bca4303f1a32966.png) 44 | 45 | 所以,如果要想保证原子操作,切记一定要使用 atomic 提供的方法。 46 | 47 | ## atomic 原子操作的应用场景 48 | 49 | 使用 atomic 的一些方法,我们可以实现更底层的一些优化。如果使用 Mutex 等并发原语进行这些优化,虽然可以解决问题,但是这些并发原语的实现逻辑比较复杂,对性能还是有一定的影响的。 50 | 51 | 举个例子:假设你想在程序中使用一个标志(flag,比如一个 bool 类型的变量),来标识一个定时任务是否已经启动执行了,你会怎么做呢? 52 | 53 | 加锁的方法:如果使用 Mutex 和 RWMutex,在读取和设置这个标志的时候加锁,是可以做到互斥的、保证同一时刻只有一个定时任务在执行的,所以使用 Mutex 或者 RWMutex 是一种解决方案。 54 | 55 | 其实,这个场景中的问题不涉及到对资源复杂的竞争逻辑,只是会并发地读写这个标志,这类场景就适合使用 atomic 的原子操作. 56 | 57 | 你可以使用一个 uint32 类型的变量,如果这个变量的值是 0,就标识没有任务在执行,如果它的值是 1,就标识已经有任务在完成了。你看,是不是很简单呢? 58 | 59 | 60 | 再来看一个例子。假设你在开发应用程序的时候,需要从配置服务器中读取一个节点的配置信息。而且,在这个节点的配置发生变更的时候,你需要重新从配置服务器中拉取一份新的配置并更新。你的程序中可能有多个 goroutine 都依赖这份配置,涉及到对这个配置对象的并发读写,你可以使用读写锁实现对配置对象的保护。在大部分情况下,你也可以利用 atomic 实现配置对象的更新和加载。 61 | 62 | 可以看到,这两个例子都可以使用基本并发原语来实现的,只不过,我们不需要这些基本并发原语里面的复杂逻辑,而是只需要其中的简单原子操作,所以,这些场景可以直接使用 atomic 包中的方法去实现。 63 | 64 | 有时候,**你也可以使用 atomic 实现自己定义的基本并发原语**,比如 Go issue 有人提议的 CondMutex、Mutex.LockContext、WaitGroup.Go 等,我们可以使用 atomic 或者基于它的更高一级的并发原语去实现。我先前讲的几种基本并发原语的底层(比如 Mutex),就是基于通过 atomic 的方法实现的。 65 | 66 | 除此之外,atomic 原子操作还是实现 lock-free 数据结构的基石。 67 | 68 | 69 | 在实现 lock-free 的数据结构时,我们可以不使用互斥锁,这样就不会让线程因为等待互斥锁而阻塞休眠,而是让线程保持继续处理的状态。另外,不使用互斥锁的话,lock-free 的数据结构还可以提供并发的性能。 70 | 71 | 不过,lock-free 的数据结构实现起来比较复杂,需要考虑的东西很多,有兴趣的同学可以看一位微软专家写的一篇经验分享:[Lockless Programming Considerations for Xbox 360 and Microsoft Windows](https://docs.microsoft.com/zh-cn/windows/win32/dxtecharts/lockless-programming),这里我们不细谈了。不过,这节课的最后我会带你开发一个 lock-free 的 queue,来学习下使用 atomic 操作实现 lock-free 数据结构的方法,你可以拿它和使用互斥锁实现的 queue 做性能对比,看看在性能上是否有所提升。 72 | 73 | ## atomic 提供的方法 74 | 步入正题! 75 | 76 | 目前的 Go 的泛型的特性还没有发布,Go 的标准库中的很多实现会显得非常啰嗦,多个类型会实现很多类似的方法,尤其是 atomic 包,最为明显。相信泛型支持之后,atomic 的 API 会清爽很多。 77 | 78 | atomic 为了支持 int32、int64、uint32、uint64、uintptr、Pointer(Add 方法不支持)类型,分别提供了 AddXXX、CompareAndSwapXXX、SwapXXX、LoadXXX、StoreXXX 等方法。不过,你也不要担心,你只要记住了一种数据类型的方法的意义,其它数据类型的方法也是一样的 79 | 80 | 关于 atomic,还有一个地方你一定要记住,**atomic 操作的对象是一个地址,你需要把可寻址的变量的地址作为参数传递给方法,而不是把变量的值传递给方法。** 81 | 82 | ### Add 83 | 84 | ![add方法](https://static001.geekbang.org/resource/image/95/de/95dcf8742593b1191e87beaca16f59de.png) 85 | 86 | 其实,Add 方法就是给第一个参数地址中的值增加一个 delta 值。 87 | 88 | 对于有符号的整数来说,delta 可以是一个负数,相当于减去一个值。对于无符号的整数和 uinptr 类型来说,怎么实现减去一个值呢?毕竟,atomic 并没有提供单独的减法操作。 89 | 我来跟你说一种方法。你可以利用计算机补码的规则,把减法变成加法。以 uint32 类型为例: 90 | >AddUint32(&x, ^uint32(c-1)). 91 | 92 | 如果是对 uint64 的值进行操作,那么,就把上面的代码中的 uint32 替换成 uint64。尤其是减 1 这种特殊的操作,我们可以简化为: 93 | 94 | >AddUint32(&x, ^uint32(0)) 95 | ### CAS (CompareAndSwap) 96 | 97 | 以 int32 为例,我们学习一下 CAS 提供的功能。在 CAS 的方法签名中,需要提供要操作的地址、原数据值、新值,如下所示: 98 | 99 | > func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) 100 | 101 | 这个方法会比较当前 addr 地址里的值是不是 old,如果不等于 old,就返回 false;如果等于 old,就把此地址的值替换成 new 值,返回 true。这就相当于“判断相等才替换”。 102 | 103 | 如果使用伪代码来表示这个原子操作,代码如下: 104 | ~~~go 105 | if *addr == old { 106 | *addr = new 107 | return true 108 | } 109 | return false 110 | ~~~ 111 | 它支持的类型和方法如图所示: 112 | ![cas](https://static001.geekbang.org/resource/image/1b/77/1b0ffac37d8f952ca485ff58daf27177.png) 113 | 114 | ### Swap 115 | 116 | 如果不需要比较旧值,只是比较粗暴地替换的话,就可以使用 Swap 方法,它替换后还可以返回旧值. 117 | 118 | ![Swap](https://static001.geekbang.org/resource/image/c0/0a/c02e210607aa45734bb1812c97f77c0a.png) 119 | 120 | ### Load 121 | 122 | Load 方法会取出 addr 地址中的值,即使在多处理器、多核、有 CPU cache 的情况下,这个操作也能保证 Load 是一个原子操作。 123 | 124 | 它支持的数据类型和方法如图所示: 125 | ![load](https://static001.geekbang.org/resource/image/3f/5d/3faba284bda2a666caa5727d0f0c275d.png) 126 | 127 | ### Store 128 | Store 方法会把一个值存入到指定的 addr 地址中,即使在多处理器、多核、有 CPU cache 的情况下,这个操作也能保证 Store 是一个原子操作。别的 goroutine 通过 Load 读取出来,不会看到存取了一半的值。 129 | 130 | 131 | 它支持的数据类型和方法如图所示: 132 | 133 | ![Store](https://static001.geekbang.org/resource/image/8b/a0/8b77dc0e1ede98394aa21cf10fecc9a0.png) 134 | 135 | 136 | ### Value 类型 137 | 138 | 刚刚说的都是一些比较常见的类型,其实,atomic 还提供了一个特殊的类型:Value。它可以原子地存取对象类型,但也只能存取,不能 CAS 和 Swap,常常用在配置变更等场景中。 139 | 140 | ![value](https://static001.geekbang.org/resource/image/47/76/478b665391766de77043ffeb0d6fff76.png) 141 | 142 | 接下来,我以一个配置变更的例子,来演示 Value 类型的使用。这里定义了一个 Value 类型的变量 config, 用来存储配置信息。 143 | 144 | 首先,我们启动一个 goroutine,然后让它随机 sleep 一段时间,之后就变更一下配置,并通过我们前面学到的 Cond 并发原语,通知其它的 reader 去加载新的配置。 145 | 146 | 接下来,我们启动一个 goroutine 等待配置变更的信号,一旦有变更,它就会加载最新的配置。 147 | 148 | 通过这个例子,你可以了解到 Value 的 Store/Load 方法的使用,因为它只有这两个方法,只要掌握了它们的使用,你就完全掌握了 Value 类型。 149 | 150 | ~~~go 151 | type Config struct { 152 | NodeName string 153 | Addr string 154 | Count int32 155 | } 156 | 157 | func loadNewConfig() Config { 158 | return Config{ 159 | NodeName: "北京", 160 | Addr: "10.77.95.27", 161 | Count: rand.Int31(), 162 | } 163 | } 164 | func main() { 165 | var config atomic.Value 166 | config.Store(loadNewConfig()) 167 | var cond = sync.NewCond(&sync.Mutex{}) 168 | 169 | // 设置新的config 170 | go func() { 171 | for { 172 | time.Sleep(time.Duration(5+rand.Int63n(5)) * time.Second) 173 | config.Store(loadNewConfig()) 174 | cond.Broadcast() // 通知等待着配置已变更 175 | } 176 | }() 177 | 178 | go func() { 179 | for { 180 | cond.L.Lock() 181 | cond.Wait() // 等待变更信号 182 | c := config.Load().(Config) // 读取新的配置 183 | fmt.Printf("new config: %+v\n", c) 184 | cond.L.Unlock() 185 | } 186 | }() 187 | 188 | select {} 189 | } 190 | ~~~ 191 | 192 | 好了,关于标准库的 atomic 提供的方法,到这里我们就学完了。事实上,atomic 包提供了非常好的支持各种平台的一致性的 API,绝大部分项目都是直接使用它。接下来,我再给你介绍一下第三方库,帮助你稍微开拓一下思维。 193 | 194 | ## 第三方库的扩展 195 | 196 | 其实,atomic 的 API 已经算是很简单的了,它提供了包一级的函数,可以对几种类型的数据执行原子操作。 197 | 198 | 有些人就对这些函数做了进一步的包装,跟 atomic 中的 Value 类型类似,这些类型也提供了面向对象的使用方式,比如关注度比较高的[uber-go/atomic](https://github.com/uber-go/atomic),它定义和封装了几种与常见类型相对应的原子操作类型,这些类型提供了原子操作的方法。这些类型包括 Bool、Duration、Error、Float64、Int32、Int64、String、Uint32、Uint64 等。 199 | 200 | 201 | 比如 Bool 类型,提供了 CAS、Store、Swap、Toggle 等原子方法,还提供 String、MarshalJSON、UnmarshalJSON 等辅助方法,确实是一个精心设计的 atomic 扩展库。关于这些方法,你一看名字就能猜出来它们的功能,我就不多说了。 202 | 203 | 204 | 其它的数据类型也和 Bool 类型相似,使用起来就像面向对象的编程一样,你可以看下下面的这段代码。 205 | ~~~go 206 | var running atomic.Bool 207 | running.Store(true) 208 | running.Toggle() 209 | fmt.Println(running.Load()) // false 210 | ~~~ 211 | 212 | ### 使用 atomic 实现 Lock-Free queue 213 | 214 | atomic 常常用来实现 Lock-Free 的数据结构,这次我会给你展示一个 Lock-Free queue 的实现。 215 | 216 | 217 | Lock-Free queue 最出名的就是 Maged M. Michael 和 Michael L. Scott 1996 年发表的[论文](https://www.cs.rochester.edu/u/scott/papers/1996_PODC_queues.pdf)中的算法,算法比较简单,容易实现,伪代码的每一行都提供了注释,我就不在这里贴出伪代码了,因为我们使用 Go 实现这个数据结构的代码几乎和伪代码一样: 218 | 219 | ~~~go 220 | package queue 221 | import ( 222 | "sync/atomic" 223 | "unsafe" 224 | ) 225 | // lock-free的queue 226 | type LKQueue struct { 227 | head unsafe.Pointer 228 | tail unsafe.Pointer 229 | } 230 | // 通过链表实现,这个数据结构代表链表中的节点 231 | type node struct { 232 | value interface{} 233 | next unsafe.Pointer 234 | } 235 | func NewLKQueue() *LKQueue { 236 | n := unsafe.Pointer(&node{}) 237 | return &LKQueue{head: n, tail: n} 238 | } 239 | // 入队 240 | func (q *LKQueue) Enqueue(v interface{}) { 241 | n := &node{value: v} 242 | for { 243 | tail := load(&q.tail) 244 | next := load(&tail.next) 245 | if tail == load(&q.tail) { // 尾还是尾 246 | if next == nil { // 还没有新数据入队 247 | if cas(&tail.next, next, n) { //增加到队尾 248 | cas(&q.tail, tail, n) //入队成功,移动尾巴指针 249 | return 250 | } 251 | } else { // 已有新数据加到队列后面,需要移动尾指针 252 | cas(&q.tail, tail, next) 253 | } 254 | } 255 | } 256 | } 257 | // 出队,没有元素则返回nil 258 | func (q *LKQueue) Dequeue() interface{} { 259 | for { 260 | head := load(&q.head) 261 | tail := load(&q.tail) 262 | next := load(&head.next) 263 | if head == load(&q.head) { // head还是那个head 264 | if head == tail { // head和tail一样 265 | if next == nil { // 说明是空队列 266 | return nil 267 | } 268 | // 只是尾指针还没有调整,尝试调整它指向下一个 269 | cas(&q.tail, tail, next) 270 | } else { 271 | // 读取出队的数据 272 | v := next.value 273 | // 既然要出队了,头指针移动到下一个 274 | if cas(&q.head, head, next) { 275 | return v // Dequeue is done. return 276 | } 277 | } 278 | } 279 | } 280 | } 281 | 282 | // 将unsafe.Pointer原子加载转换成node 283 | func load(p *unsafe.Pointer) (n *node) { 284 | return (*node)(atomic.LoadPointer(p)) 285 | } 286 | 287 | // 封装CAS,避免直接将*node转换成unsafe.Pointer 288 | func cas(p *unsafe.Pointer, old, new *node) (ok bool) { 289 | return atomic.CompareAndSwapPointer( 290 | p, unsafe.Pointer(old), unsafe.Pointer(new)) 291 | } 292 | ~~~ 293 | 294 | 295 | 这个 lock-free 的实现使用了一个辅助头指针(head),头指针不包含有意义的数据,只是一个辅助的节点,这样的话,出队入队中的节点会更简单。 296 | 297 | 入队的时候,通过 CAS 操作将一个元素添加到队尾,并且移动尾指针。 298 | 299 | 出队的时候移除一个节点,并通过 CAS 操作移动 head 指针,同时在必要的时候移动尾指针。 300 | 301 | ## 总结 302 | 303 | 好了,我们来小结一下。这节课,我们学习了 atomic 的基本使用方法,以及它提供的几种方法,包括 Add、CAS、Swap、Load、Store、Value 类型。除此之外,我还介绍了一些第三方库,并且带你实现了 Lock-free queue。到这里,相信你已经掌握了 atomic 提供的各种方法,并且能够应用到实践中了。 304 | 305 | 最后,我还想和你讨论一个额外的问题:对一个地址的赋值是原子操作吗?这是一个很有趣的问题,如果是原子操作,还要 atomic 包干什么?官方的文档中并没有特意的介绍,不过,在一些 issue 或者论坛中,每当有人谈到这个问题时,总是会被建议用 atomic 包。 306 | 307 | Dave Cheney就谈到过这个问题,讲得非常好。我来给你总结一下他讲的知识点,这样你就比较容易理解使用 atomic 和直接内存操作的区别了。在现在的系统中,write 的地址基本上都是对齐的(aligned)。 比如,32 位的操作系统、CPU 以及编译器,write 的地址总是 4 的倍数,64 位的系统总是 8 的倍数(还记得 WaitGroup 针对 64 位系统和 32 位系统对 state1 的字段不同的处理吗)。对齐地址的写,不会导致其他人看到只写了一半的数据,因为它通过一个指令就可以实现对地址的操作。如果地址不是对齐的话,那么,处理器就需要分成两个指令去处理,如果执行了一个指令,其它人就会看到更新了一半的错误的数据,这被称做撕裂写(torn write) 。所以,你可以认为赋值操作是一个原子操作,这个“原子操作”可以认为是保证数据的完整性。 308 | 309 | 在现在的系统中,write 的地址基本上都是对齐的(aligned)。 比如,32 位的操作系统、CPU 以及编译器,write 的地址总是 4 的倍数,64 位的系统总是 8 的倍数(还记得 WaitGroup 针对 64 位系统和 32 位系统对 state1 的字段不同的处理吗)。对齐地址的写,不会导致其他人看到只写了一半的数据,因为它通过一个指令就可以实现对地址的操作。如果地址不是对齐的话,那么,处理器就需要分成两个指令去处理,如果执行了一个指令,其它人就会看到更新了一半的错误的数据,这被称做撕裂写(torn write) 。所以,你可以认为赋值操作是一个原子操作,这个“原子操作”可以认为是保证数据的完整性。 310 | 311 | 但是,对于现代的多处理多核的系统来说,由于 cache、指令重排,可见性等问题,我们对原子操作的意义有了更多的追求。在多核系统中,一个核对地址的值的更改,在更新到主内存中之前,是在多级缓存中存放的。这时,多个核看到的数据可能是不一样的,其它的核可能还没有看到更新的数据,还在使用旧的数据。 312 | 313 | 多处理器多核心系统为了处理这类问题,使用了一种叫做内存屏障(memory fence 或 memory barrier)的方式。一个写内存屏障会告诉处理器,必须要等到它管道中的未完成的操作(特别是写操作)都被刷新到内存中,再进行操作。此操作还会让相关的处理器的 CPU 缓存失效,以便让它们从主存中拉取最新的值。 314 | 315 | atomic 包提供的方法会提供内存屏障的功能,所以,atomic 不仅仅可以保证赋值的数据完整性,还能保证数据的可见性,一旦一个核更新了该地址的值,其它处理器总是能读取到它的最新值。但是,需要注意的是,因为需要处理器之间保证数据的一致性,atomic 的操作也是会降低性能的。 316 | 317 | ![结构](https://static001.geekbang.org/resource/image/53/13/53d55255fe851754659d90cbee814f13.jpg) 318 | 319 | 320 | ## 思考题 321 | atomic.Value 只有 Load/Store 方法,你是不是感觉意犹未尽?你可以尝试为 Value 类型增加 Swap 和 CompareAndSwap 方法(可以参考一下这份[资料](https://github.com/golang/go/issues/39351))。 -------------------------------------------------------------------------------- /2.原子操作/2.01:atomic:要保证原子操作,一定要使用这几种方法/2.01.test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | const x int64 = 1 + 1<<33 4 | 5 | func main() { 6 | var i = x 7 | _ = i 8 | } 9 | 10 | // 11 | 12 | // 13 | -------------------------------------------------------------------------------- /2.原子操作/2.01:atomic:要保证原子操作,一定要使用这几种方法/2.02.配置变更.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | ) 10 | 11 | type Config struct { 12 | NodeName string 13 | Addr string 14 | Count int32 15 | } 16 | 17 | func loadNewConfig() Config { 18 | return Config{ 19 | NodeName: "北京", 20 | Addr: "10.77.95.27", 21 | Count: rand.Int31(), 22 | } 23 | } 24 | func main() { 25 | var config atomic.Value 26 | config.Store(loadNewConfig()) 27 | var cond = sync.NewCond(&sync.Mutex{}) 28 | 29 | // 设置新的config 30 | go func() { 31 | for { 32 | time.Sleep(time.Duration(5+rand.Int63n(5)) * time.Second) 33 | config.Store(loadNewConfig()) 34 | cond.Broadcast() // 通知等待着配置已变更 35 | } 36 | }() 37 | 38 | go func() { 39 | for { 40 | cond.L.Lock() 41 | cond.Wait() // 等待变更信号 42 | c := config.Load().(Config) // 读取新的配置 43 | fmt.Printf("new config: %+v\n", c) 44 | cond.L.Unlock() 45 | } 46 | }() 47 | 48 | select {} 49 | } 50 | -------------------------------------------------------------------------------- /2.原子操作/2.01:atomic:要保证原子操作,一定要使用这几种方法/2.03.LKQueue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "sync/atomic" 5 | "unsafe" 6 | ) 7 | 8 | // lock-free的queue 9 | type LKQueue struct { 10 | head unsafe.Pointer 11 | tail unsafe.Pointer 12 | } 13 | 14 | // 通过链表实现,这个数据结构代表链表中的节点 15 | type node struct { 16 | value interface{} 17 | next unsafe.Pointer 18 | } 19 | 20 | func NewLKQueue() *LKQueue { 21 | n := unsafe.Pointer(&node{}) 22 | return &LKQueue{head: n, tail: n} 23 | } 24 | 25 | // 入队 26 | func (q *LKQueue) Enqueue(v interface{}) { 27 | n := &node{value: v} 28 | for { 29 | tail := load(&q.tail) 30 | next := load(&tail.next) 31 | if tail == load(&q.tail) { // 尾还是尾 32 | if next == nil { // 还没有新数据入队 33 | if cas(&tail.next, next, n) { //增加到队尾 34 | cas(&q.tail, tail, n) //入队成功,移动尾巴指针 35 | return 36 | } 37 | } else { // 已有新数据加到队列后面,需要移动尾指针 38 | cas(&q.tail, tail, next) 39 | } 40 | } 41 | } 42 | } 43 | 44 | // 出队,没有元素则返回nil 45 | func (q *LKQueue) Dequeue() interface{} { 46 | for { 47 | head := load(&q.head) 48 | tail := load(&q.tail) 49 | next := load(&head.next) 50 | if head == load(&q.head) { // head还是那个head 51 | if head == tail { // head和tail一样 52 | if next == nil { // 说明是空队列 53 | return nil 54 | } 55 | // 只是尾指针还没有调整,尝试调整它指向下一个 56 | cas(&q.tail, tail, next) 57 | } else { 58 | // 读取出队的数据 59 | v := next.value 60 | // 既然要出队了,头指针移动到下一个 61 | if cas(&q.head, head, next) { 62 | return v // Dequeue is done. return 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | // 将unsafe.Pointer原子加载转换成node 70 | func load(p *unsafe.Pointer) (n *node) { 71 | return (*node)(atomic.LoadPointer(p)) 72 | } 73 | 74 | // 封装CAS,避免直接将*node转换成unsafe.Pointer 75 | func cas(p *unsafe.Pointer, old, new *node) (ok bool) { 76 | return atomic.CompareAndSwapPointer( 77 | p, unsafe.Pointer(old), unsafe.Pointer(new)) 78 | } 79 | -------------------------------------------------------------------------------- /3.Channel/3.01:Channel:另辟蹊径,解决并发问题/03.00-Channel:另辟蹊径,解决并发问题.md: -------------------------------------------------------------------------------- 1 | # Channel:另辟蹊径,解决并发问题 2 | 3 | Channel 是 Go 语言内建的 first-class 类型,也是 Go 语言与众不同的特性之一。Channel 让并发消息处理在GO里面变得轻松加愉快了~ 4 | 5 | ## Channel 的发展 6 | 在刚刚学go并发的时候,就听到一个概念叫做CSP,那么CSP是什么呢? 7 | 8 | CSP 是 Communicating Sequential Process 的简称,中文直译为通信顺序进程,或者叫做交换信息的循序进程,是用来描述并发系统中进行交互的一种模式。 9 | 10 | 11 | CSP 最早出现于计算机科学家 Tony Hoare 在 1978 年发表的[论文](https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf)中(你可能不熟悉 Tony Hoare 这个名字,但是你一定很熟悉排序算法中的 Quicksort 算法,他就是 Quicksort 算法的作者,图灵奖的获得者)。最初,论文中提出的 CSP 版本在本质上不是一种进程演算,而是一种并发编程语言,但之后又经过了一系列的改进,最终发展并精炼出 CSP 的理论。**CSP 允许使用进程组件来描述系统,它们独立运行,并且只通过消息传递的方式通信。** 12 | 13 | 就像 Go 的创始人之一 Rob Pike 所说的:“每一个计算机程序员都应该读一读 Tony Hoare 1978 年的关于 CSP 的论文。”他和 Ken Thompson 在设计 Go 语言的时候也深受此论文的影响,并将 CSP 理论真正应用于语言本身(Russ Cox 专门写了[一篇文章记录这个历史](https://swtch.com/~rsc/thread/)),通过引入 Channel 这个新的类型,来实现 CSP 的思想。 14 | 15 | 16 | **Channel 类型是 Go 语言内置的类型,你无需引入某个包,就能使用它**。虽然 Go 也提供了传统的并发原语,但是它们都是通过库的方式提供的,你必须要引入 sync 包或者 atomic 包才能使用它们,而 Channel 就不一样了,它是内置类型,使用起来非常方便。 17 | 18 | 19 | Channel 和 Go 的另一个独特的特性 goroutine 一起为并发编程提供了优雅的、便利的、与传统并发控制不同的方案,并演化出很多并发模式。接下来,我们就来看一看 Channel 的应用场景。 20 | 21 | ## Channel 的应用场景 22 | 23 | >Don’t communicate by sharing memory, share memory by communicating. 24 | 25 | 这是 Rob Pike 在 2015 年的一次 Gopher 会议中提到的一句话,虽然有一点绕,但也指出了使用 Go 语言的哲学,翻译一下:“**不要通过共享内存的方式通信,而是要通过 Channel 通信的方式分享数据**。” 26 | 27 | 28 | 综合起来,我把 Channel 的应用场景分为五种类型。这里你先有个印象,这样你可以有目的地去学习 Channel 的基本原理。下节课我会借助具体的例子,来带你掌握这几种类型。 29 | 30 | 1. **数据交流**:当作并发的 buffer 或者 queue,解决生产者 - 消费者问题。多个 goroutine 可以并发当作生产者(Producer)和消费者(Consumer)。 31 | 2. **数据传递**:一个 goroutine 将数据交给另一个 goroutine,相当于把数据的拥有权 (引用) 托付出去。 32 | 3. **信号通知**:一个 goroutine 可以将信号 (closing、closed、data ready 等) 传递给另一个或者另一组 goroutine 。 33 | 4. **任务编排**:可以让一组 goroutine 按照一定的顺序并发或者串行的执行,这就是编排的功能。 34 | 5. **锁**:利用 Channel 也可以实现互斥锁的机制。 35 | 36 | 37 | ## Channel 基本用法 38 | 39 | 你可以往 Channel 中发送数据,也可以从 Channel 中接收数据,所以,Channel 类型(为了说起来方便,我们下面都把 Channel 叫做 chan)分为只能接收、只能发送、既可以接收又可以发送三种类型。下面是它的语法定义: 40 | ~~~go 41 | ChannelType = ( "chan" | "chan" "<-" | "<-" "chan" ) ElementType . 42 | ~~~ 43 | 相应地,Channel 的正确语法如下: 44 | ~~~go 45 | chan string // 可以发送接收string 46 | chan<- struct{} // 只能发送struct{} 47 | <-chan int // 只能从chan接收int 48 | ~~~ 49 | 50 | 我们把既能接收又能发送的 chan 叫做双向的 chan,把只能发送和只能接收的 chan 叫做单向的 chan。其中,“<-”表示单向的 chan,如果你记不住,我告诉你一个简便的方法:**这个箭头总是射向左边的,元素类型总在最右边。如果箭头指向 chan,就表示可以往 chan 中塞数据;如果箭头远离 chan,就表示 chan 会往外吐数据。** 51 | 52 | 53 | chan 中的元素是任意的类型,所以也可能是 chan 类型,我来举个例子,比如下面的 chan 类型也是合法的: 54 | ~~~go 55 | chan<- chan int 56 | chan<- <-chan int 57 | <-chan <-chan int 58 | chan (<-chan int) 59 | ~~~ 60 | 61 | 可是,怎么判定箭头符号属于哪个 chan 呢?其实,“<-”有个规则,总是尽量和左边的 chan 结合(The <- operator associates with the leftmost chan possible:),因此,上面的定义和下面的使用括号的划分是一样的: 62 | 63 | ~~~go 64 | chan<- (chan int) // <- 和第一个chan结合 65 | chan<- (<-chan int) // 第一个<-和最左边的chan结合,第二个<-和左边第二个chan结合 66 | <-chan (<-chan int) // 第一个<-和最左边的chan结合,第二个<-和左边第二个chan结合 67 | chan (<-chan int) // 因为括号的原因,<-和括号内第一个chan结合 68 | ~~~ 69 | 70 | 通过 make,我们可以初始化一个 chan,未初始化的 chan 的零值是 nil。你可以设置它的容量,比如下面的 chan 的容量是 9527,我们把这样的 chan 叫做 buffered chan;如果没有设置,它的容量是 0,我们把这样的 chan 叫做 unbuffered chan。 71 | ~~~go 72 | make(chan int, 9527) 73 | ~~~ 74 | 75 | 如果 chan 中还有数据,那么,从这个 chan 接收数据的时候就不会阻塞,如果 chan 还未满(“满”指达到其容量),给它发送数据也不会阻塞,否则就会阻塞。unbuffered chan 只有读写都准备好之后才不会阻塞,这也是很多使用 unbuffered chan 时的常见 Bug。 76 | 77 | 还有一个知识点需要你记住:nil 是 chan 的零值,是一种特殊的 chan,对值是 nil 的 chan 的发送接收调用者总是会阻塞。 78 | 79 | 下面,我来具体给你介绍几种基本操作,分别是发送数据、接收数据,以及一些其它操作。 80 | 81 | **1. 发送数据** 82 | 83 | 往 chan 中发送一个数据使用“ch<-”,发送数据是一条语句: 84 | ~~~go 85 | ch <- 2000 86 | ~~~ 87 | 这里的 ch 是 chan int 类型或者是 chan <-int。 88 | 89 | **2. 接收数据** 90 | 91 | 从 chan 中接收一条数据使用“<-ch”,接收数据也是一条语句: 92 | ~~~go 93 | x := <-ch // 把接收的一条数据赋值给变量x 94 | foo(<-ch) // 把接收的一个的数据作为参数传给函数 95 | <-ch // 丢弃接收的一条数据 96 | ~~~ 97 | 98 | **3. 其它操作** 99 | 100 | Go 内建的函数 close、cap、len 都可以操作 chan 类型:close 会把 chan 关闭掉,cap 返回 chan 的容量,len 返回 chan 中缓存的还未被取走的元素数量。 101 | 102 | send 和 recv 都可以作为 select 语句的 case clause,如下面的例子: 103 | 104 | ~~~go 105 | func main() { 106 | var ch = make(chan int, 10) 107 | for i := 0; i < 10; i++ { 108 | select { 109 | case ch <- i: 110 | case v := <-ch: 111 | fmt.Println(v) 112 | } 113 | } 114 | } 115 | ~~~ 116 | 117 | chan 还可以应用于 for-range 语句中,比如: 118 | 119 | ~~~go 120 | for v := range ch { 121 | fmt.Println(v) 122 | } 123 | ~~~ 124 | 125 | 126 | 或者是忽略读取的值,只是清空 chan: 127 | ~~~go 128 | for range ch { 129 | } 130 | ~~~ 131 | 132 | 好了,到这里,Channel 的基本用法,我们就学完了。下面我从代码实现的角度分析 chan 类型的实现。毕竟,只有掌握了原理,你才能真正地用好它。 133 | 134 | ## Channel 的实现原理 135 | 136 | 接下来,我会给你介绍 chan 的数据结构、初始化的方法以及三个重要的操作方法,分别是 send、recv 和 close。通过学习 Channel 的底层实现,你会对 Channel 的功能和异常情况有更深的理解。 137 | 138 | 139 | ### chan 数据结构 140 | 141 | chan 类型的数据结构如下图所示,它的数据类型是[runtime.hchan](https://github.com/golang/go/blob/master/src/runtime/chan.go#L32)。 142 | 143 | ![](https://static001.geekbang.org/resource/image/81/dd/81304c1f1845d21c66195798b6ba48dd.jpg) 144 | 145 | - qcount:代表 chan 中已经接收但还没被取走的元素的个数。内建函数 len 可以返回这个字段的值。 146 | - dataqsiz:队列的大小。chan 使用一个循环队列来存放元素,循环队列很适合这种生产者 - 消费者的场景(我很好奇为什么这个字段省略 size 中的 e)。 147 | - buf:存放元素的循环队列的 buffer。 148 | - elemtype 和 elemsize:chan 中元素的类型和 size。因为 chan 一旦声明,它的元素类型是固定的,即普通类型或者指针类型,所以元素大小也是固定的。 149 | - sendx:处理发送数据的指针在 buf 中的位置。一旦接收了新的数据,指针就会加上 elemsize,移向下一个位置。buf 的总大小是 elemsize 的整数倍,而且 buf 是一个循环列表。 150 | - recvx:处理接收请求时的指针在 buf 中的位置。一旦取出数据,此指针会移动到下一个位置。 151 | - recvq:chan 是多生产者多消费者的模式,如果消费者因为没有数据可读而被阻塞了,就会被加入到 recvq 队列中。 152 | - sendq:如果生产者因为 buf 满了而阻塞,会被加入到 sendq 队列中。 153 | 154 | ### 初始化 155 | 156 | Go 在编译的时候,会根据容量的大小选择调用 makechan64,还是 makechan。 157 | 158 | 下面的代码是处理 make chan 的逻辑,它会决定是使用 makechan 还是 makechan64 来实现 chan 的初始化: 159 | 160 | ![](https://static001.geekbang.org/resource/image/e9/d7/e96f2fee0633c8157a88b8b725f702d7.png) 161 | 162 | **我们只关注 makechan 就好了,因为 makechan64 只是做了 size 检查,底层还是调用 makechan 实现的**。makechan 的目标就是生成 hchan 对象。 163 | 164 | 那么,接下来,就让我们来看一下 makechan 的主要逻辑。主要的逻辑我都加上了注释,它会根据 chan 的容量的大小和元素的类型不同,初始化不同的存储空间: 165 | 166 | ~~~go 167 | func makechan(t *chantype, size int) *hchan { 168 | elem := t.elem 169 | 170 | // 略去检查代码 171 | mem, overflow := math.MulUintptr(elem.size, uintptr(size)) 172 | 173 | // 174 | var c *hchan 175 | switch { 176 | case mem == 0: 177 | // chan的size或者元素的size是0,不必创建buf 178 | c = (*hchan)(mallocgc(hchanSize, nil, true)) 179 | c.buf = c.raceaddr() 180 | case elem.ptrdata == 0: 181 | // 元素不是指针,分配一块连续的内存给hchan数据结构和buf 182 | c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) 183 | // hchan数据结构后面紧接着就是buf 184 | c.buf = add(unsafe.Pointer(c), hchanSize) 185 | default: 186 | // 元素包含指针,那么单独分配buf 187 | c = new(hchan) 188 | c.buf = mallocgc(mem, elem, true) 189 | } 190 | 191 | // 元素大小、类型、容量都记录下来 192 | c.elemsize = uint16(elem.size) 193 | c.elemtype = elem 194 | c.dataqsiz = uint(size) 195 | lockInit(&c.lock, lockRankHchan) 196 | 197 | return c 198 | } 199 | ~~~ 200 | 201 | 最终,针对不同的容量和元素类型,这段代码分配了不同的对象来初始化 hchan 对象的字段,返回 hchan 对象。 202 | 203 | ### send 204 | 205 | Go 在编译发送数据给 chan 的时候,会把 send 语句转换成 chansend1 函数,chansend1 函数会调用 chansend,我们分段学习它的逻辑: 206 | 207 | ~~~go 208 | func chansend1(c *hchan, elem unsafe.Pointer) { 209 | chansend(c, elem, true, getcallerpc()) 210 | } 211 | func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { 212 | // 第一部分 213 | if c == nil { 214 | if !block { 215 | return false 216 | } 217 | gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2) 218 | throw("unreachable") 219 | } 220 | ...... 221 | } 222 | ~~~ 223 | 224 | 最开始,第一部分是进行判断:如果 chan 是 nil 的话,就把调用者 goroutine park(阻塞休眠), 调用者就永远被阻塞住了,所以,第 11 行是不可能执行到的代码。 225 | 226 | ~~~go 227 | // 第二部分,如果chan没有被close,并且chan满了,直接返回 228 | if !block && c.closed == 0 && full(c) { 229 | return false 230 | } 231 | ~~~ 232 | 233 | 第二部分的逻辑是当你往一个已经满了的 chan 实例发送数据时,并且想不阻塞当前调用,那么这里的逻辑是直接返回。chansend1 方法在调用 chansend 的时候设置了阻塞参数,所以不会执行到第二部分的分支里。 234 | 235 | ~~~go 236 | // 第三部分,chan已经被close的情景 237 | lock(&c.lock) // 开始加锁 238 | if c.closed != 0 { 239 | unlock(&c.lock) 240 | panic(plainError("send on closed channel")) 241 | } 242 | ~~~ 243 | 244 | 第三部分显示的是,如果 chan 已经被 close 了,再往里面发送数据的话会 panic。 245 | ~~~go 246 | // 第四部分,从接收队列中出队一个等待的receiver 247 | if sg := c.recvq.dequeue(); sg != nil { 248 | // 249 | send(c, sg, ep, func() { unlock(&c.lock) }, 3) 250 | return true 251 | } 252 | ~~~ 253 | 254 | 第四部分,如果等待队列中有等待的 receiver,那么这段代码就把它从队列中弹出,然后直接把数据交给它(通过 memmove(dst, src, t.size)),而不需要放入到 buf 中,速度可以更快一些。 255 | 256 | ~~~go 257 | 258 | // 第五部分,buf还没满 259 | if c.qcount < c.dataqsiz { 260 | qp := chanbuf(c, c.sendx) 261 | if raceenabled { 262 | raceacquire(qp) 263 | racerelease(qp) 264 | } 265 | typedmemmove(c.elemtype, qp, ep) 266 | c.sendx++ 267 | if c.sendx == c.dataqsiz { 268 | c.sendx = 0 269 | } 270 | c.qcount++ 271 | unlock(&c.lock) 272 | return true 273 | } 274 | ~~~ 275 | 276 | 第五部分说明当前没有 receiver,需要把数据放入到 buf 中,放入之后,就成功返回了。 277 | 278 | ~~~go 279 | // 第六部分,buf满。 280 | // chansend1不会进入if块里,因为chansend1的block=true 281 | if !block { 282 | unlock(&c.lock) 283 | return false 284 | } 285 | ...... 286 | ~~~ 287 | 288 | ### recv 289 | 290 | 在处理从 chan 中接收数据时,Go 会把代码转换成 chanrecv1 函数,如果要返回两个返回值,会转换成 chanrecv2,chanrecv1 函数和 chanrecv2 会调用 chanrecv。我们分段学习它的逻辑: 291 | 292 | ~~~go 293 | func chanrecv1(c *hchan, elem unsafe.Pointer) { 294 | chanrecv(c, elem, true) 295 | } 296 | func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) { 297 | _, received = chanrecv(c, elem, true) 298 | return 299 | } 300 | 301 | func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { 302 | // 第一部分,chan为nil 303 | if c == nil { 304 | if !block { 305 | return 306 | } 307 | gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2) 308 | throw("unreachable") 309 | } 310 | ~~~ 311 | 312 | chanrecv1 和 chanrecv2 传入的 block 参数的值是 true,都是阻塞方式,所以我们分析 chanrecv 的实现的时候,不考虑 block=false 的情况。 313 | 314 | 第一部分是 chan 为 nil 的情况。和 send 一样,从 nil chan 中接收(读取、获取)数据时,调用者会被永远阻塞。 315 | ~~~go 316 | // 第二部分, block=false且c为空 317 | if !block && empty(c) { 318 | ...... 319 | } 320 | ~~~ 321 | 322 | 第二部分你可以直接忽略,因为不是我们这次要分析的场景。 323 | 324 | ~~~go 325 | // 加锁,返回时释放锁 326 | lock(&c.lock) 327 | // 第三部分,c已经被close,且chan为空empty 328 | if c.closed != 0 && c.qcount == 0 { 329 | unlock(&c.lock) 330 | if ep != nil { 331 | typedmemclr(c.elemtype, ep) 332 | } 333 | return true, false 334 | } 335 | ~~~ 336 | 337 | 第三部分是 chan 已经被 close 的情况。如果 chan 已经被 close 了,并且队列中没有缓存的元素,那么返回 true、false。 338 | 339 | ~~~go 340 | // 第四部分,如果sendq队列中有等待发送的sender 341 | if sg := c.sendq.dequeue(); sg != nil { 342 | recv(c, sg, ep, func() { unlock(&c.lock) }, 3) 343 | return true, true 344 | } 345 | ~~~ 346 | 347 | 348 | 第四部分是处理 buf 满的情况。这个时候,如果是 unbuffer 的 chan,就直接将 sender 的数据复制给 receiver,否则就从队列头部读取一个值,并把这个 sender 的值加入到队列尾部。 349 | 350 | ~~~go 351 | 352 | // 第五部分, 没有等待的sender, buf中有数据 353 | if c.qcount > 0 { 354 | qp := chanbuf(c, c.recvx) 355 | if ep != nil { 356 | typedmemmove(c.elemtype, ep, qp) 357 | } 358 | typedmemclr(c.elemtype, qp) 359 | c.recvx++ 360 | if c.recvx == c.dataqsiz { 361 | c.recvx = 0 362 | } 363 | c.qcount-- 364 | unlock(&c.lock) 365 | return true, true 366 | } 367 | 368 | if !block { 369 | unlock(&c.lock) 370 | return false, false 371 | } 372 | 373 | // 第六部分, buf中没有元素,阻塞 374 | ...... 375 | 376 | ~~~ 377 | 第五部分是处理没有等待的 sender 的情况。这个是和 chansend 共用一把大锁,所以不会有并发的问题。如果 buf 有元素,就取出一个元素给 receiver。 378 | 379 | 第六部分是处理 buf 中没有元素的情况。如果没有元素,那么当前的 receiver 就会被阻塞,直到它从 sender 中接收了数据,或者是 chan 被 close,才返回。 380 | 381 | 382 | ### close 383 | 384 | 通过 close 函数,可以把 chan 关闭,编译器会替换成 closechan 方法的调用。 385 | 386 | 下面的代码是 close chan 的主要逻辑。如果 chan 为 nil,close 会 panic;如果 chan 已经 closed,再次 close 也会 panic。否则的话,如果 chan 不为 nil,chan 也没有 closed,就把等待队列中的 sender(writer)和 receiver(reader)从队列中全部移除并唤醒。 387 | 388 | 下面的代码就是 close chan 的逻辑: 389 | ~~~go 390 | func closechan(c *hchan) { 391 | if c == nil { // chan为nil, panic 392 | panic(plainError("close of nil channel")) 393 | } 394 | 395 | lock(&c.lock) 396 | if c.closed != 0 {// chan已经closed, panic 397 | unlock(&c.lock) 398 | panic(plainError("close of closed channel")) 399 | } 400 | 401 | c.closed = 1 402 | 403 | var glist gList 404 | 405 | // 释放所有的reader 406 | for { 407 | sg := c.recvq.dequeue() 408 | ...... 409 | gp := sg.g 410 | ...... 411 | glist.push(gp) 412 | } 413 | 414 | // 释放所有的writer (它们会panic) 415 | for { 416 | sg := c.sendq.dequeue() 417 | ...... 418 | gp := sg.g 419 | ...... 420 | glist.push(gp) 421 | } 422 | unlock(&c.lock) 423 | 424 | for !glist.empty() { 425 | gp := glist.pop() 426 | gp.schedlink = 0 427 | goready(gp, 3) 428 | } 429 | } 430 | ~~~ 431 | 432 | 433 | 掌握了 Channel 的基本用法和实现原理,下面我再来给你讲一讲容易犯的错误。你一定要认真看,毕竟,这些可都是帮助你避坑的。 434 | 435 | ### 使用 Channel 容易犯的错误 436 | 437 | 根据 2019 年第一篇全面分析 Go 并发 Bug 的[论文](https://songlh.github.io/paper/go-study.pdf),那些知名的 Go 项目中使用 Channel 所犯的 Bug 反而比传统的并发原语的 Bug 还要多。主要有两个原因:一个是,Channel 的概念还比较新,程序员还不能很好地掌握相应的使用方法和最佳实践;第二个是,Channel 有时候比传统的并发原语更复杂,使用起来很容易顾此失彼。 438 | 439 | **使用 Channel 最常见的错误是 panic 和 goroutine 泄漏。** 440 | 441 | 首先,我们来总结下会 panic 的情况,总共有 3 种: 442 | 443 | 1. close 为 nil 的 chan; 444 | 2. send 已经 close 的 chan; 445 | 3. close 已经 close 的 chan。 446 | 447 | 448 | goroutine 泄漏的问题也很常见,下面的代码也是一个实际项目中的例子: 449 | 450 | ~~~go 451 | func process(timeout time.Duration) bool { 452 | ch := make(chan bool) 453 | 454 | go func() { 455 | // 模拟处理耗时的业务 456 | time.Sleep((timeout + time.Second)) 457 | ch <- true // block 458 | fmt.Println("exit goroutine") 459 | }() 460 | select { 461 | case result := <-ch: 462 | return result 463 | case <-time.After(timeout): 464 | return false 465 | } 466 | } 467 | ~~~ 468 | 469 | 在这个例子中,process 函数会启动一个 goroutine,去处理需要长时间处理的业务,处理完之后,会发送 true 到 chan 中,目的是通知其它等待的 goroutine,可以继续处理了。 470 | 471 | 我们来看一下第 10 行到第 15 行,主 goroutine 接收到任务处理完成的通知,或者超时后就返回了。这段代码有问题吗? 472 | 473 | 474 | 如果发生超时,process 函数就返回了,这就会导致 unbuffered 的 chan 从来就没有被读取。我们知道,unbuffered chan 必须等 reader 和 writer 都准备好了才能交流,否则就会阻塞。超时导致未读,结果就是子 goroutine 就阻塞在第 7 行永远结束不了,进而导致 goroutine 泄漏。 475 | 476 | 解决这个 Bug 的办法很简单,就是将 unbuffered chan 改成容量为 1 的 chan,这样第 7 行就不会被阻塞了 477 | 478 | 479 | Go 的开发者极力推荐使用 Channel,不过,这两年,大家意识到,Channel 并不是处理并发问题的“银弹”,有时候使用并发原语更简单,而且不容易出错。所以,我给你提供一套选择的方法: 480 | 481 | 1. 共享资源的并发访问使用传统并发原语; 482 | 2. 复杂的任务编排和消息传递使用 Channel; 483 | 3. 消息通知机制使用 Channel,除非只想 signal 一个 goroutine,才使用 Cond; 484 | 4. 简单等待所有任务的完成用 WaitGroup,也有 Channel 的推崇者用 Channel,都可以; 485 | 5. 需要和 Select 语句结合,使用 Channel;需要和超时配合时,使用 Channel 和 Context。 486 | 487 | ## 总结 488 | 489 | chan 的值和状态有多种情况,而不同的操作(send、recv、close)又可能得到不同的结果,这是使用 chan 类型时经常让人困惑的地方。 490 | 491 | 492 | 为了帮助你快速地了解不同状态下各种操作的结果,我总结了一个表格,你一定要特别关注下那些 panic 的情况,另外还要掌握那些会 block 的场景,它们是导致死锁或者 goroutine 泄露的罪魁祸首。 493 | 494 | 还有一个值得注意的点是,只要一个 chan 还有未读的数据,即使把它 close 掉,你还是可以继续把这些未读的数据消费完,之后才是读取零值数据。 495 | 496 | ![](https://static001.geekbang.org/resource/image/51/98/5108954ea36559860e5e5aaa42b2f998.jpg) 497 | 498 | 思考题 499 | 1. 有一道经典的使用 Channel 进行任务编排的题,你可以尝试做一下:有四个 goroutine,编号为 1、2、3、4。每秒钟会有一个 goroutine 打印出它自己的编号,要求你编写一个程序,让输出的编号总是按照 1、2、3、4、1、2、3、4、……的顺序打印出来。 500 | 2. chan T 是否可以给 <- chan T 和 chan<- T 类型的变量赋值?反过来呢? -------------------------------------------------------------------------------- /3.Channel/3.01:Channel:另辟蹊径,解决并发问题/03.01.思考题.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type NumberChan struct { 9 | Ch chan int 10 | ChannelNumber int 11 | } 12 | 13 | func (nch *NumberChan) SendNotify() { 14 | go func() { 15 | nch.Ch <- nch.ChannelNumber 16 | }() 17 | } 18 | 19 | func (nch *NumberChan) PrintInfo() { 20 | fmt.Println(nch.ChannelNumber) 21 | time.Sleep(time.Second) 22 | } 23 | 24 | func NewNumberChan(seq int) *NumberChan { 25 | nch := NumberChan{ 26 | Ch: make(chan int), 27 | ChannelNumber: seq, 28 | } 29 | return &nch 30 | } 31 | 32 | func main() { 33 | var ( 34 | nch1 = NewNumberChan(1) 35 | nch2 = NewNumberChan(2) 36 | nch3 = NewNumberChan(3) 37 | nch4 = NewNumberChan(4) 38 | ) 39 | go func() { 40 | nch1.SendNotify() 41 | }() 42 | for { 43 | select { 44 | case <-nch1.Ch: 45 | nch1.PrintInfo() 46 | nch2.SendNotify() 47 | case <-nch2.Ch: 48 | nch2.PrintInfo() 49 | nch3.SendNotify() 50 | case <-nch3.Ch: 51 | nch3.PrintInfo() 52 | nch4.SendNotify() 53 | case <-nch4.Ch: 54 | nch4.PrintInfo() 55 | nch1.SendNotify() 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /3.Channel/3.02:Channel:透过代码看典型的应用模式/02.01.反射动态处理chan.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | /** 9 | 通过一个循环 10 次的 for 循环执行 reflect.Select, 10 | 这个方法会从 cases 中选择一个 case 执行。 11 | 第一次肯定是 send case, 12 | 因为此时 chan 还没有元素,recv 还不可用。等 chan 中有了数据以后,recv case 就可以被选择了。这样,你就可以处理不定数量的 chan 了。 13 | */ 14 | func main() { 15 | var ch1 = make(chan int, 10) 16 | var ch2 = make(chan int, 10) 17 | 18 | // 创建SelectCase 19 | var cases = createCases(ch1, ch2) 20 | 21 | // 执行10次select 22 | for i := 0; i < 10; i++ { 23 | chosen, recv, ok := reflect.Select(cases) 24 | if recv.IsValid() { // recv case 25 | fmt.Println("recv:", cases[chosen].Dir, recv, ok) 26 | } else { // send case 27 | fmt.Println("send:", cases[chosen].Dir, ok) 28 | } 29 | } 30 | } 31 | 32 | //createCases 函数分别为每个 chan 生成了 recv case 和 send case, 33 | //并返回一个 reflect.SelectCase 数组。 34 | func createCases(chs ...chan int) []reflect.SelectCase { 35 | var cases []reflect.SelectCase 36 | 37 | // 创建recv case 38 | for _, ch := range chs { 39 | cases = append(cases, reflect.SelectCase{ 40 | Dir: reflect.SelectRecv, 41 | Chan: reflect.ValueOf(ch), 42 | }) 43 | } 44 | 45 | // 创建send case 46 | for i, ch := range chs { 47 | v := reflect.ValueOf(i) 48 | cases = append(cases, reflect.SelectCase{ 49 | Dir: reflect.SelectSend, 50 | Chan: reflect.ValueOf(ch), 51 | Send: v, 52 | }) 53 | } 54 | 55 | return cases 56 | } 57 | -------------------------------------------------------------------------------- /3.Channel/3.02:Channel:透过代码看典型的应用模式/04.00-Channel:透过代码看典型的应用模式.md: -------------------------------------------------------------------------------- 1 | # Channel:透过代码看典型的应用模式 2 | 3 | 一个知识点:通过反射的方式执行 select 语句,在处理很多的 case clause,尤其是不定长的 case clause 的时候,非常有用。而且,在后面介绍任务编排的实现时,我也会采用这种方法,所以,我先带你具体学习下 Channel 的反射用法。 4 | 5 | ## 使用反射操作 Channel 6 | 7 | select 语句可以处理 chan 的 send 和 recv,send 和 recv 都可以作为 case clause。如果我们同时处理两个 chan,就可以写成下面的样子: 8 | 9 | ~~~go 10 | select { 11 | case v := <-ch1: 12 | fmt.Println(v) 13 | case v := <-ch2: 14 | fmt.Println(v) 15 | } 16 | ~~~ 17 | 18 | 如果需要处理三个 chan,你就可以再添加一个 case clause,用它来处理第三个 chan。可是,如果要处理 100 个 chan 呢?一万个 chan 呢? 19 | 20 | 或者是,chan 的数量在编译的时候是不定的,在运行的时候需要处理一个 slice of chan,这个时候,也没有办法在编译前写成字面意义的 select。那该怎么办? 21 | 22 | 23 | 这个时候,就要“祭”出我们的反射大法了。 24 | 25 | 通过 reflect.Select 函数,你可以将一组运行时的 case clause 传入,当作参数执行。Go 的 select 是伪随机的,它可以在执行的 case 中随机选择一个 case,并把选择的这个 case 的索引(chosen)返回,如果没有可用的 case 返回,会返回一个 bool 类型的返回值,这个返回值用来表示是否有 case 成功被选择。如果是 recv case,还会返回接收的元素。Select 的方法签名如下: 26 | ~~~go 27 | func Select(cases []SelectCase) (chosen int, recv Value, recvOK bool) 28 | ~~~ 29 | 30 | 下面,我来借助一个例子,来演示一下,动态处理两个 chan 的情形。因为这样的方式可以动态处理 case 数据,所以,你可以传入几百几千几万的 chan,这就解决了不能动态处理 n 个 chan 的问题。 31 | 32 | 首先,createCases 函数分别为每个 chan 生成了 recv case 和 send case,并返回一个 reflect.SelectCase 数组。 33 | 34 | 然后,通过一个循环 10 次的 for 循环执行 reflect.Select,这个方法会从 cases 中选择一个 case 执行。第一次肯定是 send case,因为此时 chan 还没有元素,recv 还不可用。等 chan 中有了数据以后,recv case 就可以被选择了。这样,你就可以处理不定数量的 chan 了。 35 | 36 | ~~~go 37 | func main() { 38 | var ch1 = make(chan int, 10) 39 | var ch2 = make(chan int, 10) 40 | 41 | // 创建SelectCase 42 | var cases = createCases(ch1, ch2) 43 | 44 | // 执行10次select 45 | for i := 0; i < 10; i++ { 46 | chosen, recv, ok := reflect.Select(cases) 47 | if recv.IsValid() { // recv case 48 | fmt.Println("recv:", cases[chosen].Dir, recv, ok) 49 | } else { // send case 50 | fmt.Println("send:", cases[chosen].Dir, ok) 51 | } 52 | } 53 | } 54 | 55 | func createCases(chs ...chan int) []reflect.SelectCase { 56 | var cases []reflect.SelectCase 57 | 58 | 59 | // 创建recv case 60 | for _, ch := range chs { 61 | cases = append(cases, reflect.SelectCase{ 62 | Dir: reflect.SelectRecv, 63 | Chan: reflect.ValueOf(ch), 64 | }) 65 | } 66 | 67 | // 创建send case 68 | for i, ch := range chs { 69 | v := reflect.ValueOf(i) 70 | cases = append(cases, reflect.SelectCase{ 71 | Dir: reflect.SelectSend, 72 | Chan: reflect.ValueOf(ch), 73 | Send: v, 74 | }) 75 | } 76 | 77 | return cases 78 | } 79 | ~~~ 80 | 81 | ## 典型的应用场景 82 | 83 | 了解刚刚的反射用法,我们就解决了今天的基础知识问题,接下来,我就带你具体学习下 Channel 的应用场景。 84 | 85 | 首先来看消息交流。 86 | 87 | ### 消息交流 88 | 89 | 从 chan 的内部实现看,它是以一个循环队列的方式存放数据,所以,它有时候也会被当成线程安全的队列和 buffer 使用。一个 goroutine 可以安全地往 Channel 中塞数据,另外一个 goroutine 可以安全地从 Channel 中读取数据,goroutine 就可以安全地实现信息交流了。 90 | 91 | 我们来看几个例子。 92 | 93 | 第一个例子是 worker 池的例子。Marcio Castilho 在[ 使用 Go 每分钟处理百万请求](http://marcio.io/2015/07/handling-1-million-requests-per-minute-with-golang/) 这篇文章中,就介绍了他们应对大并发请求的设计。他们将用户的请求放在一个 chan Job 中,这个 chan Job 就相当于一个待处理任务队列。除此之外,还有一个 chan chan Job 队列,用来存放可以处理任务的 worker 的缓存队列。 94 | 95 | dispatcher 会把待处理任务队列中的任务放到一个可用的缓存队列中,worker 会一直处理它的缓存队列。通过使用 Channel,实现了一个 worker 池的任务处理中心,并且解耦了前端 HTTP 请求处理和后端任务处理的逻辑。 96 | 97 | 我在讲 Pool 的时候,提到了一些第三方实现的 worker 池,它们全部都是通过 Channel 实现的,这是 Channel 的一个常见的应用场景。worker 池的生产者和消费者的消息交流都是通过 Channel 实现的。 98 | 99 | 第二个例子是 etcd 中的 node 节点的实现,包含大量的 chan 字段,比如 recvc 是消息处理的 chan,待处理的 protobuf 消息都扔到这个 chan 中,node 有一个专门的 run goroutine 负责处理这些消息。 100 | 101 | ![](https://static001.geekbang.org/resource/image/06/a4/0643503a1yy135b476d41345d71766a4.png) 102 | 103 | ### 数据传递 104 | 105 | “击鼓传花”的游戏很多人都玩过,花从一个人手中传给另外一个人,就有点类似流水线的操作。这个花就是数据,花在游戏者之间流转,这就类似编程中的数据传递。 106 | 107 | 还记得上节课我给你留了一道任务编排的题吗?其实它就可以用数据传递的方式实现。 108 | 109 | >有 4 个 goroutine,编号为 1、2、3、4。每秒钟会有一个 goroutine 打印出它自己的编号,要求你编写程序,让输出的编号总是按照 1、2、3、4、1、2、3、4……这个顺序打印出来。 110 | 111 | 为了实现顺序的数据传递,我们可以定义一个令牌的变量,谁得到令牌,谁就可以打印一次自己的编号,同时将令牌传递给下一个 goroutine,我们尝试使用 chan 来实现,可以看下下面的代码。 112 | 113 | ~~~go 114 | type Token struct{} 115 | 116 | func newWorker(id int, ch chan Token, nextCh chan Token) { 117 | for { 118 | token := <-ch // 取得令牌 119 | fmt.Println((id + 1)) // id从1开始 120 | time.Sleep(time.Second) 121 | nextCh <- token 122 | } 123 | } 124 | func main() { 125 | chs := []chan Token{make(chan Token), make(chan Token), make(chan Token), make(chan Token)} 126 | 127 | // 创建4个worker 128 | for i := 0; i < 4; i++ { 129 | go newWorker(i, chs[i], chs[(i+1)%4]) 130 | } 131 | 132 | //首先把令牌交给第一个worker 133 | chs[0] <- struct{}{} 134 | 135 | select {} 136 | } 137 | ~~~ 138 | 139 | 我来给你具体解释下这个实现方式。 140 | 141 | 首先,我们定义一个令牌类型(Token),接着定义一个创建 worker 的方法,这个方法会从它自己的 chan 中读取令牌。哪个 goroutine 取得了令牌,就可以打印出自己编号,因为需要每秒打印一次数据,所以,我们让它休眠 1 秒后,再把令牌交给它的下家。 142 | 143 | 144 | 接着,在第 16 行启动每个 worker 的 goroutine,并在第 20 行将令牌先交给第一个 worker。 145 | 146 | 如果你运行这个程序,就会在命令行中看到每一秒就会输出一个编号,而且编号是以 1、2、3、4 这样的顺序输出的。 147 | 148 | 这类场景有一个特点,就是当前持有数据的 goroutine 都有一个信箱,信箱使用 chan 实现,goroutine 只需要关注自己的信箱中的数据,处理完毕后,就把结果发送到下一家的信箱中。 149 | 150 | ### 信号通知 151 | 152 | chan 类型有这样一个特点:chan 如果为空,那么,receiver 接收数据的时候就会阻塞等待,直到 chan 被关闭或者有新的数据到来。利用这个机制,我们可以实现 wait/notify 的设计模式。 153 | 154 | 传统的并发原语 Cond 也能实现这个功能。但是,Cond 使用起来比较复杂,容易出错,而使用 chan 实现 wait/notify 模式,就方便多了。 155 | 156 | 除了正常的业务处理时的 wait/notify,我们经常碰到的一个场景,就是程序关闭的时候,我们需要在退出之前做一些清理(doCleanup 方法)的动作。这个时候,我们经常要使用 chan。 157 | 158 | 比如,使用 chan 实现程序的 graceful shutdown,在退出之前执行一些连接关闭、文件 close、缓存落盘等一些动作。 159 | 160 | ~~~go 161 | func main() { 162 | go func() { 163 | ...... // 执行业务处理 164 | }() 165 | 166 | // 处理CTRL+C等中断信号 167 | termChan := make(chan os.Signal) 168 | signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM) 169 | <-termChan 170 | 171 | // 执行退出之前的清理动作 172 | doCleanup() 173 | 174 | fmt.Println("优雅退出") 175 | } 176 | ~~~ 177 | 178 | 有时候,doCleanup 可能是一个很耗时的操作,比如十几分钟才能完成,如果程序退出需要等待这么长时间,用户是不能接受的,所以,在实践中,我们需要设置一个最长的等待时间。只要超过了这个时间,程序就不再等待,可以直接退出。所以,退出的时候分为两个阶段: 179 | 180 | 1. closing,代表程序退出,但是清理工作还没做; 181 | 2. closed,代表清理工作已经做完。 182 | 183 | 所以,上面的例子可以改写如下: 184 | 185 | ~~~go 186 | func main() { 187 | var closing = make(chan struct{}) 188 | var closed = make(chan struct{}) 189 | 190 | go func() { 191 | // 模拟业务处理 192 | for { 193 | select { 194 | case <-closing: 195 | return 196 | default: 197 | // ....... 业务计算 198 | time.Sleep(100 * time.Millisecond) 199 | } 200 | } 201 | }() 202 | 203 | // 处理CTRL+C等中断信号 204 | termChan := make(chan os.Signal) 205 | signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM) 206 | <-termChan 207 | 208 | close(closing) 209 | // 执行退出之前的清理动作 210 | go doCleanup(closed) 211 | 212 | select { 213 | case <-closed: 214 | case <-time.After(time.Second): 215 | fmt.Println("清理超时,不等了") 216 | } 217 | fmt.Println("优雅退出") 218 | } 219 | 220 | func doCleanup(closed chan struct{}) { 221 | time.Sleep((time.Minute)) 222 | close(closed) 223 | } 224 | ~~~ 225 | 226 | ### 锁 227 | 228 | 使用 chan 也可以实现互斥锁。 229 | 230 | 在 chan 的内部实现中,就有一把互斥锁保护着它的所有字段。从外在表现上,chan 的发送和接收之间也存在着 happens-before 的关系,保证元素放进去之后,receiver 才能读取到(关于 happends-before 的关系,是指事件发生的先后顺序关系,我会在下一讲详细介绍,这里你只需要知道它是一种描述事件先后顺序的方法)。 231 | 232 | 要想使用 chan 实现互斥锁,至少有两种方式。一种方式是先初始化一个 capacity 等于 1 的 Channel,然后再放入一个元素。这个元素就代表锁,谁取得了这个元素,就相当于获取了这把锁。另一种方式是,先初始化一个 capacity 等于 1 的 Channel,它的“空槽”代表锁,谁能成功地把元素发送到这个 Channel,谁就获取了这把锁。 233 | 234 | 这是使用 Channel 实现锁的两种不同实现方式,我重点介绍下第一种。理解了这种实现方式,第二种方式也就很容易掌握了,我就不多说了。 235 | 236 | ~~~go 237 | // 使用chan实现互斥锁 238 | type Mutex struct { 239 | ch chan struct{} 240 | } 241 | 242 | // 使用锁需要初始化 243 | func NewMutex() *Mutex { 244 | mu := &Mutex{make(chan struct{}, 1)} 245 | mu.ch <- struct{}{} 246 | return mu 247 | } 248 | 249 | // 请求锁,直到获取到 250 | func (m *Mutex) Lock() { 251 | <-m.ch 252 | } 253 | 254 | // 解锁 255 | func (m *Mutex) Unlock() { 256 | select { 257 | case m.ch <- struct{}{}: 258 | default: 259 | panic("unlock of unlocked mutex") 260 | } 261 | } 262 | 263 | // 尝试获取锁 264 | func (m *Mutex) TryLock() bool { 265 | select { 266 | case <-m.ch: 267 | return true 268 | default: 269 | } 270 | return false 271 | } 272 | 273 | // 加入一个超时的设置 274 | func (m *Mutex) LockTimeout(timeout time.Duration) bool { 275 | timer := time.NewTimer(timeout) 276 | select { 277 | case <-m.ch: 278 | timer.Stop() 279 | return true 280 | case <-timer.C: 281 | } 282 | return false 283 | } 284 | 285 | // 锁是否已被持有 286 | func (m *Mutex) IsLocked() bool { 287 | return len(m.ch) == 0 288 | } 289 | 290 | 291 | func main() { 292 | m := NewMutex() 293 | ok := m.TryLock() 294 | fmt.Printf("locked v %v\n", ok) 295 | ok = m.TryLock() 296 | fmt.Printf("locked %v\n", ok) 297 | } 298 | ~~~ 299 | 300 | 301 | 你可以用 buffer 等于 1 的 chan 实现互斥锁,在初始化这个锁的时候往 Channel 中先塞入一个元素,谁把这个元素取走,谁就获取了这把锁,把元素放回去,就是释放了锁。元素在放回到 chan 之前,不会有 goroutine 能从 chan 中取出元素的,这就保证了互斥性。 302 | 303 | 在这段代码中,还有一点需要我们注意下:利用 select+chan 的方式,很容易实现 TryLock、Timeout 的功能。具体来说就是,在 select 语句中,我们可以使用 default 实现 TryLock,使用一个 Timer 来实现 Timeout 的功能。 304 | 305 | ### 任务编排 306 | 307 | 前面所说的消息交流的场景是一个特殊的任务编排的场景,这个“击鼓传花”的模式也被称为流水线模式。 308 | 309 | 我们学习了 WaitGroup,我们可以利用它实现等待模式:启动一组 goroutine 执行任务,然后等待这些任务都完成。其实,我们也可以使用 chan 实现 WaitGroup 的功能。这个比较简单,我就不举例子了,接下来我介绍几种更复杂的编排模式。 310 | 311 | 这里的编排既指安排 goroutine 按照指定的顺序执行,也指多个 chan 按照指定的方式组合处理的方式。goroutine 的编排类似“击鼓传花”的例子,我们通过编排数据在 chan 之间的流转,就可以控制 goroutine 的执行。接下来,我来重点介绍下多个 chan 的编排方式,总共 5 种,分别是 Or-Done 模式、扇入模式、扇出模式、Stream 和 map-reduce。 312 | 313 | ### Or-Done 模式 314 | 315 | 首先来看 Or-Done 模式。Or-Done 模式是信号通知模式中更宽泛的一种模式。这里提到了“信号通知模式”,我先来解释一下。 316 | 317 | 我们会使用“信号通知”实现某个任务执行完成后的通知机制,在实现时,我们为这个任务定义一个类型为 chan struct{}类型的 done 变量,等任务结束后,我们就可以 close 这个变量,然后,其它 receiver 就会收到这个通知。 318 | 319 | 这是有一个任务的情况,如果有多个任务,只要有任意一个任务执行完,我们就想获得这个信号,这就是 Or-Done 模式。 320 | 321 | 比如,你发送同一个请求到多个微服务节点,只要任意一个微服务节点返回结果,就算成功,这个时候,就可以参考下面的实现: 322 | 323 | ~~~go 324 | func or(channels ...<-chan interface{}) <-chan interface{} { 325 | // 特殊情况,只有零个或者1个chan 326 | switch len(channels) { 327 | case 0: 328 | return nil 329 | case 1: 330 | return channels[0] 331 | } 332 | 333 | orDone := make(chan interface{}) 334 | go func() { 335 | defer close(orDone) 336 | 337 | switch len(channels) { 338 | case 2: // 2个也是一种特殊情况 339 | select { 340 | case <-channels[0]: 341 | case <-channels[1]: 342 | } 343 | default: //超过两个,二分法递归处理 344 | m := len(channels) / 2 345 | select { 346 | case <-or(channels[:m]...): 347 | case <-or(channels[m:]...): 348 | } 349 | } 350 | }() 351 | 352 | return orDone 353 | } 354 | ~~~ 355 | 356 | 我们可以写一个测试程序测试它: 357 | 358 | ~~~go 359 | func sig(after time.Duration) <-chan interface{} { 360 | c := make(chan interface{}) 361 | go func() { 362 | defer close(c) 363 | time.Sleep(after) 364 | }() 365 | return c 366 | } 367 | 368 | 369 | func main() { 370 | start := time.Now() 371 | 372 | <-or( 373 | sig(10*time.Second), 374 | sig(20*time.Second), 375 | sig(30*time.Second), 376 | sig(40*time.Second), 377 | sig(50*time.Second), 378 | sig(01*time.Minute), 379 | ) 380 | 381 | fmt.Printf("done after %v", time.Since(start)) 382 | } 383 | ~~~ 384 | 385 | 这里的实现使用了一个巧妙的方式,**当 chan 的数量大于 2 时,使用递归的方式等待信号。** 386 | 387 | 在 chan 数量比较多的情况下,递归并不是一个很好的解决方式,根据这一讲最开始介绍的反射的方法,我们也可以实现 Or-Done 模式: 388 | 389 | 390 | ~~~go 391 | func or(channels ...<-chan interface{}) <-chan interface{} { 392 | //特殊情况,只有0个或者1个 393 | switch len(channels) { 394 | case 0: 395 | return nil 396 | case 1: 397 | return channels[0] 398 | } 399 | 400 | orDone := make(chan interface{}) 401 | go func() { 402 | defer close(orDone) 403 | // 利用反射构建SelectCase 404 | var cases []reflect.SelectCase 405 | for _, c := range channels { 406 | cases = append(cases, reflect.SelectCase{ 407 | Dir: reflect.SelectRecv, 408 | Chan: reflect.ValueOf(c), 409 | }) 410 | } 411 | 412 | // 随机选择一个可用的case 413 | reflect.Select(cases) 414 | }() 415 | 416 | 417 | return orDone 418 | } 419 | ~~~ 420 | 421 | 这是递归和反射两种方法实现 Or-Done 模式的代码。反射方式避免了深层递归的情况,可以处理有大量 chan 的情况。其实最笨的一种方法就是为每一个 Channel 启动一个 goroutine,不过这会启动非常多的 goroutine,太多的 goroutine 会影响性能,所以不太常用。你只要知道这种用法就行了,不用重点掌握。 422 | 423 | ### 扇入模式 424 | 425 | 426 | 扇入借鉴了数字电路的概念,它定义了单个逻辑门能够接受的数字信号输入最大量的术语。一个逻辑门可以有多个输入,一个输出。 427 | 428 | 在软件工程中,模块的扇入是指有多少个上级模块调用它。而对于我们这里的 Channel 扇入模式来说,就是指有多个源 Channel 输入、一个目的 Channel 输出的情况。扇入比就是源 Channel 数量比 1 429 | 430 | 每个源 Channel 的元素都会发送给目标 Channel,相当于目标 Channel 的 receiver 只需要监听目标 Channel,就可以接收所有发送给源 Channel 的数据。 431 | 432 | 扇入模式也可以使用反射、递归,或者是用最笨的每个 goroutine 处理一个 Channel 的方式来实现。 433 | 434 | 435 | 这里我列举下递归和反射的方式,帮你加深一下对这个技巧的理解。 436 | 437 | 反射的代码比较简短,易于理解,主要就是构造出 SelectCase slice,然后传递给 reflect.Select 语句。 438 | 439 | 440 | ~~~go 441 | func fanInReflect(chans ...<-chan interface{}) <-chan interface{} { 442 | out := make(chan interface{}) 443 | go func() { 444 | defer close(out) 445 | // 构造SelectCase slice 446 | var cases []reflect.SelectCase 447 | for _, c := range chans { 448 | cases = append(cases, reflect.SelectCase{ 449 | Dir: reflect.SelectRecv, 450 | Chan: reflect.ValueOf(c), 451 | }) 452 | } 453 | 454 | // 循环,从cases中选择一个可用的 455 | for len(cases) > 0 { 456 | i, v, ok := reflect.Select(cases) 457 | if !ok { // 此channel已经close 458 | cases = append(cases[:i], cases[i+1:]...) 459 | continue 460 | } 461 | out <- v.Interface() 462 | } 463 | }() 464 | return out 465 | } 466 | ~~~ 467 | 468 | 469 | 递归模式也是在 Channel 大于 2 时,采用二分法递归 merge。 470 | 471 | ~~~go 472 | func fanInRec(chans ...<-chan interface{}) <-chan interface{} { 473 | switch len(chans) { 474 | case 0: 475 | c := make(chan interface{}) 476 | close(c) 477 | return c 478 | case 1: 479 | return chans[0] 480 | case 2: 481 | return mergeTwo(chans[0], chans[1]) 482 | default: 483 | m := len(chans) / 2 484 | return mergeTwo( 485 | fanInRec(chans[:m]...), 486 | fanInRec(chans[m:]...)) 487 | } 488 | } 489 | ~~~ 490 | 491 | 这里有一个 mergeTwo 的方法,是将两个 Channel 合并成一个 Channel,是扇入形式的一种特例(只处理两个 Channel)。 下面我来借助一段代码帮你理解下这个方法。 492 | 493 | ~~~go 494 | 495 | func mergeTwo(a, b <-chan interface{}) <-chan interface{} { 496 | c := make(chan interface{}) 497 | go func() { 498 | defer close(c) 499 | for a != nil || b != nil { //只要还有可读的chan 500 | select { 501 | case v, ok := <-a: 502 | if !ok { // a 已关闭,设置为nil 503 | a = nil 504 | continue 505 | } 506 | c <- v 507 | case v, ok := <-b: 508 | if !ok { // b 已关闭,设置为nil 509 | b = nil 510 | continue 511 | } 512 | c <- v 513 | } 514 | } 515 | }() 516 | return c 517 | } 518 | 519 | ~~~ 520 | 521 | 522 | ### 扇出模式 523 | 524 | 有扇入模式,就有扇出模式,扇出模式是和扇入模式相反的。 525 | 526 | 扇出模式只有一个输入源 Channel,有多个目标 Channel,扇出比就是 1 比目标 Channel 数的值,经常用在设计模式中的观察者模式中(观察者设计模式定义了对象间的一种一对多的组合关系。这样一来,一个对象的状态发生变化时,所有依赖于它的对象都会得到通知并自动刷新)。在观察者模式中,数据变动后,多个观察者都会收到这个变更信号。 527 | 528 | 下面是一个扇出模式的实现。从源 Channel 取出一个数据后,依次发送给目标 Channel。在发送给目标 Channel 的时候,可以同步发送,也可以异步发送: 529 | 530 | ~~~go 531 | func fanOut(ch <-chan interface{}, out []chan interface{}, async bool) { 532 | go func() { 533 | defer func() { //退出时关闭所有的输出chan 534 | for i := 0; i < len(out); i++ { 535 | close(out[i]) 536 | } 537 | }() 538 | 539 | for v := range ch { // 从输入chan中读取数据 540 | v := v 541 | for i := 0; i < len(out); i++ { 542 | i := i 543 | if async { //异步 544 | go func() { 545 | out[i] <- v // 放入到输出chan中,异步方式 546 | }() 547 | } else { 548 | out[i] <- v // 放入到输出chan中,同步方式 549 | } 550 | } 551 | } 552 | }() 553 | } 554 | ~~~ 555 | 556 | 你也可以尝试使用反射的方式来实现,我就不列相关代码了,希望你课后可以自己思考下。 557 | 558 | ### Stream 559 | 560 | 561 | 这里我来介绍一种把 Channel 当作流式管道使用的方式,也就是把 Channel 看作流(Stream),提供跳过几个元素,或者是只取其中的几个元素等方法。 562 | 563 | 首先,我们提供创建流的方法。这个方法把一个数据 slice 转换成流: 564 | 565 | ~~~go 566 | func asStream(done <-chan struct{}, values ...interface{}) <-chan interface{} { 567 | s := make(chan interface{}) //创建一个unbuffered的channel 568 | go func() { // 启动一个goroutine,往s中塞数据 569 | defer close(s) // 退出时关闭chan 570 | for _, v := range values { // 遍历数组 571 | select { 572 | case <-done: 573 | return 574 | case s <- v: // 将数组元素塞入到chan中 575 | } 576 | } 577 | }() 578 | return s 579 | } 580 | ~~~ 581 | 582 | 583 | 流创建好以后,该咋处理呢?下面我再给你介绍下实现流的方法。 584 | 585 | 1. takeN:只取流中的前 n 个数据; 586 | 2. takeFn:筛选流中的数据,只保留满足条件的数据; 587 | 3. takeWhile:只取前面满足条件的数据,一旦不满足条件,就不再取; 588 | 4. skipN:跳过流中前几个数据; 589 | 5. skipFn:跳过满足条件的数据; 590 | 6. skipWhile:跳过前面满足条件的数据,一旦不满足条件,当前这个元素和以后的元素都会输出给 Channel 的 receiver。 591 | 592 | 这些方法的实现很类似,我们以 takeN 为例来具体解释一下。 593 | 594 | ~~~go 595 | func takeN(done <-chan struct{}, valueStream <-chan interface{}, num int) <-chan interface{} { 596 | takeStream := make(chan interface{}) // 创建输出流 597 | go func() { 598 | defer close(takeStream) 599 | for i := 0; i < num; i++ { // 只读取前num个元素 600 | select { 601 | case <-done: 602 | return 603 | case takeStream <- <-valueStream: //从输入流中读取元素 604 | } 605 | } 606 | }() 607 | return takeStream 608 | } 609 | ~~~ 610 | 611 | 612 | ### map-reduce 613 | 614 | map-reduce 是一种处理数据的方式,最早是由 Google 公司研究提出的一种面向大规模数据处理的并行计算模型和方法,开源的版本是 hadoop,前几年比较火。 615 | 616 | 不过,我要讲的并不是分布式的 map-reduce,而是单机单进程的 map-reduce 方法。 617 | 618 | 619 | map-reduce 分为两个步骤,第一步是映射(map),处理队列中的数据,第二步是规约(reduce),把列表中的每一个元素按照一定的处理方式处理成结果,放入到结果队列中。 620 | 621 | 就像做汉堡一样,map 就是单独处理每一种食材,reduce 就是从每一份食材中取一部分,做成一个汉堡。 622 | 623 | 我们先来看下 map 函数的处理逻辑: 624 | 625 | ~~~go 626 | func mapChan(in <-chan interface{}, fn func(interface{}) interface{}) <-chan interface{} { 627 | out := make(chan interface{}) //创建一个输出chan 628 | if in == nil { // 异常检查 629 | close(out) 630 | return out 631 | } 632 | 633 | go func() { // 启动一个goroutine,实现map的主要逻辑 634 | defer close(out) 635 | for v := range in { // 从输入chan读取数据,执行业务操作,也就是map操作 636 | out <- fn(v) 637 | } 638 | }() 639 | 640 | return out 641 | } 642 | ~~~ 643 | reduce 函数的处理逻辑如下: 644 | ~~~go 645 | func reduce(in <-chan interface{}, fn func(r, v interface{}) interface{}) interface{} { 646 | if in == nil { // 异常检查 647 | return nil 648 | } 649 | 650 | out := <-in // 先读取第一个元素 651 | for v := range in { // 实现reduce的主要逻辑 652 | out = fn(out, v) 653 | } 654 | 655 | return out 656 | } 657 | ~~~ 658 | 659 | 我们可以写一个程序,这个程序使用 map-reduce 模式处理一组整数,map 函数就是为每个整数乘以 10,reduce 函数就是把 map 处理的结果累加起来: 660 | 661 | ~~~go 662 | // 生成一个数据流 663 | func asStream(done <-chan struct{}) <-chan interface{} { 664 | s := make(chan interface{}) 665 | values := []int{1, 2, 3, 4, 5} 666 | go func() { 667 | defer close(s) 668 | for _, v := range values { // 从数组生成 669 | select { 670 | case <-done: 671 | return 672 | case s <- v: 673 | } 674 | } 675 | }() 676 | return s 677 | } 678 | 679 | func main() { 680 | in := asStream(nil) 681 | 682 | // map操作: 乘以10 683 | mapFn := func(v interface{}) interface{} { 684 | return v.(int) * 10 685 | } 686 | 687 | // reduce操作: 对map的结果进行累加 688 | reduceFn := func(r, v interface{}) interface{} { 689 | return r.(int) + v.(int) 690 | } 691 | 692 | sum := reduce(mapChan(in, mapFn), reduceFn) //返回累加结果 693 | fmt.Println(sum) 694 | } 695 | ~~~ 696 | 697 | ## 总结 698 | 699 | 这节课,我借助代码示例,带你学习了 Channel 的应用场景和应用模式。这几种模式不是我们学习的终点,而是学习的起点。掌握了这几种模式之后,我们可以延伸出更多的模式。 700 | 701 | 虽然 Channel 最初是基于 CSP 设计的用于 goroutine 之间的消息传递的一种数据类型,但是,除了消息传递这个功能之外,大家居然还演化出了各式各样的应用模式。我不确定 Go 的创始人在设计这个类型的时候,有没有想到这一点,但是,我确实被各位大牛利用 Channel 的各种点子折服了,比如有人实现了一个基于 TCP 网络的分布式的 Channel。 702 | 703 | 在使用 Go 开发程序的时候,你也不妨多考虑考虑是否能够使用 chan 类型,看看你是不是也能创造出别具一格的应用模式。 704 | 705 | 706 | ![](https://static001.geekbang.org/resource/image/41/c9/4140728d1f331beaf92e712cd34681c9.jpg) 707 | 708 | 709 | ## 思考题 710 | 711 | 想一想,我们在利用 chan 实现互斥锁的时候,如果 buffer 设置的不是 1,而是一个更大的值,会出现什么状况吗?能解决什么问题吗? 712 | 713 | 714 | channel 来实现互斥锁,优势是 trylock,timeout 吧,因为mutex 没有这些功能。否则的话,是不是用回 mutex 呢 715 | 716 | 这样就能走多个gorouting获取到锁了,这就是一个共享锁,对于读多写少的场景,很有用。但是就是对于写锁,还是要配合buffer是1的chann。这类似于Java中的RentrantReadWriteLock -------------------------------------------------------------------------------- /3.Channel/3.03:内存模型:Go如何保证并发读写的顺序?/03.00-内存模型:Go如何保证并发读写的顺序?.md: -------------------------------------------------------------------------------- 1 | # 内存模型:Go如何保证并发读写的顺序? 2 | 3 | 先看看这篇[官方文档](https://golang.org/ref/mem)吧,这篇官方文档介绍了Go的内存模型,当然,这里的内存模型指的并不是Go 对象的内存分配、内存回收和内存整理的规范,它描述的是并发环境中多 goroutine 读相同变量的时候,变量的可见性条件。具体点说,就是指,在什么条件下,goroutine 在读取一个变量的值的时候,能够看到其它 goroutine 对这个变量进行的写的结果。 4 | 5 | 由于 CPU 指令重排和多级 Cache 的存在,保证多核访问同一个变量这件事儿变得非常复杂。毕竟,不同 CPU 架构(x86/amd64、ARM、Power 等)的处理方式也不一样,再加上编译器的优化也可能对指令进行重排,所以编程语言需要一个规范,来明确多线程同时访问同一个变量的可见性和顺序( Russ Cox 在麻省理工学院 [6.824 分布式系统 Distributed Systems 课程](https://pdos.csail.mit.edu/6.824/) 的一课,专门介绍了相关的[知识](http://nil.csail.mit.edu/6.824/2016/notes/gomem.pdf))。在编程语言中,这个规范被叫做内存模型。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go 语言并发编程笔记 2 | 3 | ## 1. 基本并发原语 4 | 5 | - [1.01:Mutex 简介](./1.基本并发原语/1.01:Mutex如何解决资源并发访问问题/01.00-Mutex基本简介和应用.md) 6 | - [1.02:Mutex底层实现](./1.基本并发原语/1.02:Mutex的底层实现/02.00-Mutex的实现.md) 7 | - [1.03:Mutex易错场景](./1.基本并发原语/1.03:Mutex:4种易错场景大盘点/03.00-Mutex:4种易错场景大盘点.md) 8 | - [1.04:Mutex拓展编程](./1.基本并发原语/1.04:Mutex:骇客编程,如何拓展额外功能?/04.00-Mutex:骇客编程,如何拓展额外功能?.md) 9 | - [1.05:RWMutex:读写锁的实现原理及使用指南](./1.基本并发原语/1.05:RWMutex:读写锁的实现原理及避坑指南/05.00-RWMutex:读写锁的实现原理及避坑指南.md) 10 | - [1.06:WaitGroup等待队列](./1.基本并发原语/1.06:WaitGroup:协同等待,任务编排利器/06.00-WaitGroup:协同等待,任务编排利器.md) 11 | - [1.07:Cond条件控制变量](./1.基本并发原语/1.07:Cond:条件变量的实现机制及避坑指南/07.00-Cond:条件变量的实现机制及避坑指南.md) 12 | - [1.08:Once一次就好](./1.基本并发原语/1.08:Once:一个简约而不简单的并发原语/08.00-Once:一个简约而不简单的并发原语.md) 13 | - [1.09:map 实现并发安全的Map](./1.基本并发原语/1.09:map:如何实现线程安全的map类型?/09.00-map:如何实现线程安全的map类型?.md) 14 | - [1.10:Pool池的使用](./1.基本并发原语/1.10:Pool:性能提升大杀器/10.00-Pool:性能提升大杀器.md) 15 | - [1.11:Context上下文](./1.基本并发原语/1.11:Context:信息穿透上下文/11.00-Context:信息穿透上下文.md) 16 | 17 | ## 2. 原子操作 18 | - [2.1:atomic:原子操作](./2.原子操作/2.01:atomic:要保证原子操作,一定要使用这几种方法/2.00-atomic:要保证原子操作,一定要使用这几种方法.md) 19 | 20 | ## Channel 21 | 22 | ## 扩展并发原语 23 | 24 | 25 | ## 分布式并发原语 26 | 27 | ## and? -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module Go并发编程实践学习笔记 2 | 3 | go 1.15 4 | 5 | require github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 6 | --------------------------------------------------------------------------------