learn-tech/专栏/22讲通关Go语言-完/09同步原语:sync包让你对并发控制得心应手.md
2024-10-15 23:13:09 +08:00

362 lines
14 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相关通知网站将会择期关闭。相关通知内容
09 同步原语sync 包让你对并发控制得心应手
上节课留了一个思考题channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁来保证并发的安全,这节课,我将为你介绍互斥锁的使用。
在 Go 语言中,不仅有 channel 这类比较易用且高级的同步机制,还有 sync.Mutex、sync.WaitGroup 等比较原始的同步机制。通过它们,我们可以更加灵活地控制数据的同步和多协程的并发,下面我为你逐一讲解。
资源竞争
在一个 goroutine 中,如果分配的内存没有被其他 goroutine 访问,只在该 goroutine 中被使用,那么不存在资源竞争的问题。
但如果同一块内存被多个 goroutine 同时访问,就会产生不知道谁先访问也无法预料最后结果的情况。这就是资源竞争,这块内存可以称为共享的资源。
我们通过下面的示例来进一步地了解:
ch09/main.go
//共享的资源
var sum = 0
func main() {
//开启100个协程让sum+10
for i := 0; i < 100; i++ {
go add(10)
}
//防止提前退出
time.Sleep(2 * time.Second)
fmt.Println("和为:",sum)
}
func add(i int) {
sum += i
}
示例中你期待的结果可能是和为 1000但当运行程序后可能如预期所示但也可能是 990 或者 980导致这种情况的核心原因是资源 sum 不是并发安全的因为同时会有多个协程交叉执行 sum+=i产生不可预料的结果
既然已经知道了原因解决的办法也就有了只需要确保同时只有一个协程执行 sum+=i 操作即可要达到该目的可以使用 sync.Mutex 互斥锁
小技巧使用 go buildgo rungo test 这些 Go 语言工具链提供的命令时添加 -race 标识可以帮你检查 Go 语言代码是否存在资源竞争
同步原语
sync.Mutex
互斥锁顾名思义指的是在同一时刻只有一个协程执行某段代码其他协程都要等待该协程执行完毕后才能继续执行
在下面的示例中我声明了一个互斥锁 mutex然后修改 add 函数 sum+=i 这段代码加锁保护这样这段访问共享资源的代码片段就并发安全了可以得到正确的结果
ch09/main.go
var(
sum int
mutex sync.Mutex
)
func add(i int) {
mutex.Lock()
sum += i
mutex.Unlock()
}
小提示以上被加锁保护的 sum+=i 代码片段又称为临界区在同步的程序设计中临界区段指的是一个访问共享资源的程序片段而这些共享资源又有无法同时被多个协程访问的特性 当有协程进入临界区段时其他协程必须等待这样就保证了临界区的并发安全
互斥锁的使用非常简单它只有两个方法 Lock Unlock代表加锁和解锁当一个协程获得 Mutex 锁后其他协程只能等到 Mutex 锁释放后才能再次获得锁
Mutex Lock Unlock 方法总是成对出现而且要确保 Lock 获得锁后一定执行 UnLock 释放锁所以在函数或者方法中会采用 defer 语句释放锁如下面的代码所示
func add(i int) {
mutex.Lock()
defer mutex.Unlock()
sum += i
}
这样可以确保锁一定会被释放不会被遗忘
sync.RWMutex
sync.Mutex 小节中我对共享资源 sum 的加法操作进行了加锁这样可以保证在修改 sum 值的时候是并发安全的如果读取操作也采用多个协程呢如下面的代码所示
ch09/main.go
func main() {
for i := 0; i < 100; i++ {
go add(10)
}
for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}
//增加了一个读取sum的函数便于演示并发
func readSum() int {
b:=sum
return b
}
这个示例开启了 10 个协程它们同时读取 sum 的值因为 readSum 函数并没有任何加锁控制所以它不是并发安全的即一个 goroutine 正在执行 sum+=i 操作的时候另一个 goroutine 可能正在执行 b:=sum 操作这就会导致读取的 num 值是一个过期的值结果不可预期
如果要解决以上资源竞争的问题可以使用互斥锁 sync.Mutex如下面的代码所示
ch09/main.go
func readSum() int {
mutex.Lock()
defer mutex.Unlock()
b:=sum
return b
}
因为 add readSum 函数使用的是同一个 sync.Mutex所以它们的操作是互斥的也就是一个 goroutine 进行修改操作 sum+=i 的时候另一个 gouroutine 读取 sum 的操作 b:=sum 会等待直到修改操作执行完毕
现在我们解决了多个 goroutine 同时读写的资源竞争问题但是又遇到另外一个问题性能因为每次读写共享资源都要加锁所以性能低下这该怎么解决呢
现在我们分析读写这个特殊场景有以下几种情况
写的时候不能同时读因为这个时候读取的话可能读到脏数据不正确的数据
读的时候不能同时写因为也可能产生不可预料的结果
读的时候可以同时读因为数据不会改变所以不管多少个 goroutine 读都是并发安全的
所以就可以通过读写锁 sync.RWMutex 来优化这段代码提升性能现在我将以上示例改为读写锁来实现我们想要的结果如下所示
ch09/main.go
var mutex sync.RWMutex
func readSum() int {
//只获取读锁
mutex.RLock()
defer mutex.RUnlock()
b:=sum
return b
}
对比互斥锁的示例读写锁的改动有两处
把锁的声明换成读写锁 sync.RWMutex
把函数 readSum 读取数据的代码换成读锁也就是 RLock RUnlock
这样性能就会有很大的提升因为多个 goroutine 可以同时读数据不再相互等待
sync.WaitGroup
在以上示例中相信你注意到了这段 time.Sleep(2 * time.Second) 代码这是为了防止主函数 main 返回使用一旦 main 函数返回了程序也就退出了
因为我们不知道 100 个执行 add 的协程和 10 个执行 readSum 的协程什么时候完全执行完毕所以设置了一个比较长的等待时间也就是两秒
小提示一个函数或者方法的返回 (return) 也就意味着当前函数执行完毕
所以存在一个问题如果这 110 个协程在两秒内执行完毕main 函数本该提前返回但是偏偏要等两秒才能返回会产生性能问题
如果这 110 个协程执行的时间超过两秒因为设置的等待时间只有两秒程序就会提前返回导致有协程没有执行完毕产生不可预知的结果
那么有没有办法解决这个问题呢也就是说有没有办法监听所有协程的执行一旦全部执行完毕程序马上退出这样既可保证所有协程执行完毕又可以及时退出节省时间提升性能你第一时间应该会想到上节课讲到的 channel没错channel 的确可以解决这个问题不过非常复杂Go 语言为我们提供了更简洁的解决办法它就是 sync.WaitGroup
在使用 sync.WaitGroup 改造示例之前我先把 main 函数中的代码进行重构抽取成一个函数 run这样可以更好地理解如下所示
ch09/main.go
func main() {
run()
}
func run(){
for i := 0; i < 100; i++ {
go add(10)
}
for i:=0; i<10;i++ {
go fmt.Println("和为:",readSum())
}
time.Sleep(2 * time.Second)
}
这样执行读写的 110 个协程代码逻辑就都放在了 run 函数中 main 函数中直接调用 run 函数即可现在只需通过 sync.WaitGroup run 函数进行改造让其恰好执行完毕如下所示
ch09/main.go
func run(){
var wg sync.WaitGroup
//因为要监控110个协程所以设置计数器为110
wg.Add(110)
for i := 0; i < 100; i++ {
go func() {
//计数器值减1
defer wg.Done()
add(10)
}()
}
for i:=0; i<10;i++ {
go func() {
//计数器值减1
defer wg.Done()
fmt.Println("和为:",readSum())
}()
}
//一直等待只要计数器值为0
wg.Wait()
}
sync.WaitGroup 的使用比较简单一共分为三步
声明一个 sync.WaitGroup然后通过 Add 方法设置计数器的值需要跟踪多少个协程就设置多少这里是 110
在每个协程执行完毕时调用 Done 方法让计数器减 1告诉 sync.WaitGroup 该协程已经执行完毕
最后调用 Wait 方法一直等待直到计数器值为 0也就是所有跟踪的协程都执行完毕
通过 sync.WaitGroup 可以很好地跟踪协程在协程执行完毕后整个 run 函数才能执行完毕时间不多不少正好是协程执行的时间
sync.WaitGroup 适合协调多个协程共同做一件事情的场景比如下载一个文件假设使用 10 个协程每个协程下载文件的 110 大小只有 10 个协程都下载好了整个文件才算是下载好了这就是我们经常听到的多线程下载通过多个线程共同做一件事情显著提高效率
小提示其实你也可以把 Go 语言中的协程理解为平常说的线程从用户体验上也并无不可但是从技术实现上你知道他们是不一样的就可以了
sync.Once
在实际的工作中你可能会有这样的需求让代码只执行一次哪怕是在高并发的情况下比如创建一个单例
针对这种情形Go 语言为我们提供了 sync.Once 来保证代码只执行一次如下所示
ch09/main.go
func main() {
doOnce()
}
func doOnce() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
//用于等待协程执行完毕
done := make(chan bool)
//启动10个协程执行once.Do(onceBody)
for i := 0; i < 10; i++ {
go func() {
//把要执行的函数(方法)作为参数传给once.Do方法即可
once.Do(onceBody)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
这是 Go 语言自带的一个示例虽然启动了 10 个协程来执行 onceBody 函数但是因为用了 once.Do 方法所以函数 onceBody 只会被执行一次也就是说在高并发的情况下sync.Once 也会保证 onceBody 函数只执行一次
sync.Once 适用于创建某个对象的单例只加载一次的资源等只执行一次的场景
sync.Cond
Go 语言中sync.WaitGroup 用于最终完成的场景关键点在于一定要等待所有协程都执行完毕
sync.Cond 可以用于发号施令一声令下所有协程都可以开始执行关键点在于协程开始的时候是等待的要等待 sync.Cond 唤醒才能执行
sync.Cond 从字面意思看是条件变量它具有阻塞协程和唤醒协程的功能所以可以在满足一定条件的情况下唤醒协程但条件变量只是它的一种使用场景
下面我以 10 个人赛跑为例来演示 sync.Cond 的用法在这个示例中有一个裁判裁判要先等这 10 个人准备就绪然后一声发令枪响 10 个人就可以开始跑了如下所示
//10个人赛跑1个裁判发号施令
func race(){
cond :=sync.NewCond(&sync.Mutex{})
var wg sync.WaitGroup
wg.Add(11)
for i:=0;i<10; i++ {
go func(num int) {
defer wg.Done()
fmt.Println(num,"号已经就位")
cond.L.Lock()
cond.Wait()//等待发令枪响
fmt.Println(num,"号开始跑")
cond.L.Unlock()
}(i)
}
//等待所有goroutine都进入wait状态
time.Sleep(2*time.Second)
go func() {
defer wg.Done()
fmt.Println("裁判已经就位准备发令枪")
fmt.Println("比赛开始大家准备跑")
cond.Broadcast()//发令枪响
}()
//防止函数提前返回退出
wg.Wait()
}
以上示例中有注释说明已经很好理解我这里再大概讲解一下步骤
通过 sync.NewCond 函数生成一个 *sync.Cond用于阻塞和唤醒协程
然后启动 10 个协程模拟 10 个人准备就位后调用 cond.Wait() 方法阻塞当前协程等待发令枪响这里需要注意的是调用 cond.Wait() 方法时要加锁
time.Sleep 用于等待所有人都进入 wait 阻塞状态这样裁判才能调用 cond.Broadcast() 发号施令
裁判准备完毕后就可以调用 cond.Broadcast() 通知所有人开始跑了
sync.Cond 有三个方法它们分别是
Wait阻塞当前协程直到被其他协程调用 Broadcast 或者 Signal 方法唤醒使用的时候需要加锁使用 sync.Cond 中的锁即可也就是 L 字段
Signal唤醒一个等待时间最长的协程
Broadcast唤醒所有等待的协程
注意在调用 Signal 或者 Broadcast 之前要确保目标协程处于 Wait 阻塞状态不然会出现死锁问题
如果你以前学过 Java会发现 sync.Cond Java 的等待唤醒机制很像它的三个方法 WaitSignalBroadcast 就分别对应 Java 中的 waitnotifynotifyAll
总结
这节课主要讲解 Go 语言的同步原语使用通过它们可以更灵活地控制多协程的并发从使用上讲Go 语言还是更推荐 channel 这种更高级别的并发控制方式因为它更简洁也更容易理解和使用
当然本节课讲的这些比较基础的同步原语也很有用同步原语通常用于更复杂的并发控制如果追求更灵活的控制方式和性能你可以使用它们
本节课到这里就要结束了sync 包里还有一个同步原语我没有讲它就是 sync.Mapsync.Map 的使用和内置的 map 类型一样只不过它是并发安全的所以这节课的作业就是练习使用 sync.Map