10.3 什么时候使用锁?

很明显,锁限制了并发性,也限制了性能。那这带来了一个问题,什么时候才必须要加锁呢?我这里会给你们一个非常保守同时也是非常简单的规则:如果两个进程访问了一个共享的数据结构,并且其中一个进程会更新共享的数据结构,那么就需要对于这个共享的数据结构加锁。

这是个保守的规则,如果一个数据结构可以被多个进程访问,其中一个进程会更新这个数据,那么可能会产生race condition,应该使用锁来确保race condition不会发生。

但是同时,这条规则某种程度上来说又太过严格了。如果有两个进程共享一个数据结构,并且其中一个进程会更新这个数据结构,在某些场合不加锁也可以正常工作。不加锁的程序通常称为lock-free program,不加锁的目的是为了获得更好的性能和并发度,不过lock-free program比带锁的程序更加复杂一些。这节课的大部分时间我们还是会考虑如何使用锁来控制共享的数据,因为这已经足够复杂了,很多时候就算直接使用锁也不是那么的直观。

矛盾的是,有时候这个规则太过严格,而有时候这个规则又太过宽松了。除了共享的数据,在一些其他场合也需要锁,例如对于printf,如果我们将一个字符串传递给它,XV6会尝试原子性的将整个字符串输出,而不是与其他进程的printf交织输出。尽管这里没有共享的数据结构,但在这里锁仍然很有用处,因为我们想要printf的输出也是序列化的。

所以,这条规则并不完美,但是它已经是一个足够好的指导准则。

学生提问:有没有可能两个进程同时acquire锁,然后同时修改数据?

Franz教授:不会的,对于锁来说不可能同时被两个进程acquire,我们之后会看到acquire是如何实现的,现在从acquire的说明来看,任何时间最多只能有一个进程持有锁。

因为有了race condition,所以需要锁。我们之前在kfree函数中构造的race condition是很容易被识别到的,实际上如果你使用race detection工具,就可以立即找到它。但是对于一些更复杂的场景,就不是那么容易探测到race condition。

那么我们能通过自动的创建锁来自动避免race condition吗?如果按照刚刚的简单规则,一旦我们有了一个共享的数据结构,任何操作这个共享数据结构都需要获取锁,那么对于XV6来说,每个结构体都需要自带一个锁,当我们对于结构体做任何操作的时候,会自动获取锁。

可是如果我们这样做的话,结果就太过严格了,所以不能自动加锁。接下来看一个具体的例子。

假设我们有一个对于rename的调用,这个调用会将文件从一个目录移到另一个目录,我们现在将文件d1/x移到文件d2/y。

如果我们按照前面说的,对数据结构自动加锁。现在我们有两个目录对象,一个是d1,另一个是d2,那么我们会先对d1加锁,删除x,之后再释放对于d1的锁;之后我们会对d2加锁,增加y,之后再释放d2的锁。这是我们在使用自动加锁之后的一个假设的场景。

在这个例子中,我们会有错误的结果,那么为什么这是一个有问题的场景呢?为什么这个场景不能正常工作?

在我们完成了第一步,也就是删除了d1下的x文件,但是还没有执行第二步,也就是创建d2下的y文件时。其他的进程会看到什么样的结果?是的,其他的进程会看到文件完全不存在。这明显是个错误的结果,因为文件还存在只是被重命名了,文件在任何一个时间点都是应该存在的。但是如果我们按照上面的方式实现锁的话,那么在某个时间点,文件看起来就是不存在的。

所以这里正确的解决方法是,我们在重命名的一开始就对d1和d2加锁,之后删除x再添加y,最后再释放对于d1和d2的锁。

在这个例子中,我们的操作需要涉及到多个锁,但是直接为每个对象自动分配一个锁会带来错误的结果。在这个例子中,锁应该与操作而不是数据关联,所以自动加锁在某些场景下会出问题。

学生提问:可不可以在访问某个数据结构的时候,就获取所有相关联的数据结构的锁?

Frans教授:这是一种实现方式。但是这种方式最后会很快演进成big kernel lock,这样你就失去了并发执行的能力,但是你肯定想做得更好。这里就是使用锁的矛盾点了,如果你想要程序简单点,可以通过coarse-grain locking(注,也就是大锁),但是这时你就失去了性能。

Last updated