Operating System Chapter5 并发控制:互斥
Operating System
$Nanjing\ University\rightarrow Yanyan\ Jiang\newline$
Update in 2023-03-08 17:30
继续看,并发真™️👨
Overview
复习
- 状态机、状态机、状态机
本次课回答的问题
- Q: 如何在多处理器上实现线程互斥?
本次课主要内容
- 自旋锁的实现
- 互斥锁的实现
共享内存上的互斥
回顾:并发编程
理解并发的工具
- 线程 = 人 (大脑能完成局部存储和计算)
- 共享内存 = 物理世界 (物理世界天生并行)
- 一切都是状态机
回顾:互斥算法
互斥 (mutual exclusion),“互相排斥”
- 实现
lock_t
数据结构和lock/unlock
API:
|
|
一把 “排他性” 的锁——对于锁对象 lk
- 如果某个线程持有锁,则其他线程的
lock
不能返回
在共享内存上实现互斥
失败的尝试
(部分) 成功的尝试
实现互斥的根本困难:不能同时读/写共享内存
- load (环顾四周) 的时候不能写,只能 “看一眼就把眼睛闭上”
- 看到的东西马上就过时了
- store (改变物理世界状态) 的时候不能读,只能 “闭着眼睛动手”
- 也不知道把什么改成了什么
- 这是
简单、粗暴 (稳定)、有效的《操作系统》课
自旋锁 (Spin Lock)
解决问题的两种方法
提出算法、解决问题 (Dekker/Peterson/…’s Protocols)
或者……
改变假设 (软件不够,硬件来凑$\Longrightarrow$ x86架构的风格,软件指令做不好直接写一个指令集让硬件这么做hhh)
假设硬件能为我们提供一条 “瞬间完成” 的读 + 写指令
- 请所有人闭上眼睛,看一眼 (load),然后贴上标签 (store)
- 如果多人同时请求,硬件选出一个 “胜者”
- “败者” 要等 “胜者” 完成后才能继续执行
x86 原子操作:LOCK
指令前缀
例子:sum-atomic.c
sum = 200000000
Atomic exchange (load + store)
|
|
-
重点就是asm内联汇编那句,这里是“一气呵成”的
-
更多的原子指令:stdatomic.h (C11)
用 xchg
实现互斥(也就是自旋锁的本质实现过程)
- xchg在之前的文章中有提到过
如何协调宿舍若干位同学上厕所问题?
- 在厕所门口放一个桌子 (共享变量)
- 初始时,桌上是 🔑
实现互斥的协议
- 想上厕所的同学 (一条 xchg 指令)
- 天黑请闭眼
- 看一眼桌子上有什么 (🔑 或 🔞)
- 把 🔞 放到桌上 (覆盖之前有的任何东西)
- 天亮请睁眼;看到 🔑 才可以进厕所哦
- 出厕所的同学
- 把 🔑 放到桌上
实现互斥:自旋锁
|
|
- 重要的还是xchg这个东西,保证那一步是原子性的,就没有其他事情了
实现互斥:自旋锁 (cont’d)
并发编程:千万小心
- 做详尽的测试 (在此省略,你们做 Labs 就知道了)
- 尽可能地证明 (model-checker.py 和 spinlock.py)
原子指令的模型
- 保证之前的 store 都写入内存
- 保证 load/store 不与原子指令乱序
原子指令的诞生:Bus Lock (80486)
Update in 2023-03-09 10:00
早起开完SB大会,✋接着看
咖啡机里面的咖啡真提神,估计下午毛概得狠狠睡一把
- 486 (20-50MHz) 就支持
dual-socket
了
-
如果CPU1需要执行
add x, 1
,那么需要分成3步- 从memory里面load
- 再把这个值add 1之后先放入一个内部的寄存器里
- 再store把m的值写回内存里
-
为什么486时代会诞生LOCK这样的指令呢?
- 因为涉及了两个CPU面对同一个memory的状况,所以需要对memory进行lock的操作
-
设计方法
- x86的指令集有指令的前缀(比如
rep
),我们让lock
也变成一个指令前缀
- x86的指令集有指令的前缀(比如
|
|
- 当CPU开始读指令的时候,它会先读到lock指令,这个时候就上锁(也即是对应的CPU拿到了总线的锁)
- 等到CPU拿到总线的锁之后,它会再执行后面的部分
- 把有关指令执行完成之后再把锁释放掉
负担
- 今天的CPU与memory之间都有各自的cache,而且cache已经和CPU集成在一个元件上面了,而且是多级cache
- 486时代仅仅只有外面的一块cache(而且这块cache在主板上),所以说只要CPU拿到了总线🔒的控制权,就把自己和外面的一切隔离开了
-
多级cache会使得lock变得很麻烦
-
例子:一个数据在两个CPU的cache里面都有副本
- 这个时候如果CPU1要lock访问m的话,就要将cache21里面的m副本“踢掉” $\Longrightarrow$ 也就是今天inter一个很大的历史包袱:缓存的一致性 $\Longrightarrow$ 解决方案:将所有的cache用总线连接起来
- 一旦其中有一个数据需要被lock,那么总线需要遍历一遍所有CPU的cache,如果发现了该数据的副本,就要将该数据从其他CPU的cache里面“踢掉”,这是很浪费时间的
Lock 指令的现代实现
在 L1 cache 层保持一致性 (ring/mesh bus)
- 相当于每个 cache line 有分别的锁
- store(x) 进入 L1 缓存即保证对其他处理器可见
- 但要小心 store buffer 和乱序执行
L1 cache line 根据状态进行协调
- M (Modified), 脏值
- E (Exclusive), 独占访问
- S (Shared), 只读共享
- I (Invalid), 不拥有 cache line
RISC-V: 另一种原子操作的设计
考虑常见的原子操作:
- atomic test-and-set
reg = load(x); if (reg == XX) { store(x, YY); }
- lock xchg
reg = load(x); store(x, XX);
- lock add
t = load(x); t++; store(x, t);
它们的本质都是
- load
- exec (处理器本地寄存器的运算)
- store
Load-Reserved/Store-Conditional (LR/SC)
- LR: 在内存上标记 reserved (盯上你了),中断、其他处理器写入都会导致标记消除
- 我先读,读完之后在这个物品上面做一个标记
- 标记一直在物品上,同时在做本地的计算
- 本地计算算完了,该写了
- 写的时候要先检测标记,只有标记还在的时候才可以写,否则返回fail,只能再次尝试重新上锁写入
- 本地内存的计算不重要,重要的是共享内存的计算
|
|
- SC: 如果 “盯上” 未被解除,则写入
|
|
Compare-and-Swap 的 LR/SC 实现
|
|
|
|
- lr,sc还可以检测🔒的拥堵程度
LR/SC 的硬件实现
-
BOOM (Berkeley Out-of-Order Processor)
-
留意s2_sc_fail的条件
- s2 是流水线 Stage 2
-
(yzh 扒出的代码)
互斥锁 (Mutex Lock)
自旋锁的缺陷
-
性能问题 (0)
- 自旋 (共享变量) 会触发处理器间的缓存同步,延迟增加(总线遍历cache踢副本)
-
性能问题 (1)
-
除了进入临界区的线程,其他处理器上的线程都在空转(看自旋锁的实现代码,没有拿到🔒的线程在空转死循环)
- 会造成多CPU机器利用率过低的问题(就有🔒的在工作)
-
争抢锁的处理器越多,利用率越低
-
-
性能问题 (2)
- 获得自旋锁的线程可能被操作系统切换出去(出现在单CPU时间片轮转的条件下,轮转时间片导致拿🔒的线程休眠,相当于你拿着🔒回宿舍睡觉,全教室的人只能等着你 $\Longrightarrow$ 还在死循环里面空转)
- 操作系统不 “感知” 线程在做什么
- (但为什么不能呢?) $\Longrightarrow$ 课后思考
- 获得自旋锁的线程可能被操作系统切换出去(出现在单CPU时间片轮转的条件下,轮转时间片导致拿🔒的线程休眠,相当于你拿着🔒回宿舍睡觉,全教室的人只能等着你 $\Longrightarrow$ 还在死循环里面空转)
-
实现 100% 的资源浪费
Scalability: 性能的新维度
同一份计算任务,时间 (CPU cycles) 和空间 (mapped memory) 会随处理器数量的增长而变化。
注意和数据结构里面的计算数据量n区别,这里面衡量的标准是依据处理器的数量而定的
- sum-scalability.c,stat.py
- thread-sync.h
- 严谨的统计很难
- CPU 动态功耗(散热跟不上只能做一峰⌚真👨(悲 )
- 系统中的其他进程
- ……
- Benchmarking crimes
sum-scalability.c,里面用的就是自旋锁,在自旋锁的保护下执行sum++
|
|
自旋锁的实现(来自thread-sync.h
)
|
|
Update in 2023-03-09 15:20
绷不住直接😪了,睡醒继续
- 运行
|
|
- 现象:同样的工作量,线程越多,效率越低
自旋锁的使用场景
- 两个重要的约束
- 临界区几乎不“拥堵”$\Longrightarrow$ 几乎就只有一个线程进入临界区 $\Longrightarrow$ 这样争抢🔒的状况就会很少
- 持有自旋锁时禁止执行流切换 $\Longrightarrow$ 翻译成上面的人话就是禁止拿着🔒回宿舍😴 $\Longrightarrow$ 但是,我们的应用程序是做不到这一点的(如果应用程序能够防止自己在时间片轮转的时候被切出去,那么一个while(1);就能让电脑崩溃,操作系统是绝对禁止这样的事情的)
使用场景:操作系统内核的并发数据结构 (短临界区)
- 操作系统可以关闭中断和抢占 $\Longrightarrow$ 这就是和应用程序不一样的地方
- 保证锁的持有者在很短的时间内可以释放锁
- (如果是虚拟机呢…😂)
- PAUSE 指令会触发 VM Exit $\Longrightarrow$ 防止有故障把物理机给卡死
- 但依旧很难做好
- An analysis of Linux scalability to many cores (OSDI'10) (10年paper,吐槽Linux内核的自旋锁有多烂)
实现线程 + 长临界区的互斥
作业那么多,与其干等 Online Judge 发布,不如把自己 (CPU) 让给其他作业 (线程) 执行?
说白了就是有事情卡住了就去干别的,别就瞎🐔8️⃣干等着这一件事(这也就是比自旋锁高明的地方)
“让” 不是 C 语言代码可以做到的 (C 代码只能计算)
-
把锁的实现放到操作系统里就好啦!$\Longrightarrow$ 系统调用
-
syscall(SYSCALL_lock, &lk);
- 试图获得
lk
,但如果失败,就切换到其他线程 $\Longrightarrow$ 这个就是精髓
- 试图获得
-
syscall(SYSCALL_unlock, &lk);
- 释放
lk
,如果有等待锁的线程就唤醒
- 释放
-
Update in 2023-03-09 19:15
干拌面吃完真撑的慌,下回还是吃汤面
晚上看完
实现线程 + 长临界区的互斥 (cont’d)
-
操作系统 = 更衣室管理员
-
先到的人 (线程)
- 成功获得手环,进入游泳馆
*lock = 🔒
,系统调用直接返回
-
后到的人 (线程)
- 不能进入游泳馆,排队等待
- 线程放入等待队列,执行线程切换 (yield)
-
洗完澡出来的人 (线程)
-
交还手环给管理员;管理员把手环再交给排队的人
-
如果等待队列不空,从等待队列中取出一个线程允许执行
-
如果等待队列为空,
*lock = ✅
-
-
管理员 (OS) 使用自旋锁确保自己处理手环的过程是原子的
Futex = Spin + Mutex
关于互斥的一些分析
-
自旋锁 (线程直接共享 locked)
-
更快的 fast path
- xchg 成功 → 立即进入临界区,开销很小
-
更慢的 slow path
- xchg 失败 → 浪费 CPU 自旋等待(交换一些不是🔒,没用的东西)
-
-
睡眠(互斥)锁 (通过系统调用访问 locked)
-
更快的 slow path
- 上锁失败线程不再占用 CPU
-
更慢的 fast path
- 即便上锁成功也需要进出内核 (syscall) $\Longrightarrow$ 速度相对比较慢
-
Futex: Fast Userspace muTexes
解决方案:全都要
-
性能优化的最常见技巧
- 看 average (frequent) case 而不是 worst case $\Longrightarrow$ 这里是和算法题做对比,算法题要保证所有可能的情况都可以被高效计算(worst case),而操作系统要求的则是大部分情况都可以被快速处理,少数的情况处理地慢也可以(average)
-
POSIX 线程库中的互斥锁 (
pthread_mutex
) $\Longrightarrow$ 两种🔒的好处都得了 -
我们将sum-scalability.c里面的自旋锁换成互斥锁
|
|
- 互斥锁的实现(通过syscall)
|
|
- 编译+测试
|
|
-
可以看到当线程数量变多的时候,
pthread_mutex
处理的效率要远高于自旋锁 -
观察系统调用 (strace) $\Longrightarrow$ 太长了就复制一段
|
|
- 里面有很多类似73行的语句
[pid 4616] futex(0x55d83cf30060, FUTEX_WAIT_PRIVATE, 2, NULL <unfinished ...>
一、什么是futex?
futex是Fast Userspace muTEX的缩写,该机制是由Rusty Russell、Hubertus Franke和Mathew Kirkwood在2.5.7版本的内核中引入,虽然名字中有互斥锁(mutex)的含义,但实际它是一种用于用户空间应用程序的通用同步工具(基于futex可以在userspace实现互斥锁、读写锁、condition variable等同步机制)。Futex组成包括:
- 内核空间的等待队列
- 用户空间层的32-bit futex word(所有平台都是32bit,包括64位平台)
在没有竞争的场景下,锁的获取和释放性能都非常高,不需要内核的参与,仅仅是通过用户空间的原子操作来修改futex word的状态即可。在有竞争的场景下,如果线程无法获取futex锁,那么把自己放入到 wait queue中(陷入内核,有系统调用的开销),而在owner task释放锁的时候,如果检测到有竞争(等待队列中有阻塞任务),就会通过系统调用来唤醒等待队列中的任务,使其恢复执行,继续去持锁。如果没有竞争,那么也无需陷入内核。
摘自:《什么是futex?》
Futex(Fast Userspace Mutex)是一种用户空间锁,它是Linux内核提供的一种同步原语,用于控制多个进程或线程之间的访问共享资源。Futex主要用于实现更高级别的同步原语,例如条件变量和读写锁。Futex提供了一种低开销的锁机制,当锁被持有时,它可以避免将线程或进程阻塞在内核空间。
在Futex中,锁的状态保存在用户空间中,而不是内核空间中,当线程或进程需要获取锁时,它会尝试将锁的状态从“未锁定”改为“锁定”状态。如果成功,线程或进程就可以访问共享资源。如果锁已被其他线程或进程占用,则它将在用户空间内忙等待,直到锁被释放。
Futex还支持一些其他的操作,例如等待一个特定的值或比较并交换值,这些操作可以用于实现条件变量和读写锁等高级别的同步原语。由于Futex的低开销和高效性,它成为了Linux系统中许多高级别同步原语的基础。
来自ChatGPT
-
如果我们仔细观察就会发现,futex调用的数量远远小于lock和unlock的数量
-
gdb 调试
set scheduler-locking on
,info threads
,thread X
Futex: Fast Userspace muTexes (cont’d)
先在用户空间自旋
- 如果获得锁,直接进入
- 未能获得锁,系统调用
- 解锁以后也需要系统调用
- futex.py
- 更好的设计可以在 fast-path 不进行系统调用
futex.py
|
|
-
python代码的优势:如果你想让一句代码原子执行——>加入
yield breakpoint()
-
python3 model-checker.py futex.py | python3 visualize.py -t > a.html
重定向,可视化
RTFM (劝退)
- futex (7), futex (2)
- A futex overview and update (LWN)
- Futexes are tricky (论 model checker 的重要性) $\Longrightarrow$ 同时也提示我们从最简单的写起,千万不要觉得自己行就装🖊搞大的
- (我们不讲并发算法)
总结
本次课回答的问题
- Q: 如何在多处理器系统上实现互斥?
Take-away message
- 软件不够,硬件来凑 (自旋锁)
- 用户不够,内核来凑 (互斥锁)
- 找到你依赖的假设,并大胆地打破它
- Fast/slow paths: 性能优化的重要途径
声明:本文章引用资料与图像均已做标注,如有侵权本人会马上删除