1.为什么要讨论locking
呢?
应用程序希望使用多核
处理器并行加速
因此内核必须处理并行
系统调用
从而并行访问内核数据(缓存、进程和c)
locks有助于正确地共享数据
locks可以限制并行加速
2.Locking homework
recall: ph.c, multi-threaded hash table, put(), get()
vi ph0.c
问:为什么要在多核
处理器上运行ph.c ?
diagram: CPUs, bus, RAM
假设:CPU是瓶颈,可以在CPU之间划分工作
问:我们能打败单核put()时间吗?单核get()的时间吗?
./ph0 1
问:如何测量并行加速?
./ph0 2
单位时间的工作量是原来的两倍!
问:the missing keys
在哪里?
问:具体情况?
diagram…
table[0] = 15
(并发)concurrent put(5), put(10)
两个insert()都分配新entry,指向15
两者都将表[0]设置为它们的新entry
最后一个插入方赢了,另一个输了!
称为“lost update”;
“race”的例子。race = concurrent accesses;至少写一个
问:lock/unlock
放在哪里?
一个lock覆盖整个hash table
?
why? why not?
称为“big”或“coarse-grained”(粗粒度)lock
./ph1 2
faster? slower? why?
问:每个table[] entry
一个锁?
这个锁是“finer grained”(细粒度)的
为什么这是好事呢?
./ph2 2
faster? slower? why?
对于per-bucket locks,什么可能更困难?
我们会得到一个良好的有着10核心的加速?NBUCKET = 5…
问:每个struct entry一个lock,保护下一个指针?
why? why not?
问:get()
需要锁定吗?
问:如果存在并发的put()
, get()
是否需要锁定吗?
3.The lock abstraction:
lock l
acquire(l)
x = x + 1 -- "critical section"
release(l)
a lock 本身就是一个对象
如果多个线程调用acquire(l)
只有一个会马上return
其他的将等待release()——“block”
一个程序通常有很多数据,很多锁
如果不同的线程使用不同的数据,
那么他们可能持有不同的锁,
所以它们可以并行执行——完成更多的工作。
注意,锁l并没有特别绑定到数据x
程序员有一个通信计划
4.决定何时需要lock的保守规则:
任何时候,只要两个线程使用一个内存位置,并且至少有一个线程是写线程
除非您持有正确的锁,否则不要触摸共享数据
!
(太严格:程序逻辑有时可能排除共享;无锁)
(过于宽松:printf ();并不总是简单的lock/data通信)
5.锁是自动的吗?
也许该语言可以将锁与每个数据对象关联起来,编译器在每次使用时都会添加获取/释放
让程序员不容易忘记!
这种想法往往过于死板:
rename(“d1/x”, “d2/y”):
lock d1, erase x, unlock d1
lock d2, add y, unlock d2
问题:文件暂时不存在!
rename()应该是原子的
其他系统调用应该在调用之前或之后查看,而不是在调用之间,否则很难编写程序
我们需要:
lock d1 ; lock d2
erase x, add y
unlock d2; unlock d1
也就是说,程序员经常需要显式控制
持有锁的代码区域,为了隐藏
尴尬的中间状态
6.思考锁实现什么功能?
锁有助于避免丢失更新
锁帮助您创建原子多步骤操作(atomic multi-step operations)——隐藏中间状态
锁帮助操作维护数据结构上的不变量(invariants
)
假设不变量在操作开始时为真,操作使用锁来隐藏对不变量的临时改变,操作在释放锁之前恢复不变量
7.Problem: deadlock
死锁
notice rename() held two locks
what if:
core A core B
rename(d1/x, d2/y) rename(d2/a, d1/b)
lock d1 lock d2
lock d2 ... lock d1 ...
解决方案:
程序员计算出所有锁的顺序,所有代码都必须按照这个顺序获取锁
i.e. predict locks(预测锁), sort, acquire – complex!
8.Locks 对比 modularity
(模块化)
锁使得在模块中隐藏细节变得困难,为了避免死锁,我需要知道调用函数获得的锁,即使我不使用它们,我也可能需要在calling之前获得它们
例如:锁通常不是单个模块的私有业务
9.如何考虑在哪里放置锁?
下面是一个简单的新代码计划:
1). 在串行执行(serial execution
)下编写正确的模块
假设一个CPU,一个线程
insert(){e->next = l;l = e;}
但如果并行执行则不正确
2). 添加锁来强制串行执行
因为acquire/release
一次只允许一个CPU执行
要点:
程序员更容易推断出串行代码,锁可以使您的串行代码正确,尽管并行
10.Locks and parallelism
锁prevent
并行执行
为了获得并行性,通常需要分割数据和锁,让每个核心使用不同的数据和不同的锁。“fine grained locks”(细粒度锁)
选择最佳的data/locks
分割是一个设计挑战
whole ph.c table; each table[] row; each entry
whole FS; directory/file; disk block
whole kernel; each subsystem; each object
您可能需要重新设计代码,使其能够很好地并行工作
示例:将单个空闲内存列表分解为每个内核的空闲列表,如果线程为单个free list
在锁上等待了很长时间,那么这是有帮助的
Let’s look at locking in xv6.
11.A typical use of locks: ide.c
典型的许多O/S的设备驱动程序安排
diagram:
user processes, kernel, FS, iderw, append to disk queue(添加到磁盘队列)
IDE disk hardware
ideintr
并发性的来源: processes, interrupt
only one lock in ide.c:idelock – fairly coarse-grained
iderw()
– what does idelock protect?
1). idequeue操作中没有竞争
2). 如果队列不是空的,IDE h/w将执行队列的头部
3).没有对IDE寄存器的并发访问
ideintr()
– interrupt handler
获取锁——可能必须在中断级别等待!
使用idequeue (1)
将下一个排队的请求提交给IDE h/w (2)
touches IDE h/w寄存器(3)
12.How to implement locks?
why not:
struct lock { int locked; }
acquire(l) {
while(1){
if(l->locked == 0){ // A
l->locked = 1; // B
return;
}
}
}
oops: race between lines A and B
how can we do A and B atomically?
13.Atomic exchange instruction:
mov $1, %eax
xchg %eax, addr
does this in hardware:
lock addr globally (other cores cannot use it)
temp = *addr
*addr = %eax
%eax = temp
unlock addr
x86 h/w提供了锁定内存位置
的概念
diagram: cores, bus, RAM, lock thing
所以我们真的把问题推到了硬件上,h/w实现缓存线或整个总线的粒度
内存锁强制并发xchg一次运行一个,而不是交错运行
Now:
acquire(l){
while(1){
if(xchg(&l->locked, 1) == 0){
break
}
}
}
如果l->locked已经是1,xchg设置为1(再次),返回1,循环继续
如果l->locked为0,最多有一个xchg会看到0;它将它等于1,然后返回0;其他xchgs将返回1
这是一个"spin lock"(自旋锁),因为在获取循环中等待内核“自旋”
Look at xv6 spinlock implementation
spinlock.h -- you can see "locked" member of struct lock
spinlock.c / acquire():
see while-loop and xchg() call
what is the pushcli() about?
why disable interrupts?
release():
sets lk->locked = 0
and re-enables interrupts
Detail: memory read/write ordering
假设两个核使用锁来保护计数器x
我们有一个简单的锁实现
Core A: Core 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(),并(可能)发出"memory barrier"指令告诉CPU
acquire()对xchg()的调用具有类似的效果:
intel承诺不会重新使用过去的xchg指令。x86.h xchg()中的一些垃圾代码告诉C编译器不要删除或重新排序
(volatile asm说不要删除,"m"说不要重新排序)
14.为什么 spin locks
(自旋锁)?
它们在等待时不会浪费CPU吗?
为什么不放弃CPU,切换到另一个进程,让它运行呢?
如果持有线程需要运行;等待线程不应该产生CPU吗?
旋转锁指南:
持有旋转锁很短的时间
持有自旋锁时,不要释放CPU
系统通常为较长的临界段提供“blocking”(阻塞)锁
等待线程会产生CPU
但一般来说,管理费用较高
稍后您将看到一些xv6阻塞方案
15.建议:
如果没有必要,就不要share
从一些coarse-grained locks
(粗粒度的锁)开始
测试代码——哪些锁防止并行?
仅在并行性能需要时才使用fine-grained locks
(细粒度锁)
使用自动 race
检测器