4、自旋锁与互斥锁:自旋锁实现原理、读写锁、优先级反转与解决方法
各位同学,今天我们来聊聊多核实时系统里最基础也最容易踩坑的话题——锁。
说实话,锁这个东西,用好了是系统的守护神,用不好就是性能的杀手。我在项目中见过太多因为锁没用好导致系统崩溃的案例了。嗯,咱们今天就把自旋锁、互斥锁、读写锁这些家伙彻底讲明白。
4.1 自旋锁的实现原理
自旋锁,说白了就是「忙等待」。当一个CPU核想要获取锁时,如果锁被别的核占着,它不会去睡觉,而是原地转圈,不停地检查锁是否释放了。
核心思想:自旋锁适用于锁持有时间极短的场景。如果锁持有时间超过几十微秒,自旋锁就是灾难。
来看一个最简单的自旋锁实现:
// 最简单的自旋锁实现(ARMv8 汇编风格)
typedef struct {
volatile int locked;
} spinlock_t;
void spin_lock(spinlock_t *lock) {
while (__atomic_test_and_set(&lock->locked, __ATOMIC_ACQUIRE)) {
// 自旋等待
while (lock->locked) {
// 内存屏障,防止编译器优化
__asm__ volatile("" ::: "memory");
}
}
}
void spin_unlock(spinlock_t *lock) {
__atomic_clear(&lock->locked, __ATOMIC_RELEASE);
}
我个人习惯在自旋锁里加一个「自旋次数上限」。为什么?因为我在一个项目中遇到过,某个外设中断没处理好,导致自旋锁一直拿不到,整个系统卡死。后来加了超时机制,至少能报错恢复。
避坑指南:我曾经在一个项目中,把自旋锁用在了中断上下文里,结果中断处理函数里又去获取同一个锁——死锁了。记住:自旋锁持有期间,绝对不能睡眠,也不能被同一个CPU再次获取。
4.2 读写锁:读多写少的利器
你想想看,如果大部分时候只是读数据,偶尔才写一次,用普通的互斥锁是不是太浪费了?读写锁就是为这种场景设计的。
读写锁的核心规则很简单:
- 多个读者可以同时持有锁——读操作不互斥
- 写者必须独占锁——写操作与所有读者、其他写者互斥
我建议你在实现读写锁时,特别注意「写者饥饿」问题。什么意思?就是如果读者源源不断地来,写者可能永远拿不到锁。
// 带写者优先的读写锁实现
typedef struct {
int readers; // 当前读者数量
int writers; // 是否有写者等待
spinlock_t lock; // 内部自旋锁保护
} rwlock_t;
void read_lock(rwlock_t *rw) {
spin_lock(&rw->lock);
while (rw->writers) {
// 有写者在等,读者先让让
spin_unlock(&rw->lock);
// 短暂等待后重试
__asm__ volatile("yield" ::: "memory");
spin_lock(&rw->lock);
}
rw->readers++;
spin_unlock(&rw->lock);
}
void write_lock(rwlock_t *rw) {
spin_lock(&rw->lock);
rw->writers++;
while (rw->readers > 0) {
// 等所有读者离开
spin_unlock(&rw->lock);
__asm__ volatile("yield" ::: "memory");
spin_lock(&rw->lock);
}
// 现在可以安全写入了
}
经验之谈:我在做网络协议栈时,路由表查询就是典型的读多写少场景。用读写锁后,吞吐量提升了将近3倍。但要注意,如果写操作频繁(超过10%),读写锁反而比普通互斥锁慢。
4.3 优先级反转:实时系统的噩梦
优先级反转,这个词听起来挺学术,其实场景很常见。我来给你讲个真实案例:
假设有三个任务:
- 任务A:高优先级,负责刹车控制
- 任务B:中优先级,负责仪表盘显示
- 任务C:低优先级,负责日志记录
任务C先拿到了一个互斥锁,然后被任务B抢占。任务B运行了一会儿,任务A来了,它也需要那个锁。结果呢?任务A只能等任务C释放锁,但任务C又被任务B抢占了——高优先级的任务A,居然被中优先级的任务B间接阻塞了!
这就是优先级反转:高优先级任务被低优先级任务间接阻塞,而中优先级任务趁机「插队」运行。
4.4 解决方法:优先级继承与优先级天花板
怎么解决?业界主要有两种方案。
4.4.1 优先级继承协议
说白了就是:当低优先级任务持有高优先级任务需要的锁时,低优先级任务临时「继承」高优先级任务的优先级。
// 优先级继承的伪代码实现
void mutex_lock(mutex_t *m, task_t *current) {
if (m->owner == NULL) {
m->owner = current;
return;
}
// 如果锁被低优先级任务持有
if (m->owner->priority < current->priority) {
// 临时提升持有者的优先级
m->owner->priority = current->priority;
m->inherit = 1;
}
// 当前任务阻塞等待
block_task(current);
}
void mutex_unlock(mutex_t *m) {
if (m->inherit) {
// 恢复原始优先级
m->owner->priority = m->owner->original_priority;
m->inherit = 0;
}
// 唤醒等待队列中的最高优先级任务
wakeup_highest_waiter(m);
}
我建议:优先级继承虽然好,但实现起来比较复杂。如果你的系统比较简单,可以考虑优先级天花板协议——直接把所有可能访问同一资源的任务优先级设成一样高。
4.4.2 优先级天花板协议
这个更粗暴:给每个资源设定一个「天花板优先级」,等于所有可能访问该资源的任务中的最高优先级。任何任务访问该资源时,优先级临时提升到天花板级别。
| 协议名称 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 优先级继承 | 灵活,不会过度提升优先级 | 实现复杂,可能死锁 | 复杂实时系统 |
| 优先级天花板 | 实现简单,无死锁风险 | 可能过度提升优先级 | 简单嵌入式系统 |
| 关中断 | 最简单,无优先级反转 | 影响中断响应时间 | 极短临界区 |
嗯,这里要注意:关中断虽然能彻底避免优先级反转,但代价太大。我一般只在操作几个寄存器的场景下用。
血的教训:我曾经在一个汽车电子项目中,因为没处理好优先级反转,导致刹车控制任务被延迟了200微秒。200微秒在高速行驶中意味着什么?将近5米的刹车距离。从那以后,我对锁的使用就格外谨慎了。
最后总结一下我的个人经验:
- 临界区极短(< 10微秒):用自旋锁,记得加超时
- 读多写少:用读写锁,注意写者饥饿
- 有优先级要求:用互斥锁+优先级继承
- 实在搞不定:用关中断,但别超过几十条指令
记住,没有银弹。每种锁都有它的适用场景,关键是要理解你的系统到底需要什么。