3、同步原语(一):互斥锁(Mutex)的实现原理、自旋锁(Spinlock)与信号量(Semaphore)的对比
聊到任务调度,就绕不开同步。说白了,多任务环境下,资源竞争是家常便饭。你想想看,两个线程同时往一个队列里写数据,不出乱子才怪。这时候就需要同步原语上场了。今天咱们重点聊聊三个最基础的家伙:互斥锁、自旋锁和信号量。
我个人习惯把同步原语比作「交通管制」。互斥锁就像单车道上的红绿灯,一次只让一辆车通过;自旋锁呢,更像收费站前的等待区,车没过去就一直原地打转;信号量则像是停车场的管理员,控制着总共能停多少辆车。嗯,这个比喻虽然糙了点,但核心意思到了。
3.1 互斥锁(Mutex)的实现原理
互斥锁,全称 Mutual Exclusion Lock。它的目标很纯粹:保证同一时刻只有一个线程能访问共享资源。
我在项目中遇到过不少新手把互斥锁当成「万能药」,到处加锁,结果性能一塌糊涂。其实理解它的实现原理,你就知道什么时候该用、什么时候不该用了。
3.4.1 核心数据结构
一个典型的互斥锁,内部通常包含三个关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| owner | 线程 ID | 当前持有锁的线程标识 |
| lock | 原子标志位 | 0 表示未锁定,1 表示已锁定 |
| wait_queue | 队列 | 等待获取锁的线程列表 |
别小看这三个字段。我曾经在一个嵌入式项目中,因为忽略了 wait_queue 的优先级排序,导致高优先级任务被低优先级任务阻塞,最后整个系统响应延迟飙升。嗯,这就是典型的优先级反转问题。
3.4.2 加锁与解锁流程
互斥锁的加锁过程,大致分三步:
- 尝试获取:通过原子操作(如 CAS)尝试将 lock 从 0 置为 1。如果成功,直接设置 owner 为当前线程,加锁完成。
- 失败入队:如果 lock 已经是 1,说明锁被占用了。当前线程会被放入 wait_queue,然后触发线程调度,让出 CPU。
- 唤醒等待:持有锁的线程解锁时,会从 wait_queue 中取出一个等待线程,将其状态改为就绪,等待调度器分配 CPU。
这里有个关键点:线程在等待互斥锁时是「睡眠」的,不消耗 CPU。这是互斥锁和自旋锁最大的区别。
核心要点:互斥锁适用于锁持有时间较长的场景。因为线程睡眠和唤醒的开销比较大,如果锁很快就释放了,那睡眠唤醒的成本可能比锁本身的工作还高。
3.2 自旋锁(Spinlock)
自旋锁的思路和互斥锁完全不同。它不睡眠,而是「原地打转」——不断循环检查锁是否可用。
你想想看,如果锁只被占用几个指令周期,让线程睡眠再唤醒,光上下文切换的开销就够受的了。这时候自旋锁就派上用场了。
3.2.1 实现原理
自旋锁的核心就是一个原子变量和一个忙等待循环:
// 自旋锁的典型实现(伪代码)
void spin_lock(atomic_t *lock) {
while (atomic_test_and_set(lock, 1)) {
// 忙等待:不断循环,直到 lock 变为 0
// 现代 CPU 通常会插入 PAUSE 指令来优化
}
}
void spin_unlock(atomic_t *lock) {
atomic_clear(lock, 0);
}
看到了吗?while 循环就是那个「自旋」。线程在这里不会让出 CPU,而是一直占用着处理器核心在那空转。
注意:自旋锁在单核 CPU 上是危险的。如果锁被一个线程持有,而该线程被中断了,另一个线程自旋到天荒地老也拿不到锁。我曾经在调试一个老旧单核系统时,就因为这个问题导致系统看起来像死机了一样——其实是在自旋锁里空转。
3.2.2 适用场景
- 锁持有时间极短:比如只保护一个变量的读写,几个指令就能完成。
- 中断上下文:在 Linux 内核的中断处理函数中,不能睡眠,只能用自旋锁。
- 多核系统:自旋锁在多核环境下才能真正发挥优势,因为另一个核心可能很快释放锁。
3.3 信号量(Semaphore)
信号量比互斥锁更灵活。它不限于 0 和 1 两种状态,而是可以有一个计数器,允许多个线程同时访问资源。
我记得刚接触信号量时,总觉得它和互斥锁差不多。后来在做一个生产者-消费者模型时才发现,信号量的「计数」特性简直是天赐之物。
3.3.1 核心机制
信号量维护一个整型计数器 count 和一个等待队列:
- P 操作(wait/down):将 count 减 1。如果 count < 0,线程进入等待队列。
- V 操作(signal/up):将 count 加 1。如果 count <= 0,从等待队列中唤醒一个线程。
当 count 初始化为 1 时,信号量退化为互斥锁(称为二元信号量)。当 count 初始化为 N 时,最多允许 N 个线程同时访问资源。
经验之谈:我个人习惯用互斥锁保护共享数据,用信号量做事件通知或资源计数。比如控制一个连接池的最大连接数,信号量就是天然的选择。
3.4 三者对比
说了这么多,咱们直接上对比表,一目了然:
| 特性 | 互斥锁(Mutex) | 自旋锁(Spinlock) | 信号量(Semaphore) |
|---|---|---|---|
| 等待方式 | 线程睡眠,让出 CPU | 忙等待,占用 CPU | 线程睡眠,让出 CPU |
| 适用场景 | 锁持有时间长 | 锁持有时间极短 | 资源计数、事件通知 |
| 并发度 | 一次一个线程 | 一次一个线程 | 可配置(N 个线程) |
| 上下文切换 | 有(睡眠唤醒) | 无 | 有(睡眠唤醒) |
| 中断上下文 | 不可用 | 可用 | 不可用 |
| 实现复杂度 | 中等 | 简单 | 中等 |
从表里能看出来,互斥锁和信号量在等待机制上类似,都是睡眠等待;自旋锁则是完全不同的路子。选择哪个,核心就看锁的持有时间。
3.5 知识体系总览
下面这张图把三者的关系和应用场景梳理了一下,方便你建立整体认知:
3.6 避坑指南
最后,分享几个我踩过的坑,希望能帮你少走弯路:
- 不要在持有互斥锁时调用可能睡眠的函数:比如
malloc、printf等。我曾经在一个网络服务中这么干过,结果导致死锁,整个服务挂了半小时才恢复。 - 自旋锁要配合 PAUSE 指令:在 x86 上,自旋循环里加
_mm_pause()能减少功耗和内存顺序冲突。别小看这一条,在高负载下能省不少电。 - 信号量的 count 初始值要谨慎:设大了资源保护失效,设小了又可能造成不必要的阻塞。我一般会结合压测数据来调整这个值。
- 注意优先级反转:使用互斥锁时,如果任务有优先级,记得考虑优先级继承或优先级天花板协议。嗯,这个话题后面会细讲。
好了,同步原语的第一部分就到这里。这三种锁是并发编程的基石,理解透了,后面的读写锁、条件变量、屏障等高级原语学起来就轻松多了。