Golang学习 - sync.Mutex
hanpy

sync.Mutex 的简单使用

一、前导概念

竞态条件

锁是一种并发编程中的同步原语(Synchronization Primitives),它能保证多个 Goroutine 在访问同一片内存时不会出现竞争条件(Race condition)等问题。

竞态条件:一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破坏共享数据的一致性。

临界区

多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section)

image

二、sync.Mutex

sync.Mutex 是一个互斥锁,保证同时只有一个Goroutine可以访问共享资源。Mutex 类型的锁和 Goroutine 无关,可以由不同的 Goroutine 加锁和解锁。也可以为其他结构体的字段,零值为解锁状态。

互斥锁可以看做是进入临界区的令牌,在进入之前申请

2.1、结构和方法

结构体

1
2
3
4
type Mutex struct {
state int32
sema uint32
}

方法列表

方法名 描述
(m *Mutex) Lock() 方法锁住m,如果 m 已经加锁,则阻塞直到 m 解锁。
(m *Mutex) Unlock() 解锁 m,如果 m 未加锁会导致运行时错误。

2.2、简单的使用

sync.Mutex 是开箱即用的,就是声明了之后就可以使用

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
43
44
45
46
47
48
49
50
51
52
package main

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

// 声明全局锁
var mu sync.Mutex

// 声明全局总量
var total int = 10

func main() {
fmt.Println("Main Start")

// 多个协程同时去减total
go subTotal("G1", &mu)
go subTotal("G2", &mu)
go subTotal("G3", &mu)

time.Sleep(time.Second * 1)
fmt.Println("Main Stop!")
}

func subTotal(gName string, mu *sync.Mutex) {
for {
mu.Lock()
if total == 0 {
mu.Unlock()
break
}
time.Sleep(10 * time.Millisecond)
total--
fmt.Printf("%s 执行减减,剩余: %d \n", gName, total)
mu.Unlock()
}
}

Main Start
G3 执行减减,剩余: 9
G3 执行减减,剩余: 8
G2 执行减减,剩余: 7
G1 执行减减,剩余: 6
G3 执行减减,剩余: 5
G2 执行减减,剩余: 4
G1 执行减减,剩余: 3
G3 执行减减,剩余: 2
G2 执行减减,剩余: 1
G1 执行减减,剩余: 0
Main Stop!

互斥锁使用注意事项:

  1. 不要重复锁定互斥锁
  2. 不要忘记解锁互斥锁,必要时使用defer语句
  3. 不要对尚未锁定或者已解锁的互斥锁解锁
  4. 不要在多个函数之间直接传递互斥锁

2.3、数据结构

1
2
3
4
type Mutex struct {
state int32
sema uint32
}

state:代表互斥锁的状态
sema:互斥锁的信号量,mutex的阻塞队列的定位是通过这个变量来实现的,从而可以实现goroutine的阻塞和唤醒

互斥锁的状态

最低三位分别表示 mutexLockedmutexWokenmutexStarving,剩下的位置用来表示当前有多少个 Goroutine 在等待互斥锁的释放

image

默认情况下,互斥锁的所有状态位都是 0,int32 中的不同位分别表示了不同的状态:

mutexLocked — 表示互斥锁的锁定状态;
mutexWoken — 表示从正常模式被从唤醒;
mutexStarving — 当前的互斥锁进入饥饿状态;
waitersCount — 当前互斥锁上等待的 Goroutine 个数;

2.4、正常模式和饥饿模式

正常模式(非公平锁):锁的等待者会按照先进先出的顺序获取锁,唤醒的gotouine不会直接拥有锁,而是会和新请求竞争锁,因为新请求正在运行,获取被唤醒的goroutine大概率不会获取锁,一旦 Goroutine 超过 1ms 没有获取到锁,就会切换到饥饿模式

饥饿模式(公平锁):锁会直接给等待队列中的第一个goroutine,新的请求不会自旋也不会竞争锁,会直接放在等待队列的最后一位。 满足下面条件中的一个就会退出饥饿模式,G的执行时间小于1ms;等待队列清空

正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

2.5、加锁

go/src/sync/mutex.go

1
2
3
4
5
6
7
8
9
10
11
12
func (m *Mutex) Lock() {
// 判断锁的状态是不是0,如果是0就标记为mutexLocked状态
// 互斥锁的第1个阶段是使用原子操作快速抢占锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// 尝试通过自旋(Spinnig)等方式等待锁的释放
m.lockSlow()
}

加锁过程

  1. 互斥锁的第1个阶段是使用原子操作快速抢占锁,如果抢占成功则 立即返回,如果抢占失败则调用lockSlow方法。
  2. 互斥锁的第2个阶段,使用信号量 进行同步。当信号量计 数值大于0时,意味着有其他协程执行了解锁操作,这时加锁协程可以 直接退出。当信号量计数值等于0时,意味着当前加锁协程需要陷入休 眠状态。

互斥锁自旋

自旋是一种多线程同步机制,当前的线程在进入自旋的过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用恰当会对性能带来很大的增益,但是使用的不恰当就会拖慢整个程序。

Goroutine 进入自旋的条件

  1. 互斥锁只有在普通模式才能进入自旋
  2. 运行在多 CPU 的机器上
  3. 当前 Goroutine 为了获取该锁进入自旋的次数小于四次
  4. 当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空

2.6、解锁