并发编程的噩梦:从竞态条件到数据竞争,为什么需要原子操作?

大家好,我是你们的RTOS内核课讲师。今天咱们来聊聊并发编程里最让人头疼的问题——竞态条件和数据竞争。

说实话,我刚开始做嵌入式开发那会儿,对并发这事儿真没太当回事。直到有一次,一个跑了两周的设备突然死机了,排查了整整三天才发现是两个任务同时修改了一个全局变量。嗯,从那以后,我再也不敢小看并发问题了。

一个简单的例子,藏着大问题

先看一段代码。你想想看,两个任务同时执行,会发生什么?

// 共享变量
int counter = 0;

// 任务A
void taskA(void) {
    counter++;  // 读取-修改-写入
}

// 任务B
void taskB(void) {
    counter++;  // 读取-修改-写入
}

表面上看,两个任务各加一次,counter应该变成2。但实际运行中,它可能是1,也可能是2。为什么会这样?

因为 counter++ 不是一条指令。它拆成了三步:

  1. 从内存读取counter的值
  2. 在CPU寄存器里加1
  3. 把结果写回内存

任务A刚读完counter(值为0),还没来得及写回去,任务B也读了counter(还是0)。然后两个任务各自加1,都写回1。你看,两次加法,结果却是1。这就是典型的竞态条件。

核心概念:竞态条件(Race Condition)是指多个任务并发访问共享资源,最终结果依赖于任务执行的精确时序。说白了,谁先谁后决定了最终结果,而这不是我们能控制的。

数据竞争:更隐蔽的杀手

竞态条件是个大概念,数据竞争是它的一种具体表现。我个人的理解是:数据竞争发生在多个任务同时访问同一内存位置,且至少有一个是写操作,又没有同步机制保护的时候。

我在项目中遇到过这样一个案例:一个传感器数据采集任务和一个显示任务共享一个缓冲区。采集任务往缓冲区写数据,显示任务从缓冲区读数据。没有加锁,也没有原子操作。结果呢?显示任务读到一半的数据,采集任务开始写新的数据——显示的画面就花掉了。

注意:数据竞争在C语言中是未定义行为!编译器可能会对代码做各种"优化",导致你完全意想不到的结果。我曾经见过一个bug,加了volatile关键字后问题消失了,但换了个编译器又出现了。根本原因还是没解决数据竞争。

为什么锁不够用?

你可能会说:"加个互斥锁不就完了?"

嗯,锁确实能解决问题。但锁有它的代价:

锁的问题 具体表现
性能开销 获取/释放锁需要系统调用,上下文切换成本高
优先级反转 低优先级任务持有锁,高优先级任务被阻塞
死锁风险 多个锁互相等待,系统卡死
中断上下文不能用 ISR里不能调用阻塞的锁操作

说白了,锁是重量级方案。对于简单的共享变量操作,用锁就像用大炮打蚊子。这时候,原子操作就登场了。

原子操作:硬件级别的保障

原子操作,就是不可分割的操作。要么全部执行完,要么一点都没执行。中间不会被任何其他任务或中断打断。

现代CPU都提供了原子指令,比如ARM的 LDREX/STREX,x86的 LOCK 前缀。这些指令在硬件层面保证了操作的原子性。

// 使用原子操作实现计数器
#include <stdatomic.h>

atomic_int counter = 0;

void taskA(void) {
    atomic_fetch_add(&counter, 1);
}

void taskB(void) {
    atomic_fetch_add(&counter, 1);
}

这段代码,无论两个任务怎么调度,counter最终一定是2。因为 atomic_fetch_add 在硬件层面保证了读取-修改-写入三步是不可分割的。

我的建议:在RTOS开发中,能用原子操作解决的问题,尽量别用锁。原子操作的开销通常只有几条指令,而锁可能涉及任务调度、上下文切换,开销大得多。但要注意,原子操作只适合简单的共享变量,复杂的数据结构还是得用锁。

从竞态条件到原子操作:一个完整的思考路径

咱们理一下思路:

  • 并发执行 → 多个任务同时访问共享资源
  • 竞态条件 → 执行时序影响最终结果
  • 数据竞争 → 无保护的并发读写,未定义行为
  • 锁方案 → 能解决,但开销大,有副作用
  • 原子操作 → 轻量级解决方案,硬件级保障

你想想看,如果只是对一个整数做加减操作,用锁是不是有点小题大做?原子操作就是为这种场景量身定做的。

避坑指南

最后分享几个我踩过的坑:

我曾经...在Cortex-M3上写了一个自旋锁,用volatile修饰了锁变量,以为就安全了。结果发现,两个不同优先级的中断同时获取锁时,低优先级中断永远拿不到锁——因为高优先级中断一直在循环等待。这就是典型的优先级反转。后来改用原子操作+关中断的方式才解决。

还有一点要注意:原子操作不是万能的。它只能保证单个变量的原子性。如果你需要同时修改多个变量,比如更新一个结构体的多个字段,原子操作就帮不上忙了。这时候还是得用锁,或者用更高级的无锁数据结构。

好了,这一章就到这里。下一章咱们深入看看RTOS里常用的原子操作API,以及如何在实际项目中用好它们。