《Operating System:Three Easy Pieces》阅读笔记<十八>—基于锁的数据结构

368 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第22天,点击查看活动详情

基于锁的并发数据结构

在了解锁的概念后,我们将目光放在程序访问的核心——数据结构上。即我们要怎样利用锁构建线程安全的数据结构呢?这是一个非常深的领域,这一节我们只探讨几个经典的例子。

第一个是并发计数器,计数器就是一个可以进行自加自减的数据结构,对于计数器的并发控制我们已经很熟悉了,可以简单构建出一种并发计数器,逻辑如下图代码所示。

可以看到右边的代码在数据变化的语句周围都上了锁,保证了数据的并发安全。这种计数器设计简单,但是完全不是scalable(可拓展的),性能有很大问题。

我们知道每个CPU核心都有一个计数器,假设在四核Intel 2.7Ghz i5 CPU上运行四个线程,每个线程自加一百万次,结果我们会发现线程之间有很严重的延迟问题,一个线程自增只需0.3s,两个线程自增总耗时就需要4s以上,正常来说我们希望两个线程是并行的,一个线程应当和两个线程差不多,但事实是,线程越多,耗时指数增加,这就是为什么说这样实现的计数器不可扩展的含义。

该怎么解决这个问题呢,我们知道理想状况下多个线程多个核的运行时间要和一个线程一个核的运行时间接近。因此有人设计出一种近似计数器,它的工作原理是通过多个本地物理计数器表示单个逻辑计数器,每个CPU核一个计数器,以及一个全局计数器。其体来说,在一台有四个cpu的机器上,有四个cpu。前7个单位时间内各个计数器的状态如图。

设置一个阈值,当线程自增(或自减)达到阈值时统计到全局变量G中,不同的核心用不同的锁。为什么说是这是近似的方法呢,假如进程结束时,

同样的我们量化这种方法的效率,可以看到,通过避免所有线程共享同一个锁,程序执行时间有了质的提升,更关键的,这种数据结构变为可拓展了。

在这里我们通过增加可操作的锁的数量提供了数据结构的并发性,但是这并不是通用的方法,大多数时候减少调用锁例程才是优化性能的方向,下面会提到。

第二个是并发链表,首先遵循简单的思路,即直接在数据操作的部分加上锁,具体逻辑如下图代码所示,在这里有一些小细节,插入和查询部分的代码中锁的放置需要遵循一个准则,具体的在原书中进行查看。注意这种实现的链表并不是可伸缩化的,一种伸缩化的思路是在链表的每一个元素上都上锁,但这很可能造成调用锁例程过多,具体性能要根据情况进行评测。

第三个是并发队列,我们知道,总是有一种标准的方法来创建并发数据结构:添加一个大锁,将其嵌套在操作之外,但队列的实现略有不同,我们定义两个锁,一个用于队列的头,一个用于队列的尾。这两个锁的目标是支持入队列和出队列操作的并发性,具体逻辑可以看下图代码。

队列通常用于多线程应用程序。然而,这里使用的队列类型(仅带锁)通常不能完全满足这类程序的需要。一个更完整的并发队列实现是有界队列,它允许线程在队列为空或过满时进入等待状态,这里就不展开讲了。

下图是并发链表并发队列的简易构建代码:

最后一个是并发哈希表,我们基于之前构建的并发链表来构建并发哈希表,下图显示了并发更新下哈希表的性能。为了便于比较,还展示了一个链表(只有一个锁)的性能。从图中可以看出,这个简单的并发哈希表扩展性非常好;相比之下,链表则比较差。

因为并发数据结构的构建实际上非常复杂,并且在现代系统中也非常重要,这一研究领域的详情不可能在这么短的篇幅下展示出来,因此本节只是稍微讨论了并发数据结构的部分思想,详细的还是要自己学习。