操作系统学习笔记 · 第 12 课 · 并发与竞态——多个线程抢一个变量
上一课我们讲了调度——"谁先跑"的问题。但真正的麻烦,是从"真的同时跑起来"这一刻才开始的。当 100 个线程同时去加同一个变量,结果会怎样?这一课,我们要亲手制造一个"并发翻车现场",看看计算机世界里最阴险的一类 bug——竞态条件。
从一个"鬼故事"说起
想象你有一张银行卡,余额 100 元。某天,你的两笔退款同时到账,各退 50 元。系统应该把你的余额变成 200 元,对吧?
但如果程序写错了,可能发生这样的事:
- 两个线程同时读到"当前余额 = 100";
- 线程 A 算出 100+50=150,写回 150;
- 线程 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++;照样竞态。
留三个问题:
- 为什么
i++在多线程下不安全? - 竞态的"随机性"来自哪里?
volatile能不能当锁用?为什么?
病确诊了,下一课我们开药。怎么让 100 个线程安全地加同一个变量?答案是给临界区"上锁"。而锁的世界,比你想象的更丰富——互斥锁、信号量、条件变量,各有各的用武之地。我们下回见。