操作系统中的多处理器同步与锁机制
在多处理器系统中,多个CPU核心可能同时访问共享数据,这带来了比单处理器更复杂的同步挑战。我们将深入探讨这个问题及其核心解决方案。
1. 问题背景:为什么多处理器同步更复杂?
在单处理器系统中,虽然存在线程并发,但在任一时刻,只有一个线程在CPU上运行。即使多个线程不正确地访问共享数据,导致竞态条件,由于线程的交替执行在时间上是串行的,一些简单的“禁用中断”或软件算法可能暂时掩盖问题。然而,在多处理器系统中,多个线程是真正并行地在不同CPU上执行,它们对共享内存的读写操作可能在同一物理时刻发生,这使得竞态条件暴露无遗且后果更严重。
核心挑战:确保在一个CPU上运行的线程对共享数据的修改,能够被其他CPU上运行的线程正确、及时地观察到。这涉及到缓存一致性和内存屏障两个关键概念。
2. 基础工具:原子操作
原子操作是构建所有高级同步原语(如锁)的基石。
- 定义:一个原子操作在执行过程中是不可分割的。从系统其他部分(包括其他处理器)的视角看,这个操作要么完全没执行,要么已经完全执行,不会看到中间状态。
- 硬件支持:现代处理器提供特殊的指令来实现原子操作,例如:
- Test-and-Set:原子性地将一个内存位置设置为新值,并返回其旧值。
- Compare-and-Swap (CAS):原子性地比较一个内存位置的值是否等于预期值,如果相等,则交换为新值。
- Fetch-and-Add:原子性地获取一个内存位置的值并将其增加指定的量。
3. 自旋锁的演进:从简单实现到可伸缩性优化
我们使用原子操作来构建最简单的锁——自旋锁。线程在获取锁时,如果锁已被占用,它会在一个循环中不断“自旋”检查,直到锁被释放。
-
简单自旋锁(不可行版本):
最初的想法可能是用一个简单的布尔变量lock(0表示空闲,1表示占用)。// 错误示例! void lock(int *lock) { while (*lock == 1); // 等待锁被释放 *lock = 1; // 获取锁 } void unlock(int *lock) { *lock = 0; }问题:
检查锁状态和设置锁这两个操作不是原子的。可能有两个线程同时检查到lock为0,然后都将其改为1,导致两个线程都进入了临界区。 -
使用Test-and-Set的正确自旋锁:
我们使用原子操作来解决上述问题。void spin_lock(int *lock) { while (test_and_set(lock) == 1); // 如果旧值是1,继续自旋;如果是0,则获取成功。 } void spin_unlock(int *lock) { *lock = 0; }工作原理:
test_and_set指令原子性地完成了“读-改-写”操作。只有第一个执行该指令的线程会读到0并将其设为1,从而获得锁。其他线程执行时只会读到1,并继续循环。 -
简单自旋锁的性能问题:
上述正确的自旋锁在小型系统上可行,但在多处理器上存在严重的可伸缩性问题:- 总线/内存带宽竞争:所有等待锁的CPU都在不停地执行
test_and_set指令。这个指令需要向总线或内存控制器发送“读修改”的请求,这会导致大量的总线流量和缓存一致性流量(使其他CPU的缓存副本失效),严重拖慢整个系统。 - 公平性问题:不能保证等待时间最长的线程能先获得锁。
- 总线/内存带宽竞争:所有等待锁的CPU都在不停地执行
4. 高级锁机制:提升可伸缩性与公平性
为了解决简单自旋锁的问题,研究人员设计了更先进的锁。
-
排队锁(MCS Lock):
MCS锁通过让每个等待线程在一个本地变量上自旋,避免了全局竞争。- 数据结构:锁维护一个队列(可以是隐式的)。每个想获取锁的线程会创建一个自己的节点(包含一个
locked标志),并使用原子操作将这个节点加入到队列尾部。 - 工作流程:
- 获取锁:如果队列是空的,当前线程直接获得锁。否则,它将前驱节点的
next指针指向自己,然后在自己节点的locked标志上自旋(等待)。 - 释放锁:线程释放锁时,会检查是否有后继节点在等待。如果有,它通过修改后继节点的
locked标志(通常设为0)来唤醒后继线程。
- 获取锁:如果队列是空的,当前线程直接获得锁。否则,它将前驱节点的
- 优点:每个线程在不同的内存地址上自旋,消除了“test-and-test-and-set”锁的缓存行“乒乓效应”,可伸缩性极好。
- 数据结构:锁维护一个队列(可以是隐式的)。每个想获取锁的线程会创建一个自己的节点(包含一个
-
读写锁:
针对“读多写少”的场景,读写锁允许多个读者同时进入临界区,但写者必须独占访问。- 实现思路:锁内部维护一个读者计数器和一个写者标志。获取读锁时,如果没有写者,就增加读者计数。获取写锁时,必须等待没有任何读者和写者。
- 挑战:要避免写者“饿死”(即一直有读者进入,导致写者无法获取锁)。公平的读写锁会实现一种机制,当有写者等待时,阻止新的读者获取锁。
5. 内存屏障:确保指令执行顺序
在多处理器系统中,编译器和CPU为了性能会进行指令重排序。这可能导致一个非常反直觉的结果:即使你正确使用了锁,代码执行顺序也可能出错。
-
问题示例:
// 线程A (在CPU A上执行) data = 42; // 1. 写入数据 flag = 1; // 2. 设置标志,表示数据就绪 // 线程B (在CPU B上执行) while (flag == 0); // 3. 等待标志就绪 print(data); // 4. 读取数据从逻辑上看,线程B在
print(data)时,data应该已经是42。但由于重排序,CPU A可能先执行flag=1,再执行data=42(因为这两个写入操作互不依赖)。同时,CPU B的缓存中可能flag已更新,但data还是旧值。结果就是线程B打印出了未初始化的data。 -
解决方案:使用内存屏障。
- 写屏障:确保屏障之前的所有写操作,在屏障之后的写操作之前对其他CPU可见。在线程A中,在
flag=1之前插入写屏障,可保证data=42的结果先于flag=1被其他CPU看到。 - 读屏障:确保屏障之后的所有读操作,在屏障之前的读操作之后执行。在线程B中,在
print(data)之前插入读屏障,可保证在读取data之前,一定能读到最新的flag值。
- 写屏障:确保屏障之前的所有写操作,在屏障之后的写操作之前对其他CPU可见。在线程A中,在
总结
多处理器同步是一个复杂而深刻的话题。其核心是:
- 基础:依靠硬件提供的原子操作(如CAS)来构建互斥的基本单元。
- 演进:从简单的自旋锁出发,为了解决其性能瓶颈,发展出更高级的锁如MCS锁,它们通过改变等待队列的组织方式来提升可伸缩性。
- 扩展:根据访问模式(读/写)的特异性,设计读写锁等变体来提升并发度。
- 保障:使用内存屏障来强制约束内存访问顺序,这是保证多处理器环境下程序正确性的关键,通常这些屏障操作被封装在锁的API实现中。
理解这些机制,有助于我们编写出在当今多核时代下既正确又高效的系统软件。