第九课 锁
为什么要需要锁?
- 应用程序需要同时使用多个物理核来加速运行
- 因此内核必须支持并发调用系统调用
- 同时,可能出现并发地访问内核数据(buffer cache, processes)
- 锁帮助了正确地实现数据共享
- 但是锁的引入,也导致性能有一定的损失
第八天 Homework: 锁
锁的概述
lock l
acquire(l)
x = x + 1 -- "critical section"
release(l)
- 锁本身是一个对象
- 若多个线程同时尝试获取锁,只有一个线程会成功获得.
- 其他线程将会阻塞,直到锁被释放
- 通常一个大型项目中会有许多数据和对应的锁,各个线程使用并发的使用不同的数据以及不同的锁.这样项目可以并发地同时运行,从而节约了总运行时间.
- 注意,锁不是先天性的绑定在某个数据上的.这种对应关系是由程序员自己指定的.
(保守)何时需要锁
- 两个线程存在同时访问一个块内存的时候,并且至少有一个线程在写.
- 在访问共享内存前,请确保获得了正确的锁.
锁的作用
- 锁帮助避免无效的写入
- 锁帮助开发者创建一个多步的原子操作,隐藏了中间的临时状态
死锁
- 考虑经典的哲学家问题
- 经典解法: 对锁进行排序,所有调用必须按照同样的顺序获取锁,或者释放锁.
锁与模块封装性
- 锁的问题导致难以隐藏模块内部的细节
- 为了避免死锁,开发者必须知道模块内函数需要获取的锁.并且在调用模块前,获取这些锁.
锁和并发
- 锁限制了并发运行
- 为了获得并发的优势,开发者经常需要将数据分成更小的粒度,并用不同的锁来保护它们.
- 选择合适的锁粒度,是非常困难的.
锁的设计
- 从一个大锁开始,比如一整个模块一个锁
- 减少了锁的总数,因此死锁的可能性比较小
- 使用时,不需要记住太多锁的细节
- 通过测量性能,找到性能瓶颈
- 大锁往往能够满足性能的要求,往往锁的某块并没有耗费太多的CPU时间.
- 如果锁的竞争导致了性能瓶颈,再考虑使用细粒度的锁
xv6中锁的应用
一个典型的应用: ide.c
场景分析
- ide.c中实现了对ide设备的操作,我们知道硬盘操作是一个比较慢的操作,因此需要一个
idequeue
来保存还未处理的请求. - 本质上,类似于一个生产者消费者问题.
- 用户空间最终调用
iderw()
发起ide请求,并将请求放到idequeue
中.同一时间,可能有多个请求被发起. - 同一时间,只有
idequeue
队头的请求会被完成. - ide硬件发起中断通知内核一次ide请求完成,内核调用
ideintr()
来处理当前完成的ide请求.并开始处理下一个请求.
临界区分析
idequeue
链表用于保存请求,必须是被保护的临界区.- ide硬件设备同一时间只能处理一个请求,因此也是需要保护的临界区
ide.c
仅包含了一个锁idelock
.因此这是一个比较大的锁,并没有实现更细的粒度.
锁的实现 V1
Q. 以下面的方式实现锁是否可行?为什么?
struct lock { int locked; }
acquire(l) {
while(1){
if(l->locked == 0){ // A
l->locked = 1; // B
return;
}
}
}
A: 显然是不行的,因此查询,修改locked的值,并不是一个原子操作.
原子操作
如下指令实现了对锁的原子操作.
mov $1, %eax xchg %eax, addr
硬件模拟如下:
lock addr globally (other cores cannot use it) temp = *addr *addr = %eax %eax = temp unlock addr
事实上,我们对锁的原子操作是由x86的硬件提供指令完成的.目前大多数体系结构都有类似的指令.
锁的实现 V2
锁的实际实现如下:
acquire(l){ while(1){ if(xchg(&l->locked, 1) == 0){ break } } }
如果锁已经被锁住了,那么xchg后仍然是1,所以将会继续循环.
- 如果锁没有被锁住,并且多个core在同时抢锁,那么只会有一个core能看到xchg返回0,而其余core仍然返回1.
- 这就是所谓的
自旋锁
,因为所有等待的core并没有让出CPU,而是在不停的循环尝试获得锁.
xv6 自旋锁的实现
- 头文件
spinlock.h
,源文件spinlock.c
- 注意阅读
acquire()
和release()
的实现 - 在
acquire()
时,为什么需要禁止中断?
指令重排
- 假设我们实现了一个本地的锁,用于在两个核之间保护一个临界区,模拟代码如下:
Core A: Core B:
locked = 1
x = x + 1 while(locked == 1)
locked = 0 ...
locked = 1
x = x + 1
locked = 0
- 指令重排是指编译器或者CPU为了加速执行会将源码进行优化,在实际执行时,未必会按照源码实现的步骤执行.
- 比如上面core A的指令可能被实现为如下形式:
locked = 1 locked = 0 x = x + 1
- 如果发生了指令重排,那么我们的锁完成没有起到保护临界区的目的.
- 所以在xv6
spinlock
锁的实现中,都有显式的使用内存屏障来组织指令重排的代码 - 如果使用了标准实现的锁,那么并不需要考虑指令重排和内存屏障的问题.
- 如果需要自己实现一个
lock-free
的代码,并且存在并发的情况,那么必须考虑指令重排的问题.
自旋锁
- 在自旋锁竞争的时候,CPU并不会被调度.因此存在一定的CPU空转浪费的问题.
- 那么为什么不在抢锁的时候,让出CPU呢?
- 自旋锁的使用原则:
- 持有锁的线程必须很快完成工作,也就是说在临界区的时间要尽可能的短.
- 在持有自旋锁的时候,线程不会被调度.
- 针对耗时比较久的临界区,系统另外提供了一种可以阻塞的锁.
- 在抢锁的时候,线程可以睡眠,从而让出CPU
- 因为存在线程切换,因此耗费也要大一些
建议
- 如非必要,不要共享数据.
- 从一个大锁开始调试
- 测试代码,找出性能瓶颈
- 针对性能瓶颈,设计更小粒度的锁
- 使用自动化工具,查找条件竞争