网站首页 > 技术文章 正文
并发作为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一次
- 上一篇: Mutex互斥锁设计思路详解和源码探究
- 下一篇: 你了解多线程自旋锁、互斥锁、递归锁等锁吗?
猜你喜欢
- 2024-10-12 漫画 | Linux 并发和竞态问题究竟是什么?
- 2024-10-12 【驱动】串口驱动分析(三)-serial driver
- 2024-10-12 synchronized锁 synchronized锁的是类还是对象
- 2024-10-12 Golang 程序遇到性能问题该怎么办?
- 2024-10-12 线程间通信——互斥锁 线程间互斥方式
- 2024-10-12 【Linux系统编程】互斥锁 linux 互斥锁优先级反转
- 2024-10-12 linux c/c++开发:多线程并发锁:互斥锁、自旋锁、原子操作、CAS
- 2024-10-12 每行代码都带注释,带你看懂Go互斥锁的源码
- 2024-10-12 一文搞懂pprof 一文搞懂伤寒论六经辨证
- 2024-10-12 并发原理系列八:信号量、互斥锁、自旋锁
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- oraclesql优化 (66)
- 类的加载机制 (75)
- feignclient (62)
- 一致性hash算法 (71)
- dockfile (66)
- 锁机制 (57)
- javaresponse (60)
- 查看hive版本 (59)
- phpworkerman (57)
- spark算子 (58)
- vue双向绑定的原理 (68)
- springbootget请求 (58)
- docker网络三种模式 (67)
- spring控制反转 (71)
- data:image/jpeg (69)
- base64 (69)
- java分页 (64)
- kibanadocker (60)
- qabstracttablemodel (62)
- java生成pdf文件 (69)
- deletelater (62)
- com.aspose.words (58)
- android.mk (62)
- qopengl (73)
- epoch_millis (61)
本文暂时没有评论,来添加一个吧(●'◡'●)