Appearance
6.1810 2024 第10讲:锁
为什么要讨论锁?
- 应用程序希望使用多核处理器以获得并行加速
- 因此内核必须处理并行的系统调用
- 从而并行访问内核数据(缓冲区缓存、进程等)
- 锁有助于正确共享数据
- 锁可能会限制并行加速
如果没有锁会出什么问题?
- 案例研究:删除
kalloc.c中的acquire/release - 启动
- 内核工作正常!
- 运行
usertests- 多次通过!
- 有些会崩溃,我们会丢失一些页面
- 为什么我们会丢失页面?
- 共享内存多处理器的图示
- 两个核心调用
kfree()时发生竞争
糟糕的是:
- 我们需要锁来保证正确性
- 但会损失性能(
kfree被序列化了)
锁的抽象:
lock l
acquire(l)
x = x + 1 -- "临界区"
release(l)- 锁本身就是一个对象
- 如果多个线程调用
acquire(l)- 只有一个会立即返回
- 其他的会等待
release()—— “阻塞”
- 一个程序通常有很多数据,很多锁
- 如果不同的线程使用不同的数据,
- 那么它们可能持有不同的锁,
- 所以它们可以并行执行 —— 完成更多的工作。
- 注意,锁
l并没有特别绑定到数据x- 程序员对对应关系有一个计划
一个保守的规则来决定何时需要锁:
- 任何时候两个线程使用一个内存位置,并且至少有一个是写入操作
- 除非你持有正确的锁,否则不要接触共享数据!
- (太严格了:程序逻辑有时可能会排除共享;无锁)
- (太宽松了:
printf();并不总是简单的锁/数据对应关系)
锁可以是自动的吗?
- 也许语言可以为每个数据对象关联一个锁
- 编译器在每次使用周围添加
acquire/release - 程序员忘记的可能性更小!
- 编译器在每次使用周围添加
- 这个想法通常太僵化了:
if present(table1, key1): add(table2, key1) - 竞争:
- 另一个线程可能在当前线程有机会添加到
table2之前,从table1中删除key1
- 另一个线程可能在当前线程有机会添加到
- 我们需要:
lock table1 lock table2 present(..) add() unlock table1; unlock table2 - 也就是说,程序员通常需要显式控制
- 持有锁的代码区域
- 以便隐藏尴尬的中间状态
思考锁实现了什么的方式
- 锁有助于避免丢失更新
- 锁帮助你创建原子的多步操作 —— 隐藏中间状态
- 锁帮助操作维护数据结构的不变性
- 假设在操作开始时不变性为真
- 操作使用锁来隐藏对不变性的临时违反
- 操作在释放锁之前恢复不变性
问题:死锁
- 注意
rename()持有两个锁 - 如果:
核心 A 核心 B rename(d1/x, d2/y) rename(d2/a, d1/b) lock d1 lock d2 lock d2 ... lock d1 ... - 解决方案:
- 程序员为所有锁制定一个顺序
- 所有代码必须按该顺序获取锁
- 即预测锁,排序,获取 —— 复杂!
锁与模块化
- 锁使得在模块内部隐藏细节变得困难
- 为了避免死锁,我需要知道我调用的函数获取了哪些锁
- 并且我可能需要在调用之前获取它们,即使我不使用它们
- 即,锁通常不是单个模块的私事
锁与并行性
- 锁阻止并行执行
- 为了获得并行性,你通常需要拆分数据和锁
- 以一种让每个核心使用不同数据和不同锁的方式
- “细粒度锁”
- 选择最佳的数据/锁拆分是一个设计挑战
- 整个文件系统;目录/文件;磁盘块
- 整个内核;每个子系统;每个对象
- 你可能需要重新设计代码以使其在并行下良好工作
- 示例:将单个空闲内存列表分解为每个核心的空闲列表
- 如果线程在单个空闲列表的锁上等待很多,这会有所帮助
- 示例:将单个空闲内存列表分解为每个核心的空闲列表
- 这样的重写可能需要大量工作!
锁粒度建议
- 从大锁开始,例如一个锁保护整个模块
- 死锁更少,因为持有两个锁的机会更少
- 需要更少关于不变性/原子性的推理
- 测量以查看是否存在问题
- 大锁通常就足够了 —— 也许在该模块中花费的时间很少
- 仅在需要时才为并行性能重新设计为细粒度锁
让我们看看 xv6 中的锁。
一个典型的锁使用:uart.c
- 许多操作系统设备驱动程序安排的典型
- 图示:
- 用户进程、内核、UART、
uartputc、从uart_tx_buf中移除、uartintr()
- 用户进程、内核、UART、
- 并发源:进程、中断
uart.c中只有一个锁:uart_tx_lock—— 相当粗粒度uartputc()——uart_tx_lock保护什么?uart_tx_buf操作中没有竞争- 如果队列不为空,UART 硬件正在执行队列的头部
- 没有对 UART 写入寄存器的并发访问
uartintr()—— 中断处理程序- 获取锁 —— 可能必须在中断级别等待!
- 从
uart_tx_buf中移除字符 - 将下一个排队的字符交给 UART 硬件 (2)
- 接触 UART 硬件寄存器 (3)
如何实现锁?
- 为什么不这样:c
struct lock { int locked; } acquire(l) { while(1){ if(l->locked == 0){ // A l->locked = 1; // B return; } } } - 糟糕:A 行和 B 行之间存在竞争
- 我们如何原子地执行 A 和 B?
原子交换指令:
assembly
a5 = 1
s1 = &lk->locked
amoswap.w.aq a5, a5, (s1)- 这在硬件中执行:
- 全局锁定地址(其他核心不能使用它)
temp = *s1*addr = a5a5 = temp- 解锁地址
- RISC-V 硬件提供了一种锁定内存位置的概念
- 不同的 CPU 有不同的实现
- 图示:核心、总线、RAM、锁的东西
- 所以我们实际上是将问题推给了硬件
- 硬件在缓存行或整个总线的粒度上实现
- 内存锁强制并发交换一个接一个地运行,而不是交错进行
看看 xv6 的自旋锁实现
c
acquire(l){
while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
}如果
l->locked已经是 1,sync_lock_test_and_set将其(再次)设置为 1,返回 1,- 并且循环继续“自旋”
如果
l->locked是 0,最多只有一个lock_test_and_set会看到 0;它会将其设置- 为 1 并返回 0;其他的
test_and_set将返回 1
- 为 1 并返回 0;其他的
这是一个“自旋锁”,因为等待的核心在
acquire循环中“自旋”push_off()是关于什么的?- 为什么要禁用中断?
release():- 设置
lk->locked = 0 - 并重新启用中断
- 设置
细节:内存读/写顺序
- 假设两个核心使用一个锁来保护一个计数器
x - 我们有一个天真的锁实现
- 核心 A: 核心 B:
locked = 1 x = x + 1 while(locked == 1) locked = 0 ... locked = 1 x = x + 1 locked = 0 - 编译器和 CPU 会重新排序内存访问
- 即它们不遵守源程序的内存引用顺序
- 例如,编译器可能会为核心 A 生成此代码:
locked = 1 locked = 0 x = x + 1- 即将增量移出临界区!
- 合法的行为称为“内存模型”
release()对__sync_synchronize()的调用可防止重排序- 编译器不会将内存引用移动到
__sync_synchronize()之后 - 并且(可能)发出“内存屏障”指令来告诉 CPU
- 编译器不会将内存引用移动到
acquire()对__sync_synchronize()的调用具有类似的效果:- 如果你使用锁,你不需要理解内存排序规则
- 如果你想编写奇特的“无锁”代码,你需要它们
为什么是自旋锁?
- 它们在等待时不浪费 CPU 吗?
- 为什么不放弃 CPU 并切换到另一个进程,让它运行?
- 如果持有线程需要运行;等待线程不应该让出 CPU 吗?
- 自旋锁指南:
- 持有自旋锁的时间非常短
- 持有自旋锁时不要让出 CPU
- 系统为更长的临界区提供“阻塞”锁
- 等待的线程让出 CPU
- 但开销通常更高
- 你稍后会看到一些 xv6 的阻塞方案
建议:
- 如果不必共享,就不要共享
- 从几个粗粒度的锁开始
- 检测你的代码 —— 哪些锁在阻止并行性?
- 仅在需要并行性能时才使用细粒度锁
- 使用自动竞争检测器