Kodewick
开始学 →
第 22 课,共 24 课目录

WaitGroup 和 Mutex

第 22 课,共 24 课 · 第 5 部分 · 并发 · 约 15 分钟

第 19 课用 channel 等一个 goroutine 做完。要等的 goroutine 有很多个,而且不需要它们传回什么值时,sync 包里的 WaitGroup 更简单:它是一个计数器,数着还有几个没做完。

prog.go
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 永远等不到。

prog.go
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 Agoroutine Bcount
1读到 55
2读到 55
3写回 66
4写回 66

加了两次,只多了 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() 会等,等到锁被还回来。这样,读、加、写这三步就不会被别人插进来。

prog.go
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,这里不真的等),再写回新余额。三笔钱同时存进一个账户:

prog.go
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}

defer mu.Unlock()

一个函数里有好几个 return 时,很容易某条路忘了解锁。锁不还回去,下一个 Lock() 就永远等下去。习惯写法是拿到锁马上 defer mu.Unlock():

prog.go
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 个苹果的那一单不管排在第几个都不够,所以结果和谁先谁后无关。

练一练

写好代码,点检查。提示随便看,实在想不出来再看答案。

练习 1

等所有报价回来

每家店的报价由一个 goroutine 算出来(原价打九折),可现在 main 不等它们就显示了。用 sync.WaitGroup 让 main 等所有 goroutine 做完。程序算了两组价格。

要显示
[90 225 72]
[36 54]
exercise1.go
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}
输出
点「运行」或按 Ctrl+Enter
练习 2

一起记账的收银台

每个收银台一个 goroutine,把自己收到的钱一笔笔加到同一个总数上;每一笔先读总数,刷卡要等一会儿(Sleep),再写回去。现在算出来的总数不对。加一把 sync.Mutex,让读和写之间没人能插进来。程序算了两天的账。

要显示
100
115
exercise2.go
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}
输出
点「运行」或按 Ctrl+Enter
练习 3

修好它:提前 return 忘了解锁

这段程序会卡死:取钱时余额不够就提前 return 了,锁没还回去。改成用 defer 解锁,让每条路都会解锁。

要显示
true 70
false 70
true 0
exercise3.go
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}
输出
点「运行」或按 Ctrl+Enter

这一课学会了

  • 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 也不会忘了解锁。
这一课学完了吗?点一下标记。进度存在这个浏览器里,随时可以撤销。