《Operating System:Three Easy Pieces》阅读笔记<十七>—锁

197 阅读8分钟

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

上面提到我们需要一种将critical section组合成一个原子操作的方法,这种方法就是Lock(锁),其基本思想就是维护一个各线程之间共享的lock variable,当lock variable为available (or unlocked or free)时critical section部分内只有一个线程在执行代码,其它线程的在lock()处因为lock variable是acquired (or locked or held)的状态,所以无法进入critical section部分。

线程虽然主要收到操作系统调度,但是通过这种方式,程序员能够获得部分调度的控制权,锁有助于将传统操作系统调度中的混乱转变为更可控的活动,本质上是将CPU调度无法覆盖到的方面交给程序员来管理。

锁的一种实例是在POSIX库中包含的互斥锁(mutex lock),其逻辑可包装成下图右侧所示,图中变量lock就是lock variable,只有lock未被占用时,才允许线程进入critical section(这里是变量balance自增)。

系统如何构建锁?在讨论锁的构建原理之前,首先要对锁的性质进行标准制定,根据锁的实际应用需求,可以按照重要性提出锁应当满足以下要求

  • 能否提供mutual exclusion,即最基本的只让一个线程进入critical section。
  • 能否让线程公平竞争,当有线程运行时,其它线程能否公平的得到CPU资源
  • 能否在不同情况下让锁带来的性能开销最低

最初的lock方法依循最简单的思路,即提供一个系统调用,直接暂时停止中断器工作,直到另一个系统调用恢复中断器工作,这种方法的唯一好处就是实现简单。另外就是一堆缺点

  • 程序权限过大,非常不安全,如果程序一直调用停止中断器,相当于接管了系统
  • 在多核处理器上不通用
  • 造成系统调度紊乱,中断器是操作系统能够介入问题程序的物理保障,会造成调度困难
  • 性能太差,这样做开销太大

由于这些缺点,所以现在只有在一些小型机器还能找到这种提供锁的方法

既然直接停止不行,我们就想到lock variable本身,即使用一个flag来表示是否有进程占用锁,这就是自旋锁的最基础原理,似乎解决了问题。但这种方法太过原始,有两大问题,一是准确性:假如线程1在结束之前就遇到计时中断,而切换到另一个线程2,此时线程2依然可以进入critical section。基础过程伪代码和准确性问题解释如下图所示(因为篇幅原因这里不能讲太细,可以结合下图思考理解)。

二是性能问题,上图右边在切换回线程1的时候,线程1是无法再次进入critical section的(假如需要再次进入)。那么这一个time slice相当于进程1空转,CPU资源被浪费了。

Test-And-Set

这时我们可以寻求一些硬件支持,在60年代的Burroughs B5000系统中,设计师提出了一种功能更强大的指令:Test-And-Set。它的功能如下图中C代码所示,返回old_ptr所指的数同时更改其为new。这个操作序列是以原子的方式执行的。它被称为Test-And-Set的原因是,你能够“test”旧的值同时“set”一个新值;事实证明,这条更强大的指令足以构建一个简单的自旋锁。下面代码能够用于描述它的作用原理。

:不同硬件架构中Test-And-Set的名称不同,例如SPARC中叫 load/store unsigned byte,x86中叫atomic exchange。

我们如何评价这种简单的自旋锁呢,首先是准确性,它毫无疑问是准确的,只有一个线程能够进入critical section。

然后是公平性,它并不能保证所有等待的线程都能够有机会进入critical section,因此它是不公平的。

之后是性能,这里我们要分情况看待,假如在单核CPU上,这种锁是性能很差的,原因和上面一样,当有一个线程运行时,其它所有线程都在空转浪费CPU资源。但是在多核CPU上,这种方法性能还行,其它线程可以到其它核去空转,虽然也是浪费,至少程序执行速度不会减慢多少。

Compare-And-Swap

另一种硬件支持是Compare-And-Swap,也叫CAS算法,它的功能如下图中C代码所示,其基本思想是通过比较和交换来测试ptr指定地址处的值是否等于预期值;如果是,用新值更新PTR指向的内存位置。如果没有,什么也不做。

可以看出来,compare-and-swap是一种比test-and-set功能更强大的指令,当我们要研究无锁同步之类的主题时,我们将使用这种功能。但是如果我们只是用它构建一个简单的旋转锁,它的行为与我们上面分析的旋转锁相同。

Load-Linked and Store-Conditional

还有另一种硬件支持是Load-Linked and Store-Conditional,分别有Load-Linked和Store-Conditional两个指令,其中load-linked的操作很像一个典型的load指令,只是从内存中获取一个值并将其放入寄存器中,store-conditional定义只有在LoadLinked读取ptr地址上的数据之前ptr没有更新过才表示成功。如果成功,storeconditional返回1,并将ptr的值更新为value;如果失败,则不会更新PTR上的值,并返回0。通过Load-Linked和Store-Conditional组合来构建锁,具体过程如下图所示

Load-Linked and Store-Conditional在MIPS和ARM架构中比较常见

Fetch-And-Add

最后一种硬件支持是Fetch-And-Add,基于这一支持构建的票锁有一个非常易懂的例子,假设饭店只有一个位子

  1. 初始时位子是空的,叫号0
  2. 第一位顾客取号,取号0
  3. 匹配号成功,成功就餐
  4. 第二位顾客取号,取号1,当前叫号为0,不匹配,自旋
  5. 第一位顾客用餐结束,工作人员叫号1
  6. 第二位顾客取号1与叫号1匹配,进入就餐。

通过Fetch-And-Add这种方式构建的锁保证了公平性,因为每一个线程都一定会在之后得到进入critical section的机会。缺点是拓展性差,如果等待者很多,在释放锁时,递增(餐厅服务人员叫号:请100号客人用餐)锁号时,会将其他所有等待线程中的(叫号)锁号缓存cacheline置为无效invalid。会通过内存主线重新加载新的值到缓存cacheline。这样会造成“拥堵”。因为叫号更新了,我手里的号码需要跟最新的叫号号码比较是否相等,如果相等说明到我用餐了。

提升性能

上面我们提到多个线程在自旋锁中会有很严重的CPU资源浪费情况,怎么办呢?我们假设有一个系统调用yield(),一旦线程遇到lock被占用就调用yield(),直接从running状态调换成ready状态。这样减少了很多线程空转,但是依然每次都要让每一个线程执行yield(),这依然相当浪费时间

于是我们的思路转换为,不应该让每一个线程每次都去试探是否有锁,在当前持有者释放锁后,我们必须显式地对接下来哪个线程获得锁施加一些控制。我们寻求操作系统的支持,帮助我们调整线程的状态,再创建一个队列来跟踪哪些线程正在等待获取锁。

以Solaris系统为例,系统调用park()将调用线程置于睡眠状态,而unpark(threaddid)唤醒由threaddid指定的特定线程。具体过程如下图所示

这种方法并不能够完全避免线程自旋,但是已经优化到足够的程度了

其它系统也有类似的调用,如Linux中的futex,类似Solaris,但是提供了更多的内核内功能。具体来说,每个futex都有一个特定的物理内存位置,以及一个每个futex的内核队列。调用者可以根据需要使用futex调用来调整线程Sleeping还是Wake,这两种futex调用分别是futex wait(address, expected)和futex wake(address)

最后,Linux的方法有一种已经断断续续使用了多年的老方法的风格,至少可以追溯到20世纪60年代早期的Dahm Locks,现在被称为两阶段锁。两阶段锁意识到旋转可能很有用,特别是当锁即将被释放时。所以在第一阶段,锁会旋转一段时间,希望它能获得锁。

但是,如果在第一个旋转阶段没有获得锁,则进入第二个阶段,在这个阶段调用者被置于睡眠状态,只有在锁稍后被释放时才被唤醒。上面的Linux锁就是这种锁的一种形式,但它只旋转一次;在使用futex支持睡眠之前,这种方法的泛化可以在一个固定的时间内循环。