从竞态条件到无锁编程:深入理解操作系统的并发与同步机制
一台现代计算机通常拥有多个 CPU 核心,可以同时执行多个线程。在浏览器中,一个线程负责处理用户交互,另一个线程负责网络通信,还有线程负责页面渲染;在数据库中,大量工作线程同时处理查询、修改数据和维护缓存;在操作系统内核中,不同核心也可能同时访问进程表、文件系统缓存和内存管理数据结构。
这些线程往往需要共享数据。
但共享数据会带来一个重要问题:如果两个线程同时修改同一个变量,最终结果一定正确吗?
假设两个线程分别将一个全局计数器增加 10000 次。按照直觉,最终结果应该是 20000。然而,程序可能得到一个更小的数字。更令人困惑的是,同一段代码有时运行正常,有时却出现错误,甚至只在高负载或者多核处理器上才能复现。
为什么会出现这种情况?操作系统如何保证多个线程安全地访问共享资源?互斥锁、信号量、条件变量分别解决什么问题?为什么有时程序会发生死锁?为什么使用原子变量并不意味着整个算法就是线程安全的?所谓无锁编程,又是如何实现的?
回答这些问题,需要理解计算机系统中的并发与同步机制(Concurrency and Synchronization)。
本文从并发的基本概念出发,介绍竞态条件、临界区与互斥锁、信号量与条件变量、经典同步问题、死锁及其解决方法、原子操作与内存模型、无锁算法、多核缓存一致性,以及现代操作系统和应用程序中的并发性能优化。
一、并发的基本概念
并发与并行
并发(Concurrency)与并行(Parallelism)是两个密切相关但并不相同的概念。
并发强调多个任务在同一段时间内推进,而不要求它们在同一个时刻真正执行。
例如,在单核处理器上,操作系统通过快速切换线程,让多个任务交替使用 CPU。这些任务虽然不能在同一时刻执行指令,但从整体来看,它们都在不断推进。
并行则强调多个任务在同一个时刻真正执行。
例如,一台拥有四个 CPU 核心的计算机,可以在四个核心上同时运行四个线程。
因此:
-
并发不一定意味着并行:单核处理器也可以实现并发;
-
并行通常涉及并发执行的任务:多个核心可以同时处理不同工作;
-
并发程序不一定比串行程序更快:线程管理、同步与数据竞争都可能引入额外开销。
并发主要解决多个任务如何正确协作的问题,而并行更关注如何利用多个计算资源提高执行效率。
线程与共享资源
同一个进程中的多个线程共享进程的虚拟地址空间。
这意味着它们通常可以访问相同的全局变量、堆内存和文件等进程资源。
与此同时,每个线程拥有自己的程序计数器、寄存器状态和执行栈。
共享内存让线程之间的数据交换非常方便,但也意味着线程之间可能相互干扰。
例如:
int counter = 0;
void increment(void) {
counter++;
}
如果只有一个线程执行 increment(),程序通常没有问题。
但如果多个线程同时调用这个函数,最终结果就可能不符合预期。
原因在于,counter++ 并不一定对应一条不可分割的机器指令。
竞态条件
竞态条件(Race Condition)是指程序的结果依赖多个并发操作之间的执行时序,并且某些时序会产生不符合预期的行为。
考虑两个线程同时执行:
counter++;
从逻辑上可以将这个操作分解为:
-
从内存中读取
counter; -
将读取到的值增加 1;
-
将结果写回内存。
假设初始值为 0,可能发生如下过程:
| 步骤 | 线程 A | 线程 B |
|---|---|---|
| 1 | 读取 0 | |
| 2 | 读取 0 | |
| 3 | 计算得到 1 | |
| 4 | 计算得到 1 | |
| 5 | 写入 1 | |
| 6 | 写入 1 |
两个线程都执行了一次加一操作,但最终结果却是 1,而不是 2。
这就是典型的丢失更新(Lost Update)问题。
在 C 和 C++ 的内存模型中,如果多个线程对同一内存位置进行冲突访问,其中至少一个是非原子写入,而且这些访问之间没有适当的同步关系,就可能构成数据竞争(Data Race),导致未定义行为。
因此,实际问题可能不仅仅是丢失更新,还可能涉及编译器优化产生的其他不可预测行为。
原子性、可见性与有序性
理解并发程序,需要区分三个重要性质。
原子性(Atomicity):一个操作在其规定的语义范围内不可被其他线程观察为部分完成。
可见性(Visibility):一个线程对共享数据的修改,能够在适当的同步关系下被其他线程观察到。
有序性(Ordering):多个内存操作之间,需要满足程序和内存模型规定的先后顺序约束。
这三个性质并不是同一回事。
一个变量的单次读写即使是原子的,也不代表一组涉及多个变量的操作能够自动保持一致性。
同样,一个线程修改了某个变量,也不意味着另一个线程可以在没有同步的情况下可靠地观察到更新。
二、临界区与互斥锁
临界区
当多个线程需要访问同一份可变共享数据时,通常需要限制某些操作不能同时执行。
这类必须受到协调的代码区域,称为临界区(Critical Section)。
例如:
counter++;
如果多个线程都可能修改 counter,那么这个操作就需要适当的同步保护。
临界区设计通常需要考虑三个问题:
-
互斥:不允许多个冲突操作同时进入临界区;
-
进展:当临界区没有被占用时,等待线程应该有机会继续执行;
-
有界等待或公平性:尽量避免某个线程长期无法获得资源。
不同同步算法提供的公平性保证可能不同,并不是所有互斥锁都严格保证等待顺序。
互斥锁
互斥锁(Mutex)是最常用的线程同步工具之一。
一个线程进入受保护的临界区之前,需要先获得互斥锁。
如果锁已经被另一个线程持有,新线程就必须等待。
当持锁线程完成临界区操作后,释放锁,其他线程才有机会继续执行。
使用 POSIX 线程库,可以编写:
#include <pthread.h>
int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void increment(void) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
只要所有访问 counter 的相关线程都遵守相同的锁保护协议,就可以避免前面示例中的数据竞争。
互斥锁的意义不仅在于限制线程同时进入临界区,还在于建立必要的内存同步关系。
例如,在符合相关规则的情况下,一个线程释放互斥锁之前完成的修改,可以被随后成功获得同一把锁的线程正确观察到。
自旋锁
自旋锁(Spinlock)是一种通过不断尝试获取锁来等待的同步机制。
其基本逻辑可以理解为:
while (无法获得锁) {
继续尝试;
}
等待线程不会因为每次获取失败就主动进入睡眠,而是持续消耗处理器执行资源,直到锁可用。
自旋锁适用于临界区非常短、预计等待时间很小,并且执行环境允许忙等待的场景。
例如,操作系统内核中的部分短临界区会使用自旋锁。
但它也有明显的缺点。
如果锁长时间不释放,等待线程就会持续占用 CPU 时间。
尤其是在单核环境或持锁线程无法及时运行的情况下,自旋等待可能导致严重的效率问题。
因此,自旋锁并不适合长时间等待,也不应该在不允许阻塞的自旋锁保护区域内随意执行可能睡眠的操作。
互斥锁与自旋锁的区别
两者最关键的区别是等待方式。
| 特征 | 互斥锁 | 自旋锁 |
|---|---|---|
| 等待方式 | 竞争时通常可阻塞睡眠 | 持续尝试获取 |
| CPU 消耗 | 阻塞期间通常较低 | 自旋期间持续消耗 CPU |
| 适用场景 | 较长或不可预测的等待 | 极短临界区 |
| 调度开销 | 可能涉及睡眠和唤醒 | 短等待可避免睡眠切换 |
| 主要风险 | 锁竞争、调度延迟 | 忙等待、CPU 浪费 |
需要注意,现代用户态互斥锁的实现通常并不是每次获取锁都需要进入内核。
在没有竞争时,它可以通过用户态原子操作快速获取锁;只有在发生竞争时,才可能使用内核提供的等待机制。
三、信号量与条件变量
信号量
信号量(Semaphore)是一种通过计数管理资源或协调任务的同步机制。
它通常维护一个非负计数值,并提供两类基本操作:
-
等待操作:尝试减少计数;如果资源不可用,则等待;
-
通知操作:增加计数,并可能唤醒等待者。
假设服务器有 5 个可同时使用的数据库连接。
可以将信号量初始值设为 5。
每个线程在使用数据库连接前,先执行等待操作;使用结束后,再执行通知操作。
这样,无论有多少线程同时发起请求,最多只有 5 个线程能够持有这些连接资源。
信号量适合限制并发访问数量,也可以用于线程之间的事件通知。
二元信号量与互斥锁
当信号量计数只能在 0 和 1 之间变化时,可以用它实现某些互斥行为。
但二元信号量与互斥锁并不完全等价。
互斥锁通常具有明确的持有者语义:哪个线程获得锁,就应该由符合锁协议的线程释放它。
信号量则更偏向资源计数或事件通知,通常不要求通知操作必须由最初执行等待操作的线程完成。
因此,不应该简单地将所有互斥锁都替换为二元信号量。
条件变量
条件变量(Condition Variable)用于让线程等待某个共享状态满足条件。
例如,一个消费者线程希望从队列中取出任务,但当前队列为空。
最直接的实现可能是:
while (queue_empty()) {
// 不断检查队列
}
这会产生忙等待,浪费 CPU 时间。
条件变量允许线程在条件不满足时进入等待,并在其他线程修改共享状态后被通知。
它通常与互斥锁配合使用。
基本模式如下:
pthread_mutex_lock(&mutex);
while (!condition) {
pthread_cond_wait(&cond, &mutex);
}
/* 条件已满足,处理共享数据 */
pthread_mutex_unlock(&mutex);
pthread_cond_wait() 会以协调好的方式释放互斥锁并进入等待,在返回前重新获得互斥锁。
这避免了线程在检查条件与进入等待之间出现丢失通知的竞态窗口。
为什么必须使用 while
一个常见问题是:
为什么等待条件时推荐使用 while,而不是 if?
原因是,线程被唤醒,并不意味着它所等待的条件此时仍然成立。
例如:
-
生产者向队列添加一个任务;
-
生产者通知消费者;
-
多个消费者可能被唤醒,或者发生其他并发调度;
-
某个消费者先获得锁并取走任务;
-
另一个消费者获得锁时,队列可能再次为空。
此外,条件变量还允许出现虚假唤醒(Spurious Wakeup)。
因此,正确的做法是在每次唤醒后重新检查条件。
条件变量负责通知状态可能发生了变化,真正决定线程能否继续执行的,是受到互斥锁保护的共享状态。
四、经典生产者—消费者问题
问题描述
生产者—消费者问题是操作系统同步机制中最经典的模型之一。
假设存在一个固定容量的任务队列。
生产者负责创建任务并放入队列,消费者负责从队列中取出任务并执行。
系统需要满足三个要求:
-
生产者不能向已经满的队列继续添加任务;
-
消费者不能从空队列中取出任务;
-
多个线程不能并发破坏队列内部的数据结构。
这实际上涉及两种不同的同步需求。
第一种是互斥:保护队列内部结构。
第二种是条件同步:让生产者等待队列有空位,让消费者等待队列非空。
使用条件变量解决
可以使用一把互斥锁和两个条件变量。
-
not_full:通知生产者,队列可能存在空位; -
not_empty:通知消费者,队列可能存在可取出的任务。
生产者的逻辑如下:
pthread_mutex_lock(&mutex);
while (queue_is_full()) {
pthread_cond_wait(¬_full, &mutex);
}
queue_push(task);
pthread_cond_signal(¬_empty);
pthread_mutex_unlock(&mutex);
消费者的逻辑如下:
pthread_mutex_lock(&mutex);
while (queue_is_empty()) {
pthread_cond_wait(¬_empty, &mutex);
}
task = queue_pop();
pthread_cond_signal(¬_full);
pthread_mutex_unlock(&mutex);
这里的 queue_push() 和 queue_pop() 表示具体队列操作,它们必须按照相同的互斥协议访问共享状态。
实际程序还需要考虑队列关闭、线程退出和错误处理等问题。
为什么要分开两种条件
队列满和队列空属于不同的等待条件。
当生产者添加任务后,需要通知可能正在等待任务的消费者。
当消费者取走任务后,需要通知可能正在等待空位的生产者。
如果所有线程都在同一个条件变量上等待,通知操作可能唤醒当前根本无法继续执行的线程,造成额外的等待和调度开销。
将条件拆分,可以让同步关系更加清晰。
不过,条件变量的通知并不是资源所有权的直接转移;被通知的线程仍然需要重新获得锁,并再次检查条件。
为什么队列不能无限增长
生产者—消费者模型还揭示了一个更广泛的问题:背压(Backpressure)。
如果任务产生速度长期超过消费速度,而队列没有容量上限,那么任务就会不断堆积,最终消耗大量内存。
合理的有界队列可以通过阻塞生产者、拒绝请求或者采用其他限流机制,限制资源消耗。
这种思想广泛应用于网络服务器、消息队列、数据库连接池和异步任务系统。
五、死锁、饥饿与活锁
死锁的产生
死锁(Deadlock)是指多个线程或进程相互等待对方持有的资源,导致它们都无法继续执行。
假设存在两把锁 A 和 B。
线程 1:
lock(A);
lock(B);
/* 访问共享资源 */
unlock(B);
unlock(A);
线程 2:
lock(B);
lock(A);
/* 访问共享资源 */
unlock(A);
unlock(B);
如果发生以下执行顺序:
-
线程 1 获得锁 A;
-
线程 2 获得锁 B;
-
线程 1 尝试获得锁 B,进入等待;
-
线程 2 尝试获得锁 A,进入等待。
此时线程 1 等待线程 2,线程 2 又等待线程 1。
如果没有其他机制介入,它们就无法继续执行。
死锁的四个必要条件
经典操作系统理论指出,死锁需要同时满足四个条件。
互斥条件:某些资源不能被多个任务同时使用。
占有并等待条件:任务已经持有部分资源,同时等待获取其他资源。
不可抢占条件:已经分配给任务的资源不能被系统随意强制收回。
循环等待条件:存在一个任务等待环,每个任务都在等待下一个任务持有的资源。
破坏其中任意一个必要条件,就可以避免相应模型中的死锁。
统一锁顺序
工程实践中,一种常见的死锁预防方法是统一锁的获取顺序。
例如,规定所有线程都必须先获取锁 A,再获取锁 B。
这样就不会出现某个线程先持有 B、再等待 A,而另一个线程先持有 A、再等待 B 的循环关系。
对于复杂程序,可以为锁定义全局层级,要求线程始终按照固定的层级顺序获取锁。
这种方法简单有效,因此广泛应用于内核和并发程序设计。
银行家算法
银行家算法(Banker's Algorithm)是一种经典的死锁避免算法。
它要求系统预先知道各任务可能需要的最大资源数量。
当任务申请资源时,系统不仅判断当前是否有足够的空闲资源,还会模拟分配后的状态是否仍然安全。
所谓安全状态,是指存在某种任务完成顺序,使所有任务都能够在获得所需资源后最终完成。
如果某次资源分配会导致系统进入不安全状态,银行家算法就暂时拒绝该请求。
它在操作系统理论中具有重要意义,但实际通用操作系统通常不会对所有线程锁采用这种方式,因为资源需求难以准确预知,而且维护成本较高。
饥饿与活锁
死锁并不是并发系统中唯一的进展问题。
饥饿(Starvation)是指某个线程虽然理论上能够继续运行,但因为资源分配不公平,长期得不到执行机会。
例如,一个读写锁如果不断优先服务新到来的读线程,等待写入的线程可能长期无法获得独占访问权限。
活锁(Livelock)则是指多个线程没有进入阻塞状态,并且持续执行操作,却始终无法完成实际工作。
例如,两个线程为了避免冲突不断主动让出资源,但每次都同时改变策略,结果反复互相避让。
死锁表现为相互等待而停止推进;活锁表现为不断行动却没有有效进展;饥饿则表现为某个任务长期无法获得所需服务。
这三种问题需要不同的解决策略。
六、原子操作与内存模型
原子操作
现代处理器提供了多种硬件原子操作,用于支持线程同步。
例如:
-
原子交换(Atomic Exchange);
-
比较并交换(Compare-and-Swap,CAS);
-
获取并增加(Fetch-and-Add);
-
加载链接与条件存储(Load-Linked/Store-Conditional,LL/SC)。
这些操作为互斥锁、无锁队列和其他并发数据结构提供了基础。
以 CAS 为例,它的逻辑是:
如果当前值 == 期望值:
将当前值更新为新值
返回成功
否则:
不进行更新
返回失败
CAS 的比较与条件更新在其原子操作语义下不可被其他线程观察为部分完成。
使用原子变量实现计数器
在 C++ 中,可以使用标准库提供的原子变量:
#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
这里的 fetch_add() 是原子读—修改—写操作。
多个线程同时执行时,不会再出现普通非原子加法中的丢失更新问题。
memory_order_relaxed 保证了该变量操作本身的原子性,以及该原子对象修改顺序的一致性,但不额外建立与其他普通内存访问之间的跨线程同步关系。
对于只需要准确统计次数、而不通过计数器发布其他数据的场景,这种方式通常是合适的。
为什么 volatile 不能代替原子操作
在一些程序中,可以看到使用 volatile 修饰共享变量:
volatile int counter = 0;
但在 C 和 C++ 中,volatile 并不提供完整的线程同步语义。
它既不保证 counter++ 成为原子操作,也不自动建立线程间的同步关系。
因此,单纯使用 volatile 不能可靠解决多线程数据竞争问题。
需要注意,volatile 在访问某些具有特殊语义的对象,例如内存映射硬件寄存器时,仍然可能有其用途,但具体规则取决于语言和运行环境。
线程之间的数据同步,应优先使用互斥锁、原子类型等专门机制。
内存重排序
为了提高执行效率,编译器和处理器可能在满足单线程可观察行为以及内存模型约束的前提下,对指令或内存操作进行重排序。
例如,一个线程执行:
data = 42;
ready = true;
另一个线程执行:
while (!ready) {
}
use(data);
如果 data 和 ready 都是普通共享变量,且没有建立同步关系,在 C++ 中就可能构成数据竞争。
因此,不能简单认为程序文本中的执行顺序就足以保证另一个线程观察到相同的顺序。
释放与获取语义
C++ 原子内存模型提供了多种内存序,其中常见的是:
-
释放语义(Release):约束发布操作之前的内存访问;
-
获取语义(Acquire):约束获取操作之后的内存访问;
-
顺序一致性(Sequential Consistency):提供更强的全局顺序约束。
可以通过发布一个原子标志,协调另一个线程读取已经准备好的数据。
#include <atomic>
int data = 0;
std::atomic<bool> ready{false};
void producer() {
data = 42;
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
}
int value = data;
}
当消费者的获取操作观察到生产者相应的释放写入时,两者之间建立同步关系。
因此,生产者在发布标志之前完成的 data 写入,可以被消费者正确观察。
这一示例假设没有其他线程在此后无同步地修改 data,也只演示一次性发布。实际工程中还需要考虑生命周期、等待方式和重复使用等问题。
原子操作负责提供特定的不可分割性和内存顺序保证,但整个并发算法是否正确,仍然取决于所有共享状态之间的同步设计。
七、无锁编程
什么是无锁算法
传统互斥锁通过限制同一时刻进入临界区的线程数量,保证共享数据的一致性。
另一种思路是使用原子操作,让多个线程在不依赖传统互斥锁的情况下协调修改共享数据。
这类算法通常被称为无锁算法(Lock-Free Algorithm)。
但需要注意,无锁并不是简单地指程序没有使用 mutex。
在严格的并发算法术语中,Lock-Free 指一种整体进展保证:在持续执行的线程中,即使某些线程暂停,系统层面仍然能够有操作完成。
它并不保证每个线程都能在有限步骤内完成自己的操作。
无锁计数器
使用原子变量实现计数器,就是一种简单的无锁编程模式。
std::atomic<int> count{0};
void add() {
count.fetch_add(1);
}
如果目标平台上的该原子操作具有无锁实现,那么多个线程可以通过底层原子指令更新计数器,而不需要使用传统互斥锁保护整个操作。
不过,C++ 标准并不保证所有类型、所有平台上的原子操作都一定是无锁的。
复杂的原子对象在某些实现中可能借助内部锁完成操作。
因此,不能仅仅因为使用了 std::atomic,就认定底层实现一定完全无锁。
CAS 重试循环
更复杂的无锁算法经常使用 CAS 重试循环。
基本思路是:
-
读取当前共享状态;
-
根据读取值计算新的状态;
-
尝试通过 CAS 提交更新;
-
如果其他线程已经修改了共享状态,CAS 失败;
-
重新读取状态并再次尝试。
这种方式允许多个线程并发竞争更新,而不是必须先独占一把互斥锁。
但它也会产生新的问题。
当线程竞争非常激烈时,大量 CAS 操作可能失败,导致不断重试,增加缓存一致性流量和 CPU 消耗。
因此,无锁并不意味着没有等待成本,更不意味着性能一定优于互斥锁。
ABA 问题
ABA 问题是 CAS 算法中的一个经典陷阱。
假设线程 A 读取到共享变量的值为 A,准备执行 CAS。
在它真正提交修改之前,另一个线程将共享值从 A 改成 B,然后又改回 A。
此时,线程 A 执行 CAS,发现当前值仍然是 A,于是认为共享状态没有变化。
但实际上,共享数据可能已经经历了一系列重要操作。
这就是 ABA 问题。
对于简单整数计数器,值重新变回原值未必有害;但对于链表、栈和其他指针型数据结构,ABA 可能导致严重的逻辑错误。
常见应对方法包括使用版本计数、带标记的指针,以及适当的内存回收策略。
无锁数据结构的内存回收
无锁链表或队列还面临一个比普通锁保护结构更困难的问题:何时释放节点内存?
假设线程 A 正在读取一个节点指针,而线程 B 将该节点从数据结构中移除。
如果线程 B 立即释放节点,线程 A 就可能继续访问已经释放的内存。
因此,节点从共享数据结构中移除,并不意味着它可以立即被回收。
常见的安全内存回收技术包括:
-
危险指针(Hazard Pointers);
-
基于纪元的回收(Epoch-Based Reclamation);
-
读取—复制—更新(Read-Copy-Update,RCU)等机制。
这些方法分别通过保护正在使用的对象、延迟回收或等待读侧操作结束等手段,确保内存不会在仍有读者使用时被释放。
这也说明,真正复杂的无锁数据结构,往往比使用互斥锁的数据结构更难设计和验证。
八、多核处理器与缓存一致性
CPU 缓存
现代处理器通常具有多级缓存。
一个典型的缓存层次可能包括:
-
L1 Cache:容量较小,访问延迟很低;
-
L2 Cache:容量较大,访问延迟通常略高;
-
L3 Cache:通常由多个核心共享,容量进一步增大;
-
主内存:容量大,但访问延迟明显高于处理器缓存。
不同处理器的具体缓存结构可能不同。
为了提高性能,处理器通常尽可能从缓存中获取数据,而不是每次都访问主内存。
但多个 CPU 核心各自拥有缓存副本后,就会出现一个问题:如果一个核心修改了数据,其他核心如何知道自己的缓存副本已经过期?
缓存一致性协议
缓存一致性(Cache Coherence)机制用于协调多个核心对同一内存位置的缓存访问。
在很多处理器架构中,缓存一致性以缓存行为单位进行管理。
例如,典型的 MESI 协议定义了四种缓存行状态:
-
Modified:缓存行已被修改,内容可能不同于内存中的旧数据;
-
Exclusive:缓存行仅由当前缓存持有,内容未修改;
-
Shared:缓存行可能被多个缓存共享;
-
Invalid:当前缓存行副本无效。
当一个核心准备修改被其他核心共享的缓存行时,需要通过缓存一致性机制获取相应的写入权限,并使其他核心的相关副本失效或进行其他协议规定的协调操作。
缓存一致性保证了硬件层面对缓存数据的一致性要求,但它并不自动保证高级语言中任意多个内存操作构成正确的同步协议。
程序仍然需要遵循语言内存模型和适当的同步规则。
伪共享
伪共享(False Sharing)是多核程序中一种容易被忽略的性能问题。
假设两个线程分别修改两个完全独立的变量:
struct Counters {
std::atomic<long> a{0};
std::atomic<long> b{0};
};
线程 1 只修改 a,线程 2 只修改 b。
从程序逻辑上看,两个变量互不影响。
但如果它们恰好位于同一个缓存行中,那么两个核心对不同变量的写入,仍然可能触发同一缓存行的所有权竞争与缓存一致性通信。
结果是,虽然没有真正的数据共享需求,程序却产生了明显的性能损失。
这种现象就称为伪共享。
缓存行填充
一种常见的优化方法,是将频繁由不同线程修改的变量分隔到不同缓存行中。
在 C++ 中可以使用合适的对齐策略:
#include <atomic>
#include <new>
struct alignas(std::hardware_destructive_interference_size)
Counter {
std::atomic<long> value{0};
};
Counter a;
Counter b;
这个例子利用标准库提供的硬件干扰大小信息,表达减少缓存行竞争的设计意图。
不过,具体布局和性能效果取决于编译器、标准库、硬件以及对象的实际放置方式。
实际优化时,应通过性能测试确认是否存在伪共享,而不是对所有变量都进行大规模填充。
因为过度填充会增加内存占用,也可能降低缓存利用率。
NUMA 的影响
在多处理器服务器中,不同 CPU 核心访问不同物理内存节点的延迟可能不同。
这种架构称为非一致内存访问(NUMA)。
如果多个线程频繁访问位于远端 NUMA 节点上的共享数据,就可能产生额外的跨节点通信开销。
因此,高性能并发程序除了需要控制锁竞争,还需要关注线程与数据的物理位置。
例如,可以将相互独立的工作分配到不同 NUMA 节点,并尽量让线程访问本地内存。
但如果程序具有大量跨节点共享状态,简单地绑定线程并不一定带来性能提升,反而可能影响系统负载均衡。
九、现代操作系统中的同步机制
Linux Futex
在 Linux 用户态并发编程中,Futex(Fast Userspace Mutex)是一种非常重要的底层机制。
它的核心思想是:
没有竞争时,尽量在用户空间完成同步;只有需要等待或唤醒时,才借助内核。
例如,线程尝试获得一把互斥锁。
如果锁处于空闲状态,线程可能通过原子操作直接获得它,而不需要系统调用。
如果锁已经被占用,线程可能通过 futex 等待机制进入内核,阻塞直到锁状态发生变化并得到适当唤醒。
这样,现代互斥锁可以同时兼顾低竞争时的快速路径,以及高竞争时避免持续忙等待的需求。
但 Futex 本身并不是完整的高级互斥锁,应用或线程库仍然需要正确管理锁状态、竞争过程和唤醒协议。
读写锁
读写锁(Read-Write Lock)允许多个读线程同时访问共享数据,但写线程需要独占访问权限。
它特别适合读操作远多于写操作,并且读取过程具有一定长度的场景。
例如,一个线程安全的共享配置表:
-
多个线程可以同时读取配置;
-
当配置发生更新时,写线程需要独占访问;
-
写入期间,其他线程不能读取到不符合协议的中间状态。
但读写锁也存在问题。
如果读操作非常短,读写锁本身的管理成本可能超过并行读取带来的收益。
此外,不同读写锁实现对读者和写者的优先策略不同,可能出现读者饥饿或写者饥饿。
因此,读写锁并不总是优于普通互斥锁。
RCU
读取—复制—更新(Read-Copy-Update,RCU)是 Linux 内核中广泛使用的一类并发同步机制。
它特别适合读操作非常频繁、写操作相对较少的场景。
RCU 的基本思想是:
-
读者在规定的读侧临界区中访问共享数据;
-
写者通过适当方式发布更新后的数据结构或新版本;
-
旧版本不会在仍可能被已有读者访问时立即释放;
-
经过必要的宽限期后,再安全回收旧数据。
这种方式可以让某些读操作以非常低的同步成本完成。
但 RCU 并不自动解决所有并发问题。
写者之间仍然可能需要其他同步措施,读者也必须遵循对应的访问规则。
对于需要频繁修改、或者读操作必须立即观察最新版本的场景,RCU 未必是最合适的方案。
优先级反转
同步机制还可能与 CPU 调度产生复杂的交互。
假设一个低优先级线程持有互斥锁,而高优先级线程需要这把锁才能继续执行。
如果此时中等优先级线程不断抢占低优先级线程,那么低优先级线程就无法及时释放锁,高优先级线程也只能继续等待。
这就是优先级反转(Priority Inversion)。
一种常见的解决方法是优先级继承(Priority Inheritance)。
当高优先级线程等待低优先级线程持有的锁时,系统可以临时提高持锁线程的有效优先级,让它更快地完成临界区并释放锁。
在实时系统中,这类机制尤其重要。
这也说明,线程同步并不是独立于操作系统调度存在的。
锁的持有时间、线程优先级和调度策略,共同影响并发程序的实际响应延迟。
十、并发程序的性能分析与优化
线程数量不是越多越好
增加线程数量不一定会提高程序性能。
对于 CPU 密集型任务,如果可运行线程数量远大于可用 CPU 核心数量,线程之间就需要频繁竞争处理器。
这可能增加上下文切换、缓存竞争和调度开销。
对于 I/O 密集型任务,适当增加并发度可以让其他任务在部分线程等待 I/O 时继续执行。
但并发度过高仍然可能导致内存占用上升、资源竞争和请求排队时间增加。
因此,合理的线程数量需要结合 CPU 核心数、任务类型、I/O 特征和同步开销确定。
减少锁的持有时间
锁竞争严重时,一种直接的优化方式是缩短临界区。
例如,不应在持有全局互斥锁的情况下执行耗时的网络请求或普通阻塞 I/O。
可以考虑:
-
在锁外完成能够独立执行的计算;
-
只将真正需要同步的共享状态修改放进临界区;
-
将一个全局锁拆分为多个保护不同资源的锁;
-
对适合的工作负载使用分片计数器或分片数据结构。
但拆分锁也会提高设计复杂度。
如果多个操作需要共同维护一个不变量,就不能简单地将它们拆成多个互不协调的临界区。
锁竞争与锁粒度
锁粒度是指一把锁所保护的数据范围或操作范围。
粗粒度锁保护的数据较多,实现相对简单,但并行度可能较低。
细粒度锁允许不同线程同时访问不同的数据区域,但通常需要管理更多锁,并且更容易产生复杂的死锁和一致性问题。
例如,一个哈希表可以选择:
-
使用一把全局锁保护整个表;
-
为不同桶设置独立的锁;
-
使用更复杂的无锁或分段并发结构。
选择哪种方式,应取决于实际访问模式和性能测试结果。
如何排查数据竞争
并发程序的一个难点,是问题往往无法稳定复现。
常规测试即使运行成功,也不能证明程序没有数据竞争。
在 C/C++ 项目中,可以考虑使用 ThreadSanitizer 等动态分析工具。
例如,在支持该工具的环境下:
clang++ -O1 -g -fsanitize=thread \
-fno-omit-frame-pointer -pthread \
main.cpp -o main
然后运行程序:
./main
ThreadSanitizer 可以在被实际执行到的代码路径中,检测多种数据竞争和线程同步问题。
但它不能保证发现所有并发错误,也可能与某些运行环境或其他检测工具不兼容。
对于复杂问题,还需要配合代码审查、压力测试和更严格的并发算法验证。
如何观察线程调度与锁竞争
在 Linux 系统中,可以使用一些工具分析并发性能。
观察线程状态:
top -H -p <PID>
查看进程中的线程:
ps -T -p <PID>
观察调度活动:
perf sched record -- ./program
perf sched latency
分析锁相关事件:
perf lock record -- ./program
perf lock report
这些命令的可用性和实际输出,取决于内核版本、性能事件支持、系统权限以及程序的同步方式。
在某些场景下,还可以使用 perf、eBPF 等工具观察 futex 等待、调度延迟、CPU 使用情况和缓存相关性能事件。
进行并发性能分析时,应避免只观察平均 CPU 使用率。
更重要的指标可能包括:
-
线程等待锁的时间;
-
锁竞争次数;
-
上下文切换频率;
-
CPU 运行队列等待时间;
-
缓存未命中和缓存行争用;
-
请求延迟的高百分位数;
-
不同并发度下的吞吐量变化。
这些指标可以帮助判断程序究竟受限于计算能力、内存访问,还是线程同步。
十一、并发程序设计的实践原则
理解并发与同步原理后,可以总结出一些具有普遍意义的工程经验。
共享状态越少,并发程序通常越容易正确实现。 如果线程之间可以通过消息传递交换数据,而不必频繁修改同一份共享内存,就能够减少大量同步问题。
优先保证正确性,再优化同步成本。 一把简单的互斥锁可能比一个未经充分验证的无锁算法更可靠。只有在性能分析表明锁确实成为瓶颈时,才有必要引入更复杂的同步结构。
不要把原子操作等同于线程安全。 单个变量的原子更新并不能自动保证多个变量之间的一致性。需要明确整个数据结构的不变量和同步协议。
不要在持锁状态下执行无法预测耗时的操作。 长时间阻塞、网络 I/O 和复杂回调都可能扩大锁竞争,甚至引入新的死锁风险。
为多把锁建立明确的获取顺序。 在复杂系统中,锁顺序往往比单个锁的实现细节更重要。统一的锁层级能够减少循环等待问题。
将数据布局纳入并发性能分析。 多核系统中,即使两个线程没有逻辑上的共享变量,也可能因为缓存行伪共享而相互影响。合理的数据分片和缓存布局有时比更换同步算法更有效。
并发测试不能只依赖正常运行结果。 并发错误具有强烈的时序依赖,应该结合动态检测、压力测试和代码审查,特别关注线程启动、退出、异常路径和资源释放过程。
结语
并发与同步是现代操作系统和多线程应用程序中最基础、也最容易出现复杂问题的领域之一。
从竞态条件与临界区,到互斥锁、自旋锁、信号量和条件变量;从生产者—消费者问题、死锁与饥饿,到原子操作、内存模型和无锁算法;从多核缓存一致性与伪共享,到 Futex、读写锁和 RCU,计算机系统通过多个层次的机制,协调并发任务对共享资源的访问。
这些机制之间始终存在权衡。
互斥锁能够简化共享数据保护,但可能产生竞争和调度开销;自旋锁可以避免短时间等待时的睡眠切换,却会持续消耗 CPU;读写锁能够提高某些读密集型负载的并行度,但也可能引入公平性问题;无锁算法能够避免传统互斥锁的一些阻塞问题,却需要处理更加复杂的内存顺序、状态竞争和安全回收。
而在多核处理器上,并发性能也不仅仅取决于代码中有多少线程。缓存一致性、内存访问局部性、NUMA 拓扑、任务调度和同步结构,都可能成为影响程序性能的关键因素。
对于应用开发者而言,理解并发机制能够帮助解释许多难以定位的问题:为什么相同的代码偶尔得到不同结果,为什么增加线程数量反而降低性能,为什么程序 CPU 使用率很高却没有实际进展,以及为什么一个看似简单的共享数据结构会引发复杂的系统故障。
并发编程的核心并不是让尽可能多的线程同时运行,而是在保证共享状态正确、线程能够持续推进的前提下,尽可能减少不必要的同步与资源竞争,让多个计算任务真正高效地协作。
- 点赞
- 收藏
- 关注作者
评论(0)