C — 线程与并发
线程是进程内的一个执行流,同一进程内的多个线程共享虚拟地址空间和文件描述符表,但各自拥有独立的寄存器和栈。
线程与进程的对比
| 维度 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立虚拟地址空间、独立文件描述符表 | 共享地址空间和文件表 |
| 创建开销 | 大(复制页表、fork 等) | 小(只分配栈和 task_struct) |
| 上下文切换 | 需要切换页表(CR3) | 不需要切换页表(更快) |
| 隔离性 | 强(进程间不能直接访问对方内存) | 弱(线程间可互相读写) |
| 调度 | 内核以线程为单位调度 | 内核以线程为单位调度 |
Linux 实际上把线程实现为一种轻量级进程(LWP):用 clone() 系统调用创建线程,通过不同标志位决定父进程的哪些资源被共享。
内核线程 vs 用户线程
| 类型 | 说明 | 调度者 | 代表 |
|---|---|---|---|
| 内核线程(1:1) | 每个用户线程对应一个内核可见的调度实体 | 内核 | Linux(NPTL) |
| 用户线程(N:1) | 多个用户线程映射到一个内核线程,在用户态自行调度 | 用户态库 | GNU Pth(已过时) |
| 混合(M:N) | 多个用户线程映射到多个内核线程 | 协同 | Go(goroutine) |
Linux 默认采用 1:1 模型(通过 clone + futex 等系统调用实现),这也是所有主流编程语言线程的标准实现。
竞争条件(Race Condition)
多个线程同时访问共享数据,且至少有一个是写操作,最终结果取决于线程的执行顺序:
// 两个线程同时执行 counter++
// counter++ 不是原子操作!它被编译为三条汇编指令:
// mov eax, [counter] ; 1. 从内存读
// add eax, 1 ; 2. 加一
// mov [counter], eax ; 3. 写回内存
// 如果两线程交错执行这 3 条指令,最终 counter 可能只加了 1 而非 2对容器的意义:多个线程同时操作 std::vector(一端 push_back 一端读取容量)或 std::map(同时插入两个节点)会破坏内部数据结构。即使单次操作的线程安全可能有保证(如 const 成员函数是读操作),但容器整体不是线程安全的。
原子操作
原子操作是不可分割的 CPU 指令,不会被其他线程打断。x86 提供 LOCK 前缀实现原子性:
#include <stdatomic.h> // C11 原子类型
atomic_int counter = 0;
atomic_fetch_add(&counter, 1); // 原子递增,编译为 lock xadd| 操作 | 指令(x86) | 说明 |
|---|---|---|
| 原子加 | lock xadd | 读-修改-写,保证原子性 |
| CAS | lock cmpxchg | Compare-And-Swap,无锁数据结构的基础 |
| 内存屏障 | mfence / lfence / sfence | 保证访存顺序 |
CAS 是无锁数据结构(lock-free queue、lock-free stack、无锁哈希表)的核心原语。这类数据结构在多线程下不需要互斥锁,避免了锁的开销和死锁风险。
缓存一致性与多核
多核 CPU 各自有 L1/L2 缓存。当一个核修改了某条 cache line,另一个核读到旧数据就是数据不一致。CPU 通过缓存一致性协议(MESI)自动维护一致性:
| 状态 | 含义 |
|---|---|
| Modified(修改) | 该 cache line 在当前核被修改,其他核无效 |
| Exclusive(独占) | 仅当前核持有,数据与主存一致 |
| Shared(共享) | 多个核持有,均为只读 |
| Invalid(无效) | cache line 不可用 |
两个核对同一条 cache line 做写入 → Modified → Shared 的 bounce → 频繁传递,性能下降。
对容器的意义:两个线程分别写 vector 中相邻的两个 int,它们在同一条 cache line 中。即使每个线程只写”自己的那一部分”,缓存一致性协议也会导致 cache line 在核间来回传递(伪共享)。
本章与其他模块的链接
- 缓存一致性导致的伪共享 → B_缓存层级 > 伪共享
- 原子操作与锁的关系 → E_同步与死锁
- 无锁数据结构 → 跳表 SkipList