跳到内容

第 3 卷 / 第 9 章 / 第 1 课

9.1 线程、数据竞争与互斥 ​

同一应用里的采集、解析和写盘任务需要协作,可以用线程共享数据。线程共享进程的地址空间和多数资源,通信更直接,也把竞争带进了同一片内存:两个线程同时修改一个变量时,结果由什么决定?

process 默认隔离地址空间,thread 则共享进程资源并各自被调度。本篇建立 C/POSIX thread 的正确性底线;条件变量、信号量和 barrier 放在同步模式继续。

thread 共享什么,又保留什么 ​

一个传感器应用可以分成采集、解析和写盘三个 thread。它们能直接访问同一 heap 与 global object,不必把每批数据序列化到 pipe;代价是任何一个错误指针都可能破坏整个 process,未经同步的冲突访问可能构成 data race。只读共享本身不是错误,前提是对象已正确发布且仍在有效期内。

同一 process 的 threads 通常共享:

  • virtual address space 与其中的 code、global、heap;
  • file descriptor table;
  • current directory、credentials 等 process 属性;
  • signal disposition。

每个 thread 有自己的 register context、stack、thread ID、signal mask、scheduling state 和 thread-local storage。

“共享 heap”不表示 local variable 一定私有。automatic local 通常位于当前 thread stack,但把它的地址传给其他 thread 后,别人同样能访问。反过来,global read-only data 虽然共享,只读就不产生写竞争。

创建 thread 前先安排参数寿命 ​

pthread_create 接收一个 void * 参数。这个指针必须在 worker 使用期间保持有效。把 loop variable 地址反复传给多个 thread,既可能让 worker 读取正在修改的 index,形成 data race,又可能在循环结束后留下失效指针:

c
/* 错误示意:所有 worker 都指向同一个会变化的 index */
for (int index = 0; index < thread_count; index++) {
    pthread_create(&threads[index], NULL, worker, &index);
}

正确做法是为每个 worker 准备独立 argument object,并在 join 后再释放。创建也可能失败;POSIX thread 函数通常直接返回 error number,而不是用 -1 配合 errno。

c
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

enum { THREAD_COUNT = 4 };

struct WorkerArgument {
    int index;
};

static void *report_index(void *raw_argument) {
    const struct WorkerArgument *argument = raw_argument;
    printf("worker %d\n", argument->index);
    return NULL;
}

int main(void) {
    pthread_t threads[THREAD_COUNT];
    struct WorkerArgument arguments[THREAD_COUNT];
    int created = 0;

    for (int index = 0; index < THREAD_COUNT; index++) {
        arguments[index].index = index;
        int error = pthread_create(&threads[index], NULL,
                                   report_index, &arguments[index]);
        if (error != 0) {
            fprintf(stderr, "pthread_create: %s\n", strerror(error));
            break;
        }
        created++;
    }

    int failed = created != THREAD_COUNT;
    for (int index = 0; index < created; index++) {
        int error = pthread_join(threads[index], NULL);
        if (error != 0) {
            fprintf(stderr, "pthread_join: %s\n", strerror(error));
            failed = 1;
        }
    }
    return failed ? 1 : 0;
}

若四次创建均成功,输出顺序没有保证,argument 的值却应恰好覆盖 0–3。保存为 thread_arguments.c,用 cc -std=c17 -O2 -Wall -Wextra -Werror -pthread thread_arguments.c -o thread_arguments 编译。pthread_join 既等待结束,也回收 joinable thread 的实现资源;若确定不 join,可创建 detached thread,但要另行设计 process shutdown 与 object lifetime。

race condition 与 data race 不是完全同义 ​

race condition 泛指结果依赖不可控的事件先后;它可以存在于 file rename、network request 或两个 signal 之间。data race 是语言 memory model 中更具体的情况。

在 C 中,不同 thread 对同一 memory location 进行冲突访问,至少一个是写、至少一个是非原子访问,且没有 happens-before 关系,就产生 data race;程序行为未定义。相邻非零宽度 bit-field 还可能属于同一个 memory location,不能只按字段名判断是否独立。

经典错误是:

c
static int counter;

static void *unsafe_worker(void *unused) {
    (void)unused;
    for (int index = 0; index < 1000000; index++) {
        counter++;
    }
    return NULL;
}

counter++ 是 read-modify-write,不具备原子保证。用“读、加、写三条汇编交错”可以解释 lost update 的硬件直觉,却不能完整描述 C:编译器知道 data-race-free 才是合法前提,可以把 load/store 合并、缓存到 register 或作其他变换。

因此,错误程序不保证“每次得到不同且小于 2,000,000 的数”。它可能偶尔打印期望值,也可能出现编译优化带来的其他结果。反复运行没有失败,不是正确性证明;race detector、code review 与同步协议才有意义。

mutex 同时提供互斥与内存同步 ​

pthread_mutex_lock 成功后,当前 thread 独占临界区;unlock 让其他参与者获得锁。所有访问同一 invariant 的代码必须使用同一同步协议。

text
lock(mutex)
  read shared state
  validate / modify the whole invariant
unlock(mutex)

mutex 保护的是 invariant,不只是某一行赋值。假设 bank transfer 要同时更新两个 account balance,只给每个单独 store 加锁仍可能让 observer 看见中间总额。

unlock 与随后成功 lock 建立 POSIX/C 所需的同步关系,使前一个临界区的写对后一个持锁者可见。它不只是“防止 CPU 在同一时间运行两行代码”。第 16 章会用 acquire/release 与 C memory model 精确定义这种关系。

一份经过验证的分批计数 ​

每次 increment 都 lock 虽然正确,却会让所有 thread 争用同一 cache line。若业务只需要最终总数,可在 thread-private local variable 中累计,结束前合并一次:

c
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

enum {
    THREAD_COUNT = 4,
    ITERATIONS_PER_THREAD = 250000
};

static pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;
static long long counter = 0;

static void fail_pthread(const char *operation, int error) {
    fprintf(stderr, "%s: %s\n", operation, strerror(error));
    abort();
}

static void *count_batch(void *unused) {
    (void)unused;
    long long local = 0;
    for (int index = 0; index < ITERATIONS_PER_THREAD; index++) {
        local++;
    }

    int error = pthread_mutex_lock(&counter_mutex);
    if (error != 0) {
        fail_pthread("pthread_mutex_lock", error);
    }
    counter += local;
    error = pthread_mutex_unlock(&counter_mutex);
    if (error != 0) {
        fail_pthread("pthread_mutex_unlock", error);
    }
    return NULL;
}

int main(void) {
    pthread_t threads[THREAD_COUNT];

    for (int index = 0; index < THREAD_COUNT; index++) {
        int error = pthread_create(&threads[index], NULL, count_batch, NULL);
        if (error != 0) {
            fail_pthread("pthread_create", error);
        }
    }
    for (int index = 0; index < THREAD_COUNT; index++) {
        int error = pthread_join(threads[index], NULL);
        if (error != 0) {
            fail_pthread("pthread_join", error);
        }
    }

    long long expected =
        (long long)THREAD_COUNT * ITERATIONS_PER_THREAD;
    printf("expected=%lld actual=%lld\n", expected, counter);

    int error = pthread_mutex_destroy(&counter_mutex);
    if (error != 0) {
        fail_pthread("pthread_mutex_destroy", error);
    }
    return counter == expected ? 0 : 1;
}

这份代码把源码中的一百万次计数合并为四次 shared read-modify-write。编译器可以把 local 循环直接折叠为常数,所以它是正确性示例,不是实际执行一百万次工作后的性能基准。前提是中间值无需实时对其他 thread 可见;若 counter 还承担 progress、quota 或 cancellation 语义,就不能擅自批量化。

mutex 不等于每次 system call ​

未竞争的 POSIX mutex 通常在 user space 通过 atomic state 完成 fast path。发生竞争、需要 sleep/wake 时,Linux implementation 可能使用 futex;具体策略还与 mutex type、adaptive spin、priority inheritance 有关。

所以“每次 lock 都是系统调用,必然慢两个数量级”是错误模型。锁成本主要看:

  • contention 有多大、critical section 多长;
  • ownership 是否跨 core 导致 cache-line transfer;
  • thread 是否 sleep/wake,引入 scheduler latency;
  • lock 是否公平、是否有 priority protocol;
  • 临界区内是否做 I/O、allocation 或嵌套加锁。

优化顺序通常是先缩小共享状态、批量化、分片,再考虑更复杂原语。把一个 global mutex 换成自制 lock-free code,若 memory reclamation 没处理好,只会把明显瓶颈换成隐蔽 bug。

错误路径也必须释放锁 ​

C 没有 RAII。临界区内出现多个 return/goto/error 时,要确保每条路径都 unlock。下面把解锁集中到一个退出段。这是集成片段:先包含 <pthread.h>,提供 Store、Record 和业务函数声明,并使用非 robust mutex;robust mutex 返回 EOWNERDEAD 时仍可能已经取得锁,必须另行恢复状态。

c
static int update_record(struct Store *store, const struct Record *record) {
    int error = pthread_mutex_lock(&store->mutex);
    if (error != 0) {
        return error;
    }

    int result = validate(record);
    if (result == 0) {
        result = write_record(store, record);
    }

    error = pthread_mutex_unlock(&store->mutex);
    return result != 0 ? result : error;
}

这里 validate 与 write_record 是业务函数,必须保证不会违反 lock-order 或做无限期阻塞。若函数可能被 thread cancellation 打断,还要研究 cleanup handler 与 cancellation point;简单的“所有 return 前 unlock”仍不够。

pthread_cleanup_push/pop 是 macro-based API,有词法作用域要求。不了解 cancellation 时,更稳妥的策略往往是关闭 deferred cancellation 或只在受控边界允许退出。

Python 与 Java 有相同概念,不同执行语义 ​

Java synchronized、ReentrantLock 与 POSIX mutex 都能建立 mutual exclusion 与 language-defined visibility,但公平性、reentrancy、interruptibility 和异常清理不同。synchronized 退出块时自动释放 monitor;ReentrantLock 应在成功 lock 后用 try/finally 解锁。Lock 本身不是 AutoCloseable,不能直接交给 try-with-resources。

在启用 GIL 的 CPython 构建中,GIL 保护解释器内部状态,不保护跨多步操作的应用 invariant。线程可能在操作之间切换,I/O 和 C extension 也可能释放 GIL。CPython 自 3.13 起还提供可禁用 GIL 的 free-threaded 构建;不能把某一构建的行为当成所有 Python 的保证。共享 mutable object 仍应使用 threading.Lock、queue 或更高层并发容器。

Python thread 对 CPU-bound pure Python 的 parallelism 还取决于 implementation 与运行模式;I/O concurrency、native extension 和多进程各有不同权衡。不要用“有 GIL,所以不会 data race”作结论。

thread、process 与 event loop 的选择 ​

需求倾向方案需要承担的代价
共享大对象并并行计算thread / task runtime需要显式同步,故障隔离较弱
隔离不可信或易崩溃组件processIPC、serialization、resource duplication
大量 mostly-waiting connectionevent loop 或 structured asynccallback/task lifecycle、backpressure
CPU-heavy taskbounded worker poolqueueing、load balance、oversubscription
实时 deadlineRT thread/policy + bounded designpriority inversion、allocation/I/O 限制

这些方案可以组合。Nginx 使用多个 worker process 加 event loop;Java server 也可能用 event loop 加 worker pool。按共享边界、blocking 行为、failure domain 和 deployment constraint 选择,而不是背固定时延表。

创建开销的微秒/毫秒数字会随 OS、stack policy、address-space size 与安全配置变化。实际 workload 通常更受 task 粒度与数据移动影响。

调试 data race ​

可用工具包括:

  • Clang/GCC ThreadSanitizer 检测许多动态 data race;
  • Helgrind/DRD 等观察 lock 与访问;
  • compiler warning 与 static analyzer 查错误路径;
  • deterministic stress、fault injection 和 model checking 扩大 interleaving 覆盖。

-fsanitize=thread 会显著改变 timing,但 data-race 报告仍比“加 sleep 让 bug 消失”可靠。sanitizer 不能证明没有 race:未执行路径、unsupported primitive 与 false negative 仍需 review。

调试输出本身带 lock 和 I/O,会改变 scheduling,这就是 Heisenbug 常见来源。尽量使用结构化 trace、per-thread buffer 或外部 profiler,避免在临界 timing 路径随手 printf。

动手建立互斥协议 ​

  1. 解释为什么 unsafe counter 的结果不只可能是 lost update,而是 C undefined behavior。
  2. 把分批计数改成每 1000 次合并,比较实时性、contention 与 cache transfer。
  3. 设计一个同时维护 size、head、tail 的 queue invariant,标出 mutex 必须覆盖的范围。
  4. 在 error path 注入 write_record 失败,验证 mutex 始终释放。
  5. 用 ThreadSanitizer 运行一个故意 data race 和正确 mutex 版本,比较报告。
  6. 列出 multi-threaded process 中 fork 后 child 为什么不能安全调用任意 library。

只有互斥还不够 ​

mutex 能保证一次只有一个 thread 修改状态,却不能让 consumer 在“队列非空”之前高效睡眠,也不能表达有限资源数量或阶段汇合。下一篇进入条件变量、信号量与 barrier,重点是 predicate 与 wake-up protocol。

对照接口约定 ​

Built with VitePress | Software Systems Atlas