分享

Go 语言系列28:互斥锁与读写锁

 菜籽爱编程 2022-04-27

在 Go 语言中,经常会遇到并发的问题,当然我们会优先考虑使用信道,但在这里我们会讨论如何通过 Mutex(互斥锁)RWMutex(读写锁) 来处理竞争条件。

临界区

首先我们要理解并发编程中临界区的概念。当程序并发地运行时,多个 Go 协程不应该同时访问那些修改共享资源的代码。这些修改共享资源的代码称为 临界区 。

我们这里举一个简单的例子,假如要实现一个功能,使当前变量的值加 100 ,我们会写出下面的代码:

x = x +100

当然,对于只有一个协程的程序来说,上面的代码没有任何问题。但是,如果有多个协程并发运行时,就会发生错误,为了简单起见,这里只用两个协程。

第一个协程获取当前 x 的值,并计算 x + 100 ,最后将计算后的值赋给 x 。同理,第二个协程也是按照第一个协程的步骤做。

一种可能发生的情况是第一个协程获取到 x 的值,然后对其加上 100 再赋给 x ,此时 x 的值更新为 x + 100 。第一个协程执行完毕后执行第二个协程,获取到 x 的值为 x + 100 ,然后再对其加上 100 再赋给 x ,此时 x 的值更新为 x + 200 。最后 x 的结果为 x + 200

但是,这里也有可能会发生另一种情况,那就是第一个协程获取完 x 的值后,还没执行完该协程,就切换到第二个协程执行,第二个协程同样获取到 x 的值,然后对 x 的值加 100 ,并赋值给 xx 更新为 x + 100 。在第二个协程执行完后,又切换到第一个协程执行,此时第一个协程 x 的值还为 x ,对其进行加 100 的操作后, x 的值更新为 x + 100 ,再赋值给 x 变量,最后导致执行完两个协程结果变量 x 的值为 x + 100

所以,由于上下文切换的不同, x 的值可能更新为 x + 100 或者 x + 200 ,这种情况就称之为竞争条件。使用下面的互斥锁 Mutex 就能避免这种情况的发生。

互斥锁 Mutex

互斥锁(Mutex,mutual exclusion) 用于提供一种 加锁机制(Locking Mechanism) ,可确保在某时刻只有一个协程在临界区运行,以防止出现竞争条件。使用其是为了来保护一个资源不会因为并发操作而引起冲突导致数据不准确。

Mutex 的定义有以下两种方法:

var lock *sync.Mutex
lock = new(sync.Mutex)

或者

lock := &sync.Mutex{}

Mutex 有两个方法,分别是 LockUnlock ,即对应的加锁和解锁。在 LockUnlock 之间的代码,都只能由一个协程执行,就能避免竞争条件。

lock.Lock()
x = x + 100
lock.Unlock()

如果有一个协程已经持有了 锁(Lock) ,当其他协程试图获得该锁时,这些协程会被阻塞,直到 Mutex 解除锁定为止。

下面使用一个例子来讲一讲互斥锁的使用,首先看到下面的代码,使用两个协程,分别计算 x 加上 10001 ,分析代码不难看出,执行完两个协程后 x 的结果应该为 2000

package main

import (
 "fmt"
 "sync"
)

func add(x *int, waitgroup *sync.WaitGroup) {
 defer waitgroup.Done()
 for i := 0; i < 1000; i++ {
  *x = *x + 1
 }
}

func main() {
 var waitGroup sync.WaitGroup
 x := 0
 waitGroup.Add(2)
 go add(&x, &waitGroup)
 go add(&x, &waitGroup)
 waitGroup.Wait()
 fmt.Println("x =", x)
}

但运行完该程序后,输出的结果值每次都不同,而且都不等于 2000 。通过上面的讲解,相信你应该知道为什么会出现这种情况了,为了解决这个问题,我们就要对 add 这个函数加上互斥锁,使同一时刻,只能有一个协程对 x 进行操作:

package main

import (
 "fmt"
 "sync"
)

func add(x *int, waitgroup *sync.WaitGroup, lock *sync.Mutex) {
 defer waitgroup.Done()
 for i := 0; i < 1000; i++ {
  lock.Lock()
  *x = *x + 1
  lock.Unlock()
 }
}

func main() {
 var waitGroup sync.WaitGroup
 lock := &sync.Mutex{}
 x := 0
 waitGroup.Add(2)
 go add(&x, &waitGroup, lock)
 go add(&x, &waitGroup, lock)
 waitGroup.Wait()
 fmt.Println("x =", x)
}

更改后的代码不管运行多少次,都只会输出一个结果,那就是 2000

当然,使用互斥锁很简单,但要注意同一协程里不要在尚未解锁时再次加锁,也不要对已经解锁的锁再次解锁。

当然,使用信道也可以处理竞争条件,把信道作为锁在前面讲信道的时候已经讲过,这里就不再赘述。

读写锁 RWMutex

sync.RWMutex 类型实现读写互斥锁,为了保证数据的安全,它规定了当有人还在读取数据(即读锁占用)时,不允许有人更新这个数据(即写锁会阻塞);为了保证程序的效率,多个人(线程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex 那样只允许有一个人(线程)读取同一个数据。

RWMutex 是基于 Mutex 的,在 Mutex 的基础之上增加了读、写的信号量,并使用了类似引用计数的读锁数量。读锁与读锁兼容,读锁与写锁互斥,写锁与写锁互斥,只有在锁释放后才可以继续申请互斥的锁:

  • 可以同时申请多个读锁;
  • 有读锁时申请写锁将阻塞,有写锁时申请读锁将阻塞;
  • 只要有写锁,后续申请读锁和写锁都将阻塞。

定义一个 RWMuteux 读写锁,同样有两种方法:

var lock *sync.RWMutex
lock = new(sync.RWMutex)

或者

lock := &sync.RWMutex{}

RWMutex 里提供了两种锁,每种锁分别对应两个方法,为了避免死锁,两个方法应成对出现,必要时请使用 defer

  • 读锁:调用 RLock 方法开启锁,调用 RUnlock 释放锁;
  • 写锁:调用 Lock 方法开启锁,调用 Unlock 释放锁。

下面是摘自网络上的一个例子:

package main

import (
 "fmt"
 "sync"
 "time"
)

func main() {
 // 创建读写互斥锁
 lock := &sync.RWMutex{}
 // 写锁开启
 lock.Lock()
 // 开启四个协程
 for i := 0; i < 4; i++ {
  go func(i int) {
   fmt.Printf("第 %d 个协程准备开始... \n", i)
   // 读锁开启
   lock.RLock()
   fmt.Printf("第 %d 个协程获得读锁, sleep 1s 后,释放锁\n", i)
   time.Sleep(time.Second)
   // 读锁释放
   lock.RUnlock()
  }(i)
 }

 time.Sleep(time.Second * 2)

 fmt.Println("准备释放写锁,读锁不再阻塞")
 // 写锁释放 读锁自由
 lock.Unlock()

 // 由于会等到读锁全部释放,才能获得写锁
 // 因为这里一定会在上面 4 个协程全部完成才能往下走
 lock.Lock()
 fmt.Println("程序退出...")
 lock.Unlock()
}

运行输出如下:

第 3 个协程准备开始... 
第 1 个协程准备开始... 
第 2 个协程准备开始... 
第 0 个协程准备开始... 
准备释放写锁,读锁不再阻塞
第 0 个协程获得读锁, sleep 1s 后,释放锁
第 2 个协程获得读锁, sleep 1s 后,释放锁
第 3 个协程获得读锁, sleep 1s 后,释放锁
第 1 个协程获得读锁, sleep 1s 后,释放锁
程序退出...

参考文献:

[1] Alan A. A. Donovan; Brian W. Kernighan, Go 程序设计语言, Translated by 李道兵, 高博, 庞向才, 金鑫鑫 and 林齐斌, 机械工业出版社, 2017.

👇周一至周五更新,期待你的关注👇

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多