第17章 SMP安全编程:线程安全、可重入函数、死锁预防、ABA问题
各位同学,欢迎来到第17章。多核编程,说白了就是「一群人同时干活」。人多了,就容易出乱子——你拿我的工具,我踩你的脚,最后活没干成,还打起来了。SMP编程的核心,就是怎么管好这群「人」。
我刚开始接触多核时,犯过一个低级错误。一个全局变量,两个核同时写,结果数据全乱了。查了两天才发现,是没加锁。嗯,从那以后,我对线程安全就特别敏感。
17.1 线程安全:你的代码禁得起「群殴」吗?
什么是线程安全?简单说,就是多个线程同时调用某个函数或访问某段数据,结果依然是正确的。不会出现数据错乱、程序崩溃。
在RT-Thread SMP环境下,线程安全主要关注三点:
- 共享资源的互斥访问——全局变量、静态变量、硬件寄存器,这些是「公共财产」,必须加锁保护。
- 原子操作——有些操作看似一条语句,实际是好几步。比如
count++,其实是「读-改-写」三步。多核同时执行,结果就错了。 - 线程局部存储——每个线程私有的数据,不会冲突。
核心原则:只要数据被多个线程共享,并且至少有一个线程在写,就必须做同步保护。
我个人的习惯是:写代码前先画个「数据流图」,标出哪些数据是共享的。这样心里有数,不容易漏掉。
17.2 可重入函数:你的函数能「分身」吗?
可重入函数,是线程安全的一种特殊形式。它要求函数在执行过程中被中断(或被另一个线程调用),再次进入时不会出错。
说白了,就是函数不能依赖任何「全局状态」。比如:
// 不可重入版本
static int counter = 0;
int get_next_id(void) {
return counter++;
}
// 可重入版本
int get_next_id_reentrant(int *counter) {
return (*counter)++;
}
第一个版本用了静态变量,两个线程同时调用,结果不可预测。第二个版本把状态交给调用者管理,就安全了。
我在项目中遇到过一个问题:一个日志打印函数,内部用了全局缓冲区。两个任务同时打印,日志就混在一起了。后来改成每个任务传自己的缓冲区,问题解决。
判断可重入的简单方法:看函数内部有没有使用全局变量、静态变量、malloc/free。如果有,大概率不可重入。除非你加了锁,但加了锁又可能引入死锁问题。
17.3 死锁预防:别让线程「互相掐脖子」
死锁,是SMP编程中最头疼的问题之一。两个线程各持有一把锁,都在等对方释放,结果谁也动不了。
死锁发生的四个必要条件:
| 条件 | 说明 |
|---|---|
| 互斥 | 资源一次只能被一个线程占用 |
| 持有并等待 | 线程持有资源,同时等待其他资源 |
| 不可剥夺 | 资源不能被强制拿走 |
| 循环等待 | 形成等待环路 |
只要破坏其中一个条件,死锁就不会发生。实际工程中,最常用的方法是:
- 固定锁顺序——所有线程按同样的顺序获取锁。比如先锁A再锁B,绝不反着来。
- 使用超时机制——获取锁时设置超时,超时了就释放已持有的锁,重试。
- 尽量少用锁——能用原子操作就别用锁,能用消息队列就别用共享内存。
我曾经踩过一个坑:两个任务,一个先锁A再锁B,另一个先锁B再锁A。平时跑得好好的,偶尔一次调度顺序不对,系统就卡死了。查了三天,最后用逻辑分析仪抓锁信号才找到原因。从那以后,我团队里强制要求「锁顺序必须一致」,并在代码注释里写明。
17.4 ABA问题:你以为没变,其实变了
ABA问题,是无锁编程里一个经典陷阱。你想想看:线程A读取共享变量,值为A。然后线程A被挂起。线程B把A改成B,又改回A。线程A恢复后,检查变量还是A,就以为没人动过。但实际上,中间已经变了。
这有什么危害?举个例子:
// 无锁栈的pop操作(有ABA问题)
Node *pop(Node *top) {
while (1) {
Node *old_top = top;
if (old_top == NULL) return NULL;
Node *new_top = old_top->next;
if (CAS(&top, old_top, new_top)) {
return old_top;
}
}
}
假设栈里有 A -> B -> C。线程1要pop,读到old_top=A,new_top=B。然后线程1被挂起。线程2把A和B都pop了,又push了一个新的A。此时栈是 A -> C。线程1恢复,CAS比较top还是A,成功,把top改成B。但B已经被释放了!栈就坏了。
解决ABA问题的常见方法:
- 带标记的指针——在指针里加一个版本号或标记位。每次修改都更新标记。CAS比较时同时比较指针和标记。
- 使用RCU(Read-Copy-Update)——读操作不加锁,写操作通过替换指针实现。适合读多写少的场景。
- 避免使用无锁结构——在RT-Thread这种实时系统中,我建议优先使用锁或消息队列。无锁编程的调试成本太高。
我的建议:在RT-Thread SMP环境下,除非你对无锁编程非常熟悉,否则老老实实用信号量或互斥量。RT-Thread的锁机制已经优化得很好了,性能损失不大。安全第一。
17.5 实战建议:SMP安全编程的「避坑清单」
最后,我总结一份清单,供大家参考:
- 全局变量必须加锁——哪怕只是一个int,也要用原子操作或锁保护。
- 中断服务函数里尽量别用锁——中断里用锁,容易导致优先级反转或死锁。用关中断代替。
- 锁的粒度要适中——锁太大,并发性能差;锁太小,容易死锁。我一般先粗后细,性能不够再优化。
- 使用静态分析工具——比如RT-Thread的检查工具,能帮你发现潜在的死锁。
- 写注释说明锁的用途和顺序——方便自己和同事维护。
- 测试时故意制造并发场景——比如用两个任务高频访问同一资源,看会不会出问题。
好了,这一章的内容就到这里。SMP安全编程,说白了就是「管好共享资源」。你想想看,多核编程的大部分bug,归根结底都是「你以为别人没动,其实动了」。记住这一点,能少踩很多坑。
下一章,我们聊聊RT-Thread SMP的调试与性能分析。到时候我会分享一些实际项目中用到的调试技巧,敬请期待。