第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安全编程的「避坑清单」

最后,我总结一份清单,供大家参考:

  1. 全局变量必须加锁——哪怕只是一个int,也要用原子操作或锁保护。
  2. 中断服务函数里尽量别用锁——中断里用锁,容易导致优先级反转或死锁。用关中断代替。
  3. 锁的粒度要适中——锁太大,并发性能差;锁太小,容易死锁。我一般先粗后细,性能不够再优化。
  4. 使用静态分析工具——比如RT-Thread的检查工具,能帮你发现潜在的死锁。
  5. 写注释说明锁的用途和顺序——方便自己和同事维护。
  6. 测试时故意制造并发场景——比如用两个任务高频访问同一资源,看会不会出问题。

好了,这一章的内容就到这里。SMP安全编程,说白了就是「管好共享资源」。你想想看,多核编程的大部分bug,归根结底都是「你以为别人没动,其实动了」。记住这一点,能少踩很多坑。

下一章,我们聊聊RT-Thread SMP的调试与性能分析。到时候我会分享一些实际项目中用到的调试技巧,敬请期待。