10.2 锁如何避免race condition?

首先你们在脑海里应该有多个CPU核在运行,比如说CPU0在运行指令,CPU1也在运行指令,这两个CPU核都连接到同一个内存上。在前面的代码中,数据freelist位于内存中,它里面记录了2个内存page。假设两个CPU核在相同的时间调用kfree。

kfree函数接收一个物理地址pa作为参数,freelist是个单链表,kfree中将pa作为单链表的新的head节点,并更新freelist指向pa(注,也就是将空闲的内存page加在单链表的头部)。当两个CPU都调用kfree时,CPU0想要释放一个page,CPU1也想要释放一个page,现在这两个page都需要加到freelist中。

kfree中首先将对应内存page的变量r指向了当前的freelist(也就是单链表当前的head节点)。我们假设CPU0先运行,那么CPU0会将它的变量r的next指向当前的freelist。如果CPU1在同一时间运行,它可能在CPU0运行第二条指令(kmem.freelist = r)之前运行代码。所以它也会完成相同的事情,它会将自己的变量r的next指向当前的freelist。现在两个物理page对应的变量r都指向了同一个freelist(注,也就是原来单链表的head节点)。

接下来,剩下的代码也会并行的执行(kmem.freelist = r),这行代码会更新freelist为r。因为我们这里只有一个内存,所以总是有一个CPU会先执行,另一个后执行。我们假设CPU0先执行,那么freelist会等于CPU0的变量r。之后CPU1再执行,它又会将freelist更新为CPU1的变量r。这样的结果是,我们丢失了CPU0对应的page。CPU0想要释放的内存page最终没有出现在freelist数据中。

这是一种具体的坏的结果,当然可能会有更多坏的结果,因为可能会有更多的CPU。例如第三个CPU可能会短暂的发现freelist等于CPU0对应的变量r,并且使用这个page,但是之后很快freelist又被CPU1更新了。所以,拥有越多的CPU,我们就可能看到比丢失page更奇怪的现象。

在代码中,用来解决这里的问题的最常见方法就是使用锁。

接下来让我具体的介绍一下锁。锁就是一个对象,就像其他在内核中的对象一样。有一个结构体叫做lock,它包含了一些字段,这些字段中维护了锁的状态。锁有非常直观的API:

  • acquire,接收指向lock的指针作为参数。acquire确保了在任何时间,只会有一个进程能够成功的获取锁。

  • release,也接收指向lock的指针作为参数。在同一时间尝试获取锁的其他进程需要等待,直到持有锁的进程对锁调用release。

锁的acquire和release之间的代码,通常被称为critical section。

之所以被称为critical section,是因为通常会在这里以原子的方式执行共享数据的更新。所以基本上来说,如果在acquire和release之间有多条指令,它们要么会一起执行,要么一条也不会执行。所以永远也不可能看到位于critical section中的代码,如同在race condition中一样在多个CPU上交织的执行,所以这样就能避免race condition。

现在的程序通常会有许多锁。实际上,XV6中就有很多的锁。为什么会有这么多锁呢?因为锁序列化了代码的执行。如果两个处理器想要进入到同一个critical section中,只会有一个能成功进入,另一个处理器会在第一个处理器从critical section中退出之后再进入。所以这里完全没有并行执行。

如果内核中只有一把大锁,我们暂时将之称为big kernel lock。基本上所有的系统调用都会被这把大锁保护而被序列化。系统调用会按照这样的流程处理:一个系统调用获取到了big kernel lock,完成自己的操作,之后释放这个big kernel lock,再返回到用户空间,之后下一个系统调用才能执行。这样的话,如果我们有一个应用程序并行的调用多个系统调用,这些系统调用会串行的执行,因为我们只有一把锁。所以通常来说,例如XV6的操作系统会有多把锁,这样就能获得某种程度的并发执行。如果两个系统调用使用了两把不同的锁,那么它们就能完全的并行运行。

这里有几点很重要,首先,并没有强制说一定要使用锁,锁的使用完全是由程序员决定的。如果你想要一段代码具备原子性,那么其实是由程序员决定是否增加锁的acquire和release。其次,代码不会自动加锁,程序员自己要确定好是否将锁与数据结构关联,并在适当的位置增加锁的acquire和release。

Last updated