16、并发与竞态:数据竞争、volatile在多线程中的局限性、原子操作与内存序、线程安全函数

并发编程,说白了就是让多个执行流同时干活。听起来很美好,对吧?但我在嵌入式项目里吃过不少苦头——两个任务同时访问同一个变量,结果数据乱得一塌糊涂。这就是我们今天要聊的竞态问题。

嗯,先别急着写代码。咱们得先搞清楚,C语言标准里对并发到底说了什么。说实话,C11之前的标准几乎没提多线程的事。这意味着什么?意味着你写的很多“看起来没问题”的并发代码,其实都是未定义行为。

数据竞争:最隐蔽的陷阱

数据竞争的定义其实很简单:两个或多个线程同时访问同一个内存位置,至少有一个是写操作,而且没有同步机制。这就是数据竞争。C11标准明确说,数据竞争是未定义行为。

我遇到过最典型的例子是这样的:一个全局变量作为标志位,主循环检查它,中断服务程序修改它。看起来天衣无缝,对吧?但实际运行中,有时候主循环读到的值既不是0也不是1,而是一个“半截”的值。

⚠️ 警告: 数据竞争不是“可能出错”,而是“未定义行为”。编译器可以优化出任何结果,包括让你的程序崩溃、死循环,甚至产生看似正常但偶尔出错的行为。这种偶发bug最难排查。

为什么会这样?因为现代CPU有缓存、有指令重排。线程A写了一个值,线程B可能读到的还是缓存里的旧值。更可怕的是,编译器也可能重排指令——你以为是先写标志位再写数据,编译器可能给你反过来。

volatile:被误解的“救星”

很多初学者觉得,加上volatile就能解决并发问题。我刚开始做嵌入式时也这么想。但后来被坑了一次,才明白volatile到底能干什么、不能干什么。

volatile告诉编译器:这个变量可能会被“意想不到地”改变。所以编译器不会优化掉对它的读写,也不会把多次读写合并成一次。但仅此而已。

volatile的局限性:

  • 不保证原子性——读写volatile变量仍然可能被中断
  • 不保证内存序——CPU仍然可以重排指令
  • 不保证缓存一致性——其他核心可能看不到最新值

说白了,volatile只解决了“编译器优化”的问题,但没解决CPU硬件层面的问题。在多核系统里,volatile基本等于没用。我见过有人给所有共享变量都加上volatile,结果该出bug还是出bug。

// 错误示例:volatile不能保证线程安全
volatile int flag = 0;

void thread1(void) {
    flag = 1;  // 写操作不是原子的
}

void thread2(void) {
    while (flag == 0) {
        // 等待,但可能永远等不到
    }
}

这段代码在单核系统上可能能跑,但在多核系统上,thread2可能永远看不到flag变成1。因为flag的新值可能还留在thread1所在核心的缓存里。

原子操作:真正的解决方案

C11标准引入了,这才是处理并发共享数据的正确方式。原子操作保证了两件事:操作本身是原子的(不可分割),并且可以指定内存序。

我个人习惯,只要是多线程共享的变量,一律用原子类型。哪怕你觉得“这个变量只有几个字节,读写肯定是原子的”——别赌。在ARM Cortex-M上,32位整数的读写确实是原子的,但在某些8位MCU上就不是了。用原子类型,代码可移植性更好。

#include <stdatomic.h>

atomic_int flag = 0;

void thread1(void) {
    atomic_store(&flag, 1);  // 原子写
}

void thread2(void) {
    while (atomic_load(&flag) == 0) {
        // 安全等待
    }
}

💡 小技巧: 在嵌入式系统里,如果目标平台不支持C11原子操作,可以用编译器内置函数。GCC有__sync_系列,ARM Compiler有__atomic_系列。但最好还是用标准方式,方便移植。

内存序:控制可见性

原子操作还有一个重要参数:内存序(memory order)。它控制着原子操作对其他核心的可见性。C11定义了六种内存序,但常用的就三种:

内存序 含义 性能开销
memory_order_relaxed 只保证原子性,不保证顺序 最小
memory_order_acquire/release 保证配对操作间的顺序 中等
memory_order_seq_cst 全局顺序一致 最大

你想想看,如果只是做一个计数器,用relaxed就够了。但如果是生产者-消费者模式,就必须用acquire/release来保证“先生产、后消费”的顺序。

我曾经在项目里图省事,所有原子操作都用了seq_cst。结果性能比预期差了一大截。后来改成acquire/release,性能提升明显。所以我的建议是:默认用seq_cst保证正确性,性能瓶颈时再优化内存序。

线程安全函数:可重入与不可重入

说到线程安全,就不得不提可重入函数。一个函数如果同时被多个线程调用,还能正确工作,就是线程安全的。C标准库里的很多函数都不是线程安全的,比如strtok、rand、localtime。

为什么?因为这些函数内部用了静态变量。strtok用静态指针保存当前解析位置,两个线程同时调用就会互相干扰。C标准提供了带_r后缀的可重入版本,比如strtok_r。

判断函数是否线程安全的简单方法:

  • 看它是否使用了全局或静态变量
  • 看它是否返回了指向静态缓冲区的指针
  • 看它是否调用了非线程安全的函数

嗯,这里要注意:即使函数本身是可重入的,如果它操作了共享数据而没有同步,仍然不是线程安全的。可重入只是线程安全的必要条件,不是充分条件。

// 非线程安全版本
char *my_itoa(int value) {
    static char buf[16];  // 静态缓冲区!
    sprintf(buf, "%d", value);
    return buf;
}

// 线程安全版本
char *my_itoa_r(int value, char *buf, size_t size) {
    snprintf(buf, size, "%d", value);
    return buf;
}

知识体系总览

下面这张图总结了并发编程中需要关注的核心问题:

并发编程核心问题 数据竞争 原子操作 线程安全函数 未定义行为 缓存不一致 指令重排 原子读-改-写 内存序控制 无锁数据结构 可重入性 互斥锁保护 避免静态变量 核心原则:共享数据必须同步,同步必须用原子操作或锁 volatile不能替代同步,只能防止编译器优化

避坑指南

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

  • 不要用volatile做同步——我曾经在一个产品里这么干过,结果在高温测试时频繁死机。换成原子操作后问题消失。
  • 注意原子操作的对齐要求——有些平台要求原子变量必须自然对齐,否则会触发异常或默默失败。
  • 小心“假共享”——不同核心的变量如果恰好在同一个缓存行里,频繁修改会导致性能骤降。可以用对齐填充来避免。
  • 中断上下文慎用锁——在中断里用互斥锁可能导致死锁。嵌入式系统里常用关中断或信号量代替。

💡 我的个人习惯: 写多线程代码时,先保证正确性,再优化性能。先用最严格的内存序(seq_cst),跑通后再根据性能分析结果放宽。这样能避免很多隐蔽的并发bug。

并发编程确实不容易,但掌握了这些核心概念,你就能避开大部分陷阱。记住:数据竞争是未定义行为,volatile不是银弹,原子操作和内存序才是正道。嗯,今天就聊到这里。

公众号:蓝海资料掘金营,微信deep3321