第9章:线程与同步——分身与协作
元数据卡
| 属性 | 值 |
|---|---|
| 叙事密度 | 13% |
| 主语言 | C (POSIX Threads) |
| 配角语言 | Java, Python |
| 前置 | 第8章(进程与虚拟内存) |
| 难度 |
核心问题:一个 CPU 核心一次只能执行一个指令流,但地心的探测终端要同时处理传感器数据、LED 面板显示、日志写入——它们怎么能"同时"工作?更微妙的是,当两个探险者(线程)同时操作同一个地心仪表,为什么读数可能完全错误?
你在哪
"进程切换太慢了——每次保存加载整个地址空间。更轻量的选择出现了:线程。同一个进程内的多个线程共享内存,协作完成任务。但共享带来了新的问题——如果两个线程同时修改同一个变量,会发生什么?"
你已经理解了进程——每个进程有独立的地址空间,通过 fork() 复制自己。但 fork() 的开销很大(页表复制、文件描述符复制),而且进程间通信(IPC)繁琐。
有没有一种更轻量的"分身术"?同一进程内的多个执行流,共享地址空间,切换开销远小于进程切换?
这就是线程(thread)。
你的任务
本章分层
- 必读:Race Condition(竞态条件)的产生原因与识别;互斥锁(Mutex)保护临界区;条件变量实现生产者-消费者模式
- 选读:线程 vs 进程的选型对比;信号量的基本语义
- 深水区:读写锁(RWLock)的场景与代价;可重入锁与 Barrier 的实现 本章不会要求你掌握
- 读写锁的内部实现细节
- 信号量与条件变量的详细对比
- Lock-free 编程与 CAS 原子操作
写一个多线程程序完成以下任务:
- 主线程读取一个整数
N和一段文本 - 创建
N个工作线程,每个线程统计文本中某个特定字符的出现次数 - 所有线程结束后,汇总并打印结果
这个小项目暴露了线程编程的核心问题:数据共享与竞争条件。
遭遇战:Race Condition 演示
先看一个最简单的多线程程序。两个线程各对一个全局变量 counter 加 100 万次:
// race.c
#include <stdio.h>
#include <pthread.h>
#define ITERATIONS 1000000
int counter = 0;
void *worker(void *arg) {
for (int i = 0; i < ITERATIONS; i++) {
counter++; // ← 问题出在这一行
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Expected: %d, Got: %d\n", 2 * ITERATIONS, counter);
return 0;
}编译运行:
$ gcc -pthread race.c -o race
$ ./race
Expected: 2000000, Got: 1328491
$ ./race
Expected: 2000000, Got: 1472033
$ ./race
Expected: 2000000, Got: 989172每次结果不同! 而且永远达不到 200 万。
常见陷阱
问题出在 counter++。在机器层面,这一行看似原子的 C 代码,实际对应三条独立的 CPU 指令:
mov eax, [counter] // 1. 从内存读到寄存器
add eax, 1 // 2. 在寄存器中加 1
mov [counter], eax // 3. 写回内存两个线程同时执行时,可能发生这样的交错:
时间 → Thread 1 Thread 2
mov eax, [counter] mov eax, [counter] ← 都读到 50
add eax, 1 add eax, 1 ← 各自算成 51
mov [counter], eax mov [counter], eax ← 都写回 51!两次增加操作,计数器只加了 1。这就是 Race Condition(竞态条件)。
注意这个现象的可怕之处:它不总是出错。可能在你的机器上跑一万次才出错一次,但出错的那次恰好是飞机控制系统在计算航向角。这种不确定性使得竞态条件成为并发编程中最难调试的问题之一——你无法通过"多跑几次就能发现"来保证正确性。
获得技能:互斥锁(Mutex)
Race Condition 的根源在于多个线程同时读写共享数据,导致操作交错。解决方案看起来很简单:一次只让一个线程进入临界区(critical section)。
但实现这个方案比听上去复杂得多——你需要一个硬件级别的原子操作做支撑。大多数架构提供一条特殊指令(比如 x86 的 xchg 或 ARM 的 ldrex/strex),能在不被打断的情况下完成读-改-写操作。pthread_mutex_lock 底层就依赖这些原子指令,再加上一个内核中的等待队列来管理竞争者。
从程序员的角度,你不需要操心底层原子操作。直接用 POSIX API 就好。
互斥锁(Mutex)的用法直截了当:
// mutex_demo.c
#include <stdio.h>
#include <pthread.h>
#define ITERATIONS 1000000
int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *worker(void *arg) {
for (int i = 0; i < ITERATIONS; i++) {
pthread_mutex_lock(&lock); // 加锁——申请进入
counter++; // 临界区
pthread_mutex_unlock(&lock); // 解锁——让出机会
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Expected: %d, Got: %d\n", 2 * ITERATIONS, counter);
return 0;
}$ ./mutex_demo
Expected: 2000000, Got: 2000000正确了。但代价是什么?每次 counter++ 都要经过系统调用级别的锁操作,运行时间从几毫秒暴涨到几百毫秒。锁带来了正确性,也带来了开销。
锁的代价——用 Java 看更清楚
// Java 视角:synchronized 关键字
public class CounterDemo {
private int counter = 0;
private final Object lock = new Object();
// 方法A:不加锁——快但错误
public void unsafeIncrement() { counter++; }
// 方法B:加锁——正确但慢
public void safeIncrement() {
synchronized (lock) { counter++; }
}
}💡 多语言参考:Java 的
synchronized关键字和 Python 的threading.Lock的with语句,在底层逻辑上与 C 的pthread_mutex_lock/unlock是同一回事(尽管实现机制不同——Java 偏向锁、Python GIL 等)。 语言差异不影响并发模型的理解:互斥、等待、通知是普适的概念。
Java 的 synchronized 和 Python 的 with lock: 本质上和 C 的 pthread_mutex_lock/unlock 是同一回事。语法不同,底层逻辑相同。
获得技能:条件变量(Condition Variable)
互斥锁解决的是"互斥访问"问题——让线程之间互不干扰。但线程之间还需要协作——比如一个线程生产数据,另一个线程消费数据,消费者在数据到来之前必须等着,不能空转浪费 CPU。
空转方案长这样:
// 自旋等待:CPU 空转 100%!
while (!data_ready) {
// 啥也不做,CPU 飙到 100%
}显然不是好办法。我们需要的是:如果条件不满足,线程优雅地休眠;当条件满足时,被优雅地唤醒。
条件变量就是为此而生。它让线程等待某个条件成立,另一线程通知条件已满足。与自旋方案相比,等待的线程彻底休眠,不占用 CPU——这是从"忙等"到"阻塞等待"的质变。
// condvar.c - 生产者-消费者
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0; // 条件:数据是否就绪
void *producer(void *arg) {
sleep(1); // 模拟生产耗时
pthread_mutex_lock(&mutex);
ready = 1;
printf("Producer: data ready!\n");
pthread_cond_signal(&cond); // 通知等待的消费者
pthread_mutex_unlock(&mutex);
return NULL;
}
void *consumer(void *arg) {
pthread_mutex_lock(&mutex);
while (!ready) { // 注意:必须用 while 而非 if!
printf("Consumer: waiting...\n");
pthread_cond_wait(&cond, &mutex); // 自动释放锁,阻塞等待
}
// 被唤醒后重新获得锁
printf("Consumer: got data, processing...\n");
pthread_mutex_unlock(&mutex);
return NULL;
}
int main() {
pthread_t p, c;
pthread_create(&c, NULL, consumer, NULL);
pthread_create(&p, NULL, producer, NULL);
pthread_join(p, NULL);
pthread_join(c, NULL);
return 0;
}$ ./condvar
Consumer: waiting...
Producer: data ready!
Consumer: got data, processing...为什么 while 而不是 if?
这就是所谓的虚假唤醒(spurious wakeup)——pthread_cond_wait 可能在条件尚未满足时就返回。POSIX 标准文档明确允许这种行为。如果写成 if (!ready),醒来后直接继续执行,但条件可能仍不成立。用 while 重新检查,是一个防御性编程习惯。
Java 和 Python 同样的规矩:
// Java
synchronized (lock) {
while (!ready) { lock.wait(); }
// 处理数据
}# Python
with cv:
while not ready:
cv.wait()
# 处理数据规则:条件变量永远搭配 while 循环使用,没有例外。
获得技能:信号量(Semaphore)
信号量可以看作一个"资源计数器"。它有两种操作:
sem_wait():如果计数 > 0,减一并通过;否则阻塞sem_post():计数加一,如果有阻塞线程则唤醒一个
这是 Dijkstra 在 1965 年提出的经典原语,比 POSIX 线程早了几十年。它最优雅的地方在于:一个信号量就能同时解决资源计数和同步两个问题。
// sem_demo.c - 信号量实现生产者-消费者(有界缓冲区)
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int in = 0, out = 0;
sem_t empty; // 空槽位数量
sem_t full; // 已填槽位数量
sem_t mutex; // 互斥访问缓冲区
void *producer(void *arg) {
for (int i = 0; i < 10; i++) {
sem_wait(&empty); // 申请一个空槽
sem_wait(&mutex); // 进入临界区
buffer[in] = i;
printf("Produced: %d at slot %d\n", i, in);
in = (in + 1) % BUFFER_SIZE;
sem_post(&mutex); // 离开临界区
sem_post(&full); // 通知有一个已填槽
usleep(100000);
}
return NULL;
}
void *consumer(void *arg) {
for (int i = 0; i < 10; i++) {
sem_wait(&full); // 申请一个已填槽
sem_wait(&mutex); // 进入临界区
int val = buffer[out];
printf("Consumed: %d from slot %d\n", val, out);
out = (out + 1) % BUFFER_SIZE;
sem_post(&mutex); // 离开临界区
sem_post(&empty); // 通知有一个空槽
usleep(200000);
}
return NULL;
}
int main() {
pthread_t p, c;
sem_init(&empty, 0, BUFFER_SIZE);
sem_init(&full, 0, 0);
sem_init(&mutex, 0, 1);
pthread_create(&p, NULL, producer, NULL);
pthread_create(&c, NULL, consumer, NULL);
pthread_join(p, NULL);
pthread_join(c, NULL);
sem_destroy(&empty);
sem_destroy(&full);
sem_destroy(&mutex);
return 0;
}这种设计优雅之处在于:生产者永远不会写满缓冲区,消费者永远不会从空缓冲区读取——两种错误都被信号量的阻塞语义自动阻止了。三个信号量各司其职,代码清晰且无死锁风险(前提是 sem_wait 的顺序一致)。
深水区:读写锁是比互斥锁更精细的并发原语,在读多写少的高级场景中使用。初次学习并发编程时,先掌握互斥锁和条件变量即可。
获得技能:读写锁(RWLock)
读操作与读操作之间不需要互斥——两个线程同时读同一内存不会有问题。写操作与写操作、写操作与读操作之间才需要互斥。如果用普通的互斥锁保护一个读多写少的数据结构(比如配置缓存、路由表),读者之间也要互相等待——纯属浪费。
读写锁区分这两种情况:
// rwlock_demo.c
#include <stdio.h>
#include <pthread.h>
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
int shared_data = 0;
void *reader(void *arg) {
int id = *(int *)arg;
pthread_rwlock_rdlock(&rwlock); // 读锁:多个读者可以同时持有
printf("Reader %d: data = %d\n", id, shared_data);
pthread_rwlock_unlock(&rwlock);
return NULL;
}
void *writer(void *arg) {
pthread_rwlock_wrlock(&rwlock); // 写锁:独占
shared_data++;
printf("Writer: updated to %d\n", shared_data);
pthread_rwlock_unlock(&rwlock);
return NULL;
}读锁之间不互斥,写锁排斥一切(包括读锁和其他写锁)。在读多写少的场景(比如内存缓存、配置表、DNS 解析记录),读写锁能让并发性能提升一个数量级。代价是:锁本身的实现比互斥锁复杂,加锁/解锁的开销略大。如果读写比例接近 1:1,普通互斥锁反而可能更快。
线程 vs 进程:什么时候该用哪个?
经典面试题,但答案是"看场景"。
| 维度 | 线程 | 进程 |
|---|---|---|
| 地址空间 | 共享 | 独立 |
| 创建开销 | 微秒级(clone) | 毫秒级(fork) |
| 通信方式 | 直接读写共享变量 | 管道、共享内存、Socket |
| 隔离性 | 一个线程崩溃→整个进程崩溃 | 进程间隔离,各走各路 |
| 适用场景 | I/O 密集型、大量数据共享 | 安全性要求高、独立部署 |
经验法则:
- 要在后台并行处理多个任务且共享大量数据 → 用线程
- 要隔离不信任的代码或用户 → 用进程(容错优于性能)
- Web 服务器处理百万级连接 → 线程+事件驱动(如 Nginx 的 worker 模型)
- 地心探测终端每个传感器面板 → 进程(崩溃一个不影响其他面板)
- 音频播放 → 实时线程(低延迟比容错更重要)
通关挑战
问题:实现一个线程安全的屏障(Barrier),等 N 个线程都到达某个执行点后,再继续执行。
// barrier.c - 你的任务
// 实现 barrier_init(n) / barrier_wait()
// 所有 n 个线程调用 barrier_wait() 后,一起返回提示:需要结合使用互斥锁、条件变量和一个计数值。
参考实现:
#include <stdio.h>
#include <pthread.h>
typedef struct {
pthread_mutex_t mutex;
pthread_cond_t cond;
int count;
int target;
} Barrier;
void barrier_init(Barrier *b, int n) {
pthread_mutex_init(&b->mutex, NULL);
pthread_cond_init(&b->cond, NULL);
b->count = 0;
b->target = n;
}
void barrier_wait(Barrier *b) {
pthread_mutex_lock(&b->mutex);
b->count++;
if (b->count == b->target) {
// 最后一个到达的线程唤醒所有人
pthread_cond_broadcast(&b->cond);
} else {
// 还没到齐,等待
while (b->count < b->target) {
pthread_cond_wait(&b->cond, &b->mutex);
}
}
pthread_mutex_unlock(&b->mutex);
}这个屏障的巧妙之处:最后一个线程到达时用 pthread_cond_broadcast 唤醒所有等待线程(而不是 pthread_cond_signal 只唤醒一个),确保所有线程同时恢复执行。注意这里仍然用 while 检查条件——尽管最后一次循环中条件必定成立,但对齐到 while 模式是一种纪律。
验收标准
通关本章后,你应该能:
- 用
pthread_create/pthread_join创建和等待线程 - 识别并手动重现 Race Condition
- 用互斥锁保护临界区
- 用条件变量写出生产者-消费者模式
- 理解信号量的语义并与互斥锁/条件变量区分
- 根据场景选择线程 vs 进程
- 实现一个 Barrier 同步原语
常见卡点
| 卡点 | 症状 | 原因 |
|---|---|---|
| 死锁(deadlock) | 程序卡死,无输出 | 两个线程互相等待对方释放锁 |
| 活锁(livelock) | 线程不断运行但无进展 | 锁被不断获取释放但无人完成工作 |
| 数据竞争 | 结果每次不同 | 忘记加锁或锁的粒度不对 |
| 优先级反转 | 高优先级线程迟迟不执行 | 低优先级线程持有了高优先级需要的锁 |
| 忘记 join | 主线程退出前子线程没跑完 | 主线程 exit 会终止整个进程 |
一个经典死锁例子:
// 线程1: 锁A → 锁B
// 线程2: 锁B → 锁A
// 如果时间点恰巧:线程1持有A等B,线程2持有B等A → 死锁解决:所有线程按相同顺序加锁(总是先 A 后 B)。不够复杂的系统可以用"锁层级"——定义全局的锁顺序,任何代码都不能违反。
现在不需要理解
- Futex(Linux 快速用户态互斥锁)——
pthread_mutex的底层实现,在用户态和内核态之间动态切换。绝大部分加锁操作全程在用户态完成,只有真正发生竞争时才陷入内核。你不需要理解它就能好好用 pthread API - Memory Model & Memory Barrier —— 编译器优化和 CPU 乱序执行可能导致指令执行顺序和你写的 C 代码不同。高级程序员才需要关心。用互斥锁时,锁操作自带完整的屏障语义
- C11 标准线程库(
<threads.h>)—— C 标准委员会自己搞的一套线程 API,但 POSIX 生态太强大了,基本上没人用<threads.h>。看到别人的代码里出现thrd_create的话,知道是个什么事就行 - Lock-free 编程——用 CAS(Compare-And-Swap)原子操作实现无锁数据结构。性能极高,但正确性极难保证。通常只有标准库作者和内核开发者需要关心
旅人笔记
线程是计算机系统中的第一道"分身术"。传统程序员只用单线程也能写出正确的程序——但面对多核 CPU、高并发的现代环境,不懂线程就像只会骑单车却要上高速。
从 Race Condition 到互斥锁,从条件变量到信号量,每个原语解决一类特定的协作问题。它们不是教学玩具——MySQL 用它们保护缓冲池、Linux 内核用它们调度进程、Nginx 用它们处理百万连接。每个你日常使用的软件底层都大量依赖这些看似简单的原语。
但也要记住另一面:锁是负担。过多的锁导致性能瓶颈(锁争用),错误的锁导致死锁。一个经验是:能不用锁就不用锁(无锁数据结构),能用细粒度锁就不要用全局锁(减少冲突概率),能用现成的就不要自己造(pthread 标准库比自制锁效果好一百倍)。
还有一个更重要的思维转变:线程编程中,你不再是你唯一的指挥者。你的代码可能在任何时刻被另一个线程打断、另一个线程同时读取你正在写入的数据。这种"不再独享控制权"的感觉,是所有并发调试困难的根源。学会用"可能会被其他线程看到中间状态"的视角来审视每一行代码,是成为并发高手的核心技能。
🚪 下一站预告
第10章:当你操作系统有几十个进程和线程在等待 CPU——谁先上 CPU? 调度算法决定了"谁先执行、执行多久"。FCFS、SJF、Round Robin、多级反馈队列……每个算法都有自己的公平与不公。