一、相关概念
- 互斥锁(mutex):获取锁失败时,线程会被休眠让出cpu。当锁被其它线程释放后,阻塞线程被内核唤醒继续执行。这里会产生2次上下文切换。
- 自旋转锁(cas): 获取锁失败时,线程不会被休眠,而是调用pause指令消耗cpu,直到获取成功。因为线程没被暂停所以不会有上下文切换,但由于需要调用pause指令等待锁被释放,所以在获得锁以前会一直消耗cpu。
- 读写锁:读写锁可以基于互斥锁或自旋转锁来实现,任意时刻支持多个线程读或者一个线程写。
适合场景:如果能确定获取锁后会快速释放,优先使用自旋转锁;如果不确定锁的释放时间或需要者长时间占用锁则使用互斥锁;如果读多写少可以考虑读写锁。
二、互斥锁Mutex
互斥锁的基本思想就是自旋转等待锁释放,拿到则返回;没拿到则进入队列等待,等久了则切换为饥饿模式;如果是饥饿模式,unlock会直接把锁交给队首饥饿的gotoutine。
1.1 Mutex普通模式和饥饿模式的相互切换
- 普通模式:需要拿锁的goroutine在队列(FIFO)中排队等待,被唤醒的goroutine会和新到来的goroutine竞争mutex。新到来的goroutine因为已经在CPU上运行且数量可能很多,所以获得锁的机会明显要比唤醒的goroutine大。在这种情况下被唤醒的goroutine会插入到等待队列前面,如果有goroutine等待时间超过1ms,就会把mutex切换成饥饿模式。
- 饥饿模式:如果处于饥饿模式,unlock释放mutex后,会直接交给等待队列中的第1个goroutine,新到达的goroutine会到队列尾部等待,不会偿试获取mutex或自旋转。
- 饥饿模式转普通模式:如果当前取得mutex的goroutine是队列中的最后一个,或者它的等待时间<1ms,则把mutex置为普通模式。在普通模式下,取得mutex即使需要偿试多次,甚至可能有goroutine阻塞在队列中,但它依然拥有很好的性能,饥饿模式则可以有效的防止尾部延迟。
1.2 结构定义
1 | type Mutex struct { |
1.3 核心代码解释
1 | //获取互斥锁 |
三、读写锁RWMutex
1.1 基本思想
读写锁是对互斥锁Mutex的再次封装,读锁并没有调用系统层面的加锁,而是通过手动控制变量的方式来实现加锁和释放锁,所以获取释放读锁十分高效。获取写锁首先要获得互斥锁Mutex, 再通过把readerCount置为负数通知即将到来的读锁等待,若前面有读锁没释放完,则等待它们释放完的信号;若前面没有读锁,则直接获取到锁。写锁的释放时,如果有读锁在等待,则通过信号通知它们,此时读锁会立即获取到锁,再释放互斥锁。这里需要注意锁的释放顺序,若先释放互斥锁,则会违背读写锁的有序性。
1.2 成员及意义
1 | type RWMutex struct { |
1.3 核心代码解释
1 | func (rw *RWMutex) RLock() { |
四、使用锁需要注意
注意拿锁的顺序:在不同goroutine中获取或释放多把锁,我们应该保证,拿锁的顺序必须始终如一。假如玩家某个操作需要获取A,B,C锁,不管在哪个goroutine中,我们都必须保证同样的获取顺序,如:先获取A,再获取B,然后获取C。如果在另一段代码中不是按照这个顺序,那么当这两段代码在不同goroutine中同时被执行时,就容易产生死锁。
对临界资源的访问尽量通过函数参数实现,不要每个调用的地方都去lock,unlock, 这样即不方便,也容易出错。例如:
1
2
3
4
5
6
7
8
9
10
11func (m *Moto) withMacs(write bool, f func()) {
if write {
m.Lock()
f()
m.Unlock()
} else {
m.RLock()
f()
m.RUnlock()
}
}尽量不要把锁拿到类外部使用,这样随着功能越来越复杂,开发人员越来越多,拿到类外部使用的锁很可能被滥用,甚至失控。