The Little Book of Semaphores 信号量小书 第四章 经典同步问题 4.2 读者-写者问题
第四章 经典同步问题
4.2 读者 - 写者问题
下一个经典问题,称为Reader-Writer问题(读者-作家问题/读者-写者问题),适用于并发线程读取和修改数据结构,数据库或文件系统的任何情况。 在写入或修改数据结构时,通常需要禁止其他线程读取,以防止读者线程中断正在进行的修改并读取不一致或无效的数据。
与生产者 - 消费者问题一样,解决方案是不对称的。 读者和写者在进入关键部分之前执行不同的代码。 同步约束是:
- 任何数量的读者可以同时进入关键部分。
- 写者必须拥有对关键部分的独占访问权。
换句话说,当任何其他线程(读者或者写者)在那里时,写者不能进入临界区;而当写者在那里时,其他线程都不可以进入。
这里的排除模式可能被称为分类互斥(categorical mutual exclusion)。 临界区中的线程不一定排除其他线程,但是在临界区中存在一个类别会排除其他类别。
思考:使用信号量来强制执行这些约束,同时允许读者和写者访问数据结构,并避免死锁的可能性。
4.2.1 读者 - 写者问题提示
这是一组足以解决问题的变量。
readers计数器记录房间里有多少读者。 互斥锁mutex保护共享的计数器。
如果临界区中没有线程(读者或编写者),则roomEmpty为1,否则为0。 这演示了我用于表示条件的信号量的命名约定。 在这种惯例中,“等待(wait)”通常意味着“等待条件为真”,“信号(signal)”意味着“信号条件为真”。
4.2.2 读者 - 写者问题方案
写者的代码很简单。 如果临界区为空,则写者可以进入,但进入后具有排除所有其他线程的效果:
当写者退出时,可以确定房间现在是空的吗? 是的,因为它知道在那里没有其他线程可以进入。
读者代码类似于我们在上一节中看到的屏障代码。 我们会跟踪房间内的读者人数,以便我们可以给第一个到达的和最后一个离开的线程一个特殊的任务。
到达的第一个读者必须等待roomEmpty。 如果房间是空的,那么读者继续执行,同时禁止写者。 后续读者仍然可以进入,因为他们都不会试图在roomEmpty上等待。
如果已经有一个写者在房间里,这时有读者到了,它要等待房间变为空。 由于它拥有互斥锁,因此任何后续读者都会在互斥锁上排队。
临界区之后的代码类似。 最后一个离开房间的读者把灯都关掉了-- 也就是说,它标志着roomEmpty,可能让等待的写者进入。
同样,为了证明此代码是正确的,断言和演示关于程序必须如何表现的许多声明是有用的。 你能说服自己以下是真的吗?
- 只有一个读者可以排队等待roomEmpty,但有几个写者可能排队等候。
- 当读者发出信号roomEmpty时,房间必须为空。
与此读者代码类似的模式很常见:进入某个部分的第一个线程锁定信号量(或队列),最后一个线程解锁它。 事实上,它是如此常见,我们应该给它一个名称,并将其包装在一个对象中。
模式的名称是Lightswitch,类似于进入房间的第一个人打开灯(锁定互斥锁)的模式,最后一个人将其关闭(解锁互斥锁)。 这是Lightswitch的类定义:
lock接受一个参数,一个它将检查并可能保持的信号量。 如果信号量被锁定,则调用线程在信号量上阻塞,所有后续线程在self.mutex上阻塞。 当信号量被解锁时,第一个等待线程再次锁定它并且所有等待的线程继续。
如果信号量的初始状态是解锁的,则第一个线程将其锁定并且所有后续线程可以继续。
直到每个调用锁的线程都调用unlock时,unlock才会生效。 当最后一个线程调用unlock时,它会解锁信号量。
使用这些函数,我们可以更简单地重写读者代码:
readLightswitch是一个共享的Lightswitch对象,它的初始计数值为零。
作者的代码没有变化。
还可以将对roomEmpty的引用存储为Lightswitch的属性,而不是将其作为参数传递给锁定和解锁。 这种替代方案不太容易出错,但我认为如果锁定和解锁的每次调用都指定了它运行的信号量,它就会提高可读性。
4.2.3 饿死
在之前的解决方案中,是否存在死锁的危险?为了发生死锁,线程必须能够在保持另一个信号量的同时等待信号量,从而阻止自身收到信号。
在这个例子中,死锁是不可能的,但是有一个相关的问题几乎同样糟糕:作者可能会饿死。
如果写者到达的时候,临界区中有读者,那么当读者来来往往时,它可能会在队列中等待。只要新读者在最后一位读者离开之前到达,房间里总会有至少一位读者。
这种情况不是僵局,因为一些线程正在取得进展,但这并不是完全可取的。只要系统上的负载很低,这样的程序就可以工作,因为这样写者就有很多机会。但随着负载的增加,系统的行为会迅速恶化(至少从写者的角度来看)。
思考:扩展此解决方案,以便在写者到达时,现有读者可以完成,但不能再进入其他读者。
4.2.4 不会饿死的读者 - 写者问题提示
这是一个提示。 您可以为读者添加一个旋转门,并允许写者锁定它。 写者必须通过相同的旋转门,但是当他们在旋转门内时,他们应该检查roomEmpty信号量。 如果写者卡在旋转门中,它会强迫读者在旋转门处排队。 然后,当最后一个读者离开临界区时,我们保证接下来至少有一个写者可以进入(在任何排队的读者进入之前)。
readSwitch记录房间里有多少读者; 当第一个读者进入时会锁定roomEmpty;并在最后一个读者退出时将其解锁。
turnstile对于读者来说是一个旋转门,对于写者来说是一个互斥体。
4.2.5 不会饿死的读者 - 写者问题方案
这是写者的代码:
如果写者到达时,房间里已有读者,它将阻塞在第2行,这意味着旋转门将被锁定。 这将阻止新的读者进入,因为已经有写者在排队了。 这是读者代码:
当最后一个读者离开时,它发出roomEmpty信号,解锁等待的写者。写者立即进入它的临界区,因为没有一个等待的读者可以通过旋转门。
当写者退出时,它会发出turnstile信号,它可以解锁等待的线程,这可能是读者或写者。 因此,这个解决方案保证至少有一个写者可以继续,但是当有写者在排队时,读者仍然有可能进入。
根据应用程序,为写者提供更高的优先级可能是个好主意。 例如,如果写者要对数据结构进行更新,而这种更新又是时序要求很严格的,则最好在写者有机会继续之前,将能查看旧数据的读者的数量控制到最小。
但是,一般情况下,由调度程序而不是程序员来选择要解除阻塞的等待线程。 某些调度程序使用先进先出队列,这意味着线程按其排队的相同顺序解除阻塞。 其他调度程序随机选择,或根据基于等待线程的属性的优先级方案进行选择。
如果您的编程环境可以使某些线程优先于其他线程,那么这是解决此问题的简单方法。 如果没有,你将不得不寻找另一种方式。
思考:为读者编写一个优先考虑写者的问题的解决方案。 也就是说,一旦写者到来,在所有写者离开系统之前,不允许读者进入。
4.2.6 写者优先的读者 - 写者问题提示
像往常一样,提示是解决方案中使用的变量形式。
4.2.7 写者优先的读者 - 写者问题方案
这是读者的代码:
如果一个读者在临界区,它拥有noWriters,但它不包含noReaders。 因此,如果写者到达它可以锁定noReaders,这将导致后续读者排队。
当最后一个读者退出时,它会发出noWriters信号,允许任何排队的写者继续运行。
写者的代码:
当写者在临界区时,它同时拥有noReaders和noWriters。 这具有(相对明显的)效果,即确保在临界区没有读者并且也没有其他写者。 此外,writeSwitch具有允许多个写者在noWriters上排队的(不太明显的)效果,但是当它们存在时保持noReaders被锁定。 因此,许多写者可以通过临界区而无需发出noReaders信号。 只有当最后一位写者退出时,读者才能进入。
当然,这种解决方案的一个缺点是,现在读者可能会饿死(或至少面临长时间的延迟)。对于某些应用程序来说,最好的办法是获得具有可预测周转时间的往期的数据。