计算机系统应用教程网站

网站首页 > 技术文章 正文

互斥锁是把公平锁 互斥锁锁的是什么

btikc 2024-10-12 10:46:38 技术文章 7 ℃ 0 评论

并发作为go的特性之一,必然会带来对于资源的竞争,虽然我们常说使用channel进行通信,但也可以使用sync.Mutex这把互斥锁来保证临界资源的访问互斥。

既然在代码中经常使用这把锁,那么了解一下其内部原理,就能了解这把锁适用于什么场景了。

一、公平锁

互斥锁有两种模式:正常模式和饥饿模式

正常模式下,所有等待锁的goroutine都会存在于一个先入先出的队列中(轮流被唤醒)

但是一个被唤醒的goroutine并不是直接获取锁,而是仍要和那些新请求锁的goroutine竞争,而这是不公平的,因为新请求锁的goroutine有一个优势----它们正在CPU上运行,并且数量可能很多。所以一个刚被唤醒的goroutine拿到锁的概率是很小的。

在这种情况下,这个被唤醒的goroutine会加入到队列头部。如果一个等待的goroutine超过1ms都没有获取到锁,那么就将锁转变为饥饿模式

在饥饿模式中,锁的所有权会直接从释放锁的goroutine转交给队列头的goroutine,新请求锁的goroutine也不会去获取锁,并且也不会去自旋,它们会排列到队列尾部。

如果一个goroutine获取到锁后,会判断以下两种情况:

  • 它是队列中最后一个goroutine
  • 它拿到锁花费的时间小于1ms

以上只要有一个成立,它就会把锁转成正常模式

正常模式会有比较好的性能,因为即使有很多阻塞等待锁的goroutine,一个goroutine也会尝试请求多次锁。

而饥饿模式对于防止尾部延迟很重要。

尾部延迟:尾部延迟(也称为高百分比延迟)是指客户端很少看到的高延迟。

例如,如果第 99 个百分位数为 1 秒,则 99% 的请求的响应时间低于 1 秒。响应时间分布的上百分位数(如第 99 个百分位数和 99.9 分位数)也称为尾部延迟。

即使一小部分请求遇到这些极端延迟,它往往会影响您最有利可图的用户。这些用户往往是发出最高请求数的用户,因此出现尾部延迟的可能性更高。

二、锁状态

type Mutex struct {
	state int32  // 锁的当前状态
	sema  uint32 // 信号量,用户唤醒goroutine
}

const (
    mutexLocked = 1 << iota // mutex is locked
	mutexWoken
	mutexStarving
	mutexWaiterShift = iota
)

state表示锁的状态,这个字段会同时被多个goroutine共用:

  • mutexLocked:对应低1位bit,代表锁已被占用,0表示锁空闲
  • mutexWoken:对应低2位bit,代表锁已被唤醒,0表示锁未唤醒
  • mutexStarving:对应低3为bit,代表锁处于饥饿模式,0表示正常模式
  • mutexWaiterShift:3(011),m.state>>mutexWaiterShift得到当前阻塞的goroutine数量,最多可以阻塞229个goroutine。

三、流程

1、加锁流程

1、原子判断是否可以加锁,如果当前锁没有被使用,当前goroutine获取锁,结束Lock

2、如果当前锁已被其他goroutine持有,启动for循环去抢占锁:

会存在两种状态的切换:饥饿状态和正常状态

3、如果锁已经被其他goroutine持有,但不是饥饿状态,并且满足自旋,那么当前goroutine会不断自旋(不会再饥饿模式下自旋)

4、不满足自旋状态的goroutine结束自旋

5、此时state的状态可能是(都处于唤醒状态)

  • 锁没有释放,锁处于正常状态(011)
  • 锁没有释放,锁处于饥饿状态(111)
  • 锁已经释放,锁处于正常状态(010)
  • 锁已经释放,锁处于饥饿状态(110)

6、如果不是饥饿状态,新的goroutine会去尝试获取锁,如果是饥饿状态,直接把锁交给等待队列的队头

7、如果锁处于被获取或饥饿状态,等待队列数量+1

8、当本goroutine被唤醒了,要么持有锁,要么重新进入休眠状态

9、如果old.state是未锁状态,并且锁不是饥饿状态,那么当前goroutine获取锁,结束Lock

10、如果当前goroutine是新来的或刚被唤醒的,新来的加入到队列尾部,刚被唤醒的加入队列头部,然后信号量阻塞,直到goroutine被唤醒

11、判断当前state是否处于饥饿状态,不是则唤醒本次goroutine,继续循环

12、如果是饥饿状态,当前goroutine获取锁,等待者-1,如果当前goroutine是队列的最后一个,将锁设为正常模式。

自旋条件

  • 自旋的次数小于4
  • 单核cpu不能自旋
  • GOMAXPROCS>1,并且至少有一个其他正在运行的P,并且本地runq为空
  • 当前P没有其他等待运行的G

2、解锁流程

1、判断锁的状态,不能重复解锁

2、如果锁是正常模式,会不断尝试解锁

3、如果锁是饥饿模式,通过信号量,唤醒饥饿模式下等待队列的第一个goroutine

四、为什么公平

为什么不是绝对公平?

绝对公平的做法应该是,在锁被占用后,其他所有的竞争者,包括新来的,全部排列到队尾。

而排队的问题很明显,排序阻塞唤醒的切换成本很高。

假如临界区代码执行很快,让竞争者自旋等待一下,就能立即获取锁,效率会更高。

所以在go中,公平的做法是让新来的goroutine和唤醒被阻塞的goroutine一起竞争锁。

下面用个实例来表示

时刻

锁的所有竞争者

抢锁

抢锁成功者

等待队列

持锁时间/运行总时间

锁模式

0

G0,G1,G2,G3

LOCK

G0

G1,G2,G3

0.3ms/0ms

正常模式

1

G1,G4

LOCK

G4

G1,G2,G3

0.3ms/0.3ms

正常模式

2

G1,G5

LOCK

G1

G2,G3,G5

0.3ms/0.6ms

正常模式

3

G2,G6

LOCK

G6

G2,G3,G5

0.3ms/0.9ms

正常模式

4

G2,G7

LOCK

G2

G3,G5,G7

0.3ms/1.2ms

饥饿模式

5

G3,G8

LOCK

G3

G5,G7,G8

0.3ms/1.5ms

饥饿模式

6

G5,G9

LOCK

G5

G7,G8,G9

0.3ms/1.8ms

饥饿模式

7

G7,G10

LOCK

五、总结

  • 加锁过程会存在正常模式和饥饿模式的转换
  • 饥饿模式时保证锁的公平性,正常模式下能提供更好的性能,饥饿模式下能避免goroutine陷入等待无法获取锁造成的尾部延迟
  • 锁的切换用的是原子锁
  • 一个锁只能被Lock一次,且只能被UnLock一次

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表