type
status
date
slug
summary
tags
category
icon
password
Property
Apr 15, 2023 03:23 PM
我们希望原子式执行一系列指令,但由于单处理器上的中断(或者多个线程在多处理器上并发执行),我们做不到。本章介绍了锁(lock),直接解决这一问题。程序员在源代码中加锁,放在临界区周围,保证临界区能够像单条原子指令一样执行。
锁的基本思想
举个例子,假设临界区像这样,典型的更新共享变量:
lock()和 unlock()函数的语义很简单。调用
lock()
尝试获取锁,如果没有其他线程持有锁(即它是可用的
),该线程会获得锁
,进入临界区
当持有锁的线程在临界区时,其他线程就无法进入临界区。
锁就是一个
变量
,因此我们需要声明一个某种类型的锁变量
(lock variable,如上面的mutex),才能使用。调用
lock()
尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区。这个线程有时被称为锁的持有者
(owner)。如果另外一个线程对相同的锁变量(本例中的 mutex)调用 lock(),因为锁被另一线程持有,该调用不会返回。这样,当持有锁的线程在临界区时,其他线程就无法进入临界区。
锁的持有者一旦调用
unlock()
,锁就变成可用了。如果没有其他等待线程(即没有其他线程调用过 lock()并卡在那里),锁的状态就变成可用了。如果有等待线程(卡在 lock()里),其中一个会(最终)注意到(或收到通知)锁状态的变化,获取该锁,进入临界区。锁为程序员提供了最小程度的
调度控制
。我们把线程视为程序员创建的实体,但是被操作系统调度,具体方式由操作系统选择。锁让程序员获得一些控制权。通过给临界区加锁,可以保证临界区内只有一个线程活跃。锁将原本由操作系统调度的混乱状态变得更为可控
。Pthread 锁
POSIX 库将锁称为
互斥量
(mutex),因为它被用来提供线程之间的互斥。即当一个线程在临界区,它能够阻止其他线程进入直到本线程离开临界区。我们使用了包装函数来检查获取锁和释放锁时的错误。你可能还会注意到,POSIX 的 lock 和 unlock 函数会传入一个
变量
,因为我们可能用不同的锁来保护不同的变量
。这样可以增加并发:不同于任何临界区都使用同一个大锁(粗粒度的锁策略),通常大家会用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区(细粒度的方案)。评价锁
互斥(mutual exclusion)
能够阻止多个线程 进入临界区
公平性(fairness)
每一个竞争线程有公平的机会抢到锁。是否有竞争锁的线程会饿死(starve),一直无法获得锁
性能(performance)
使用锁之后增加的时间开销
控制中断
最早提供的互斥解决方案之一,就是在临界区关闭中断。这个解决方案是为单处理器系统开发的。
假设我们运行在这样一个单处理器系统上。通过在进入临界区之前
关闭中断
(使用特殊的硬件指令),可以保证临界区的代码不会被中断,从而原子地执行。结束之后,我们重新打开中断(同样通过硬件指令),程序正常运行。这个方法的主要优点就是
简单
。显然不需要费力思考就能弄清楚它为什么能工作。没有中断,线程可以确信它的代码会继续执行下去,不会被其他线程干扰。缺点:
- 首先,这种方法要求我们允许所有调用线程执行特权操作(打开关闭中断),即信任这种机制不会被滥用。
- 一个贪婪的程序可能在它开始时就调用 lock(),从而独占处理器。
- 更糟的情况是,恶意程序调用 lock()后,一直死循环。这种情况,系统无法重新获得控制,只能重启系统。关闭中断对应用要求太多,不太适合作为通用的同步解决方案。
- 不支持多处理器。如果多个线程运行在不同的 CPU 上,每个线程都试图进入同一个临界区,关闭中断也没有作用。线程可以运行在其他处理器上,因此能够进入临界区。
- 关闭中断导致中断丢失,可能会导致严重的系统问题。假如磁盘设备完成了读取请求,但 CPU 错失了这一事实,那么,操作系统如何知道去唤醒等待读取的进程?
- 最后一个不太重要的原因就是效率低。与正常指令执行相比,现代 CPU 对于关闭和打开中断的代码执行得较慢。
基于以上原因,只在很有限的情况下用关闭中断来实现互斥原语。例如,在某些情况下操作系统本身会采用屏蔽中断的方式,保证访问自己数据结构的原子性,或至少避免某些复杂的中断处理情况。这种用法是可行的,因为在操作系统内部不存在信任问题,它总是信任自己可以执行特权操作。
测试并设置指令(原子交换)
因为关闭中断的方法无法工作在多处理器上,所以系统设计者开始让
硬件
支持锁。
最简单的硬件支持是测试并设置指令
(test-and-set instruction),也叫作原子交换
(atomic exchange)。当第一个线程正处于临界区时,如果另一个线程调用 lock(),它会在 while 循环中自旋等待(spin-wait),直到第一个线程调用 unlock()清空标志。然后等待的线程会退出 while 循环,设置标志,执行临界区代码。
遗憾的是,这段代码有两个问题:正确性和性能。这个正确性问题在并发编程中很常见。假设代码按照表执行,开始时 flag=0。
从这种交替执行可以看出,通过适时的中断,我们很容易构造出两个线程都将标志设置为 1,都能进入临界区的场景,然没有满足最基本的要求:互斥。
性能问题主要是线程在等待已经被持有的锁时,采用了
自旋等待
(spin-waiting)的技术,就是不停地检查标志的值。自旋等待在等待其他线程释放锁的时候会浪费时间。尤其是在单处理器上,一个等待线程等待的目标线程甚至无法运行(至少在上下文切换之前)。实现可用的自旋锁
这些代码是
原子地
(atomically)执行。
我们来确保理解为什么这个锁能工作。首先假设一个线程在运行,调用 lock()
,没有其他线程持有锁,所以 flag 是 0。当调用 TestAndSet(flag, 1)
方法,返回 0,线程会跳出 while循环,获取锁。同时也会原子的设置 flag 为 1,标志锁已经被持有。当线程离开临界区,调用unlock()
将 flag 清理为 0。第二种场景是,当某一个线程已经持有锁(即 flag 为 1)。本线程调用 lock(),然后调用
TestAndSet(flag, 1)
,这一次返回 1。只要另一个线程一直持有锁,TestAndSet()
会重复返回 1,
本线程会一直自旋。当 flag 终于被改为 0,本线程会调用 TestAndSet(),返回 0 并且原子地
设置为 1,从而获得锁,进入临界区。
将测试(test 旧的锁值)和设置(set 新的值)合并为一个原子操作之后,我们保证了只有一个线程能获取锁。这就实现了一个有效的互斥原语!
自旋锁(spin lock)一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(preemptivescheduler,即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。评价自旋锁
- 正确性(correctness)自旋锁一次只允许一个线程进入临界区。因此,这是正确的锁。
- 公平性(fairness)自旋锁不提供任何公平性保证。实际上,自旋的线程在竞争条件下可能会永远自旋。自旋锁没有公平性,可能会导致饿死。
- 性能(performance)。
- 在单 CPU 的情况下,性能开销
相当大
。假设一个线程持有锁进入临界区时被抢占。调度器可能会运行其他每一个线程(假设有 N−1 个这种线程)。而其他线程都在竞争锁,都会在放弃 CPU 之前,自旋一个时间片,浪费 CPU 周期。 - 在多 CPU 上,自旋锁
性能不错
(如果线程数大致等于 CPU 数)。假设线程 A 在CPU 1,线程 B 在 CPU 2 竞争同一个锁。线程 A(CPU 1)占有锁时,线程 B 竞争锁就会自旋(在 CPU 2 上)。然而,临界区一般都很短,因此很快锁就可用,然后线程 B 获得锁。自旋等待其他处理器上的锁,并没有浪费很多 CPU 周期,因此效果不错。
比较并交换
某些系统提供了另一个
硬件原语
,即比较并交换指令(SPARC 系统中是 compare-and-swap,x86 系统是 compare-and-exchange)。比较并交换的基本思路是检测 ptr 指向的值是否和 expected 相等;如果是,更新 ptr 所指的值为新值。否则,什么也不做。不论哪种情况,都返回该内存地址的实际值,让调用者能够知道执行是否成功。
有了比较并交换指令,就可以实现一个锁,类似于用测试并设置那样。例如,我们只要用下面的代码替换 lock()函数:
其余代码和上面测试并设置的例子完全一样。代码工作的方式很类似,检查标志是否
为 0,如果是,原子地交换为 1,从而获得锁。锁被持有时,竞争锁的线程会自旋。
如果你想看看如何创建建 C 可调用的 x86 版本的比较并交换,下面的代码段可能有用
(来自[S05]):
最后,你可能会发现,比较并交换指令比测试并设置更强大。然而,如果只用它实现一个简单的自旋锁,它的行为等价于上面分析的自旋锁。
链接的加载和条件式存储指令
些平台提供了实现临界区的一对指令。例如 MIPS 架构[H93]中,链接的加载(load-linked)和条件式存储(store-conditional)可以用来配合使用,实现其他并发结构。
条件式存储
(store-conditional)指令,只有上一次执行LoadLinked
的地址在期间都没有更新
时, 才会成功,同时更新了该地址的值。先通过
LoadLinked
尝试获取锁值,如果判断到锁被释放了,就执行StoreConditional
判断在执行完LoadLinked
到StoreConditional执行前
ptr 有没有被更新
,- 没有:说明
没有
其他线程来抢
,可以进临界区;
- 有:说明已经被其他线程
抢走了
,继续重复本段落所述内容循环:
获取并增加
获取并增加
(fetch-and-add)指令它能
原子
地返回
特定地址的旧值
,并且让该值自增一
这个解决方案使用了
ticket
和 turn
变量来构建锁。原理是每次进入 lock,就获取当前
ticket值
,相当于挂号
,然后全局 ticket 本身会自增一
,后续线程都会获得属于自己的唯一ticket值
,lock->turn
表示当前叫号值
,叫到号的运行,unlock 时递增lock->turn
更新叫号值
就行。这种方法保证了公平性,FIFO
自旋过多:怎么办
基于硬件的锁简单(只有几行代码)而且有效,这也是任何好的系统或者代码的特点。但是,某些场景下,这些解决方案会效率低下。
一个线程会一直自旋检查一个
不会改变
的值,浪费
掉整个时间片!如果有 N 个
线程去竞争
一个锁,情况会更糟糕。同样的场景下,会浪费 N−1 个时间片
,只是自旋并等待一个线程释放该锁。只有硬件支持是不够的。我们还需要操作系统支持!
简单方法:让出来吧,宝贝
硬件支持让我们有了很大的进展:我们已经实现了有效、公平(通过 ticket 锁)的锁。
但是,问题仍然存在:如果临界区的线程发生上下文切换,其他线程只能一直自旋,等待被中断的(持有锁的)进程重新运行,第一种简单友好的方法就是,在要自旋的时候,放弃 CPU。
在这种方法中,我们假定操作系统提供原语yield()
,线程可以调用它主动放弃 CPU,让其他线程运行。线程可以处于 3 种状态之一(运行、就绪和阻塞)。yield()系统调用能够让运行(running)态变为就绪(ready)态,从而允许其他线程运行。因此,让出线程本质上取消调度(deschedules)了它自己。
考虑在单 CPU 上运行两个线程。在这个例子中,基于 yield 的方法十分有效。一个线程调用 lock(),发现锁被占用时,让出 CPU,另外一个线程运行,完成临界区。在这个简单的例子中,让出方法工作得非常好。
使用队列:休眠替代自旋
必须显式地施加某种控制,决定锁释放时,谁能抢到锁。为了做到这一点,我们需要操作系统的更多支持,并需要一个
队列
来保存等待锁的线程。简单起见,我们利用 Solaris 提供两个调用:
park()
能够让调用线程休眠,unpark(threadID)
则会唤醒 threadID 标识的线程。可以用这两个调用来实现锁,让调用者在获取不到锁时睡眠,在锁可用时被唤醒。- 将之前的测试并设置和等待队列结合,实现了一个更高性能的锁。
- 通过队列来控制谁会获得锁,避免饿死。
guard 基本上起到了
自旋锁
的作用,围绕着 flag 和队列操作。因此,这个方法并没有完全避免自旋等待。线程在获取锁或者释放锁时可能被中断,从而导致其他线程自旋等待。但是,这个自旋等待时间是很有限的(不是用户定义的临界区,只是在 lock和 unlock 代码中的几个指令)。
在 lock()函数中,如果线程不能获取锁(它已被持有),线程会把自己加入队列
(通过调用 gettid()获得当前的线程 ID),将 guard
设置为 0
,然后让出 CPU
。
当要唤醒另一个线程时,flag 并没有设置为0,线程被唤醒时,就像是从 park()调用返回。但是,此时它没有持有 guard,所以也不能将 flag 设置为 1。期间 flag 不必设置为 0。Solaris 通过增加了第三个系统调用
separk()
来解决这一问题。通过 setpark(),一个线程表明自己马上要 park。如果刚好另一个线程被调度,并且调用了 unpark,那么后续的 park调用就会直接返回,而不是一直睡眠。lock()调用可以做一点小修改:另外一种方案就是将 guard 传入内核。在这种情况下,内核能够采取预防措施,保证原子地释放锁,把运行线程移出队列。
不同操作系统,不同实现
Linux 提供了
futex
,它类似于 Solaris 的接口,但提供了更多内核功能。具体来说,每个 futex 都关联一个特定的物理内存位置
,也有一个事先建好的内核队列
。调用者通过futex 调用(见下面的描述)来睡眠或者唤醒。
具体来说,有两个调用。调用 futex_wait(address, expected)
时,如果 address 处的值等于expected,就会让调线程睡眠。否则,调用立刻返回。调用 futex_wake(address)
唤醒等待队列中的一个线程。基本上,它利用一个整数,同时记录锁是否被持有(整数的最高位),以及等待者的个数(整数的其余所有位)。因此,如果锁是负的,它就被持有(因为最高位被设置,该位决定了整数的符号)。这段代码的有趣之处还在于,它展示了如何优化常见的情况,即没有竞争时:只有一个线程获取和释放锁,所做的工作很少(获取锁时测试和设置的原子位运算,释放锁时原子的加法)。
两阶段锁
两阶段锁
(two-phase lock)如果第一个
自旋阶段
没有获得锁,第二阶段
调用者会睡眠
,直到锁可用。上文的 Linux 锁就是这种锁,不过只自旋一次;更常见的方式是在循环中自旋固定的次数(希望
这段时间内能获取到锁
),然后使用futex
睡眠。参考
- 作者:GJJ
- 链接:https://blog.gaojj.cn/article/blog-46
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。