互斥锁

2.3.2 的硬件指令(TS、Swap)解决了”检查+上锁”的原子性问题,但直接用指令写代码太原始。互斥锁就是把它包装成一对好用的接口。

这一节最需要注意的不是机制,而是术语——教材在这里把两个概念合并了,读得越仔细越容易困惑。

机制

最简单的锁

互斥锁只需要一个布尔变量和两个操作:

bool available = true;      // 锁是否可用
 
void acquire() {            // 进入临界区前调用
    while (!available)      // 忙等,直到锁可用
        ;
    available = false;      // 上锁
}
 
void release() {            // 退出临界区后调用
    available = true;       // 解锁
}

使用方式就是把它填进 2.3.1 的四段式结构:

acquire();          // 进入区
critical section;   // 临界区
release();          // 退出区
remainder section;  // 剩余区

关键要求:acquire() 和 release() 的执行必须是原子操作。

原因就是 2.3.2 里”双标志先检查法”的教训——while (!available) 和 available = false 之间若能被打断,两个进程就会同时通过。所以互斥锁通常用硬件机制(TS、Swap、CAS)来实现,而不是像上面那样用普通赋值语句。上面的代码是语义描述,不是可用实现。

优缺点

缺点:忙等待(busy waiting)。

当有进程在临界区里时,其他进程会在 while 循环里不停地调用检查,白白消耗 CPU 周期。这违反了四准则中的让权等待。

优点:等待期间没有上下文切换。

这是它真正的价值所在。相比之下,“阻塞”式的等待要经历:保存现场 → 挂入等待队列 → 调度别的进程 → 将来被唤醒 → 恢复现场。这一套的开销可能比临界区本身还大。

因此适用场景非常明确:多处理器系统 + 临界区很短。

在多处理器上,一个线程可以在某个核上自旋,而持有锁的线程在另一个核上继续执行并很快释放锁。自旋几十个周期就拿到锁,远比切换划算。

反过来,单处理器上自旋是纯粹的浪费:持有锁的那个进程此刻根本没在运行(因为 CPU 被自旋的这个占着),自旋者要一直转到自己的时间片耗尽为止,锁才有可能被释放。

边界

互斥锁与自旋锁:教材的口径与通行口径

疑问点:互斥锁是否就是自旋锁,二者关系应如何记忆

教材原文:“上面描述的互斥锁也称自旋锁,其主要缺点是忙等待……因此,互斥锁通常用于多处理器系统,一个线程可以在一个处理器上旋转,而不影响其他线程的执行。”

但同一段前文又说”当一个进程试图获取不可用的锁时,会被阻塞”。 究竟应当按哪种口径记忆?两种说法似乎都能讲通。

两种说法都能讲通,因为它们描述的层次不同。这个困惑是有根据的,需要分三层讲清。

第一层:教材在这一节描述的是一个具体实现。

教材给出的 acquire() 用的是 while (!available);——这是忙等。忙等的锁就是自旋锁。所以教材说”上面描述的互斥锁也称自旋锁”,这句话限定在”上面描述的”这个实现上,完全正确。

第二层:教材前文那句”会被阻塞”是宽泛用法。

那里的”阻塞”指的是”进不去、被挡住”,不是进程状态意义上的阻塞态。这是同一个词的两种用法。紧接着的”其主要缺点是忙等待”已经点明了:它并没有真的进入阻塞态让出 CPU,而是在原地转圈。

第三层:从概念上说,自旋锁是互斥锁的一种。

“互斥锁”这个名字只规定了目的(提供互斥),没有规定等不到时怎么办。等待方式有两种选择:

等不到锁时名称开销
自旋原地循环检查,不让出 CPU自旋锁无切换,但耗 CPU
阻塞挂入等待队列,让出 CPU阻塞型互斥锁有切换开销,但不耗 CPU

所以准确的关系是:自旋锁 ⊂ 互斥锁。

考试怎么答:按教材口径。凡题目描述了 while(!available); 这样的忙等实现,就按自旋锁的性质作答——主要缺点是忙等待、违反让权等待、适用于多处理器系统、优点是无上下文切换。

持有自旋锁时能否进行调度

疑问点:持有自旋锁期间能否发生进程调度

教材称”持有自旋锁时不能睡眠,不能做可能阻塞的操作,临界区必须很短”。 那么此时能否进行进程调度?

不能,而且理由与 2.2.2 的”内核程序临界区禁止调度”完全一致——事实上,自旋锁正是保护内核程序临界区的那把锁。

两个层次的原因:

第一,会造成大规模空转。 假设持有锁的进程被换下 CPU,那么其他 CPU 上所有等这把锁的进程会持续自旋一整个时间片——它们在等一个此刻根本没在运行的进程释放锁。锁的持有时间从”几十个周期”暴涨到”一个时间片”,自旋锁的全部优势荡然无存。

第二,同一 CPU 上可能直接死锁。 若持有锁的进程被切换后,新调度上来的进程(或一个中断处理程序)也要获取同一把锁,它会开始自旋等待。但持有锁的那个进程已经被换下去了,在当前 CPU 上永远得不到执行机会去释放锁——自旋者转到天荒地老。

因此实际系统的做法是:获取自旋锁时同时关闭内核抢占(Linux 的 spin_lock() 内部就包含 preempt_disable());若该锁还可能被中断处理程序获取,则还要一并关中断(spin_lock_irqsave())。

这也解释了教材那句”不能睡眠、不能做可能阻塞的操作”:睡眠意味着让出 CPU,而让出 CPU 就会触发上述两个问题。

自旋锁是操作系统层面的还是应用层面的

疑问点:自旋锁属于操作系统机制还是应用程序自行实现

自旋锁具体由什么实现?以 Java 并发包(JUC)为例,其底层是什么。

两个层面都有,因为自旋锁的本质要求只有一条:一条硬件原子指令。

只要能拿到 TS、Swap 或 CAS(x86 的 cmpxchg),任何一层的代码都能自己实现自旋锁。它不是操作系统的专属机制。

内核层面:Linux 的 spin_lock() 是典型的内核自旋锁,专门用于保护内核数据结构。内核必须用自旋锁而不能用阻塞锁的场景是中断上下文——中断处理程序不能睡眠。

用户层面:JUC 的锁建立在 CAS 之上,但不是纯自旋锁。

以 ReentrantLock 为例,它基于 AQS(AbstractQueuedSynchronizer):

  1. AQS 内部有一个 volatile int state,通过 CAS 尝试把它从 0 改成 1 来抢锁。这一步是无锁的原子操作,由 Unsafe.compareAndSwapInt 最终落到 CPU 的 lock cmpxchg 指令。
  2. 抢不到时,线程进入 AQS 的 CLH 等待队列,会先短暂自旋几次(前驱是头节点时值得再试一下)。
  3. 自旋若干次仍拿不到,就调用 LockSupport.park() —— 这会通过 futex 系统调用真正陷入内核并阻塞,让出 CPU。

所以 JUC 的锁是”自适应”的:先自旋一小会儿赌锁马上释放,赌输了就转为阻塞。 这正是对本节那张表的工程化折中——短临界区享受自旋的低开销,长临界区避免自旋的浪费。

synchronized 关键字的路径类似:轻量级锁阶段用 CAS 自旋,竞争加剧后膨胀为重量级锁,底层用操作系统互斥量,线程真正阻塞。

关联对照:JVM 的偏向锁不属于自旋锁

偏向锁与自旋锁不是一回事,这个判断是准确的。

偏向锁优化的是完全没有竞争的场景:把获得锁的线程 ID 直接记录在对象头的 Mark Word 里,同一个线程之后再进入这个同步块时,连 CAS 都不需要执行,只做一次线程 ID 比对。

它解决的问题是”无竞争时 CAS 也是有成本的”,而自旋锁解决的是”有竞争时如何等待”。两者针对的是完全相反的场景。

补充一点背景:偏向锁自 JDK 15 起已被默认禁用并标记为废弃,之后从 HotSpot 中移除。原因是它使 JVM 同步子系统复杂度大增,而它所优化的那类单线程重复加锁场景在现代应用中已不常见。

这部分内容不在 408 考纲内,此处列出仅为把概念区分开。

互斥锁 vs 信号量

互斥锁只能取两个值(可用/不可用),只能解决互斥;信号量可以是任意非负整数,既能解决互斥(初值为 1),也能解决同步(初值为 0 或 n)。

所以互斥锁可以看作信号量的一个特例——初值为 1 的互斥信号量。但反过来不成立。

对照速查

自旋锁(教材所述互斥锁)阻塞型互斥锁
等不到锁时原地循环,不让出 CPU挂入等待队列,让出 CPU
让权等待违反满足
上下文切换无有
适用处理机多处理器单/多均可
适用临界区很短较长
主要缺点忙等待切换开销
层面实现依赖
硬件TS / Swap / CAS(cmpxchg)—
内核Linux spin_lock()(含关抢占)硬件原子指令
用户态JUC:CAS + 短自旋 + park() 阻塞硬件原子指令 + futex

考点

  • 互斥锁的 acquire()/release() 必须是原子操作,因此需硬件支持
  • 主要缺点是忙等待,违反让权等待
  • 优点是无上下文切换,因此适用于多处理器 + 短临界区
  • 单处理器上自旋是纯浪费(持有锁者此刻根本没在运行)
  • 教材口径:所描述的互斥锁即自旋锁;概念上自旋锁 ⊂ 互斥锁
  • 互斥锁只能解决互斥,信号量还能解决同步

链接