[译] Locks, Mutexes, and Semaphores: Types of Synchronization Objects
译文
“我”最近收到一封电子邮件,询问有关锁和不同类型的同步对象的问题,因此“我”发布了这篇文章,说不定能对其他人有帮助。
Locks
锁(Lock)是一个抽象的概念。它的基本前提是一个锁保护对某种共享资源的访问。如果你拥有一个锁,那么你可以访问其受保护的共享资源。如果你不拥有锁,那么你就不能访问该共享资源。
要拥有一把锁,你首先需要某种可锁定的对象。然后你从该对象上获得锁。精确的术语可能有所不同。例如,如果你有一个可锁定的对象 XYZ,你可以:
- 获得 XYZ 的锁(acquire the lock on XYZ)
- 拿走 XYZ 的锁(take the lock on XYZ)
- 锁定 XYZ(lock XYZ)
- 拥有 XYZ 的所有权(take ownership of XYZ)
- 或一些针对 XYZ 类型的类似术语
锁的概念也意味着某种排他性(exclusion):有时你可能无法取得锁的所有权,并且这样做的操作要么失败(fail),要么阻塞(block)。在前一种情况下,操作将返回一些错误代码或异常以表明获取所有权的尝试失败。在后一种情况下,该操作将不会返回一直到它取得了所有权,这通常需要系统中的另一个线程做一些事情来允许它发生。
最常见的排他形式是一个简单的数字计数:一个可锁定对象的所有者已达最大值。如果已经达到了这个值,那么任何进一步获取锁的尝试都将无法成功。因此,这要求我们在完成后有某种放弃所有权的机制。这通常被称为解锁,但术语也可能有所不同。例如,你可以:
- 释放 XYZ 上的锁(release the lock on XYZ)
- 放下 XYZ 上的锁(drop the lock on XYZ)
- 解锁 XYZ(unlock XYZ)
- 放弃 XYZ 的所有权(relinquish ownership of XYZ)
- 或一些针对 XYZ 类型的类似术语
当你以适当的方式放弃所有权时,如果已满足所需条件,则尝试获取锁的阻塞操作可能不会继续。
例如,如果一个可锁定的对象只允许三个所有者,那么第四次试图获取锁的尝试将阻塞。当前三个所有者之一释放锁时,第四次获取锁的尝试将成功。
Ownership
“拥有”锁意味着什么取决于可锁定对象的精确类型。 对于某些可锁定对象,所有权的定义非常严格:该特定线程通过使用该特定对象在该特定范围内拥有锁。
在其他情况下,定义更加不确定,锁的所有权更具有概念性。在这些情况下,所有权可以由与获取锁的线程或对象不同的线程或对象放弃。
Mutexes
互斥锁(mutex) 是 MUTual EXclusion 的缩写。除非这个词被额外的术语所限定,如shared mutex、recursive mutex 或 read/write mutex,否则它指的是一种可锁定的对象,一次只能由一个线程拥有。只有获得锁的线程才能释放对一个 mutex 的锁。当 mutex 被锁定时,任何获取锁的尝试都会失败或阻塞,即使这种尝试是由同一个线程完成的。
Recursive Mutexes
递归互斥锁(recursive mutex) 与普通的 mutex 类似,但一个线程可以同时拥有多个锁。如果线程 A 已经获得了一个 recursive mutex 的锁,那么线程 A 可以在不释放已经持有的锁的情况下获得 recursive mutex 上的更多锁。然而,线程 B 不能获得 recursive mutex 上的任何锁,直到线程 A 持有的所有锁都被释放。
在大多数情况下,recursive mutex 是不理想的,因为它使代码的正确推理更加困难。对于普通的 mutex,如果你在释放所有权之前确保被保护资源中的不变量(invariants)是有效的,那么你就知道当你获得所有权时,这些不变量也是有效的。
对于 recursive mutex 来说情况并非如此,因为能够获得锁并不意味着当前线程尚未持有该锁,因此并不意味着不变量是有效的。
Reader/Writer Mutexes
有时称为 shared mutexes、multiple-reader/single-writer mutexes 或 read/write mutexes,它们提供两种不同类型的所有权:
- 共享所有权,也称为读所有权,或读锁
- 独占所有权,也叫写所有权,或写锁
独占所有权就像普通 mutex 的所有权一样工作:只有一个线程可以持有 mutex 上的独占锁,只有该线程可以释放锁。 当该线程持有其锁时,其他线程不得在该 mutex 上持有任何类型的锁。
共享所有权则更为宽松。任意数量的线程可以同时共享一个 mutex。当任何线程持有共享锁时,任何线程都不能在 mutex 上取得独占锁。
这些 mutexes 通常用于保护很少更新的共享数据,但如果任何线程正在读取它,就不能安全地更新。因此,在读取数据时,读取线程拥有共享所有权。当数据需要被修改时,修改线程首先会获得该 mutex 的独占所有权,从而确保没有其他线程在读取它,然后在修改完成后释放独占锁。
Spinlocks
自旋锁(spinlock)是一种特殊类型的 mutex,当锁操作必须等待时,它不使用操作系统同步功能。 相反,它只是不断尝试更新 mutex 数据结构以在循环中获取锁。
如果锁不是经常被持有,和/或只被持有很短的时间,那么这可能比调用重量级的线程同步函数更有效率。然而,如果处理器不得不循环太多次,那么它只是在浪费时间什么都不做,如果操作系统调度另一个具有活动工作的线程而不是无法获得自旋锁的线程,系统会做得更好。
Semaphores
信号量(semaphore) 是一种非常宽松的可锁定对象。一个给定的信号量具有一个预定的最大计数和一个当前计数。你可以通过等待(wait)操作获得信号量的所有权,也可以称为递减(decrementing)信号量,甚至可以抽象地称为 P。你可以通过信号(signal)操作释放所有权,也可以称为递增(incrementing)信号量、后(post)操作,也可以抽象地称为 V。单字母操作名称来自 Dijkstra 关于信号量的原始论文。
每当你在等待(wait)一个信号量时,就会减少当前的计数。如果计数大于零,那么递减就会发生,并且等待调用会返回。如果计数已经为零,那么就不能再递减了,所以等待调用将阻塞(block),直到另一个线程通过发出信号(signaling)给信号量来增加计数。
每次你发出(signal)信号量时,都会增加当前计数。 如果在你调用信号(signal)之前计数为零,并且有一个线程在等待(wait)中阻塞,那么该线程将被唤醒。如果有多个线程在等待,则只会唤醒一个。如果计数已经达到最大值,则通常会忽略该信号,尽管某些信号量可能会报告错误。
Mutex 的所有权与线程紧密相连,只有获得 mutex 锁的线程才能释放它,而信号量的所有权则更加宽松和短暂。任何线程都可以在任何时候向信号量发出信号,无论该线程之前是否等待过信号量。
一个类比
信号量就像一个没有滞纳金的公共借阅图书馆。 他们可能有五本《C++ Concurrency in Action》可供借用。前五个来图书馆找书的人将会得到一本,但是第六个人将不得不等待,或者走开以后再来。
图书馆并不关心谁来还书,因为没有滞纳金,但是当得到一本归还的书时,就会把它交给等待它的人。如果没有人在等,这本书就会放在书架上,直到有人想要一本。
Binary semaphores and Mutexes
二进制信号量是最大计数为 1 的信号量。你可以将二进制信号灯作为一个 mutex,方法是要求一个线程只有在最后成功等待它的线程(当它锁定 mutex 时)才会向信号量发出信号(解锁 mutex)。然而,这只是一个约定; 信号量本身并不关心,如果“错误的”线程向信号量发出信号,也不会抱怨。
Critical Sections
在同步术语中,临界区(critical sections)是拥有锁的代码块。它从获取锁开始,到释放锁结束。
Windows CRITICAL_SECTIONs
Windows 程序员可能很熟悉 CRITICAL_SECTION 对象。 CRITICAL_SECTION 是一种特定类型的 mutex,而不是作为通用术语临界区的使用。
Mutexes in C++
C++14 标准有五种 mutex 类型:
std::mutexstd::timed_mutexstd::recursive_mutexstd::recursive_timed_mutexstd::shared_timed_mutex
名称中带有 timed 的变体(variant)与没有 timed 的变体相同,只是锁操作可以指定超时,以限制最大等待时间。如果没有指定超时时间(或可能),那么锁操作将阻塞,直到可以获得锁 —— 如果持有锁的线程从未释放它,则可能永远如此。
std::mutex 和 std::timed_mutex 只是普通的单所有者互斥锁。
std::recursive_mutex 和 std::recursive_timed_mutex 是递归互斥锁,因此单个线程可以持有多个锁。
std::shared_timed_mutex 是一个读/写互斥锁。
C++ lock objects
为了配合各种 mutex 类型,C++ 标准为持有锁的对象定义了三组类模板。这些模板是:
std::lock_gurard<>std::unique_lock<>std::shared_lock<>
对于基本操作来说,它们都是在构造函数中获取锁,并在析构函数中释放锁,不过如果需要的话,它们也可以用更复杂的方式来使用。
std::lock_guard<> 是最简单的类型,它只在单个块中的临界区持有锁:
1 | std::mutex m; |
std::unique_lock<> 类似,除了它可以从函数返回而不释放锁,并且可以在析构函数之前释放锁:
1 | std::mutex m; |
有关 std::unique_lock<> 和 std::lock_guard<> 的更多信息,请参阅“我”之前的博客文章。
std::shared_lock<> 几乎与 std::unique_lock<> 相同,只是它获取互斥锁上的共享锁。如果您使用的是 std::shared_timed_mutex 那么您可以使用 std::lock_guard<std::shared_timed_mutex> 或 std::unique_lock<std::shared_timed_mutex> 作为排他锁,以及 std::shared_lock<std::shared_timed_mutex> 作为共享锁。
1 | std::shared_timed_mutex m; |
Semaphores in C++
C++ 标准没有定义信号量类型。 如果需要,你可以使用原子计数器、互斥锁和条件变量编写自己的代码,但无论如何,信号量的大多数用途最好用互斥锁和/或条件变量代替。
不幸的是,对于信号量确实是你想要的那些情况,使用互斥锁和条件变量会增加开销,并且 C++ 标准中没有提供任何帮助。 Olivier Giroux 和 Carter Edwards 关于 std::synchronic 类模板 (N4195) 的提议可能允许有效实现信号量,但这仍然只是一个提议。
信号量已在 C++20 标准中实现。
由 Anthony Williams 发布
2014 年 10 月 21 日,周二