Golang 锁机制

731 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情 >>

go语言提供两种类型的锁,一个是互斥锁 sync.Mutex,一个是读写锁sync.RWMutex.

互斥锁 sync.Mutex

sync互斥锁有两个常用的方法,Lock()加锁,Unlock()解锁。是绝对锁,同一时间内只能有一个锁。

  • 如果对一个未加锁的资源进行解锁,会引发panic异常。
  • 使用 Lock () 加锁后,同一个goroutine(同步调用)不能再继续对其加锁,否则会 panic。异步调用Lock会堵塞等待锁释放。
  • 可以在一个goroutine中对一个资源加锁,而在另外一个goroutine中对该资源进行解锁。
  • 不要在持有锁的时候做 IO 操作。尽量只通过持有锁来保护 IO 操作需要的资源而不是 IO 操作本身:

互斥锁是传统的并发程序对共享资源进行访问控制的主要手段。

  • 使用场景:读写不确定场景,即读写次数没有明显的区别,并且只允许只有一个读或者写的场景,所以该锁也叫做全局锁。

读写锁sync.RWMutex

读写锁,有两种锁: 读锁RLock() 和 写锁Lock()。读写锁意味着能够对统一资源进行同时读,但是不能进行同时写,也不能进行一边读,一边写。

sync.RWMutex有四个常用的方法:Lock()加写锁 、 Unlock()取消写锁 、 RLock()加读锁 、 RUnlock()释放读锁。

  • 读锁不是绝对锁,拥有更高的并行度,允许多个读者同时读取。读锁堵塞时,新的写锁没有办法申请,可以视情况申请新的读锁

    • 在请求 RLock() 锁时发现资源被 Lock() 锁住了,它会等待。发现是被 RLock()锁住,自己也可以读取。
  • 写锁是绝对锁,统一时间只能上一把写锁。写锁堵塞时,无法申请新的读锁,所有读锁的申请都将会被堵塞。

  • 对未被写锁定的读写锁进行写解锁,会引发 Panic;

    对未被读锁定的读写锁进行读解锁的时候也会引发 Panic;

    写解锁在进行的同时会试图唤醒所有因进行读锁定而被阻塞的 goroutine;

    读解锁在进行的时候则会试图唤醒一个因进行写锁定而被阻塞的 goroutine。

举个例子🌰

  • 第一秒:读者1在第1秒成功申请了读锁
  • 第二秒:写者1在第2秒申请写锁,申请失败,阻塞,但它会防止新的读者获锁
  • 第三秒:读者2在第3秒申请读锁,申请失败
  • 第四秒:读者1释放读锁,写者1获得写锁
  • 第五秒:写者1释放写锁,读者2获得读锁
  • 第六秒:读者1再次申请读锁,申请成功,与读者2共享
  • 第七秒:读者1、读者2释放读锁,结束 !

image.png

代码参考:

package main
import (
  "fmt"
  "sync"
  "time"
)
func main() {
  rw := new(sync.RWMutex)
  var deadLockCase time.Duration = 1
  go func() {
    time.Sleep(time.Second * deadLockCase)
    fmt.Println("Writer Try")
    rw.Lock()
    fmt.Println("Writer Fetch")
    time.Sleep(time.Second * 1)
    fmt.Println("Writer Release")
    rw.Unlock()
  }()
  fmt.Println("Reader 1 Try")
  rw.RLock()
  fmt.Println("Reader 1 Fetch")
  time.Sleep(time.Second * 2)
  fmt.Println("Reader 2 Try")
  rw.RLock()
  fmt.Println("Reader 2 Fetch")
  time.Sleep(time.Second * 2)
  fmt.Println("Reader 1 Release")
  rw.RUnlock()
  time.Sleep(time.Second * 1)
  fmt.Println("Reader 2 Release")
  rw.RUnlock()
  time.Sleep(time.Second * 2)
  fmt.Println("Done")
}

/*
* 运行结果:
* Reader 1 Try
* Reader 1 Fetch
* Writer Try
* Reader 2 Try
* fatal error: all goroutines are asleep - deadlock!
*/

死锁

有些死锁是很容易发现的,比如:

  • 互斥锁(sync.Mutexsync.RWMutex 的 Lock())是不可以互相嵌套的,这是明显的死锁。
  • sync.RWMutex 的 Lock()不可以使用与其 RLock() 也不可以互相嵌套,这也是明显的死锁。

死锁 发生时,系统就会报一个运行时错误 fatal error: all goroutines are asleep - deadlock!

可以这样通俗地解释这个错误发生的原因:一个 goroutine 请求的资源被他人锁住,就等待它被释放,但检测到程序中没有其他 goroutine 在执行了,或者其他 goroutine 也都在等待这个锁被某人释放,这样它就知道了自己永远不会拿到这个锁了,便抛出了此死锁的错误。

但是在嵌套使用 RLock() 时,它本身一个协程不会报错,但当其他 goroutine 在使用Lock()时,则有可能发生死锁。

  • 仍然可以用7秒读来举例子🌰

为了避免死锁,尽量不要嵌套地使用RLock,能defer最好用defer,不然就要检查每一个return的地方有没有解锁!

性能比较

假定每个读写操作耗时 1 微秒(百万分之一秒)

  • 读写比为 9:1 时,读写锁的性能约为互斥锁的 8 倍
  • 读写比为 1:9 时,读写锁性能相当
  • 读写比为 5:5 时,读写锁的性能约为互斥锁的 2 倍

假定每个读写操作耗时 0.1 微秒(千万分之一秒)

  • 单位读写操作时间下降后,读写锁的性能优势下降到 3 倍,这也是可以理解的,因加锁而阻塞的时间占比减小,互斥锁带来的损耗自然就减小了。

假定每个读写操作耗时10 微秒(十万分之一秒)

  • 单位时间增加后,读写锁和互斥锁的性能比与 1 微秒时基本一致。

附:互斥锁如何实现公平

sync.mutex 源代码分析 这篇文章介绍了 sync.Mutex 的演进历史和当前的实现机制。重要的部分引用如下:

根据Mutex的注释,当前的 Mutex 有如下的性质。这些注释将极大的帮助我们理解Mutex的实现。

互斥锁有两种状态:正常状态和饥饿状态。

在正常状态下,所有等待锁的 goroutine 按照FIFO顺序等待。唤醒的 goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁的拥有。新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入到等待队列的前面。 如果一个等待的 goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。

在饥饿模式下,锁的所有权将从 unlock 的 goroutine 直接交给交给等待队列中的第一个。新来的 goroutine 将不会尝试去获得锁,即使锁看起来是 unlock 状态, 也不会去尝试自旋操作,而是放在等待队列的尾部。

如果一个等待的 goroutine 获取了锁,并且满足一以下其中的任何一个条件:(1)它是队列中的最后一个;(2)它等待的时候小于1ms。它会将锁的状态转换为正常状态。

正常状态有很好的性能表现,饥饿模式也是非常重要的,因为它能阻止尾部延迟的现象。

参考

Golang 之 锁(mutex)

Golang的锁机制

Golang 的同步锁与读写锁

Golang的锁机制

读写锁和互斥锁的性能比较