WaitGroup 和 Mutex
第 19 课用 channel 等一个 goroutine 做完。要等的 goroutine 有很多个,而且不需要它们传回什么值时,sync 包里的 WaitGroup 更简单:它是一个计数器,数着还有几个没做完。
1package main2 3import (4 "fmt"5 "sync"6)7 8func main() {9 var wg sync.WaitGroup10 sizes := []int{1200, 800, 2000}11 thumbs := make([]int, len(sizes))12 for i, s := range sizes {13 wg.Add(1)14 go func() {15 defer wg.Done()16 thumbs[i] = s / 1017 }()18 }19 wg.Wait()20 fmt.Printf("缩略图大小:%v\n", thumbs)21}
缩略图大小:[120 80 200]
三张图片(大小以 KB 计)各交给一个 goroutine 做缩略图,缩略图是原图的十分之一。wg.Wait() 等三个都做完,main 才往下走,所以显示的一定是做好的结果。
Add、Done、Wait
| 写法 | 意思 |
|---|---|
var wg sync.WaitGroup | 一个计数器,零值就能用,从 0 开始。 |
wg.Add(1) | 计数加 1:又多了一个要等的 goroutine。 |
wg.Done() | 计数减 1:这个 goroutine 做完了。 |
wg.Wait() | 等到计数变回 0 再往下走。 |
defer wg.Done() 写在 goroutine 的第一行:不管后面从哪里结束,Done 都会执行(第 15 课的 defer)。
每个 goroutine 写的是 thumbs 里自己的那一格(thumbs[i]),谁也不碰别人的,所以不会互相干扰。点单步:每个 goroutine 一条泳道,main 停在 wg.Wait(),等别的泳道一条条结束。
WaitGroup 要传指针
把干活的代码写成一个函数时,要传 &wg,参数类型是 *sync.WaitGroup。传 wg 本身的话,函数拿到的是一份复制(第 11 课),它的 Done 减的是复制的那个计数,main 里的 Wait 永远等不到。
1package main2 3import (4 "fmt"5 "sync"6)7 8func pack(i int, items int, boxes []int, wg *sync.WaitGroup) {9 defer wg.Done()10 boxes[i] = (items + 9) / 1011}12 13func main() {14 orders := []int{25, 10, 3, 41}15 boxes := make([]int, len(orders))16 var wg sync.WaitGroup17 wg.Add(len(orders))18 for i, n := range orders {19 go pack(i, n, boxes, &wg)20 }21 wg.Wait()22 fmt.Printf("每个订单要几个箱子:%v\n", boxes)23}
每个订单要几个箱子:[3 1 1 5]
一个箱子装 10 件,(items + 9) / 10 是向上取整。知道一共要等几个,可以一次 wg.Add(len(orders))。
Add 写在 go 前面
wg.Add 要在 go 之前、在 main 里调用,不要放进 goroutine 里:
1for i, s := range sizes {2 go func() {3 wg.Add(1) // 错:太晚了4 defer wg.Done()5 thumbs[i] = s / 106 }()7}8wg.Wait()
新的 goroutine 什么时候开始跑是说不准的。如果 main 先走到 wg.Wait(),那时候还没有人 Add,计数是 0,Wait 马上就返回了,结果还没做完。在 main 里先 Add 再 go,就不会有这个问题。
几个 goroutine 改同一个变量
各写各的格子没问题。可如果好几个 goroutine 改同一个变量呢?比如 1000 个 goroutine 各给计数器加 1:
1count := 02var wg sync.WaitGroup3for range 1000 {4 wg.Add(1)5 go func() {6 defer wg.Done()7 count++8 }()9}10wg.Wait()11fmt.Println(count)
count++ 看起来是一步,其实是三步:读出 count、加 1、写回去。在有好几个 CPU 核的电脑上,几个 goroutine 真的是同时在跑,可能出现这样的情况:
| 时间 | goroutine A | goroutine B | count |
|---|---|---|---|
| 1 | 读到 5 | 5 | |
| 2 | 读到 5 | 5 | |
| 3 | 写回 6 | 6 | |
| 4 | 写回 6 | 6 |
加了两次,只多了 1。在真机上把这段程序多跑几次,可能看到 1000,也可能看到 987、962,每次不一样。这叫数据竞争(data race)。
我们的网页一次只让一个 goroutine 跑,跑到它停下来等(channel、Wait、Lock、Sleep)才换下一个,所以这段程序在这里总是显示 1000。别被骗了:在真机上它是错的。
小技巧:在自己电脑上,用 go run -race prog.go 运行,Go 会找出这次运行里真的发生了的数据竞争,并告诉你在哪一行。
sync.Mutex:一次只让一个进
Mutex(互斥锁)保护一段代码:mu.Lock() 拿到锁,mu.Unlock() 还回去。锁在别人手里时,Lock() 会等,等到锁被还回来。这样,读、加、写这三步就不会被别人插进来。
1package main2 3import (4 "fmt"5 "sync"6)7 8func main() {9 count := 010 var mu sync.Mutex11 var wg sync.WaitGroup12 for range 1000 {13 wg.Add(1)14 go func() {15 defer wg.Done()16 mu.Lock()17 count++18 mu.Unlock()19 }()20 }21 wg.Wait()22 fmt.Println(count)23}
1000
不管在哪台电脑上、跑多少次,都是 1000。
下面这段把「慢」放大:存钱时先读余额,等一会儿(time.Sleep,这里不真的等),再写回新余额。三笔钱同时存进一个账户:
1package main2 3import (4 "fmt"5 "sync"6 "time"7)8 9type Account struct {10 mu sync.Mutex11 balance int12}13 14func (a *Account) Deposit(amount int) {15 a.mu.Lock()16 b := a.balance17 time.Sleep(10 * time.Millisecond)18 a.balance = b + amount19 a.mu.Unlock()20}21 22func main() {23 acc := &Account{}24 var wg sync.WaitGroup25 for _, amount := range []int{100, 50, 30} {26 wg.Add(1)27 go func() {28 defer wg.Done()29 acc.Deposit(amount)30 }()31 }32 wg.Wait()33 fmt.Printf("余额:%d\n", acc.balance)34}
余额:180
点单步:拿到锁的那条泳道在 Sleep,另外两条停在 a.mu.Lock(),等它解锁。然后试试删掉 Lock 和 Unlock 那两行再运行:Sleep 的时候别的 goroutine 进来,读到的还是旧余额,最后的余额就不是 180 了。这个错在这里也看得到。
锁放在结构体里,方法用指针接收者(*Account):所有 goroutine 用的是同一个账户、同一把锁。
这段程序在真机上显示什么?
1package main2 3import (4 "fmt"5 "sync"6)7 8func main() {9 total := 010 var mu sync.Mutex11 var wg sync.WaitGroup12 for range 5 {13 wg.Add(1)14 go func() {15 defer wg.Done()16 for range 10 {17 mu.Lock()18 total += 219 mu.Unlock()20 }21 }()22 }23 wg.Wait()24 fmt.Println(total)25}
5 个 goroutine,每个加 10 次 2,一共加了 100。每次 total += 2 都在锁里,没有人能插进来,所以在哪里跑都一定是 100。去掉锁,在真机上就可能比 100 小。
defer mu.Unlock()
一个函数里有好几个 return 时,很容易某条路忘了解锁。锁不还回去,下一个 Lock() 就永远等下去。习惯写法是拿到锁马上 defer mu.Unlock():
1package main2 3import (4 "fmt"5 "sync"6)7 8type Store struct {9 mu sync.Mutex10 stock map[string]int11 failed int12}13 14func (s *Store) Order(item string, n int) {15 s.mu.Lock()16 defer s.mu.Unlock()17 if s.stock[item] < n {18 s.failed++19 return20 }21 s.stock[item] -= n22}23 24func main() {25 s := &Store{stock: map[string]int{"苹果": 20, "牛奶": 8}}26 var wg sync.WaitGroup27 orders := []int{5, 3, 50, 7}28 for _, n := range orders {29 wg.Add(1)30 go func() {31 defer wg.Done()32 s.Order("苹果", n)33 }()34 }35 wg.Add(1)36 go func() {37 defer wg.Done()38 s.Order("牛奶", 2)39 }()40 wg.Wait()41 fmt.Printf("库存:%v\n", s.stock)42 fmt.Printf("没成交:%d\n", s.failed)43}
库存:map[牛奶:6 苹果:5] 没成交:1
库存不够时提前 return,defer 照样会解锁。map 也是好几个 goroutine 一起改的,同样要在锁里改。要 50 个苹果的那一单不管排在第几个都不够,所以结果和谁先谁后无关。
练一练
写好代码,点检查。提示随便看,实在想不出来再看答案。
等所有报价回来
✓每家店的报价由一个 goroutine 算出来(原价打九折),可现在 main 不等它们就显示了。用 sync.WaitGroup 让 main 等所有 goroutine 做完。程序算了两组价格。
[90 225 72] [36 54]
1package main2 3import "fmt"4 5func quotes(prices []int) []int {6 result := make([]int, len(prices))7 for i, p := range prices {8 go func() {9 result[i] = p * 9 / 1010 }()11 }12 return result13}14 15func main() {16 fmt.Println(quotes([]int{100, 250, 80}))17 fmt.Println(quotes([]int{40, 60}))18}
一起记账的收银台
✓每个收银台一个 goroutine,把自己收到的钱一笔笔加到同一个总数上;每一笔先读总数,刷卡要等一会儿(Sleep),再写回去。现在算出来的总数不对。加一把 sync.Mutex,让读和写之间没人能插进来。程序算了两天的账。
100 115
1package main2 3import (4 "fmt"5 "sync"6 "time"7)8 9func addAll(tills [][]int) int {10 total := 011 var wg sync.WaitGroup12 for _, till := range tills {13 wg.Add(1)14 go func() {15 defer wg.Done()16 for _, amount := range till {17 t := total18 time.Sleep(time.Millisecond)19 total = t + amount20 }21 }()22 }23 wg.Wait()24 return total25}26 27func main() {28 fmt.Println(addAll([][]int{{10, 20}, {5}, {30, 15, 20}}))29 fmt.Println(addAll([][]int{{7, 8}, {100}}))30}
修好它:提前 return 忘了解锁
✓这段程序会卡死:取钱时余额不够就提前 return 了,锁没还回去。改成用 defer 解锁,让每条路都会解锁。
true 70 false 70 true 0
1package main2 3import (4 "fmt"5 "sync"6)7 8type Wallet struct {9 mu sync.Mutex10 balance int11}12 13func (w *Wallet) Withdraw(n int) bool {14 w.mu.Lock()15 if w.balance < n {16 return false17 }18 w.balance -= n19 w.mu.Unlock()20 return true21}22 23func main() {24 w := &Wallet{balance: 100}25 for _, n := range []int{30, 500, 70} {26 ok := w.Withdraw(n)27 fmt.Println(ok, w.balance)28 }29}
这一课学会了
sync.WaitGroup是一个计数器:go之前wg.Add(1),goroutine 里defer wg.Done(),wg.Wait()等计数回到 0。Add写在main里、go的前面;把 WaitGroup 传给函数时传指针&wg。- 每个 goroutine 写自己的格子(
results[i])不会互相干扰。 - 几个 goroutine 改同一个变量是数据竞争:在真机上结果可能每次不同;在这里一次只跑一个 goroutine,大多看不出来。
sync.Mutex让一段代码一次只有一个 goroutine 进:mu.Lock()…mu.Unlock()。- 拿到锁马上
defer mu.Unlock(),提前return也不会忘了解锁。