Golang互斥锁与读写锁

Go语言通过 go 关键字可以很方便的创建 goroutine 实现并发编程,但是不同的 goroutine 同时访问或者修改公共资源时会带来一些意想不到的结果。在并发编程时,可以借助 Golang 的锁机制来保证数据安全,本文将介绍 Golang 的两种锁:互斥锁读写锁,首先对这两种锁进行概念上的对比,然后通过实验对比不加锁与加锁的区别,最后介绍读写锁的读锁与写锁的互斥原则。

互斥锁与读写锁的区别

Go 语言标准库 sync 提供了 2 种锁,互斥锁 sync.Mutex 和读写锁 sync.RWMutex 。他们的区别如下:

  • 互斥锁:互斥即不可同时运行。即使用了互斥锁的两个代码片段互相排斥,只有其中一个代码片段执行完成后,另一个才能执行。Go 标准库中提供了 sync.Mutex 互斥锁类型及其两个方法:
    • Lock 加锁
    • Unlock 释放锁
  • 读写锁:读写锁分为读锁和写锁,读锁是允许同时执行的,但写锁是互斥的。Go 标准库中提供了 sync.RWMutex 互斥锁类型及其四个方法:
    • Lock 加写锁
    • Unlock 释放写锁
    • RLock 加读锁
    • RUnlock 释放读锁
不加锁与加互斥锁的区别

下面的代码,开启了三个协程,每个协程让一个公共变量 count 加 10000 次 1,初看下来,最终的 count 值应该是 30000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"sync"
)

func main() {
count := 0
wg := sync.WaitGroup{}
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
defer wg.Done()
for j := 0; j < 10000; j++ {
count = count + 1
}
}()
}
wg.Wait()

fmt.Printf("count=%d\n", count)
}

但是实际上运行的结果可能并不等于 30000,而且每次运行的结果各不相同

1
2
3
4
5
6
7
8
// 第一次
count=19623

// 第二次
count=15833

// 第三次
count=20865

原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性,所以导致了数据的不准确。

解决这个问题的方法,就是给修改 count 的代码加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。修改上面的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"fmt"
"sync"
)

func main() {
count := 0
lock := sync.Mutex{} // 定义一个互斥锁
wg := sync.WaitGroup{}
wg.Add(3)
for i := 0; i < 3; i++ {
go func() {
defer wg.Done()
for j := 0; j < 10000; j++ {
lock.Lock() // 操作count之前加锁
count = count + 1
lock.Unlock() // 操作完之后释放锁
}
}()
}
wg.Wait()

fmt.Printf("count=%d\n", count)
}

这样,不管执行多少次上面的程序,输出都只有一个结果:

1
count=30000

使用 Mutext 锁虽然很简单,但仍然有几点需要注意:

  • 同一协程里,不要在尚未解锁时再次使加锁
  • 同一协程里,不要对已解锁的锁再次解锁
  • 加了锁后,别忘了解锁,必要时使用 defer 语句
读锁与写锁的互斥原则

主要的互斥原则如下:

  1. 读锁之间不互斥,没有写锁的情况下,读锁是无阻塞的,多个协程可以同时获得读锁。
  2. 写锁之间是互斥的,存在写锁,其他写锁阻塞。
  3. 写锁与读锁是互斥的,如果存在读锁,写锁阻塞,如果存在写锁,读锁阻塞。

先来看一下下面的程序,有四个协程,有两个加写锁,还有两个加读锁,加锁之后分别休眠 1s,表示处理逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

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

var (
lock = sync.RWMutex{}
wg = sync.WaitGroup{}
)

func writeLock(s string) {
defer wg.Done()
lock.Lock()
fmt.Printf("goroutine %s get write lock\n", s)
time.Sleep(1*time.Second)
fmt.Printf("goroutine %s release write lock\n", s)
lock.Unlock()
}

func readLock(s string) {
defer wg.Done()
lock.RLock()
fmt.Printf("goroutine %s get read lock\n", s)
time.Sleep(1*time.Second)
fmt.Printf("goroutine %s release read lock\n", s)
lock.RUnlock()
}

func main() {
wg.Add(4)

go writeLock("A")
go writeLock("B")

go readLock("C")
go readLock("D")

wg.Wait()
}

程序的运行结果如下:

1
2
3
4
5
6
7
8
goroutine A get write lock			// 协程A获得写锁之后,其他协程必须等它释放写锁之后才能加锁 ——验证了写锁与其他的锁互斥
goroutine A release write lock
goroutine D get read lock // 协程C与D在A释放写锁之后,同时获得了读锁,处理逻辑可以并行执行 ——验证了读锁之间不互斥
goroutine C get read lock
goroutine D release read lock
goroutine C release read lock
goroutine B get write lock // 协程B必须要等C和D都释放读锁之后,才能加写锁 ——验证了读锁与写锁互斥,存在读锁,写锁阻塞
goroutine B release write lock