本文基于 5.4.86 版本内核
mutex可视作是 spinlock 的可睡眠版本,同样是线程无法继续向前执行,但 spinlock 是"spin",导致该 CPU 上无法发生线程切换,而 mutex 是"block"(我们通常翻译成「阻塞」),可以发生线程切换,让所在 CPU 上的其他线程继续执行。阻塞既可以发生在线程试图获取 mutex 时,也可以发生在线程持有 mutex 时。
现在的 mutex 机制,要从这几方面纬度理解:
- optimistic spin 机制
- osq lock 机制(见前面文章《Linux中的锁机制 —— osq lock》、《Linux中的spinlock机制[二] - MCS Lock》)
- handoff 机制
- mutex lock fast/mid/slow 流程
- 锁的交接过程
乐观自旋机制
在早期(2016年前)的mutex实现中,使用了一个单独的"count"来记录mutex的持有情况,为1表示空闲,0表示持有,负数表示有其他线程等待。不带乐观自旋的 mutex 版本中,若 A 线程持有锁且正在运行,此时 B 线程想拿锁,发现锁正被 A 拿着,B 将立即进入睡眠状态,在 A 线程释放锁后再去唤醒 B 线程来拿锁。但是我们通常认为持有锁的 A 若是在运行的情况下,通常会很快的退出临界区并释放锁,那么 B 去睡眠则是不必要的,毕竟睡眠和唤醒也是有代价的,完全可以多等一会即可。那么当 mutex 开启乐观自旋的 feature 后,若 B 拿不到锁,且发现锁的持有者 A 仍占用着 CPU 运行时,则不再去睡眠,而是像自旋锁一样进行自旋等待,直到 A 释放锁,但期间若 A 失去 CPU(即p->on_cpu为0)调度出去,那么 B 也不会继续傻傻的自旋等待,而是进入睡眠。
唤醒睡眠线程可能需要很长时间,一旦该线程开始运行,它可能会发现处理器缓存不包含任何数据,从而导致过时的缓存未命中。相反,spin 等待 mutex 的线程将能够在它可用时快速获取锁,并且可能仍然是 cache hot 的。很显然,乐观自旋机制能提高 mutex 和整体的性能。
本文会介绍 乐观自旋 和 wait list 和 osq list 如何工作的。
- 只会有一个 task 进行 mutex 乐观自旋
- osq list 上也只有一个任务在自旋
- osq list 上的等待者结束自旋后会移入 wait list
- wait list 上的第一个等待者也会进行乐观自旋
这里就有几个角色:
the first osq list waiter 较快获取锁
the other osq list waiter 慢
the first wait list waiter 较快获取锁
the other wait list waiter 慢
the mutex spiner 最快获取
mutex_spin_on_owner()
mutex_spin_on_owner()在给的 owner 上自旋,直到以下三个条件之一达成,就会退出自旋:
- 如果 owner 不在 cpu 上运行了
- 如果设置了重新调度请求标志
- 如果 lock owner 发生了变化了
下图是一多任务竞争 mutex 的场景:
- cpu0 首先获取 mutex lock 进入临界区,cpu1 持有 osp lock 进行 mutex spin, cpu2 在尝试获取 cpu 1 的 osq lock 而 spin
- cpu0 释放 mutex lock, cpu1 获取 mutex lock 进入临界区
- cpu1 task 调度出去,正在睡眠;cpu 2 的 osq list 的等待者不再 spin,而加入 wait list 成为第一等待者;cpu3 也加入 wait list 成为第二等待者
- cpu1 唤醒后,继续执行临界区代码,再释放 mutex lock,cpu2 获取 mutex 进入临界区
- cpu2 释放 mutex lock,cpu3 获取 mutex 进入临界区
可以认为 mutex 整体 3 个路径:
fast path: 直接获取锁
mid path: mutex spin; osq spin; osq list waiter
slow path: wait list 等待
看下图,我们来看一个多 task 交错获取 mutex 的场景。
- cpu0 fastpath 获取 mutex
- cpu1 在 mutex spin 等待 cpu0,cpu2 在 osq list spin 等待 cpu0,但是 cpu1 受到 reshedule 请求,所以只能调度出去
- cpu2 在 midpath 的 osq_lock list 里 spinning, 由于 cpu1 unlock osq_lock 并调度出去,cpu2 此时获取 osq_lock 进入 mutex spin 等待 cpu0 释放 mutex,此时 cpu2 是第一继承人
- cpu0 释放 mutex, cpu2 获取该 mutex 进入临界区
- cpu2 释放 mutex, 唤醒 wait list,其中 cpu1 退出 mutex spin 后,就加入了 wait list,并且它成为 wake list 的第一个 waiter,cpu2 通过 handoff 机制把 mutex 递交给 cpu1
- cpu1 获取 mutex 并进入临界区,cpu3 此时在 mutex spinning 等待 cpu1
- cpu1 释放 mutex,cpu3 立刻获取到 mutex
- cpu3 获取 mutex 并进入临界区,而后释放 mutex
参考:http://jake.dothome.co.kr/mutex/