xv6 book risc-v 第六章 锁

发布时间 2023-03-27 08:01:09作者: yudoge

包括xv6在内的大多数内核都会交错的执行多个活动,一个原因来自于多处理器硬件:计算机有多个独立运行的CPU,比如xv6的RISC-V,这些CPU共享物理内存,并且xv6利用这一点来维护被所有CPU共同读写的数据结构。这种共享提高了在一个CPU正在更新数据结构的过程中另一个CPU读取该数据结构的可能,或者只是多个CPU在同时更新相同的数据,如果不小心的设计这种共享访问,很可能造成错误的结果或损坏的数据结构。即使是在单处理器上,内核也会在多个线程之间切换CPU,导致它们交错执行,最后,一个硬件中断处理器可能和可被中断的代码修改相同的数据,如果在一个不适当的时间发生中断,数据可能被损坏。术语并发是指多个指令流交错执行的情况,它的产生原因可能是由于多个处理器的并行执行、线程切换或者是中断。

内核中充满了被并发访问的数据。举个例子,两个CPU可能同时调用kalloc,由此并发的弹出freelist头,内核设计者愿意允一定程度上允许并发发生,因为并行执行可以带来性能以及响应性上的提升,也正是由此,尽管有这样的并发问题,内核设计者还是花费了大量精力来说服自己是正确的。有许多方式可以编写正确的代码,一些比另一些更容易理解,这些帮助在并发下获得正确性的策略,以及支持它们的抽象,被称为并发控制技术。

根据不同情况,xv6使用一系列并发控制技术,甚至还有更多可能有待发掘,本章主要聚焦在一个被广泛使用的技术上,也就是——。一个锁提供了互斥,确保在给定时间只有一个CPU可以持有锁。如果程序员在每一个共享数据项上关联一个锁,代码也总是在使用这个项目时持有关联的锁,那么这个项目在给定时间就只能被一个CPU所使用。在这种情况下,我们说这个锁保护了这个数据项。虽然锁是一个非常容易理解的并发控制机制,但它的负面影响是,它们可能会损失性能,因为它们串行化了并发操作。

本章的余下部分解释了为什么xv6需要锁,xv6如何实现它们,以及如何使用它们。

6.1. 竞态条件

作为一个我们为什么需要锁的例子,考虑两个进程在不同的CPU上调用waitwait释放子进程的内存,因此,在每一个CPU上,内核都将调用kfree来清理子进程的页。内核分配器维护了一个链表:kalloc()(kernel/kalloc.c:69)从空闲页列表中弹出一个页面,kfree()(kernel/kalloc.c:47)向空闲列表中压入一个页面。如果考虑最好的性能,我们可能希望两个父进程的kfree并行执行,它们都不用等待另一个,但是这是不正确的xv6kfree实现。

img

图6.1更清楚的描绘了当前的情况,链表在被两个CPU共享的内存中,这两个CPU通过load和store指令来管理链表(在现实中,处理器拥有缓存,但理论上来说多个处理器系统表现得就像有一个单独的共享的内存一样)。如果这里没有并发请求,你可能会这样实现一个列表的push操作:

img

如果单独执行的话,这个实现是正确的。然而,如果有另一个拷贝和它并发执行的话就不对了。如果两个CPU同时执行push,在它们两个执行第16行之前,它们都可能像图6.1中那样执行第15行,这将导致如图6.2中描绘的这种不正确的输出。酱油两个列表节点的next指针指向list从前的值。当两个list赋值在第16行发生时,第二个将覆盖第一个,第一个赋值中的元素将会丢失。

第16行中的丢失更新是竞态条件的一个例子。竞态条件是指一个内存地址被并发访问,并且至少一个访问是写。竞态条件通常是Bug的一个标志,或者是丢失更新(如果访问都是写),或者是读到尚未更新完的数据结构。竞争带来的结果取决于两个CPU具体被卷入的时间以及内存系统如何排序它们的内存操作,这导致竞争相关的错误非常难以重现和debug。举个例子,在debugging push时添加输出语句可能改变执行时机,导致竞争无法发生。

避免竞争的常见办法是使用一把锁。锁保证了互斥,所以,在给定时间只有一个CPU可以执行push中敏感的行,这让上面两种场景不再可能发生。上面代码的正确加锁版本仅仅少量添加了几行(黄色高亮行):

img
img

acquirerelease之间的指令序列通常被称作临界区

当我们说一把锁保护了数据时,我们实际上想表达的是锁保护了一系列被施加到数据上的不变式。不变式是在数据结构的操作之间被维护的属性,通常来说,一个操作的正确行为取决于当操作开始时,不变式是正确的。操作可能会临时破坏不变式,但是在结束之前必须重新建立它。举个例子,在链表的案例中,不变式是list指向列表中的第一个元素,并且每一个元素的next域指向下一个元素。在第17行,push操作暂时性的破坏了这个不变式,l指向了下一个列表元素,但是list尚未指向l(在第18行重建)。我们上面解释过的竞态条件发生了,是因为依赖列表不变性的第二个CPU在不变式被临时破坏时执行了代码。恰当的使用锁确保在给定时间内只有一个CPU可以在临界区内对数据结构进行操作,所以没有CPU会在数据结构的不变式尚未建立时对它执行操作。

你可以将锁看作是序列化并发的临界区以在给定时间内只能运行一个,因此保持了不变式,你也可以认为由锁保护的临界区彼此之间具有原子性,所以每一次查看都只是临界区内先前的一系列改变已经完成的结果,永远不会看到部分完成的更新。

尽管使用锁可以让不正确的代码变得正确,但锁会限制性能。举个例子,如果两个进程并发的调用kfree,锁将序列化两个调用,我们将无法得到在多个CPU上运行它们的任何好处。当多个进程想要在同一时间获取同一个锁时,我们就说它们是冲突的,或者说发生了锁争用。内核设计中的一个主要挑战就是避免锁争用。Xv6只做了很少的事,但是复杂的内核会组织特殊的数据结构和算法以避免锁争用。在列表的例子中,一个内核可能会为每一个CPU维护单独的空闲列表,并且只有CPU当前的列表是空的时,它不得不去“偷”其它CPU的内存,这时它才触碰其它CPU的列表,这个用例需要更复杂的设计。

锁的放置对于性能来说也尤为重要。举个例子,如果移动acquirepush中更前的位置上,移动到第13行之前都行,这可能会降低性能,因为这样的话对于malloc的调用也被序列化了。下面的“使用锁”部分提供了一些往哪插入acquirerelease调用的指导。

6.2. 代码:锁

xv6有两种类型的锁:自旋锁和睡眠锁,我们将从自旋锁开始。xv6通过struct spinlock(kernel/spinlock.h:2)表示一个自旋锁。这个结构中的重要字段是locked,当锁可用时,它为0,当它已经被持有时,则为非0。逻辑上讲,xv6可以通过执行下面这种代码来获取锁:

img

但十分不幸,这个实现并不能保证多个处理器之间的互斥。可能发生两个CPU同时到达第25行的情况,它们同时看到lk->locked是0,然后同时通过执行第26行获取锁。在这个节点上,两个不同的CPU获得了锁,违反了互斥属性。我们需要一种方式来让25和26行的执行为一个原子(不可分割的)步骤。

因为锁被广泛的使用,多核处理器通常提供实现原子版本的25和26行的指令。在RISC-V中,这条指令是amoswap r, aamoswap读取内存地址a的值,写入寄存器r中的内容到这个地址上,并将这个地址上的值读到r中。也就是说,它交换了寄存器和内存地址的值。它原子的执行这个序列,使用特殊的硬件来阻止任何其它的CPU在这两次读写之间使用这个内存地址。

xv6的acquire(kernel/spinlock.c:22)使用便携C库调用__sync_lock_test_and_set,归根结底是调用了amoswap指令,返回值是lk->locked的旧内容(已经被swap的)。acquire函数将这个swap包装到一个循环中,重试(自旋)直到它获取到了锁。每一个迭代将1与lk->locked交换,并检查之前的值,若之前的值是0,那么我们获取到了锁,并且交换将会设置lk->locked为1。如果前一个值不是1,那么某个其它的CPU正在持有锁,我们原子交换到lk->locked中的1事实上并没有改变它的值。

一旦锁被获取了,为了调试,acquire就会记录获取到锁的CPU。lk->cpu字段也是被锁保护的,并且只有在获取到锁时才能被改变。

// 获取锁
// 循环(自旋)直到锁被获取
void
acquire(struct spinlock *lk)
{
  push_off(); // 禁用中断以避免死锁
  if(holding(lk)) // 如果当前已经持有该锁了,panic(不允许重入)
    panic("acquire");

  // 在RISC-V中,sync_lock_test_and_set会被转换成一个atomic swap:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;

  // 告诉C编译器以及CPU不要移动load或store指令通过该点
  // 以确保临界区的内存引用严格地在锁获取后才发生
  // 在RISC-V中,这会发射一个fence(栅栏)指令
  __sync_synchronize();

  // 为了`holding`函数和debug记录lock获取的信息
  lk->cpu = mycpu();
}

release(kernel/spinlock.c:47)是acquire的反函数,它清空lk->cpu字段然后释放锁。从概念上来说,release仅仅是需要将lk->locked设置为0,C标准允许编译器使用多个store指令来实现赋值操作,所以一个C的赋值操作对于并发的C代码来说可能并不是原子的。所以,release使用C库函数__sync_lock_release来执行一个原子赋值,这个函数最终也使用了RISC-V的amoswap指令。

// Release the lock.
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)是一个很棒的例子。尝试练习1和练习2看看如果这些函数省略锁的话会发生什么,你将会发现很难触发不正确的行为,这也说明了很难可靠的测试是否代码已经没有锁定错误和竞争了。

void *
kalloc(void)
{
  struct run *r;

  acquire(&kmem.lock); // 锁定
  r = kmem.freelist; // 操作freelist
  if(r)
    kmem.freelist = r->next;
  release(&kmem.lock); // 解锁

  if(r)
    memset((char*)r, 5, PGSIZE); // fill with junk
  return (void*)r;
}

使用锁时,一个难点就是决定使用多少锁以及每一个锁保护什么数据和不变式。这里有一些基本原则。首先,在任何一个变量被一个CPU读取的同时另一个CPU可以读写它时,需要用一个锁来保持两个操作不会交叠;第二,记住锁保护不变式:如果一个不变式卷入了多个内存位置,通常所有的位置都需要被一个单一的锁保护以确保不变式被维护。

上面的规则说明了什么时候需要锁,但是却没说什么时候不需要锁,对于性能来说,不要太多锁是很重要的,因为锁降低了并行。如果并行并不重要,此时你可以使用一个单一线程,并且不用考虑锁的问题。一个简单的内核可以在多处理器上通过一个单一的锁来实现这一点,当进入内核和退出内核时必须获取这把锁(尽管像pipe读或者wait这种系统调用可能会出问题)。很多单一处理器的操作系统已经通过这个方式转换到运行在多处理器上了,有时这种手段被称为“大内核锁”,但是这个手段牺牲了并行性:在给定时间只有一个CPU可以在内核中执行。如果内核做了任何繁重的计算,那么使用一组更大的,更加细粒度的锁则会更加高效,这样,内核可以在多个CPU上同时执行。

作为一个粗粒度锁的示例,xv6的kalloc.c分配器有一个单独的freelist,它被一把单独的锁保护。如果在不同的CPU上的多个进程尝试同时分配页面,每一个都要通过在acquire中自旋来等待轮到自己。自旋降低了性能,因为它并不是有用的工作。如果锁竞争浪费了大部分CPU时间,或许将分配器的设计修改成具有多个freelist,每一个有自己的锁以允许真正的并行分配将会改进性能。

作为一个细粒度锁的示例,xv6对于每一个文件都有独立的锁,所以操作不同文件的进程可以不用等待对方的锁。如果你想允许进程同步的写相同文件的不同区域的话,文件锁模式可以被做到更细粒度。最终,锁粒度的决策需要通过性能测量和复杂性考虑因素来驱动。

随着后续章节对xv6各个部分的介绍,它们将会提到xv6使用锁来处理并发的例子,作为一个预览,图6.3列出了xv6中的所有锁。

img

6.4. 死锁和锁顺序

如果一个代码路径通过内核必须同时持有多个锁,那么对于所有代码路径都以同样的顺序获取这些锁是非常重要的。如果它们不这样,就会有死锁风险。比如xv6中的两个代码路径需要获取锁A和B,但是路径1以A后B的顺序获取锁,另一个路径以B后A的顺序获取,假设T1执行代码路径1,并且获取了锁A,然后T2执行代码路径2并获取了锁B,接下来T1将会尝试获取锁B,T2将会尝试获取锁A,这两个获取都将会无限阻塞下去,因为在这两种情况下,另一个线程持有它们需要的锁,在它的acquire返回前,它都不会释放。为了避免这种死锁,所有代码路径必须以一致的顺序获取锁。全局锁获取顺序的需求意味着锁实际上是每个函数定义的一部分:调用者必须以一种导致锁会以被允许的顺序被获取的方式来调用函数。

由于sleep的工作方式,xv6有很多长度为2的锁顺序链涉及每个进程的锁(每一个struct proc中的lock)。例如consoleintr是处理输入字符的中断程序,当一个新行到达,任何等待console输入的进程都必须被唤醒。为了做到这个,consoleintr在调用wakeup时持有cons.lockwakeup则会获取等待进程的锁以唤醒它。所以,全局死锁避免顺序中包含了cons.lock必须在任何进程锁之前被获取的这条规则。文件系统的代码中包含了xv6最长的锁链,举个例子,创建一个文件需要同时持有目录上的锁、一个新文件的inode上的锁、一个磁盘块缓冲上的锁、磁盘驱动器的vdisk_lock以及调用进程的p->lock,为了避免死锁,文件系统总是根据上面提到的顺序来获取锁。

// kernel/console.c:138
void
consoleintr(int c)
{
  acquire(&cons.lock); // 锁cons.lock

  switch(c){
  // ......

  default:
    if(c != 0 && cons.e-cons.r < INPUT_BUF){
      c = (c == '\r') ? '\n' : c;

      consputc(c);
      cons.buf[cons.e++ % INPUT_BUF] = c;

      // 如果已经积累了整行数据
      if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
        cons.w = cons.e;
        // 唤醒在cons.r上等待的进程
        wakeup(&cons.r);
      }
    }
    break;
  }
  
  release(&cons.lock); // 释放cons.lock
}
// kernel/proc.c:589
void
wakeup(void *chan)
{
  struct proc *p;

  // 唤醒所有在chan上等待的进程
  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock); // 获取进程的锁
    // 若进程在chan上sleep,切换为就绪状态
    if(p->state == SLEEPING && p->chan == chan) {
      p->state = RUNNABLE;
    }
    release(&p->lock); // 释放进程的锁
  }
}

遵守全局死锁避免顺序可能非常困难。有时,锁顺序与程序的逻辑结构冲突,举个例子,也许模块M1调用模块M2,但是锁顺序要求M2中的一个锁必须在M1中的一个锁之前被申请;有时,无法事先知道要加的锁,也许一个锁只有当某一个锁被持有后才能被确定持有,这种情况会发生在文件系统想要寻找在一个路径名中连续的组件时,以及waitexit的代码想要搜索进程表以查找子进程时。最后死锁的风险通常也是锁模式的粒度可以做到多细的一个限制,因为更多的锁意味着更多的死锁风险,在内核实现中,避免死锁总是一个主要因素。

6.5. 锁以及中断处理器

一些xv6自旋锁会保护线程和中断处理器会同时使用的数据。举个例子,clockintr时钟中断处理器可能递增ticks(kernel/trap.c:163),在同时,内核线程在sys_sleep(kernel/sysproc.c:64)中读取tickstickslock锁将两次访问串行化。

void
clockintr()
{
  acquire(&tickslock); // 锁tickslock
  ticks++; // 递增ticks
  wakeup(&ticks);
  release(&tickslock); // 释放tickslock
}
uint64
sys_sleep(void)
{
  int n;
  uint ticks0;

  if(argint(0, &n) < 0)
    return -1;
  acquire(&tickslock); // 锁tickslock
  ticks0 = ticks; // 读ticks
  while(ticks - ticks0 < n){
    if(myproc()->killed){
      release(&tickslock);
      return -1;
    }
    sleep(&ticks, &tickslock);
  }
  release(&tickslock); // 释放tickslock
  return 0;
}

自旋锁和中断的交互带来了一个潜在风险,假设sys_sleep持有tickslock,并且它的CPU被一个时钟中断给中断了,clockintr将会尝试获取tickslock,它会看到这个锁已经被持有了,并等待它被释放。在这个情况中,tickslock永远不会被释放,只有sys_sleep可以释放它,但是sys_sleepclockintr返回之前将永远不会继续执行,所以,CPU将死锁,任何需要这把锁的代码也会僵住。

为了避免这种情况,如果自旋锁被一个中断处理器使用,在中断开启的情况下,CPU永远不持有这个锁。xv6更加保守,当一个CPU申请任何锁时,xv6总是在这个CPU上禁用中断。中断仍然会在其它CPU上发生,所以一个中断的acquire可以等待一个不在相同CPU上的线程去释放自旋锁。

当一个CPU不持有自旋锁时,xv6会恢复中断。它必须做一些记录工作来处理嵌套的临界区,acquire调用push_off(kernel/spinlock.c:89)以及release调用pop_off(kernel/spinlock.c:100)以跟踪在当前CPU上的锁的嵌套层级。当计数器到达0,pop_off恢复存在于最外层临界区开始处的中断开启状态。intr_off以及intr_off以及intr_on函数执行RISC-V指令以分别禁用和启用中断。

void
acquire(struct spinlock *lk)
{
  push_off(); // disable interrupts to avoid deadlock.
  if(holding(lk))
    panic("acquire");

  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;

  __sync_synchronize();

  lk->cpu = mycpu();
}

// push_off/pop_off有点像intr_off/intr_on,除了:
// 两个pop_off()撤销两个push_off(),如果中断最开始就是关闭的,push_off,pop_off会保持关闭状态
void
push_off(void)
{
  int old = intr_get(); // 获取旧的中断状态

  intr_off(); // 关闭中断
  if(mycpu()->noff == 0) // 如果当前是最外层锁
    mycpu()->intena = old; // 记录旧的中断状态
  mycpu()->noff += 1; // 迭代锁的层级
}
void
release(struct spinlock *lk)
{
  if(!holding(lk))
    panic("release");

  lk->cpu = 0;

  __sync_synchronize();

  __sync_lock_release(&lk->locked);

  pop_off();
}

```c
void
pop_off(void)
{
  struct cpu *c = mycpu();
  // 下面两个if是不变性检查,我们读代码时可以忽略
  if(intr_get())
    panic("pop_off - interruptible");
  if(c->noff < 1)
    panic("pop_off");

  // 锁层级-1
  c->noff -= 1;
  if(c->noff == 0 && c->intena) // 如果锁层级为0代表已经没有锁了,并且最外层加锁时的中断状态为开启
    intr_on(); // 恢复中断状态
}

比较重要的是acquire严格的在它设置lk->locked之前调用push_off(kernel/spinlock.c:28),如果顺序反了,将会有一个锁已经上了,但是中断仍然开启的短暂窗口,一个不幸的时钟中断可能会造成系统死锁。相同的,release只有在释放锁之后才调用pop_off(kernel/spinlock.c:66)。

6.6. 指令以及内存顺序

我们很自然的认为程序根据源代码中语句出现的顺序执行,但很多编译器和CPU为了高性能都会打乱顺序,如果一个指令的完成会花费很多时钟周期,CPU可能会提前发出这个指令,这样它就可以与其它指令重叠,避免CPU掉速(译者:因为现代CPU都是流水线执行指令的,多个指令的不同阶段其实是会并发执行的)。举个例子,CPU可能发现序列中的指令A和B互相并不关联,所以CPU将会先开始B,这可能是因为它的输入比A的输入先到达,或者为了重叠执行A和B。编译器也会通过将一条语句的指令放置到在源码中先于它的语句的指令之前来执行类似的重排序。

编译器和CPU都会遵循一个规则,当它们重排序时,它们不会改变正确编写的串行代码的结果,然而,这个规则允许并行代码的结果被改变,并且轻易的引发在多核上的不正确行为。CPU的排序规则被称为内存模型

举个例子,在下面的push代码中,如果编译器或者CPU移动第4行的存储到第6行的release之后的话,这将会是一场灾难:

img

如果这样的重排序发生了,将会存在一个在其它CPU能够获取锁并观察更新的列表期间看到了一个未初始化的list->next的窗口。

为了告诉硬件以及编译器不要执行这种重排序,xv6在acquire以及release中都使用了__sync_synchronize()__sync_synchronize()是一个内存屏障:它告诉编译器和CPU不要跨这个屏障来重排序load和store指令。在xv6的acquirerelease中的屏障在几乎所有必要情况下都强制顺序,因为xv6在访问共享数据时使用锁包裹。第9章我们将讨论少量的异常。

6.7. sleep锁

有些时候xv6需要长时间持有一个锁。举个例子,文件系统(第八章)在读写文件在磁盘中的内容时持有一个文件锁,这些磁盘操作可能会耗费10几毫秒,此时,如果有另一个进程想要获取该锁时,长时间持有一个自旋锁可能会导致浪费,因为尝试获取的进程在自旋时将会长时间耗费CPU。自旋锁的另一个缺点是进程在持有一个自旋锁时,不能让出CPU,在一个持有锁的进程等待磁盘,并且另一个进程可以使用CPU时,我们会希望这样做。持有自旋锁时让步是非法的,因为如果第二个线程尝试获取自旋锁时,可能导致死锁,因为acquire并不会让出CPU,另一个线程的自旋可能会阻止第一个线程运行并释放锁。在持有一个锁时让步也可能会破坏在自旋锁被持有时中断必须关闭的约定,因此我们需要一种当等待获取时会让出CPU的锁类型,并且允许在锁被持有时让步(和中断)。

xv6以sleep-lock的形式提供了这种锁。acquiresleep(kernel/sleeplock.c:22)在等待时让出CPU,它使用了我们在第七章时会介绍的技术。从高层面来看,一个sleep-lock有一个被自旋锁保护的locked属性,acquiresleep中对sleep的调用将自动释放CPU,释放自旋锁,结果就是另一个线程可以在acquiresleep等待时执行。

因为sleep-lock保持中断的开启状态,所以它们不能在中断处理器中使用。因为acquiresleep将会让出CPU,sleeplock不能在自旋锁的临界区中使用(尽管自旋锁可以在sleep-lock的临界区中使用)。

自旋锁适合于短临界区,因为在自旋锁上等待会耗费CPU时间,sleeplock适合长操作。

6.8. 真实世界

尽管对并发源于和并行性进行了多年的研究,但使用锁编程仍然是富有挑战性的。最好将锁隐藏在某种高级结构中,比如同步队列,尽管xv6并没有这么做。如果你的程序使用锁,使用工具尝试识别竞态条件是明智的,因为很容易错过需要锁定的不变式。

大多数操作系统支持POSIX线程(Pthrads),它允许用户进程有多个在不同CPU上并发运行的线程。Pthread支持用户级别锁、屏障等。支持Pthread需要操作系统支持,比如,如果一个pthread在一个系统调用上阻塞,同一个进程的另一个pthread应该能够在这个CPU上运行;另一个例子,如果一个pthread修改它的进程地址空间(maps或unmaps内存),内核必须编排运行同个进程的线程的其它CPU更新它们的硬件页表来反映地址空间的更改。

在没有原子指令的情况下实现锁也是可能的,但是很昂贵,大部分操作系统都使用原子指令。

如果很多CPU都尝试在同一时间获取同一个锁,代价将是昂贵的,如果一个CPU在它的本地缓存中缓存了一个锁,而另一个CPU必须获得锁,那么更新持有锁的缓存行的源自指令必须将该行从一个CPU的缓存移动到另一个CPU的缓存中,或许还会使任何该缓存行的拷贝失效。从另一个CPU缓存中获取缓存行可能要比从本地缓存中获取要昂贵几个数量级。

为了避免锁相关的开销,很多操作系统使用无锁的数据结构以及算法。举个例子,如果在列表搜索期间不需要任何锁,并且有一个原子指令可以向列表中插入一个条目的话,实现一个本章开始处的链表是可能的。然而,无锁编程将比锁编程更加复杂,举个例子,你必须关心的一件事是指令和内存重排。使用锁编程已经很难了,所以xv6避免了无锁编程的复杂性。