Go 宗门第二课:三个分身抢同一颗石球,为什么结果会错?

4 阅读6分钟

Go 宗门第二课:三个分身抢同一颗石球,为什么结果会错?

墨余通过了 Go 宗门的第一轮考核。

但长老没有让他领取外派令牌。

“会启动 goroutine,不代表你会并发。”

长老将一枚刻满裂纹的青色石球放到考核台中央。

“今天的考核只有一个要求:让三个分身同时修复它。”

墨余看着石球。

“这不是正好?我派三个分身,各修一处裂纹。”

“如果三处裂纹互不相关,当然可以。”

长老抬手,石球表面的裂纹忽然汇聚到中央,变成同一条不断变化的能量轨迹。

“但这枚石球只有一个共享状态。三个分身最后都要修改它。”

墨余想起昨天石球裂开的画面。

“那我小心一点就行。”

“并发的问题,往往就藏在‘小心一点’解决不了的地方。”

考核开始。

墨余抬手,三个青色分身同时冲向石球。

第一个分身读取石球上的能量值。

第二个分身也读取了同一个值。

第三个分身紧跟着读取。

它们拿到的都是一百。

第一个分身算出一百一十。

第二个分身也算出一百一十。

第三个分身同样算出一百一十。

三个分身几乎同时把结果写回石球。

石球上的能量值停在了一百一十。

墨余愣住了。

明明三个分身都增加了十点,为什么最后只增加了一次?

“再来。”他说。

这一次,他让三个分身连续修改石球。

可结果仍然不稳定。

有时是一百二十,有时是一百三十,有时甚至比开始时还低。

三个分身的动作越来越快,石球表面的裂纹也越来越多。

“停下!”

长老一掌拍在阵台上,强行切断了三个分身和石球之间的联系。

墨余站在原地,脸色难看。

“我只是让它们一起加能量。”

“问题就出在‘一起’。”长老说。

他拿起一块白色阵盘,在上面写下一百。

“第一个分身读取一百,准备加十。”

“第二个分身也读取一百,准备加十。”

“第一个分身算出一百一十,但还没有写回。”

“第二个分身也算出一百一十,并且先写了回去。”

长老停下笔。

“第一个分身最后写回一百一十。第二个分身的结果,就被覆盖了。”

墨余盯着阵盘。

“可它们都没有算错。”

“数据竞争最危险的地方,就在这里。”长老说,“每个分身单独看都像是对的,放在一起却可能得到错误结果。”

墨余沉默了。

他终于明白,昨天裂开的不只是石球。

是他对并发的理解。

他以为并发就是把一件事拆开,再尽可能多地派出分身。

可真正的问题是:多个分身要是碰到了同一份数据,谁有权读取,谁有权修改,谁必须等待?

长老重新连接阵台。

“再试一次。”

墨余没有立刻喊 go

他先在石球前画出一道窄门。

三个分身仍然可以同时处理各自的任务,但每次读取、计算和写回共享状态时,必须先经过这道门。

同一时刻,只允许一个分身进入。

第一个分身读取一百,加十,写回一百一十。

第二个分身读取一百一十,加十,写回一百二十。

第三个分身读取一百二十,加十,写回一百三十。

石球上的能量值稳定下来。

三道裂纹也开始慢慢合拢。

长老收起阵盘。

“这道门,在 Go 里叫互斥锁。”

墨余看着石球。

“它让所有事情都排队了吗?”

“没有。三个分身依然可以并发做其他事情。只有访问共享状态的关键部分需要互斥。”

长老指向考核台旁边的三座小阵台。

“并发不是把所有东西变成串行,而是把不能同时做的那一小段保护起来。”

墨余点了点头。

这时,宗门外传来一阵陌生的铃声。

一个穿着浅白色长袍的少女站在山门前,手里拿着一枚没有 Go 纹路的黑色任务牌。

她没有进入宗门,只把任务牌交给守门弟子。

“请把这个交给墨余。”

守门弟子看了一眼任务牌。

“外宗任务?你叫什么?”

少女抬起眼。

“林知夏。”

她看向远处的考核台,像是隔着山门看见了什么。

“告诉他,想查他父亲的最后一项任务,就离开 Go 宗门,到界面城来找我。”

说完,她转身离开。

墨余握住那枚任务牌时,牌面上的光刚好亮起一行字:

“任务状态:等待接收。”

零序的声音在他身后响起:

“你该出发了。”

把石球换成 Go 代码

下面这段代码模拟三个 goroutine 同时给同一个变量增加数值:

package main

import (
	"fmt"
	"sync"
)

var energy int

func addEnergy(wg *sync.WaitGroup) {
	defer wg.Done()

	for i := 0; i < 1000; i++ {
		energy++
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(3)

	go addEnergy(&wg)
	go addEnergy(&wg)
	go addEnergy(&wg)

	wg.Wait()
	fmt.Println(energy)
}

直觉上,三个 goroutine 各增加一千次,最后应该得到三千。

energy++ 并不是不可分割的一步,它至少包含:

  1. 读取 energy
  2. 把读取到的值加一;
  3. 把新值写回 energy

如果两个 goroutine 同时读取到同一个旧值,它们可能算出相同的新值,后写入的结果就会覆盖先写入的结果。

这就是 data race,也就是数据竞争。

可以用 Go 自带的 race detector 检查:

go run -race main.go

sync.WaitGroup 只负责等待三个 goroutine 结束,并不能保护 energy。等待任务完成,和保护共享数据,是两件不同的事。

用互斥锁保护共享状态,可以这样写:

package main

import (
	"fmt"
	"sync"
)

var (
	energy int
	mu     sync.Mutex
)

func addEnergy(wg *sync.WaitGroup) {
	defer wg.Done()

	for i := 0; i < 1000; i++ {
		mu.Lock()
		energy++
		mu.Unlock()
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(3)

	go addEnergy(&wg)
	go addEnergy(&wg)
	go addEnergy(&wg)

	wg.Wait()
	fmt.Println(energy) // 3000
}

mu.Lock()mu.Unlock() 包住了读取、加一、写回这一整段操作。

这就像考核台前的窄门:三个 goroutine 仍然可以并发执行,但修改同一份共享数据的关键部分,一次只能让一个 goroutine 进入。

这一章要记住:

goroutine 可以并发工作,但访问共享数据时必须建立同步规则;WaitGroup 负责等待,Mutex 负责互斥,它们解决的不是同一个问题。

image.png

图中多个分身同时伸手触碰同一枚石球,就是数据竞争最直观的比喻:问题不在于分身不能同时工作,而在于它们没有协调就同时修改了同一个共享状态。

墨余收起任务牌,回头看了一眼 Go 宗门。

他在这里学会了 go、goroutine 和互斥锁。

接下来,他还要留在 Go 的道路上,学会让分身之间可靠地传递结果。