Skip to content

第9章:线程与同步——分身与协作


元数据卡

属性
叙事密度13%
主语言C (POSIX Threads)
配角语言Java, Python
前置第8章(进程与虚拟内存)
难度

核心问题:一个 CPU 核心一次只能执行一个指令流,但地心的探测终端要同时处理传感器数据、LED 面板显示、日志写入——它们怎么能"同时"工作?更微妙的是,当两个探险者(线程)同时操作同一个地心仪表,为什么读数可能完全错误?


你在哪

"进程切换太慢了——每次保存加载整个地址空间。更轻量的选择出现了:线程。同一个进程内的多个线程共享内存,协作完成任务。但共享带来了新的问题——如果两个线程同时修改同一个变量,会发生什么?"

你已经理解了进程——每个进程有独立的地址空间,通过 fork() 复制自己。但 fork() 的开销很大(页表复制、文件描述符复制),而且进程间通信(IPC)繁琐。

有没有一种更轻量的"分身术"?同一进程内的多个执行流,共享地址空间,切换开销远小于进程切换?

这就是线程(thread)

你的任务

本章分层

  • 必读:Race Condition(竞态条件)的产生原因与识别;互斥锁(Mutex)保护临界区;条件变量实现生产者-消费者模式
  • 选读:线程 vs 进程的选型对比;信号量的基本语义
  • 深水区:读写锁(RWLock)的场景与代价;可重入锁与 Barrier 的实现 本章不会要求你掌握
  • 读写锁的内部实现细节
  • 信号量与条件变量的详细对比
  • Lock-free 编程与 CAS 原子操作

写一个多线程程序完成以下任务:

  1. 主线程读取一个整数 N 和一段文本
  2. 创建 N 个工作线程,每个线程统计文本中某个特定字符的出现次数
  3. 所有线程结束后,汇总并打印结果

这个小项目暴露了线程编程的核心问题:数据共享竞争条件


遭遇战:Race Condition 演示

先看一个最简单的多线程程序。两个线程各对一个全局变量 counter 加 100 万次:

c
// 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;
}

编译运行:

bash
$ 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)的用法直截了当:

c
// 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;
}
bash
$ ./mutex_demo
Expected: 2000000, Got: 2000000

正确了。但代价是什么?每次 counter++ 都要经过系统调用级别的锁操作,运行时间从几毫秒暴涨到几百毫秒。锁带来了正确性,也带来了开销。

锁的代价——用 Java 看更清楚

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.Lockwith 语句,在底层逻辑上与 C 的 pthread_mutex_lock/unlock 是同一回事(尽管实现机制不同——Java 偏向锁、Python GIL 等)。 语言差异不影响并发模型的理解:互斥、等待、通知是普适的概念。

Java 的 synchronized 和 Python 的 with lock: 本质上和 C 的 pthread_mutex_lock/unlock 是同一回事。语法不同,底层逻辑相同。


获得技能:条件变量(Condition Variable)

互斥锁解决的是"互斥访问"问题——让线程之间互不干扰。但线程之间还需要协作——比如一个线程生产数据,另一个线程消费数据,消费者在数据到来之前必须等着,不能空转浪费 CPU。

空转方案长这样:

c
// 自旋等待:CPU 空转 100%!
while (!data_ready) {
    // 啥也不做,CPU 飙到 100%
}

显然不是好办法。我们需要的是:如果条件不满足,线程优雅地休眠;当条件满足时,被优雅地唤醒

条件变量就是为此而生。它让线程等待某个条件成立,另一线程通知条件已满足。与自旋方案相比,等待的线程彻底休眠,不占用 CPU——这是从"忙等"到"阻塞等待"的质变。

c
// 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;
}
bash
$ ./condvar
Consumer: waiting...
Producer: data ready!
Consumer: got data, processing...

为什么 while 而不是 if

这就是所谓的虚假唤醒(spurious wakeup)——pthread_cond_wait 可能在条件尚未满足时就返回。POSIX 标准文档明确允许这种行为。如果写成 if (!ready),醒来后直接继续执行,但条件可能仍不成立。用 while 重新检查,是一个防御性编程习惯。

Java 和 Python 同样的规矩:

java
// Java
synchronized (lock) {
    while (!ready) { lock.wait(); }
    // 处理数据
}
python
# Python
with cv:
    while not ready:
        cv.wait()
    # 处理数据

规则:条件变量永远搭配 while 循环使用,没有例外。


获得技能:信号量(Semaphore)

信号量可以看作一个"资源计数器"。它有两种操作:

  • sem_wait():如果计数 > 0,减一并通过;否则阻塞
  • sem_post():计数加一,如果有阻塞线程则唤醒一个

这是 Dijkstra 在 1965 年提出的经典原语,比 POSIX 线程早了几十年。它最优雅的地方在于:一个信号量就能同时解决资源计数和同步两个问题

c
// 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)

读操作与读操作之间不需要互斥——两个线程同时读同一内存不会有问题。写操作与写操作、写操作与读操作之间才需要互斥。如果用普通的互斥锁保护一个读多写少的数据结构(比如配置缓存、路由表),读者之间也要互相等待——纯属浪费。

读写锁区分这两种情况:

c
// 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 个线程都到达某个执行点后,再继续执行。

c
// barrier.c - 你的任务
// 实现 barrier_init(n) / barrier_wait()
// 所有 n 个线程调用 barrier_wait() 后,一起返回

提示:需要结合使用互斥锁、条件变量和一个计数值。

参考实现

c
#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 模式是一种纪律。


验收标准

通关本章后,你应该能:

  1. pthread_create/pthread_join 创建和等待线程
  2. 识别并手动重现 Race Condition
  3. 用互斥锁保护临界区
  4. 用条件变量写出生产者-消费者模式
  5. 理解信号量的语义并与互斥锁/条件变量区分
  6. 根据场景选择线程 vs 进程
  7. 实现一个 Barrier 同步原语

常见卡点

卡点症状原因
死锁(deadlock)程序卡死,无输出两个线程互相等待对方释放锁
活锁(livelock)线程不断运行但无进展锁被不断获取释放但无人完成工作
数据竞争结果每次不同忘记加锁或锁的粒度不对
优先级反转高优先级线程迟迟不执行低优先级线程持有了高优先级需要的锁
忘记 join主线程退出前子线程没跑完主线程 exit 会终止整个进程

一个经典死锁例子

c
// 线程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、多级反馈队列……每个算法都有自己的公平与不公。

Built with VitePress | Software Systems Atlas