Golang 的锁机制

近日看了一篇 文章,讲到了用锁的 panic 问题,但并没有看懂,经过多次测试,整理如下。


Golang 中的锁

Golang 中的有两种锁,为 sync.Mutexsync.RWMutex

  • sync.Mutex 的锁只有一种锁:Lock(),它是绝对锁,同一时间只能有一个锁。
  • sync.RWMutex 叫读写锁,它有两种锁: RLock()Lock()
    • RLock() 叫读锁。它不是绝对锁,可以有多个读者同时获取此锁(调用 mu.RLock)。
    • Lock() 叫写锁,它是个绝对锁,就是说,如果一旦某人拿到了这个锁,别人就不能再获取此锁了。

另外,有一种特性:

  • 当写锁阻塞时,新的读锁是无法申请的。

即在 sync.RWMutex 的使用中,一个线程请求了他的写锁(mx.Lock())后,即便它还没有取到该锁(可能由于资源已被其他人锁定),后面所有的读锁的申请,都将被阻塞,只有取写锁的请求得到了锁且用完释放后,读锁才能去取。

这种特性可以有效防止写者 饥饿。如果一个线程因为某种原因,导致得不到CPU运行时间,这种状态被称之为 饥饿。

另外,由上面的基础又衍生出一些想法并测试了一下,结果如下:

  • 读写锁中的可读锁(sync.RWMutexRLock())可以嵌套使用的,在一个线程中单独来看,它不会有问题(但这是踩坑点)。
  • 互斥锁(sync.Mutexsync.RWMutexLock())是不可以互相嵌套的,这是明显的死锁。
  • sync.RWMutexLock() 不可以使用与其 RLock() 也不可以互相嵌套,这也是明显的死锁。

本篇文章的所有 嵌套 一词均指同一个资源的锁的嵌套。即,指一个 goroutine 在对某个资源上锁(调用 (R)Lock())后解锁 (调用 (R)Unlock()) 前,再次上锁(调用 (R)Lock())。(l.RLock() -> l.RLock() -> l.RUnlock() -> l.RUnlock()

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

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

如下文的例子中在 10s passed 输出后,其才会报出死锁的错误。

踩坑点

有些死锁是很容易发现的,比如在 Lock() 自身的互相嵌套及 Lock()RLock() 的互相嵌套。

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

所以为避免踩到这种坑,最好的建议就是 不要嵌套地使用 RLock()

实例与解释

package main

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

var l sync.RWMutex

func main() {
    go readAndRead()

    time.Sleep(1 * time.Second)
    l.Lock()
    fmt.Println("----------------- got lock")
    l.Unlock()

    time.Sleep(5 * time.Second)
}

func readAndRead() {
    l.RLock()
    fmt.Println("----------------- got rlock")
    time.Sleep(10 * time.Second)
    fmt.Println("----------------- 10s passed")

    l.RLock()
    fmt.Println("----------------- got 2nd rlock")

    l.RUnlock()
    l.RUnlock()
}

/* shell 执行 `go run main.go` 的结果为:
   ----------------- got rlock
   ----------------- 10s passed
   fatal error: all goroutines are asleep - deadlock!
   ...
*/

上面的实例就会发生死锁,详细解释其死锁的过程如下:

  • A(goroutine readAndRead())先获取了读锁
  • B (主程序)申请写锁的获取,此时由于 A 加了读锁,因此写锁阻塞(等待 A 释放读锁)
  • 此时 A 中又申请了读锁(嵌套,A 还没有释放前一个读锁)
  • 这时,由于 A 对读锁的申请一定会等待 B 获取到锁并释放后才能得到,所以 A 和 B 都在等待锁。造成死锁发生。

代码的解释,这些条件保证了我上面的过程稳定重现(可以试着打破这个过程看还会不会出错):

  • readAndRead() 函数是在 goroutine 中执行的,它会嵌套地获取读锁。(A)
  • 主程序中会获取写锁。(B)
  • 为了保证 A 先获取到读锁,用了 time.Sleep(1 * time.Second),来切换时间片(或用 runtime.Gosched()),从而保证 A 和 B 两个同时停在获取锁的状态上。

总结

再次总结一下,

  • 正常情况下,在请求 Lock() 锁时发现资源被锁住了,无论是 RLock() 锁还是 Lock() 锁,它都会等待。
  • 正常情况下,在请求 RLock() 锁时发现资源被 Lock() 锁住了,它会等待。发现是被 RLock() 锁住,自己也可以读取。(这个是用数字的原子操作来控制的,原理见附的文章的源码解释)
  • 不要嵌套地去用 ,这样则有可能发生死锁,即大家(所有 goroutine)都在等待锁的释放,此时发生死锁。

参考附:

  • http://zablog.me/2017/09/27/go_sync/

注: 测试时注意 goroutine 的 panic 可能还没发生,主程序就退出了(goroutine 的 panic 发生时,会导致主程序也退出并输出 panic 信息)。

你可能感兴趣的:(Golang 的锁机制)