
共享数据的一致性
在多线程程序中,线程之间常常通过共享数据来传递信息。由于一个进程中的大部分虚拟内存地址可以被其所有线程共享,这些数据通常就存放在内存里。
想象一下,如果两个线程同时读取同一块共享内存,却得到了不同的数据,程序很可能会出错。共享数据的一致性,本质上是一种约定。只有在这个约定成立的前提下,各个线程的执行逻辑才是正确的。当操作共享数据的实际结果总是符合我们的预期时,我们才说数据一致性得到了保证。这,正是多线程程序得以正确运行的基础。
保证一致性最彻底的方法,就是把数据变成一成不变的常量。常量不会改变,自然也就不存在不一致的问题,无论有多少线程访问它,都无需额外措施。然而,现实中的计数器需要改变,它只能作为变量存在。当多个线程共享一个变量时,就需要额外的手段来保证其一致性,这就引出了临界区的概念。
临界区
临界区指的是在同一时刻只能被一个线程串行访问的代码段或资源,也常被称为串行区域。保证临界区安全最有效的方式,就是利用同步机制。针对多线程程序的同步方法有很多,包括我们即将详细讨论的互斥量、条件变量,以及原子操作。
互斥量
“在同一时刻,只允许一个线程处于临界区内”这个约束,就叫做互斥。为了实现互斥,线程在进入临界区前,必须先锁定某个对象。只有锁定成功的线程才被允许进入,否则就会被阻塞。这个对象,就是我们所说的互斥对象或互斥量。
由此可知,互斥量有两种状态:已锁定和未锁定。它有一个重要特性:只能被锁定一次。一个已锁定的互斥量无法再次被锁定,除非它先被解锁。任何试图锁定已锁定互斥量的操作都会失败。
成功锁定互斥量的线程成为它的所有者,也只有所有者才能对其进行解锁。因此,多个线程争相锁定同一个互斥量的过程,也可以看作是对其所有权的争夺。锁定是获取所有权,解锁则是释放所有权。
线程在离开临界区时,必须对相应的互斥量进行解锁。这样,其他因试图进入临界区而被阻塞的线程才会被唤醒,并有机会再次尝试锁定。注意,对同一个互斥量的锁定和解锁操作必须成对出现。
互斥量使用示例

上图展示了两个线程共享一个计数器的场景,其中使用了互斥量进行同步。
互斥量本身也是一种需要被所有相关线程访问到的共享资源,因此代表它的变量通常是全局的。在使用前,互斥量必须被初始化,且这个初始化操作应确保在任何线程真正使用它之前只执行一次。初始化后的互斥量处于未锁定状态。
在示例中,初始化计数器后,线程A首先使用它。线程A尝试锁定互斥量,由于互斥量处于未锁定状态,锁定成功。在线程A获取计数器值但尚未更新时,线程B获得运行时机并准备使用计数器。线程B尝试锁定同一个互斥量,但此时互斥量已被线程A锁定,因此锁定失败,线程B进入阻塞睡眠状态,直到线程A解锁互斥量后才会被唤醒。
上面的示例忽略了一个细节:线程在执行完数据筛选后,需要将结果写入一个集合文件。因此,对文件的写入操作也需要进行同步,以避免文件内容错乱。

从图中可以看到互斥量1和互斥量2各自保护着不同的临界区。当线程B试图进入临界区2时,因为线程A正在临界区2内,所以线程B进入睡眠状态。等待线程A离开后,线程B才被唤醒并进入。
使用互斥量的原则与陷阱
一般来说,应该尽量减少互斥量的使用。每个互斥量保护的临界区应在合理范围内尽可能大。但是,如果发现多个线程频繁出入一个较大的临界区,并且经常发生访问冲突,就应该考虑将这个大的临界区拆分成几个较小的,并用不同的互斥量保护。这样做可以减少等待进入同一个临界区的线程数量,降低线程被阻塞的概率,从而在一定程度上提升程序性能。
需要特别注意:尽量避免让不同互斥量保护的临界区发生重叠,这会大大增加死锁发生的概率。

如上图所示,线程A和线程B一开始分别锁定了互斥量1和互斥量2。随后,在未释放自己持有的互斥量的情况下,它们又试图去锁定对方持有的互斥量,导致了典型的死锁。
解决这种多互斥量死锁,有两种通用方法:
- 试锁定-回退:需要操作系统线程库的支持。核心思想是,如果需要锁定多个互斥量,在成功锁定其中一个后,使用“试锁定”函数尝试锁定下一个。如果试锁定失败,则解锁已持有的互斥量,然后重新开始整个锁定流程。“试锁定”函数在失败时会立即返回错误码,而不是阻塞线程。
- 固定顺序锁定:这是一种更简单廉价的方法。思路很简单:在任何需要锁定多个互斥量的场景下,总是以固定不变的顺序去锁定它们。
条件变量
与互斥量不同,条件变量的作用不是保证同一时刻只有一个线程访问数据,而是在共享数据的状态发生变化时,通知其他因此而被阻塞的线程。条件变量总是与互斥量组合使用。
考虑一个经典的生产者-消费者问题:有一个容量有限的队列,一些线程(生产者)向队列中添加数据块,另一些线程(消费者)从队列中取走数据块。
因为有多个线程并发访问队列,我们需要将“添加操作”和“获取操作”都置于临界区中,并用同一个互斥量保护。但这仍然会遇到问题:
情况一:队列已满,生产者等待。
生产者线程锁定互斥量后,发现队列已满,无法添加。它可能需要在临界区内循环检查,直到队列有空位。

把锁定、解锁操作和添加操作一起放在循环里,虽然解决了死锁问题(无论能否添加都会解锁),但如果队列长时间处于满状态,这个循环会空转很多次,造成CPU资源的浪费。

情况二:队列为空,消费者等待。
消费者线程也会遇到类似问题,需要循环检查队列是否为空。

条件变量就是为了解决这种“忙等待”低效问题而生的。
条件变量的操作
与互斥量一样,条件变量使用前也需要创建和初始化,并确保只初始化一次。此外,它必须与某个互斥量进行绑定。条件变量主要有三种操作:
- 等待通知:阻塞当前线程,直到收到该条件变量发来的通知。
- 单发通知:让条件变量向至少一个正在等待通知的线程发送通知,告知共享数据状态已改变。
- 广播通知:让条件变量向所有正在等待通知的线程发送通知。
“等待通知”操作并不只是简单地阻塞线程。在执行时,它会先解锁与之绑定的互斥量,然后再阻塞当前线程。这包含两个关键细节:
第一,只有在检查发现共享数据状态不满足条件时,才执行等待操作。而检查状态这个动作本身,也必须在互斥量的保护下(即在临界区内)进行。所以,等待操作中解锁互斥量的步骤是合理的。
第二,如果等待操作在阻塞线程前不对互斥量解锁,那么其他线程将无法进入临界区去改变共享数据状态,而当前线程又在等待状态改变,这就会形成死锁。等待操作中的“解锁”和“阻塞”步骤共同形成了一个原子操作,确保了在阻塞当前线程之前,没有其他线程能锁定该互斥量。

条件变量的正确使用模式
如果有多个生产者线程被唤醒,只有一个线程能成功添加数据,其他线程可能会因为队列再次变满而操作失败。为了避免低效的重试或操作失败,关键在于:在被唤醒后、执行操作前,必须再次检查条件。

这样,即使被其他线程抢先一步,当前线程也可以安全地再次进入等待状态。
注意:在某些多CPU系统中,即使没有收到通知,线程也可能被唤醒(伪唤醒)。因此,判断条件是否满足必须使用循环,而不能用简单的if语句。
通知的选择与条件变量的配对
单发通知和广播通知的区别在于唤醒线程的数量。单发通知只保证唤醒至少一个等待线程,而广播通知会唤醒所有。这决定了它们的适用场景:如果明确知道被唤醒的线程执行操作后,共享数据状态就不再满足等待条件(例如,一个生产者添加数据后队列就满了),那么使用广播通知就是低效的,单发通知即可。

在消费者完成获取操作后,执行一次条件变量的单发通知操作,这意味着每消费一个数据块,就会通知可能正在等待的生产者。
注意:条件变量的通知具有即时性,它只负责发送信号,不会存储。如果在发送通知时没有线程在等待,该通知会被直接丢弃。
完整的生产者-消费者模型
对于经典的生产者-消费者问题,我们需要两个条件变量:
- 条件变量1:关注“队列非满”状态,供生产者等待。
- 条件变量2:关注“队列非空”状态,供消费者等待。
由于生产者和消费者对队列的操作都由同一个互斥量保护,所以这两个条件变量都必须绑定到这个互斥量上。


通过互斥量保证对共享队列的互斥访问,再配合两个条件变量来高效地等待和通知状态变化,就构成了一个高效、正确的生产者-消费者同步模型。理解互斥量和条件变量的工作原理与配合方式,是掌握多线程并发编程核心同步机制的基石。希望这篇深入的分析能帮助你更好地理解这些概念,如果你想与其他开发者交流更多关于系统编程或并发的心得,云栈社区是一个不错的去处。