相关概念:
原子操作:
指那些不能够被打断的操作被称为原子操作,当有一个CPU在访问这块内容地址时,其他CPU就不能访问。
互斥锁:
用于保护临界区,确保同一时间只有一个线程访问数据(挂起,通过休眠来使线程阻塞)。
自旋锁:
指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断锁能否够被成功获取,直到拿到锁才会退出循环。获取锁的线程持续活跃,不挂起(不是通过休眠来使进程阻塞),继续占有cpu
饥饿问题:
一些悲惨的G长时间获取不到锁,导致业务逻辑不能继续完整执行。而当前正在cpu上运行的goroutine可能会更先获取到锁,比如自旋锁。
描述 |
公平性 |
|
正常模式 |
所有goroutine按照FIFO的顺序进行锁获取,被唤醒的goroutine和新请求锁的goroutine同时进行锁获取,通常新请求锁的goroutine更容易获取锁(持续占有cpu),被唤醒的goroutine则不容易获取到锁 |
否 |
饥饿模式 |
所有尝试获取锁的goroutine进行等待排队,新请求锁的goroutine不会进行锁获取(禁用自旋),而是加入队列尾部等待获取锁 |
是 |
新请求锁的goroutine更容易获取锁的原因:
用官方话说就是,新请求锁的 Goroutine具有优势,它正在CPU上执行,而且可能有好几个,所以刚刚唤醒的 Goroutine 有很大可能在锁竞争中失败.
饥饿模式:
解决问题:
主要解决了等待G队列的长尾问题(先进先出队列尾部的等待者一直无法获取到 mutex 的情况),防止G被饿死。但性能较低(由于新进入的活跃G起初处于自旋状态(消耗CPU资源),所以避免了G的切换调度)
执行过程:
饥饿模式下,直接由unlock把锁交给等待队列中排在第一位的G,同时,饥饿模式下,新进来的G不会参与抢锁也不会进入自旋状态(禁用自旋),会直接进入等待队列的尾部排队。
触发条件:
(1) 当一个G等待锁时间超过1毫秒时,Mutex切换到饥饿模式
取消条件:
(1) 当一个G获取到锁且在等待队列的末尾(等待队列的所有任务执行完了),那么Mutex切换回正常模式
(2) 这个G获取锁的等待时间在1ms内,那么Mutex切换回正常模式
Mutex结构:
编辑
type Mutex struct { state int32 // 表示锁当前的状态 sema uint32 // 信号量 用于向处于Gwaitting的G发送信号 } // 状态值: sema是个信号量,用来唤醒goroutine,初始为0,用于判断是否有可用资源。没有则一直等待。 state是一个4字节(32位)的变量,由于4部分组成,是锁的本体 (1) 0位判断当前锁是否上锁(锁定标志位,0:未锁定 1:锁定) (2) 1位判断当前锁是否是被其他goroutine唤醒的(唤醒标志位,0:未唤醒 1:唤醒) (3) 2位判断当前锁是否处于饥饿状态 (4) 3-31位用于计算当前等待的goroutine数量
关于锁的使用建议:
1. 写业务时不能全局使用同一个 Mutex
2. 千万不要将要加锁和解锁分到两个以上 Goroutine 中进行(容易形成死锁)
3. Mutex 千万不能被复制(包括不能通过函数参数传递),否则会复制传参前锁的状态:已锁定 or 未锁定。很容易产生死锁,关键是编译器还发现不了这个 Deadlock~
4. 尽量避免使用 Mutex,如果非使用不可,尽量多声明一些 Mutex,采用取模分片的方式去使用其中一个 Mutex(分段锁)(尽量减小锁的颗粒度)
更多可参考大佬文章:
2. 这可能是最容易理解的 Go Mutex 源码剖析 (什么是 Goroutine 排队?)