learn-tech/专栏/TonyBai·Go语言第一课/34并发:如何使用共享变量?.md
2024-10-16 06:37:41 +08:00

650 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
34 并发:如何使用共享变量?
你好我是Tony Bai。
在前面的讲解中我们学习了Go的并发实现方案知道了Go基于Tony Hoare的CSP并发模型理论实现了Goroutine、channel等并发原语。
并且Go语言之父Rob Pike还有一句经典名言“不要通过共享内存来通信应该通过通信来共享内存Dont 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的访问我们创建一个新Goroutineg1g1通过函数参数得到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()
}
})
}
这些基准测试都是并发测试度量的是MutexRWMutex在并发下的读写性能我们分别在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的写锁性能和MutexRWMutex读锁相比是最差的并且随着并发量增大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.Mutex sync.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 runtimeinternalatomic·Xchg64(SB)
// $GOROOT/src/runtime/internal/atomic/asm_amd64.s
TEXT runtimeinternalatomic·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、 81632的情况下运行上述性能基准测试得到结果如下
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我们下节课见