极客时间已完结课程限时免费阅读

34|并发:如何使用共享变量?

34|并发:如何使用共享变量?-极客时间

34|并发:如何使用共享变量?

讲述:Tony Bai

时长17:26大小15.92M

你好,我是 Tony Bai。
在前面的讲解中,我们学习了 Go 的并发实现方案,知道了 Go 基于 Tony Hoare 的 CSP 并发模型理论,实现了 Goroutine、channel 等并发原语。
并且,Go 语言之父 Rob Pike 还有一句经典名言:“不要通过共享内存来通信,应该通过通信来共享内存(Don’t communicate by sharing memory, share memory by communicating)”,这就奠定了 Go 应用并发设计的主流风格:使用 channel 进行不同 Goroutine 间的通信
不过,Go 也并没有彻底放弃基于共享内存的并发模型,而是在提供 CSP 并发模型原语的同时,还通过标准库的 sync 包,提供了针对传统的、基于共享内存并发模型的低级同步原语,包括:互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、条件变量(sync.Cond)等,并通过 atomic 包提供了原子操作原语等等。显然,基于共享内存的并发模型在 Go 语言中依然有它的“用武之地”。
所以,在并发的最后一讲,我们就围绕 sync 包中的几个同步结构与对应的方法,聊聊基于共享内存的并发模型在 Go 中的应用。
我们先来看看在哪些场景下,我们需要用到 sync 包提供的低级同步原语。

sync 包低级同步原语可以用在哪?

这里我要先强调一句,一般情况下,我建议你优先使用 CSP 并发模型进行并发程序设计。但是在下面一些场景中,我们依然需要 sync 包提供的低级同步原语。
首先是需要高性能的临界区(critical section)同步机制场景。
在 Go 中,channel 并发原语也可以用于对数据对象访问的同步,我们可以把 channel 看成是一种高级的同步原语,它自身的实现也是建构在低级同步原语之上的。也正因为如此,channel 自身的性能与低级同步原语相比要略微逊色,开销要更大。
这里,关于 sync.Mutex 和 channel 各自实现的临界区同步机制,我做了一个简单的性能基准测试对比,通过对比结果,我们可以很容易看出两者的性能差异:
var cs = 0 // 模拟临界区要保护的数据
var mu sync.Mutex
var c = make(chan struct{}, 1)
func criticalSectionSyncByMutex() {
mu.Lock()
cs++
mu.Unlock()
}
func criticalSectionSyncByChan() {
c <- struct{}{}
cs++
<-c
}
func BenchmarkCriticalSectionSyncByMutex(b *testing.B) {
for n := 0; n < b.N; n++ {
criticalSectionSyncByMutex()
}
}
func BenchmarkCriticalSectionSyncByMutexInParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
criticalSectionSyncByMutex()
}
})
}
func BenchmarkCriticalSectionSyncByChan(b *testing.B) {
for n := 0; n < b.N; n++ {
criticalSectionSyncByChan()
}
}
func BenchmarkCriticalSectionSyncByChanInParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
criticalSectionSyncByChan()
}
})
}
运行这个对比测试(Go 1.17),我们得到:
$go test -bench .
goos: darwin
goarch: amd64
... ...
BenchmarkCriticalSectionSyncByMutex-8 88083549 13.64 ns/op
BenchmarkCriticalSectionSyncByMutexInParallel-8 22337848 55.29 ns/op
BenchmarkCriticalSectionSyncByChan-8 28172056 42.48 ns/op
BenchmarkCriticalSectionSyncByChanInParallel-8 5722972 208.1 ns/op
PASS
通过这个对比实验,我们可以看到,无论是在单 Goroutine 情况下,还是在并发测试情况下,sync.Mutex实现的同步机制的性能,都要比 channel 实现的高出三倍多。
因此,通常在需要高性能的临界区(critical section)同步机制的情况下,sync 包提供的低级同步原语更为适合。
第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。
基于 channel 的并发设计,有一个特点:在 Goroutine 间通过 channel 转移数据对象的所有权。所以,只有拥有数据对象所有权(从 channel 接收到该数据)的 Goroutine 才可以对该数据对象进行状态变更。
如果你的设计中没有转移结构体对象所有权,但又要保证结构体内部状态数据在多个 Goroutine 之间同步访问,那么你可以使用 sync 包提供的低级同步原语来实现,比如最常用的sync.Mutex
了解了这些应用场景之后,接着我们就来看看如何使用 sync 包中的各个同步结构,不过在使用之前,我们需要先看看一个 sync 包中同步原语使用的注意事项。

sync 包中同步原语使用的注意事项

在 sync 包的注释中(在$GOROOT/src/sync/mutex.go文件的头部注释),我们看到这样一行说明:
// Values containing the types defined in this package should not be copied.
翻译过来就是:“不应复制那些包含了此包中类型的值”。
在 sync 包的其他源文件中,我们同样看到类似的一些注释:
// $GOROOT/src/sync/mutex.go
// A Mutex must not be copied after first use. (禁止复制首次使用后的Mutex)
// $GOROOT/src/sync/rwmutex.go
// A RWMutex must not be copied after first use.(禁止复制首次使用后的RWMutex)
// $GOROOT/src/sync/cond.go
// A Cond must not be copied after first use.(禁止复制首次使用后的Cond)
... ...
那么,为什么首次使用 Mutex 等 sync 包中定义的结构类型后,我们不应该再对它们进行复制操作呢?我们以 Mutex 这个同步原语为例,看看它的实现是怎样的。
Go 标准库中 sync.Mutex 的定义是这样的:
// $GOROOT/src/sync/mutex.go
type Mutex struct {
state int32
sema uint32
}
我们看到,Mutex 的定义非常简单,由两个整型字段 state 和 sema 组成:
state:表示当前互斥锁的状态;
sema:用于控制锁状态的信号量。
初始情况下,Mutex 的实例处于 Unlocked 状态(state 和 sema 均为 0)。对 Mutex 实例的复制也就是两个整型字段的复制。一旦发生复制,原变量与副本就是两个单独的内存块,各自发挥同步作用,互相就没有了关联。
如果发生复制后,你仍然认为原变量与副本保护的是同一个数据对象,那可就大错特错了。我们来看一个例子:
func main() {
var wg sync.WaitGroup
i := 0
var mu sync.Mutex // 负责对i的同步访问
wg.Add(1)
// g1
go func(mu1 sync.Mutex) {
mu1.Lock()
i = 10
time.Sleep(10 * time.Second)
fmt.Printf("g1: i = %d\n", i)
mu1.Unlock()
wg.Done()
}(mu)
time.Sleep(time.Second)
mu.Lock()
i = 1
fmt.Printf("g0: i = %d\n", i)
mu.Unlock()
wg.Wait()
}
在这个例子中,我们使用一个 sync.Mutex 类型变量 mu 来同步对整型变量 i 的访问。我们创建一个新 Goroutine:g1,g1 通过函数参数得到 mu 的一份拷贝 mu1,然后 g1 会通过 mu1 来同步对整型变量 i 的访问。
那么,g0 通过 mu 和 g1 通过 mu 的拷贝 mu1,是否能实现对同一个变量 i 的同步访问呢?我们来看看运行这个示例的运行结果:
g0: i = 1
g1: i = 1
从结果来看,这个程序并没有实现对 i 的同步访问,第 9 行 g1 对 mu1 的加锁操作,并没能阻塞第 19 行 g0 对 mu 的加锁。于是,g1 刚刚将 i 赋值为 10 后,g0 就又将 i 赋值为 1 了。
出现这种结果的原因就是我们前面分析的情况,一旦 Mutex 类型变量被拷贝,原变量与副本就各自发挥作用,互相没有关联了。甚至,如果拷贝的时机不对,比如在一个 mutex 处于 locked 的状态时对它进行了拷贝,就会对副本进行加锁操作,将导致加锁的 Goroutine 永远阻塞下去。
通过前面这个例子,我们可以很直观地看到:如果对使用过的、sync 包中的类型的示例进行复制,并使用了复制后得到的副本,将导致不可预期的结果。所以,在使用 sync 包中的类型的时候,我们推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址(指针)的方式进行。这就是使用 sync 包时最值得我们注意的事项。
接下来,我们就来逐个分析日常使用较多的 sync 包中同步原语。我们先来看看互斥锁与读写锁。

互斥锁(Mutex)还是读写锁(RWMutex)?

sync 包提供了两种用于临界区同步的原语:互斥锁(Mutex)和读写锁(RWMutex)。它们都是零值可用的数据类型,也就是不需要显式初始化就可以使用,并且使用方法都比较简单。在上面的示例中,我们已经看到了 Mutex 的应用方法,这里再总结一下:
var mu sync.Mutex
mu.Lock() // 加锁
doSomething()
mu.Unlock() // 解锁
一旦某个 Goroutine 调用的 Mutex 执行 Lock 操作成功,它将成功持有这把互斥锁。这个时候,如果有其他 Goroutine 执行 Lock 操作,就会阻塞在这把互斥锁上,直到持有这把锁的 Goroutine 调用 Unlock 释放掉这把锁后,才会抢到这把锁的持有权并进入临界区。
由此,我们也可以得到使用互斥锁的两个原则:
尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。同时,我们也可以结合第 23 讲学习到的 defer,优雅地执行解锁操作。
读写锁与互斥锁用法大致相同,只不过多了一组加读锁和解读锁的方法:
var rwmu sync.RWMutex
rwmu.RLock() //加读锁
readSomething()
rwmu.RUnlock() //解读锁
rwmu.Lock() //加写锁
changeSomething()
rwmu.Unlock() //解写锁
写锁与 Mutex 的行为十分类似,一旦某 Goroutine 持有写锁,其他 Goroutine 无论是尝试加读锁,还是加写锁,都会被阻塞在写锁上。
但读锁就宽松多了,一旦某个 Goroutine 持有读锁,它不会阻塞其他尝试加读锁的 Goroutine,但加写锁的 Goroutine 依然会被阻塞住。
通常,互斥锁(Mutex)是临时区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。相比之下,读写锁的应用就没那么广泛了,只活跃于它擅长的场景下。
那读写锁(RWMutex)究竟擅长在哪种场景下呢?我们先来看一组基准测试:
var cs1 = 0 // 模拟临界区要保护的数据
var mu1 sync.Mutex
var cs2 = 0 // 模拟临界区要保护的数据
var mu2 sync.RWMutex
func BenchmarkWriteSyncByMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu1.Lock()
cs1++
mu1.Unlock()
}
})
}
func BenchmarkReadSyncByMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu1.Lock()
_ = cs1
mu1.Unlock()
}
})
}
func BenchmarkReadSyncByRWMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu2.RLock()
_ = cs2
mu2.RUnlock()
}
})
}
func BenchmarkWriteSyncByRWMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu2.Lock()
cs2++
mu2.Unlock()
}
})
}
这些基准测试都是并发测试,度量的是 Mutex、RWMutex 在并发下的读写性能。我们分别在 cpu=2、8、16、32 的情况下运行这个并发性能测试,测试结果如下:
goos: darwin
goarch: amd64
... ...
BenchmarkWriteSyncByMutex-2 73423770 16.12 ns/op
BenchmarkReadSyncByMutex-2 84031135 15.08 ns/op
BenchmarkReadSyncByRWMutex-2 37182219 31.87 ns/op
BenchmarkWriteSyncByRWMutex-2 40727782 29.08 ns/op
BenchmarkWriteSyncByMutex-8 22153354 56.39 ns/op
BenchmarkReadSyncByMutex-8 24164278 51.12 ns/op
BenchmarkReadSyncByRWMutex-8 38589122 31.17 ns/op
BenchmarkWriteSyncByRWMutex-8 18482208 65.27 ns/op
BenchmarkWriteSyncByMutex-16 20672842 62.94 ns/op
BenchmarkReadSyncByMutex-16 19247158 62.94 ns/op
BenchmarkReadSyncByRWMutex-16 29978614 39.98 ns/op
BenchmarkWriteSyncByRWMutex-16 16095952 78.19 ns/op
BenchmarkWriteSyncByMutex-32 20539290 60.20 ns/op
BenchmarkReadSyncByMutex-32 18807060 72.61 ns/op
BenchmarkReadSyncByRWMutex-32 29772936 40.45 ns/op
BenchmarkWriteSyncByRWMutex-32 13320544 86.53 ns/op
通过测试结果对比,我们得到了一些结论:
并发量较小的情况下,Mutex 性能最好;随着并发量增大,Mutex 的竞争激烈,导致加锁和解锁性能下降;
RWMutex 的读锁性能并没有随着并发量的增大,而发生较大变化,性能始终恒定在 40ns 左右;
在并发量较大的情况下,RWMutex 的写锁性能和 Mutex、RWMutex 读锁相比,是最差的,并且随着并发量增大,RWMutex 写锁性能有继续下降趋势。
由此,我们就可以看出,读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下,多个 Goroutine 可以同时持有读锁,从而减少在锁竞争中等待的时间。
而互斥锁,即便是读请求的场合,同一时刻也只能有一个 Goroutine 持有锁,其他 Goroutine 只能阻塞在加锁操作上等待被调度。
接下来,我们继续看条件变量 sync.Cond。

条件变量

sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。当条件成立后,这些处于等待状态的 Goroutine 将得到通知,并被唤醒继续进行后续的工作。这与百米飞人大战赛场上,各位运动员等待裁判员的发令枪声的情形十分类似。
条件变量是同步原语的一种,如果没有条件变量,开发人员可能需要在 Goroutine 中通过连续轮询的方式,检查某条件是否为真,这种连续轮询非常消耗资源,因为 Goroutine 在这个过程中是处于活动状态的,但它的工作又没有进展。
这里我们先看一个用sync.Mutex 实现对条件轮询等待的例子:
type signal struct{}
var ready bool
func worker(i int) {
fmt.Printf("worker %d: is working...\n", i)
time.Sleep(1 * time.Second)
fmt.Printf("worker %d: works done\n", i)
}
func spawnGroup(f func(i int), num int, mu *sync.Mutex) <-chan signal {
c := make(chan signal)
var wg sync.WaitGroup
for i := 0; i < num; i++ {
wg.Add(1)
go func(i int) {
for {
mu.Lock()
if !ready {
mu.Unlock()
time.Sleep(100 * time.Millisecond)
continue
}
mu.Unlock()
fmt.Printf("worker %d: start to work...\n", i)
f(i)
wg.Done()
return
}
}(i + 1)
}
go func() {
wg.Wait()
c <- signal(struct{}{})
}()
return c
}
func main() {
fmt.Println("start a group of workers...")
mu := &sync.Mutex{}
c := spawnGroup(worker, 5, mu)
time.Sleep(5 * time.Second) // 模拟ready前的准备工作
fmt.Println("the group of workers start to work...")
mu.Lock()
ready = true
mu.Unlock()
<-c
fmt.Println("the group of workers work done!")
}
就像前面提到的,轮询的方式开销大,轮询间隔设置的不同,条件检查的及时性也会受到影响。
sync.Cond为 Goroutine 在这个场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。使用条件变量原语,我们可以在实现相同目标的同时,避免对条件的轮询。
我们用sync.Cond对上面的例子进行改造,改造后的代码如下:
type signal struct{}
var ready bool
func worker(i int) {
fmt.Printf("worker %d: is working...\n", i)
time.Sleep(1 * time.Second)
fmt.Printf("worker %d: works done\n", i)
}
func spawnGroup(f func(i int), num int, groupSignal *sync.Cond) <-chan signal {
c := make(chan signal)
var wg sync.WaitGroup
for i := 0; i < num; i++ {
wg.Add(1)
go func(i int) {
groupSignal.L.Lock()
for !ready {
groupSignal.Wait()
}
groupSignal.L.Unlock()
fmt.Printf("worker %d: start to work...\n", i)
f(i)
wg.Done()
}(i + 1)
}
go func() {
wg.Wait()
c <- signal(struct{}{})
}()
return c
}
func main() {
fmt.Println("start a group of workers...")
groupSignal := sync.NewCond(&sync.Mutex{})
c := spawnGroup(worker, 5, groupSignal)
time.Sleep(5 * time.Second) // 模拟ready前的准备工作
fmt.Println("the group of workers start to work...")
groupSignal.L.Lock()
ready = true
groupSignal.Broadcast()
groupSignal.L.Unlock()
<-c
fmt.Println("the group of workers work done!")
}
我们运行这个示例程序,得到:
start a group of workers...
the group of workers start to work...
worker 2: start to work...
worker 2: is working...
worker 3: start to work...
worker 3: is working...
worker 1: start to work...
worker 1: is working...
worker 4: start to work...
worker 5: start to work...
worker 5: is working...
worker 4: is working...
worker 4: works done
worker 2: works done
worker 3: works done
worker 1: works done
worker 5: works done
the group of workers work done!
我们看到,sync.Cond实例的初始化,需要一个满足实现了sync.Locker接口的类型实例,通常我们使用sync.Mutex
条件变量需要这个互斥锁来同步临界区,保护用作条件的数据。加锁后,各个等待条件成立的 Goroutine 判断条件是否成立,如果不成立,则调用sync.Cond的 Wait 方法进入等待状态。Wait 方法在 Goroutine 挂起前会进行 Unlock 操作。
当 main goroutine 将ready置为 true,并调用sync.Cond的 Broadcast 方法后,各个阻塞的 Goroutine 将被唤醒,并从 Wait 方法中返回。Wait 方法返回前,Wait 方法会再次加锁让 Goroutine 进入临界区。接下来 Goroutine 会再次对条件数据进行判定,如果条件成立,就会解锁并进入下一个工作阶段;如果条件依旧不成立,那么会再次进入循环体,并调用 Wait 方法挂起等待。
sync.Mutexsync.RWMutex等相比,sync.Cond 应用的场景更为有限,只有在需要“等待某个条件成立”的场景下,Cond 才有用武之地。
其实,面向 CSP 并发模型的 channel 原语和面向传统共享内存并发模型的 sync 包提供的原语,已经能够满足 Go 语言应用并发设计中 99.9% 的并发同步需求了。而剩余那 0.1% 的需求,我们可以使用 Go 标准库提供的 atomic 包来实现。

原子操作(atomic operations)

atomic 包是 Go 语言给用户提供的原子操作原语的相关接口。原子操作(atomic operations)是相对于普通指令操作而言的。
我们以一个整型变量自增的语句为例说明一下:
var a int
a++
a++ 这行语句需要 3 条普通机器指令来完成变量 a 的自增:
LOAD:将变量从内存加载到 CPU 寄存器;
ADD:执行加法指令;
STORE:将结果存储回原内存地址中。
这 3 条普通指令在执行过程中是可以被中断的。而原子操作的指令是不可中断的,它就好比一个事务,要么不执行,一旦执行就一次性全部执行完毕,中间不可分割。也正因为如此,原子操作也可以被用于共享数据的并发同步。
原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,因此相对于操作系统层面和 Go 运行时层面提供的同步技术而言,它更为原始。
atomic 包封装了 CPU 实现的部分原子操作指令,为用户层提供体验良好的原子操作函数,因此 atomic 包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为高级的并发同步技术,比如 channel 和 sync 包中的同步原语。
我们以 atomic.SwapInt64 函数在 x86_64 平台上的实现为例,看看这个函数的实现方法:
// $GOROOT/src/sync/atomic/doc.go
func SwapInt64(addr *int64, new int64) (old int64)
// $GOROOT/src/sync/atomic/asm.s
TEXT ·SwapInt64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchg64(SB)
// $GOROOT/src/runtime/internal/atomic/asm_amd64.s
TEXT runtime∕internal∕atomic·Xchg64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ new+8(FP), AX
XCHGQ AX, 0(BX)
MOVQ AX, ret+16(FP)
RET
从函数 SwapInt64 的实现中,我们可以看到:它基本就是对 x86_64 CPU 实现的原子操作指令XCHGQ的直接封装。
原子操作的特性,让 atomic 包也可以被用作对共享数据的并发同步,那么和更为高级的 channel 以及 sync 包中原语相比,我们究竟该怎么选择呢?
我们先来看看 atomic 包提供了哪些能力。
atomic 包提供了两大类原子操作接口,一类是针对整型变量的,包括有符号整型、无符号整型以及对应的指针类型;另外一类是针对自定义类型的。因此,第一类原子操作接口的存在让 atomic 包天然适合去实现某一个共享整型变量的并发同步。
我们再看一个例子:
var n1 int64
func addSyncByAtomic(delta int64) int64 {
return atomic.AddInt64(&n1, delta)
}
func readSyncByAtomic() int64 {
return atomic.LoadInt64(&n1)
}
var n2 int64
var rwmu sync.RWMutex
func addSyncByRWMutex(delta int64) {
rwmu.Lock()
n2 += delta
rwmu.Unlock()
}
func readSyncByRWMutex() int64 {
var n int64
rwmu.RLock()
n = n2
rwmu.RUnlock()
return n
}
func BenchmarkAddSyncByAtomic(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
addSyncByAtomic(1)
}
})
}
func BenchmarkReadSyncByAtomic(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
readSyncByAtomic()
}
})
}
func BenchmarkAddSyncByRWMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
addSyncByRWMutex(1)
}
})
}
func BenchmarkReadSyncByRWMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
readSyncByRWMutex()
}
})
}
我们分别在 cpu=2、 8、16、32 的情况下运行上述性能基准测试,得到结果如下:
goos: darwin
goarch: amd64
... ...
BenchmarkAddSyncByAtomic-2 75426774 17.69 ns/op
BenchmarkReadSyncByAtomic-2 1000000000 0.7437 ns/op
BenchmarkAddSyncByRWMutex-2 39041671 30.16 ns/op
BenchmarkReadSyncByRWMutex-2 41325093 28.48 ns/op
BenchmarkAddSyncByAtomic-8 77497987 15.25 ns/op
BenchmarkReadSyncByAtomic-8 1000000000 0.2395 ns/op
BenchmarkAddSyncByRWMutex-8 17702034 67.16 ns/op
BenchmarkReadSyncByRWMutex-8 29966182 40.37 ns/op
BenchmarkAddSyncByAtomic-16 57727968 20.39 ns/op
BenchmarkReadSyncByAtomic-16 1000000000 0.2536 ns/op
BenchmarkAddSyncByRWMutex-16 15029635 78.61 ns/op
BenchmarkReadSyncByRWMutex-16 29722464 40.28 ns/op
BenchmarkAddSyncByAtomic-32 58010497 20.40 ns/op
BenchmarkReadSyncByAtomic-32 1000000000 0.2402 ns/op
BenchmarkAddSyncByRWMutex-32 11748312 93.15 ns/op
BenchmarkReadSyncByRWMutex-32 29845912 40.54 ns/op
通过这个运行结果,我们可以得出一些结论:
读写锁的性能随着并发量增大的情况,与前面讲解的 sync.RWMutex 一致;
利用原子操作的无锁并发写的性能,随着并发量增大几乎保持恒定;
利用原子操作的无锁并发读的性能,随着并发量增大有持续提升的趋势,并且性能是读锁的约 200 倍。
通过这些结论,我们大致可以看到 atomic 原子操作的特性:随着并发量提升,使用 atomic 实现的共享变量的并发读写性能表现更为稳定,尤其是原子读操作,和 sync 包中的读写锁原语比起来,atomic 表现出了更好的伸缩性和高性能。
由此,我们也可以看出 atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合
不过,atomic 原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是 sync 包中的原语。

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
虽然 Go 推荐基于通信来共享内存的并发设计风格,但 Go 并没有彻底抛弃对基于共享内存并发模型的支持,Go 通过标准库的 sync 包以及 atomic 包提供了低级同步原语。这些原语有着它们自己的应用场景。
如果我们考虑使用低级同步原语,一般都是因为低级同步原语可以提供更佳的性能表现,性能基准测试结果告诉我们,使用低级同步原语的性能可以高出 channel 许多倍。在性能敏感的场景下,我们依然离不开这些低级同步原语。
在使用 sync 包提供的同步原语之前,我们一定要牢记这些原语使用的注意事项:不要复制首次使用后的 Mutex/RWMutex/Cond 等。一旦复制,你将很大可能得到意料之外的运行结果。
sync 包中的低级同步原语各有各的擅长领域,你可以记住:
在具有一定并发量且读多写少的场合使用 RWMutex;
在需要“等待某个条件成立”的场景下使用 Cond;
当你不确定使用什么原语时,那就使用 Mutex 吧。
如果你对同步的性能有极致要求,且并发量较大,读多写少,那么可以考虑一下 atomic 包提供的原子操作函数。

思考题

使用基于共享内存的并发模型时,最令人头疼的可能就是“死锁”问题的存在了。你了解死锁的产生条件么?能编写一个程序模拟一下死锁的发生么?
欢迎你把这节课分享给更多对 Go 并发感兴趣的朋友。我是 Tony Bai,我们下节课见。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 24

提建议

上一篇
33|并发:小channel中蕴含大智慧
下一篇
35|即学即练:如何实现一个轻量级线程池?
unpreview
 写留言

精选留言(19)

  • 罗杰
    2022-01-14
    连续三节课都是需要花费 30 分钟才能阅读完,即使是复习也要超过 20 分钟,这几节的内容真的好充实。

    作者回复: 👍

    共 6 条评论
    11
  • 步比天下
    2022-01-16
    老师,我想问个低级的问题,什么是临界区啊,不太懂

    作者回复: 临界区其实就是一个代码片段。但这个代码片段中有共享的数据,这些数据不支持多个goroutine的并发访问,只能通过像channel、锁等机制同步各个goroutine的访问。同一时间,只能有一个goroutine访问这段代码,修改或读取这段代码所共享的数据。

    共 4 条评论
    10
  • Calvin
    2022-01-15
    思考题: 死锁产生的 4 个必要条件:1) 不可剥夺;2) 请求与保持;3) 循环等待;4) 互斥。 以下模块程序会发生死锁,报 fatal error: all goroutines are asleep - deadlock! func op1(mu1, mu2 *sync.Mutex, wg *sync.WaitGroup) { mu1.Lock() time.Sleep(1 * time.Second) mu2.Lock() println("op1: do something...") mu2.Unlock() mu1.Unlock() wg.Done() } func op2(mu1, mu2 *sync.Mutex, wg *sync.WaitGroup) { mu2.Lock() time.Sleep(2 * time.Second) mu1.Lock() println("op2: do something...") mu1.Unlock() mu2.Unlock() wg.Done() } func TestDeadLock(t *testing.T) { var mu1 sync.Mutex var mu2 sync.Mutex var wg sync.WaitGroup wg.Add(2) go op1(&mu1, &mu2, &wg) go op2(&mu1, &mu2, &wg) wg.Wait() }
    展开

    作者回复: 👍

    共 4 条评论
    7
  • aoe
    2022-01-18
    读到这里,明白了 Go 的并发不是万能的! 1. Go 基于 Tony Hoare 的 CSP 并发模型理论,实现了 Goroutine、channel 等并发原语; 2. 使用低级同步原语(标准库的 sync 包以及 atomic 包提供了低级同步原语:Mutex/RWMutex/Cond 等)的性能可以高出 channel 许多倍 3. 有锁的地方就有江湖,高并发下的性能主要拼的是算法,没有一门语言有压倒性优势

    作者回复: 👍

    4
  • Calvin
    2022-01-15
    老师,Go 中没有可重入的锁吗?

    作者回复: 没有。Go团队认为递归锁或可重入锁是一个bad idea,所以不支持。

    共 9 条评论
    4
  • 非梧桐不止
    2022-05-27
    老师,既然共享内存的性能比channel好,为什么Go倡导用通信去共享内存呢?

    作者回复: 多数情况下,对性能没有那么高要求,采用基于csp模型的并发设计是主流。

    3
  • Geek_62c21a
    2022-04-25
    老师,有两个问题请教您 1,wg.Done() 是不是在函数开始的地方写成 defer wg.Done() 会好点呢? 2,条件变量 broadcast 的时候,以下两种写法有区别吗? 第一种: groupSignal.L.Lock() ready = true groupSignal.Broadcast() groupSignal.L.Unlock() 第二种: groupSignal.L.Lock() ready = true groupSignal.L.Unlock() groupSignal.Broadcast()
    展开

    作者回复: 1. 可以的。 2. 虽然Broadcast的参考文档也提到了:It is allowed but not required for the caller to hold c.L during the call. 对于例子来说两种写法都是ok的。但条件变量的测试条件表达式,比如本例子中的ready最好是始终在lock的保护下的。所以这里我建议用了第一种。否则在复杂的场景下,一旦unlock,条件变量测试表达式 就不受控了。

    3
  • Calvin
    2022-01-15
    老师,sync 包中还有其他的一些同步原语,后面会继续说下吗?比如前面课程和本节课中都有使用到的 sync.WaitGroup 就没有说到,直接就使用上了,另外还有 sync.Map,sync.Pool 等。

    作者回复: 好问题👍!虽然都在sync包中,但sync.WaitGroup,Map,Pool层级更high一些,是基于Mutex、RWMutex和Cond这三个基本原语之上实现的机制。考虑到篇幅,这里focus Mutex、RWMutex和Cond这几个基本的原语结构了。至于“sync.WaitGroup 就没有说到,直接就使用上了”, 一是用起来很简单,二是有些时候不得不用,没有可替代手段😁。

    共 5 条评论
    2
  • 文经
    2022-01-21
    白老师,我觉得条件变量的例子完全可以用channel来实现,代码逻辑还会更简单一些。 使用条件变量,跟Mutex一样,是基于性能的考虑吗?

    作者回复: 你的想法没错。但条件变量的性能我还真没有和channel对比过,介绍条件变量只是为了给大家“科普”一下它的存在和适合的场景。说实话,条件变量使用的很少。

    1
  • lesserror
    2022-01-20
    关于Go的并发,内容挺多的。专门讲Go并发编程的书籍都有不少。大白老师这里带领我们通俗易懂地入门了。另有几个小问题。 1. 文中说:“甚至,如果拷贝的时机不对,比如在一个 mutex 处于 locked 的状态时对它进行了拷贝,就会对副本进行加锁操作,将导致加锁的 Goroutine 永远阻塞下去。” 这里不是互不相关吗? 为什么还会对副本加锁呢? 2. 另外,好像也没讲到空结构体在channel中传递的好处? 3. $GOROOT/src/runtime/internal/asm_amd64.s 这个文件未找到(Mac Book 上面)。
    展开

    作者回复: 1. 当原mutex出于加锁状态时被copy,copy后的mutex本身就是加锁状态,后者被使用时就会被阻塞。 2. 记得之前的某讲里提到过 3. 路径的确不对,应该是$GOROOT/src/runtime/internal/atomic/asm_amd64.s ,后续让编辑老师更新一下。

    1
  • 看不到de颜色
    2022-12-18 来自辽宁
    老师,请教一个问题。go中存在产量可见性问题吗?对应Java中volatile解决问题。

    作者回复: 你说的是变量的线程可见性吧。go中其实没有这样的说法,也不存在volatile关键字。在go中并发编程主要依靠channel、sync包和atomic操作来实现。凡涉及到数据竞争的都需要程序员自己选择一种机制,做好并发保护。

  • 吃橘子的汤圆🐳
    2022-12-13 来自辽宁
    请问使用Csp比使用共享内存的好处体现在哪里

    作者回复: 好问题👍 31讲中有说过:“从程序的整体结构来看,Go 始终推荐以 CSP 并发模型风格构建并发程序,尤其是在复杂的业务层面,这能提升程序的逻辑清晰度,大大降低并发设计的复杂性,并让程序更具可读性和可维护性”。 只是在性能敏感的时候,才会考虑使用sync包中的低级同步原语。

  • 郑童文
    2022-11-21 来自北京
    老师似乎一直没有专门讲过WaitGroup的用法啊

    作者回复: 我一直认为WaitGroup十分好理解:),用起来模式也固定,另外和Mutex相比,它并不是最基础的同步原语,所以没有放在专栏内容中讲解:)。

  • 冯仁彬
    2022-09-22 来自辽宁
    以下代码会产生死锁: func main() { var mu1 sync.Mutex var mu2 sync.Mutex mu1.Lock() go func() { mu2.Lock() mu1.Lock() mu1.Unlock() mu2.Unlock() fmt.Println("fun") }() time.Sleep(2 * time.Second) mu2.Lock() mu2.Unlock() mu1.Unlock() fmt.Println("main") }
    展开

    作者回复: 👍

  • 菠萝吹雪—Code
    2022-09-09 来自北京
    并发这几节课需要多看几遍,老师讲解的太棒了!充实。 空结构体在channel中传递的好处,可以参考这篇文章关于空结构体的应用场景:https://mp.weixin.qq.com/s/zbYIdB0HlYwYSQRXFFpqSg

    作者回复: 👍

  • tianbingJ
    2022-03-31
    请问老师,atomic.LoadInt64(&x) 和直接读取x的区别是什么? 这里怎么体现atomic呢?

    作者回复: 在并发的情况下,采用直接读取x的方式读取到的不一定是x的最新值。

    共 2 条评论
  • singularity of sp...
    2022-02-06
    老师,请问一下Go在没有volatile的情况下如何保证共享变量在不同Goroutine的可见性?如果可见性不能保证的话,那么CAS的正确性应该也不能保证吧?还是说CAS内部的实现已经保证了可见性呢?

    作者回复: Go中没有volatile。但go给出了自己的memory model,https://go.dev/ref/mem 这个也许能回答你的问题。

    共 2 条评论
  • 文经
    2022-01-21
    白老师: “atomic 原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是 sync 包中的原语。” 这里说的整型变量和自定义类型变量,是不是可以理解为一个字长的大小。在64位cpu上就是8个字节。因为CPU通过数据总线,一次从内存中最多只能获取一个字长的信息。所以atomic的限制也是一个字长。
    展开

    作者回复: 你说的没错,无论整型变量和自定义类型变量,atomic的操作实质上针对的都是字长长度的指针。但我说的复杂临界区,还有另外一个情况,那就是在锁内部有着对数据的更为复杂的判断与操作,而不仅仅是+n,-n,或cas。

  • BinyGo
    2022-01-15
    并发每篇都干货满满,3刷4刷都还没完全消化完,加油~

    作者回复: 👍