上一课我们讲了调度——"谁先跑"的问题。但真正的麻烦,是从"真的同时跑起来"这一刻才开始的。当 100 个线程同时去加同一个变量,结果会怎样?这一课,我们要亲手制造一个"并发翻车现场",看看计算机世界里最阴险的一类 bug——竞态条件

从一个"鬼故事"说起

想象你有一张银行卡,余额 100 元。某天,你的两笔退款同时到账,各退 50 元。系统应该把你的余额变成 200 元,对吧?

但如果程序写错了,可能发生这样的事:

  1. 两个线程同时读到"当前余额 = 100";
  2. 线程 A 算出 100+50=150,写回 150;
  3. 线程 B 也算出 100+50=150,写回 150。

最后你的余额是 150,而不是 200——凭空少了 50 块

这就是竞态条件:结果取决于"谁先谁后"这个不确定的时序,而你根本控制不了这个时序。它随机、难复现、极难排查,是并发编程里最臭名昭著的问题。这一课,我们把它彻底看清。


12.1 竞态条件(Race Condition)

一句话理解:多个线程同时读写同一个变量,最终结果取决于"谁先谁后"这个不确定的时序,导致结果随机出错。

几个关键点:

  • "竞态"(race):就像赛跑,谁先到终点不确定,最终结果跟着变;
  • 触发条件:多个线程同时读写同一个共享数据;
  • 致命之处:错误是随机的——这次跑对了,下次跑错了,且很难复现。

回到退款的例子:根本问题在于"读余额"和"写回余额"之间,被另一个线程插了队。 两个线程都读到了"旧值 100",各自加 50,都写回 150——一次更新被另一个"覆盖"掉了,这就是"丢失更新(lost update)"。

一句话:竞态 = 多个线程抢同一个数据,谁先谁后不确定,结果就随机错。

12.2 临界区(Critical Section)

既然问题是"同时访问共享数据",那解法就很自然了:让"访问共享数据"这段代码,同一时刻只能有一个人进。

这段代码,就叫临界区

一句话理解:访问共享资源的那段代码就是临界区。目标是保证同一时刻只有一个线程进临界区

哪些代码是临界区?典型例子:

  • 转账、更新账户余额;
  • 更新一个计数器(counter++);
  • 往共享队列里放数据、从共享队列取数据。

保护临界区的目标很朴素,就一句话:同一时刻,只能有一个线程在临界区里。就像洗手间——进去的人锁门,出来的人开锁,下一个才能进。至于怎么"锁门",就是下一课的主角(锁、信号量)。

先记住这个因果关系:竞态是病,临界区是病灶,锁是药。这一课先确诊,下一课开药方。

12.3 为什么 i++ 不是原子操作

你可能觉得:i++ 不就一行吗?怎么还会出问题?

坏就坏在——它是"一行代码",但不是"一步操作"。

一句话理解i++ 在底层被拆成三步:① 读 i ② 加 1 ③ 写回 i。三步之间可能被别的线程插队,导致"丢更新"。

拆开看,i++ 实际是:

int tmp = i;   // ① 读:把 i 的值取出来
tmp = tmp + 1; // ② 算:加一
i = tmp;       // ③ 写:写回 i

这三步不是"一气呵成"的——中间可以被调度器打断、被别的线程插队。于是:

线程A:读 i=0        线程B:读 i=0
线程A:算 0+1=1      线程B:算 0+1=1
线程A:写 i=1        线程B:写 i=1   → 最终 i=1,而不是 2

两个线程都加了 1,结果 i 只从 0 变成了 1——一次加法被"吞"掉了

这种"读-改-写"不能被中断的操作,术语叫原子操作(atomic operation)i++ 不是原子的,所以不安全。这也正好呼应了第 10 课讲的调度——时间片用完、抢占,随时可能把这三步拦腰切断,竞态就是调度"切"出来的。

12.4 volatile 的误解

很多初学者(包括不少写了很多年的人)以为:给变量加个 volatile 就能防住竞态。大错特错。

一句话理解volatile 只保证"每次读写都从内存取、不优化掉",不保证原子性,也不能替代锁。并发场景下用它当锁是经典错误。

volatile 的真实作用是告诉编译器:这个变量可能被"你之外的东西"(硬件、信号处理函数、别的线程)改变,别自作主张优化掉,每次老老实实去内存读。 它解决的是"编译器优化"层面的可见性问题。

但它完全不解决原子性问题:

volatile int i;
i++;   // 依然会竞态!

为什么?因为 i++ 的"读-改-写"三步仍然存在,volatile 只是保证每一步都真的碰内存,并没有把三步变成一步。线程 A 读、线程 B 也读,还是可能读到同一个旧值,还是丢更新。

一句话记死:volatile 管"可见性",锁管"原子性",两者不是一回事。 防竞态,请用锁(下一课);volatile 不是锁。

动手实验:亲手跑出一个竞态现场

理论说了这么多,不如亲手跑一次,让"结果不对"真真切切发生在你眼前。

// lesson12.c —— 多线程竞态演示
#include <stdio.h>
#include <pthread.h>

#define THREADS 100
#define TIMES    10000

int counter = 0;   // 共享变量

void* worker(void* arg) {
    for (int i = 0; i < TIMES; i++)
        counter++;    // 非原子,会竞态
    return NULL;
}

int main(void) {
    pthread_t t[THREADS];
    for (int i = 0; i < THREADS; i++)
        pthread_create(&t[i], NULL, worker, NULL);
    for (int i = 0; i < THREADS; i++)
        pthread_join(t[i], NULL);

    printf("期望值:%d\n", THREADS * TIMES);
    printf("实际值:%d\n", counter);   // 几乎必然小于期望值
    return 0;
}

编译运行:

gcc lesson12.c -o lesson12 -lpthread && ./lesson12

多跑几次,你会看到:

  • 期望值是 100 × 10000 = 1000000
  • 实际值几乎总是小于 100 万,而且每次还不一样——比如这次 998743,下次 997102……

这就是竞态的可怕之处:错误是随机的、每次不同、难复现。如果你在线上系统里遇到"偶尔数据不对、日志又看不出问题",八成就是它。

注意:这个实验里用到了 pthread_create / pthread_join——这就是第 5 课学的线程,现在它们"真的同时"跑起来,撞在一起了。

深入点:两个概念要分清

① 原子操作与 CAS

那有没有办法"无锁"地安全自增?有——CPU 提供真正原子的指令(比如 x86 的 lock xadd),一条指令就把"读+改+写"做完,中间不可被插队。

C 语言层面,C11 引入了 _Atomic,以及 __atomic_fetch_add 这类内置函数,能无锁地安全自增。底层就是利用了这些原子指令,以及一个叫 CAS(Compare-And-Swap,比较并交换) 的机制——"如果值还是我预期的,就更新;否则重试"。这是下一课"锁"的地基,很多锁本身就是用 CAS 实现的。

② 数据竞争 vs 竞态条件

严格说,这是两个不同的概念:

  • 数据竞争(data race):内存模型层面的概念,指"两个线程没有同步地访问同一内存,且至少一个是写"。这在 C/C++ 里属于未定义行为(UB),编译器可以任意假设它不会发生,后果不可预测。
  • 竞态条件(race condition):更宽泛的时序问题,指"程序结果依赖事件发生的先后顺序"。

简单记:数据竞争是"没有同步的并发读写",竞态条件是"结果依赖时序"。它们经常同时出现,但不完全等价——即使加了锁避免了数据竞争,如果你的业务逻辑"先检查后操作"设计不当,仍可能出现竞态条件。


小结与思考题

这一课,我们确诊了并发世界的第一大病症:

  • 竞态条件:多个线程同时读写同一变量,结果取决于不确定的时序,随机出错;
  • 临界区:访问共享资源的代码段,目标是"同一时刻只进一个";
  • i++ 不是原子的:它是"读-改-写"三步,中间可被插队,导致丢失更新;
  • volatile 不是锁:它管可见性,不管原子性,volatile int i; i++; 照样竞态。

留三个问题:

  1. 为什么 i++ 在多线程下不安全?
  2. 竞态的"随机性"来自哪里?
  3. volatile 能不能当锁用?为什么?
病确诊了,下一课我们开药。怎么让 100 个线程安全地加同一个变量?答案是给临界区"上锁"。而锁的世界,比你想象的更丰富——互斥锁、信号量、条件变量,各有各的用武之地。我们下回见。

标签: 计算机基础, 操作系统, Linux

添加新评论