临界区与互斥
临界区与互斥
复习定位
多个进程同时访问共享变量(如counter++)时——由于该操作在机器层面分解为多条指令——进程可能被中断在指令序列中间——导致结果出错。临界区是一段访问共享资源的代码——必须保证同一时间只有一个进程在里面。实现互斥的硬件机制(关中断、TSL指令)和软件机制(Peterson算法、信号量)是解决同步问题的工具——理解了它们就能理解锁、信号量和互斥锁的底层实现。
临界区的出现
一个看似简单的操作——counter++——在汇编层面被分解为:
LOAD counter, R1 ; 读取 counter 到寄存器
ADD R1, 1, R1 ; 加1
STORE R1, counter ; 写回内存如果两个进程同时(或交错地)执行这三条指令——结果可能是两个进程读到相同的counter旧值——各自加1——写回——最终counter只增加了1而不是2——这就是竞态条件——结果依赖于进程的执行顺序。
临界区的四个原则
- 空闲让进——当没有进程在临界区时——允许一个请求进入的进程立即进入。
- 忙则等待——当已有进程在临界区——其他试图进入的进程必须等待。
- 有限等待——进程不能无限等待——必须在有限时间内进入临界区(防止饥饿)。
- 让权等待——等待的进程应主动放弃CPU——而不是占用CPU忙等(防止空转)。
Peterson算法(纯软件解决方案)
两个进程共享两个标志:flag[0]、flag[1](表示想进入临界区)和一个turn变量(表示轮到谁)。
进程0:
flag[0] = true;
turn = 1;
while (flag[1] && turn == 1); // 等待
// 临界区
flag[0] = false;进程1对称。Peterson算法证明了互斥(最多一个进程在临界区)、有空让进(没有进程在临界区时——想进的一定能进)、有限等待(不会无限等待)。但依赖内存的原子读写。
硬件原子指令
关中断——在单核CPU上——进入临界区前执行cli(关中断命令)——阻止时钟中断和进程切换——退出临界区用sti(开中断)恢复。多核CPU上关中断只对本核有效——不能防止其他核上的进程访问临界资源——所以不适用于多核。
Test-and-Set(TSL)——一条原子指令——将内存中的值读出并设置为1——返回原值。循环直到原值为0(表示没有被锁定):
int lock = 0; // 0表示未锁定——1表示已锁定
void enter_critical() {
while (TestAndSet(&lock, 1)); // 原子地:读出lock原值、将lock设为1——如果原值为0则获得锁
}
void leave_critical() {
lock = 0;
}这种自旋等待(忙等)适用于锁持有时间很短的情况——否则浪费CPU在空转。
互斥锁(Mutex)
信号量的一个特例——值只有0和1(二元信号量)。wait(mutex)阻塞,signal(mutex)唤醒。当锁被持有时——如果操作系统将wait进程加入阻塞队列而不是让其自旋——进程被移出CPU——不浪费CPU循环——这称为"阻塞锁"——适合锁持有时间较长的场景。
复习检查
为什么
counter++不是一个原子操作——因为它由三条机器指令组成——CPU可以在任意两条指令之间中断切换到另一个进程——导致同时读取相同的counter值——各自加一后写回——覆盖了对方的结果。关中断为什么在多核CPU上不能用于实现互斥——关中断只禁用了当前CPU的中断——另一个核心上的进程仍可以访问同一个共享变量并进入临界区——所以需要硬件原子(如TSL、CAS)来实现多核互斥。
TSL指令为什么能保证原子性——执行TSL时——CPU锁存内存总线(lock前缀)——阻止其他核心同时访问同一内存地址——完成读-改-写后释放总线——保证整个操作不可分割。
自旋锁和阻塞锁的区别——自旋锁(如TSL实现的锁)在等待时持续占用CPU循环检查——适合短临界区;阻塞锁(如OS的mutex)在等待时切换到另一个进程——具有上下文切换开销——适合长时间等待。
临界区应该尽可能短——否则死锁的可能性增大——其他进程等待过久——及资源争夺导致长时间的等待——占用公共资源的进程持有锁的时间过长会影响其他进程的调度。