程序的并发执行提高了程序执行的效率,这是大多数内核所追求的,xv6也是这样。
xv6采用两种方式实现程序的并发执行:
xv6采用多处理器架构(硬件系统具有多个CPU独立执行)来实现程序的并发执行。这些CPU共享同一个DRAM,这种共享就带了问题:
显然,这是一种亟待解决的问题,否则危害巨大。
即使在一个CPU中,由于内核可在多个线程间切换CPU,使它们交错执行。但若定时器中断发生的时机不对,一个设备中断处理程序可能会修改与一些与被中断代码相同的数据,从而破坏数据。
以并发下的正确性为目标的策略及支持这些策略的抽象称为并发控制技术。
换言之,并发控制技术保证了并发执行的正确性。
xv6使用了许多并发控制技术,最典型的一种就是锁(lock)。
锁提供互斥功能,确保一次只有一个CPU持有一个特定的锁。若程序员为每个更新数据关联一个锁,并且代码在使用某项时总是持有关联的锁,那么该项每次只能由一个CPU使用。在这种情况下,锁保护了数据项。
锁也有副作用,即锁将并发操作串行化,因此降低了性能。
下面以一个例子引出锁。
考虑两个进程在两个CPU上调用wait释放子进程的内存。
由第三节知识可知:内核分配器维护了一个空闲页链表。kalloc操作会从空闲页链表中pop出一页,而kfree会将一页push进空闲页链表。在wait过程中,内核会调用kfree来释放子进程的内存页。
为了实现并发执行,我们希望看到两个父进程的kfree并发执行。但这个空闲页链表是被两个CPU共享的,此时,问题就出现了。
空闲页链表在两个CPU共享的DRAM中,CPU使用加载和存储指令操作链表,如果如下实现链表的push操作:
struct element{
int data;
struct element *next;
};
struct element *list = 0;
void
push(int data)
{
struct element *l;
l = malloc(sizeof *l);
l->data = data;
l->next = list;
list = l;
}
若是两个进程单独执行(顺序执行)则没问题。但若是并发执行,就出问题了,如下图。
若两个CPU同时执行push操作,并且两个CPU都执行上图中第15操作,然后其中一个才执行第16操作,这时,后者的第16操作会覆盖前者的第16操作,导致第一次赋值中涉及的元素丢失。在上图中,就是CPU2的进程的空闲页面无法push进空闲页链表中。而第16操作的丢失更新就是竞争条件的一个例子。
竞争条件:同时访问一个内存位置,且至少有一次访问是写。
竞争通常是一个错误的标志,要么是丢失更新,要么是读取一个不完全更新的数据结构。
竞争的结果取决于所涉及的多个CPU或多个线程的确切操作时间及它们的内存操作如何被内存系统排序。
修改上面的代码得:
struct element *list=0;
struct locklist lock;
void
push (int data)
{
struct element *l;
l = malloc(sizeof *l);
l->data = data;
acquire(&listlock);
l->next = list;
list = l;
release(&listlock);
}
上面代码中的acquire与release之间的指令序列即是临界区。这里的锁保护了list,锁确保了相互排斥,一次只能有一个CPU执行push的临界区,只有执行完临界区后,另一个CPU才能执行它的进程的临界区。
这里将较为深入地探讨锁的理论原理,将从本质上解释为什么锁能实现互斥。
数据结构有一个称为不变量(invariant)的属性。一个操作的正确行为取决于操作开始时的不变量是否为真。这个属性在关于这个数据结构的不同操作中得到维护。操作可能会暂时违反不变量,但在该操作结束前,必须重新建立不变量。例如,在上面的push操作中,不变量指list指向:“链表中的第一个元素,并且某个元素的下一个字段指向下一个元素。”push操作暂时违反了这个不变量,具体来讲,是在这个语句:
l->next = list;
执行这条语句后,list未指向链表的第一个元素,此时不变量为假。本来下一条代码会使不变量为真的,但并发执行使得另一个CPU的进程开始执行,按照上述理论,此时就出错了。
而锁确保每次只有一个CPU能执行关键代码,使关键代码执行完毕后的不变量为真,进而就实现了push操作的正确性。
因此,锁机制的正确使用受关键代码的选取是否合理影响。
锁可以看成是并发程序的关键部分的序列化(串行化),这意味着关键部分具有原子操作性质。
经过上面分析,我们可以得到关键代码选取对程序结果产生影响的如下结论:
上面两个结论留给读者自己思考,思考出这两结论,意味着您已经完全懂了上面所说的。
xv6有两种类型的锁:
代码实现:
// Mutual exclusion lock.
struct spinlock {
uint locked; // Is the lock held?
// For debugging:
char *name; // Name of lock.
struct cpu *cpu; // The cpu holding the lock.
};
当locked为0时,意味着该锁可获得,否则意味着该锁被持有,不可获得。
获取锁的代码:
// Acquire the lock.
// Loops (spins) until the lock is acquired.
void acquire(struct spinlock *lk)
{
push_off(); // disable interrupts to avoid deadlock.
if(holding(lk))
panic("acquire");
// On RISC-V, sync_lock_test_and_set turns into an atomic swap:
// a5 = 1
// s1 = &lk->locked
// amoswap.w.aq a5, a5, (s1)
while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
// Tell the C compiler and the processor to not move loads or stores
// past this point, to ensure that the critical section's memory
// references happen strictly after the lock is acquired.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();
// Record info about lock acquisition for holding() and debugging.
lk->cpu = mycpu();
}
获取锁的伪代码如下:
void acquire(struct spinlock *lk)//doesnotwork! { for(;;){ if(lk->locked == 0){ lk->locked = 1; break; } } }
上面的伪代码仅仅展现了锁之所以能够实现互斥的原因,除此之外别无其它,为什么这么说?请读者想想,使用上面这种伪代码的程序能够实现互斥吗?
当然不行!!!因为上面程序中的关键代码不是原子性的。即,获得锁的代码不是原子性的。
这下就出问题了,上哪去找具有原子性的代码呢?
实现原子性的代码,需要复杂的实现机制,由于这一问题的广泛性,RISC-V提供了原子性指令—— amoswapr,a。amoswapr读取内存地址a处的值,将寄存器r的内容写入该地址,并将其读取的值放入r中。即,该指令实现了寄存器内容与内存地址内容间的交换。
xv6的acquire使用了可移植的C库调__sync_lock_test_and_set,该调用本质上为amoswapr,a指令。其返回值是lk->locked的旧内容。acquire函数循环交换,每一次交换都将1交换到lk->locked中,并检查之前的值,若之前值为0,则交换将lk->locked设置为1,否则不改变lk->locked的值。
一旦获取锁,acquire会记录获取该锁的CPU以方便调试。lk->cpu字段受到锁的保护,只有在该字段持有锁时才能被改变。
函数release与acquire作用相反,它清除lk->cpu字段,然后释放锁。当然,从逻辑上讲,锁的持有和释放只需标记或清除lk->cpu字段并对lk->locked进行置1或清0,函数release使用了C库函数__sync_lock_release实现原子操作,该C库函数也是使用了RISC-V的amoswapr,a指令。
release的代码:
// Release the lock.
void release(struct spinlock *lk)
{
if(!holding(lk))
panic("release");
lk->cpu = 0;
// Tell the C compiler and the CPU to not move loads or stores
// past this point, to ensure that all the stores in the critical
// section are visible to other CPUs before the lock is released,
// and that loads in the critical section occur strictly before
// the lock is released.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();
// Release the lock, equivalent to lk->locked = 0.
// This code doesn't use a C assignment, since the C standard
// implies that an assignment might be implemented with
// multiple store instructions.
// On RISC-V, sync_lock_release turns into an atomic swap:
// s1 = &lk->locked
// amoswap.w zero, zero, (s1)
__sync_lock_release(&lk->locked);
pop_off();
}
自旋锁的缺点:
综上,为了提高CPU的执行效率及避免死锁,我们希望一种具有以下特点的锁:允许持有锁的进程释放CPU,同时开放中断。而睡眠锁就是一个这样的锁。
睡眠锁的实现代码:
// Long-term locks for processes
struct sleeplock {
uint locked; // Is the lock held?
struct spinlock lk; // spinlock protecting this sleep lock
// For debugging:
char *name; // Name of lock.
int pid; // Process holding lock
};
获取睡眠锁的acquiresleep函数:
void acquiresleep(struct sleeplock *lk)
{
acquire(&lk->lk);
while (lk->locked) {
sleep(lk, &lk->lk);
}
lk->locked = 1;
lk->pid = myproc()->pid;
release(&lk->lk);
}
在高层次上,睡眠锁有一个由自旋锁保护的锁定字段。acquiresleep调用原子性函数sleep来释放CPU和自旋锁。结果就是,在acquiresleep等待时,其他线程也能执行。
可见,自旋锁适用于耗时短的关键部分,因为使用自旋锁会浪费时间,而睡眠锁适用于耗时长的操作。
注意:在本文的后面部分,所提到的锁,若无特殊说明,均指自旋锁。因为下文会逐渐介绍自旋锁的缺点,从而让读者更加深刻地体会到睡眠锁的必要。
xv6使用锁来避免竞争条件。使用锁的一个难点是使用多少锁,以及每个锁应该保护哪些数据和不变量。使用锁的两个原则:
至此,我们已经明白了什么时候需要锁,但现在我们必须知道,什么时候不需要锁,因为我们要在保证程序执行正确这个前提下尽可能地少使用锁,因为锁会降低并行性,从而降低程序性能。
我们使用粒度这个概念来描述锁所控制范围的大小。
xv6既使用了粗粒度的锁,也使用了细粒度的锁。
锁的粒度,由正确性、性能测量与复杂性综合考虑。
在xv6中,锁得到了广泛的应用,下表列出了xv6中的所有锁。
锁 | 描述 |
bcache.lock | 保护缓存块的分配 |
cons.lock | 序列化访问控制台硬件,避免混合输出 |
ftable.lock | 在文件表中序列化结构文件的分配 |
icache.lock | 保护节点缓存项的分配 |
vdisk_lock | 序列化磁盘硬件和DMA描述符队列的访问 |
kmem.lock | 序列化内存分配 |
log.lock | 序列化对交易日志的操作 |
pipe's pi->lock | 序列化对每一个管道的操作 |
lid_lock | 序列化下一个进程标识符的增加操作 |
lroc's p->lock | 序列化进程状态的改变 |
tickslock | 序列化对ticks计数器的操作 |
inode's ip->lock | 序列化对每个节点及其内容的操作 |
buf's b->lock | 序列化对每一个缓冲块的操作 |
考虑以下情况:
线程T1执行代码路径1并获取锁A,线程T2执行代码路径2并获取锁B,接下来T1尝试获取锁B,T2尝试获取锁A,由于另一个线程都持有所需的锁,所以这两次获取都会无限期地被阻塞,发生了死锁。
为了解决这个问题,我们规定,使用的代码路径都必须以相同的顺序获取锁。即,线程或进程必须按锁1->锁2->锁3......这样的顺序获取,不允许跨越。
xv6有许多长度为2的锁序链。例如,consoleintr是处理格式化字符的中断流程,当一个新数据到达时,任何正在等待控制台(终端)输入的进程都应该被唤醒,为了保证输出有序,consoleintr在调用wakeup时持有cons.lock,以获取进程锁来唤醒它。因此,全局避免死锁的顺序包括了cons.lock必须在任何进程锁之前获取的规则。
xv6中最长的锁链是文件系统,为避免死锁,文件系统代码必须严格按照上一句提到的顺序获取锁。
遵守全局避免死锁的顺序可能很困难。有时锁的顺序与逻辑程序结构冲突,例如,代码模块M1调用模块M2,但锁的顺序要求M2中的锁在M1中的锁之前被获取。
更多的锁意味着更多的死锁机会,这又影响了设计者对锁方案的细化程度。
自旋锁与中断的相互作用可能导致死锁的发生。
考虑以下情况:
sys_sleep持有tickslock,而它的CPU接收到一个时钟中断。clockintr会尝试获取tickslock,但tickslock现在被持有,所以clockintr被阻塞。但sys_sleep直到clockintr返回后才能继续运行,从而释放tickslock。但现在clockintr被阻塞了,无法返回,这时就发生了死锁。
xv6的解决方法是当一个CPU获取任何锁时,该CPU被禁用中断,中断可能发生在其他CPU上,所以一个中断处理程序等待一个线程释放自旋锁,但它们不在同一个CPU上。
当然,CPU没有持有自旋锁时重新启用中断。
对于嵌套的临界区,会出现嵌套的锁这种情况。acquire调用push_off和release调用pop_off来跟踪当前CPU上锁的嵌套级别。当锁数等于0时,pop_off会恢复最外层临界区开始时的中断启用状态。intr_off和intr_on函数分别执行RISC-V指令来禁用和启用中断。push_off会禁用中断。
在上锁前,严格调用push_off很重要,如果是先上锁在调用push_off,那么程序会有一个持有锁但未禁用中断的窗口期,这种情况下,一个时机恰到好处的定时器中断会导致死锁。同样,只有在释放锁后才能调用pop_off。
许多编译器和CPU为提高性能,会使指令乱序执行(对指令重新排序)。但为了确保这种改变不会改变正确编写的串行代码的结果。对指令的重新排序则需要遵顼相应规则。CPU的指令排序规则称为内存模型(memory model)。
考虑一下代码:
l = malloc(sizeof *l);
l->data = data;
acquire(&listlock);
l->next = list;
list = l;
release(&listlock);
这段代码以本节刚开始举的例子为背景。
如果将第4行的指令移到第6行指令后执行,那将会导致巨大危害。
为了不让编译器和CPU执行这样的指令重排,xv6在acquire和release中都使用了 __sync_synchronize()。__sync_synchronize()是一个内存屏障(memory barrier)。所谓内存屏障,指编译器和CPU不能越过屏障重排任何的内存读写操作。
本文通过介绍程序并发执行可能带来的问题引入了锁这一概念,接着介绍了锁的实现与相关注意事项。
[1] FrankZn/xv6-riscv-book-Chinese (github.com)
[2] mit-pdos/xv6-riscv: Xv6 for RISC-V (github.com)