1.CH-6文档学习笔记
第六章 锁
本章解释为什么xv6需要锁,xv6如何实现和使用锁。
6.1 竞态条件
可以将锁视为串行化并发的临界区域,使得任意时刻只有一个进程在运行这部分代码,从而保证正确。
尽管正确使用锁可以改正不正确的代码,但锁会限制性能。
锁的位置对性能也很重要。例如,在push
中把acquire
的位置提前也是正确的:将acquire
移动到第13行之前完全没问题。但这样对malloc
的调用也会被串行化,从而降低了性能。
6.2 代码:Locks
Xv6有两种类型的锁:自旋锁(spinlocks)
和睡眠锁(sleep-locks)
。
先讲解自旋锁(自旋,即循环等待)开始。自旋锁的结构体为struct spinlock(kernel/spinlock.h:2)
。结构体中的重要字段是locked
,当锁可用时为零,当被持有时为非零。从逻辑上讲,xv6应该通过执行以下代码来获取锁
void
acquire(struct spinlock* lk) // does not work!
{
for(;;) {
if(lk->locked == 0) {
lk->locked = 1;
break;
}
}
}
但这种实现不能保证多处理器上的互斥。两个CPU可能会同时到达第5行,看到lk->locked
为零,然后都执行第6行占有锁。此时两个CPU都持有锁,从而违反了互斥属性。需要一种方法,使第5和第6行作为原子(即不可分割)步骤执行。
现代处理器都提供了处理这种情况的原子指令,在RISC-V上,这条指令是amoswap r, a
。该指令的作用是读取内存地址a
的值,将寄存器r
的内容写入a
,再将a
的值放入r
中。
void acquire(struct spinlock *lk){
push_off(); // disable interrupts to avoid deadlock.
if(holding(lk))
panic("acquire");
#ifdef LAB_LOCK
__sync_fetch_and_add(&(lk->n), 1);
#endif
while(__sync_lock_test_and_set(&lk->locked, 1) != 0) {
#ifdef LAB_LOCK
__sync_fetch_and_add(&(lk->nts), 1);
#else
;
#endif
}
__sync_synchronize();
// Record info about lock acquisition for holding() and debugging.
lk->cpu = mycpu();
}
Xv6的acquire (kernel/spinlock.c:22)
使用__sync_lock_test_and_set
,可以当做是amoswap
指令;返回值是lk->locked
的旧的内容。
acquire()
将获取锁的操作包装在循环中,不停重试(spin)直到获得锁。每次尝试将1
和lk->locked
进行交换,并检查返回的前一个值;
- 如果前一个值为0,那么意味着我们获得了该锁。
- 如果前一个值是1,那么其他CPU此时持有该锁,我们将1和
lk->locked
交换不会改变其值。
获取锁后,为便于调试,acquire
将记录下来获取锁的CPU。lk->cpu
字段受锁保护,只能在持有锁时更改。
函数release
(kernel/spinlock.c
:47) 与acquire
相反:它清除lk->cpu
字段,然后释放锁。从概念上讲,release
只需要将0赋值给lk->locked
。xv6使用执行原子赋值的C库函数__sync_lock_release
。该函数也可以视为RISC-V的amoswap
指令。
void release(struct spinlock *lk){
if(!holding(lk))
panic("release");
lk->cpu = 0;
__sync_synchronize();
__sync_lock_release(&lk->locked);
pop_off();
}
6.3 代码:使用锁
Xv6在许多地方使用锁。kalloc(kernel/kalloc.c:69)
和kfree(kernel/kalloc.c:47)
就是很好的例子。
作为粗粒度锁的一个例子,xv6的kalloc.c
由单个锁保护空闲列表。不同CPU上的多个进程试图同时分配页面,每个进程在获得锁之前将必须在acquire
中自旋等待。如果对锁的争夺浪费了大部分时间,可通过改变分配器的设计来提高性能,使其拥有多个空闲列表,每个列表都有自己的锁,以允许真正的并行分配。
作为细粒度锁定的一个例子,xv6对每个文件都有一个单独的锁,这样操作不同文件的进程可以无需等待彼此的锁。文件锁的粒度可以进一步细化,以允许进程同时写入同一个文件的不同区域。
表6.3列出了xv6中的所有锁。
锁 | 描述 |
---|---|
bcache.lock |
保护块缓冲区缓存项(block buffer cache entries)的分配 |
cons.lock |
串行化对控制台硬件的访问,避免混合输出 |
ftable.lock |
串行化文件表中文件结构体的分配 |
icache.lock |
保护索引结点缓存项(inode cache entries)的分配 |
vdisk_lock |
串行化对磁盘硬件和DMA描述符队列的访问 |
kmem.lock |
串行化内存分配 |
log.lock |
串行化事务日志操作 |
管道的pi->lock |
串行化每个管道的操作 |
pid_lock |
串行化next_pid的增量 |
进程的p->lock |
串行化进程状态的改变 |
tickslock |
串行化时钟计数操作 |
索引结点的 ip->lock |
串行化索引结点及其内容的操作 |
缓冲区的b->lock |
串行化每个块缓冲区的操作 |
![image-20221025175805990](http://pic-save-fury.oss-cn-shanghai.aliyuncs.com/uPic/image-20221025175805990.png)
6.5 可重入锁
可重入锁容易导致bug难以被观察,xv6中没有使用
6.6 锁和中断
一些xv6自旋锁保护线程
和中断处理程序
共用的数据。例如,clockintr
定时器中断处理程序在增加ticks(kernel/trap.c:163)
的同时,内核线程可能在sys_sleep
(kernel/sysproc.c
:64)中读取ticks
。tickslock
串行化了这两个访问。
假设sys_sleep
持有tickslock
,并且它所在的CPU发生了计时器中断。clockintr
会尝试获取tickslock
,但此时该锁被sys_sleep
持有。此时就是死锁,sys_sleep
持有锁,但是无法被执行,clockintr
需要锁,却一直占有CPU,导致sys_sleep
无法被执行。
// 获取中断是否被启用
static inline int intr_get(){
uint64 x = r_sstatus();
return (x & SSTATUS_SIE) != 0;
}
// 启用中断
static inline void intr_on(){
w_sstatus(r_sstatus() | SSTATUS_SIE);
}
// 关闭中断
static inline void intr_off(){
w_sstatus(r_sstatus() & ~SSTATUS_SIE);
}
void push_off(void){
int old = intr_get();
intr_off();
if(mycpu()->noff == 0)
mycpu()->intena = old;
mycpu()->noff += 1;
}
void pop_off(void){
struct cpu *c = mycpu();
if(intr_get())
panic("pop_off - interruptible");
if(c->noff < 1)
panic("pop_off");
c->noff -= 1;
if(c->noff == 0 && c->intena)
intr_on();
}
为避免该情况,Xv6使用以下策略:当某个CPU获得锁时,xv6将禁用该CPU上的中断。其他CPU可正常中断。
当该CPU上没有自旋锁时,重新启用中断,xv6做一些记录以处理嵌套的临界区。acquire
调用push_off(kernel/spinlock.c:89)
,release
调用pop_off(kernel/spinlock.c:100)
来跟踪当前CPU上锁的嵌套级别。当计数为0时,pop_off
恢复中断启用。intr_off
和intr_on
函数分别执行RISC-V指令来禁用和启用中断。
在设置lk->locked (kernel/spinlock.c:28)
之前,严格执行acquire
调用push_off
是很重要的。如果两者颠倒,会存在一个既持有锁又启用了中断的短暂窗口期,此时如果发生定时中断将导致系统死锁。类似地,只有在释放锁之后release
才可调用pop_off
。
6.7 指令和内存访问排序
xv6在acquire
和release
中使用__sync_synchronize()
告诉编译器和CPU不要重排load
或store
指令。xv6的acquire
和release
在几乎所有情况下都会强制顺序执行。第9章讨论了一些例外。
6.8 睡眠锁
睡眠锁详见第7章文档
6.9 真实世界
尽管对并发原语和并行性进行了多年的研究,但使用锁进行编程仍然具有挑战性。通常最好将锁隐藏在更高级别的结构中,如同步队列,尽管xv6没有这样做。如果您使用锁进行编程,明智的做法是使用可识别竞争条件(race conditions)的工具,因为很容易错过需要锁的不变量。
大多数操作系统都支持POSIX线程(Pthreads),它允许一个用户进程在不同的cpu上同时运行多个线程。Pthreads支持用户级锁、屏障等。Pthreads还允许程序员有选择地指定锁应该是可重入的。
支持用户级的Pthreads需要操作系统的支持。例如,如果一个pthread在系统调用中阻塞,那么同一进程的另一个Pthread应该能够在该CPU上运行。另一个例子是,如果一个pthread改变了其进程的地址空间(例如,映射或解除映射内存),内核必须安排运行同一进程线程的其他cpu更新其硬件页表,以反映地址空间中的变化。
不使用原子指令也可以实现锁,但是代价很高,而且大多数操作系统都使用原子指令。
如果许多CPU试图同时获取相同的锁,可能会付出昂贵的开销。如果一个CPU在其本地cache中缓存了一个锁,而另一个CPU必须获取该锁,那么更新保存该锁的cache行的原子指令必须将该行从一个CPU的cache移动到另一个CPU的cache中,并且可能会使cache行的任何其他cache失效。从另一个CPU的cache中获取cache行比从本地cache中获取一行的代价要高几个数量级。
为了避免与锁相关的开销,许多操作系统使用无锁的数据结构和算法。例如,可以实现一个像本章开头那样的链表,在列表搜索期间不需要锁,并且使用一个原子指令在一个列表中插入一个条目。然而,无锁编程比有锁编程更复杂-;例如,人们必须担心指令和内存重新排序。有锁编程已经很难了,所以xv6避免了无锁编程的额外复杂性。