跳到主要内容

线程与并发:从硬件原语到无锁设计

并发 (Concurrency) 是现代高性能计算的核心。随着摩尔定律从提高时钟频率转向增加核心数量,软件必须利用线程 (Threads)同步 (Synchronization) 来有效利用现代硬件。

本章深入探讨线程的机制、硬件级原子性的真相以及内存一致性模型(Memory Consistency Models)的复杂世界。


1. 线程的本质

如果说进程是资源所有权单位,那么线程就是执行单位。

1.1 线程剖析

单个进程内的所有线程共享:

  • 地址空间:代码、全局数据和堆。
  • 资源:打开的文件描述符、信号和工作目录。

每个线程拥有私有的:

  • 程序计数器 (PC):线程当前执行到的代码位置。
  • 栈 (Stack):局部变量和函数调用历史。
  • 寄存器:当前的计算状态。
  • 线程局部存储 (TLS):线程私有的小型变量。

1.2 Linux 线程:NPTL 模型

在 Linux 中,内核级别并不区分进程和线程。两者都由 task_struct 表示。

  • NPTL (本地 POSIX 线程库):现代实现,其中每个 pthread_create() 调用通过 clone() 系统调用创建一个“轻量级进程” (LWP)。
  • 共享:线程只是共享相同 mm_struct(内存描述符)和 files_struct 的 LWP。

2. 同步原语:锁是如何工作的

在高级语言中,我们调用 mutex.lock()。但在底层发生了什么?

2.1 自旋锁 (Spinlock - 内核态原语)

自旋锁是一种“忙等”锁。线程在循环中不断检查某个内存位置,直到它变为可用。

  • 优点:没有上下文切换开销。如果锁持有的时间极短,速度极快。
  • 缺点:浪费 CPU 周期。如果管理不当,可能导致“优先级反转”。
  • 实现:使用 test-and-setexchange 原子指令。

2.2 Futex (快速用户态互斥量)

现代 Linux 互斥量 (Mutex) 构建在 Futexes 之上。

  • 优化:如果没有竞争(只有一个线程想要锁),锁的获取完全在用户态通过单个原子指令完成,不发起系统调用。
  • 竞争:如果锁已被持有,线程发起 futex() 系统调用,请求内核将其挂起,并在锁释放时唤醒它。
  • 收益:结合了用户态原子操作的速度和内核态休眠的效率。

3. 硬件支持:原子性与内存屏障

软件同步之所以可能,是因为硬件提供了特定的保证。

3.1 原子指令

  • 比较并交换 (Compare-and-Swap, CAS)atomic_compare_exchange(ptr, expected, new)
    1. 读取 ptr 处的值。
    2. 如果等于 expected,则写入 new
    3. 返回是否成功。
  • Fetch-and-Add:原子地增加一个值并返回旧值。

3.2 内存屏障 (Memory Barriers / Fences)

现代 CPU 是“乱序执行”的。如果没有数据依赖,CPU 可能会在后续的读取操作之后才执行之前的写入操作。

  • 内存屏障:一种特殊的指令(如 x86 上的 mfence),强制 CPU 在继续执行之前完成所有挂起的内存操作。
  • 重要性:没有屏障,线程 A 可能会在它准备的数据对线程 B 实际可见之前,就设置了 ready = true

3.3 内存一致性模型

  • TSO (总店序):x86 使用。大多数操作是自然有序的,多线程编程较容易,但硬件速度稍慢。
  • 弱序 (Weak Ordering):ARM 和 RISC-V 使用。CPU 几乎可以重排任何操作。软件必须在各处显式使用屏障。

4. 经典并发问题与模式

4.1 生产者-消费者问题

使用信号量协调对有界缓冲区的访问。

  • 信号量 full:跟踪已占用的槽位(初始化:0)。
  • 信号量 empty:跟踪空闲槽位(初始化:缓冲区大小)。
  • 互斥量:保护缓冲区内部指针。

4.2 读写锁 (Reader-Writer Locks)

针对“多读少写”场景的优化。

  • 读优先:允许无限读者,但可能导致写者饥饿。
  • 写优先:如果有写者在排队,新读者必须等待。

5. 死锁威胁

5.1 四个必要条件 (Coffman)

  1. 互斥:资源一次只能由一个线程使用。
  2. 持有并等待:线程持有一个资源的同时等待另一个资源。
  3. 不可抢占:不能强行夺走资源。
  4. 循环等待:线程 A 等待 B,B 等待 A。

5.2 死锁避免:银行家算法

OS 模拟资源分配,看是否会导致“安全状态”(即所有人最终都能完成的序列)。如果请求会导致不安全状态,则拒绝或延迟该请求。


6. 高级话题:无锁编程 (Lock-Free Programming)

无锁算法使用 CAS 而非互斥量,以确保系统中至少有一个线程始终在取得进展。

6.1 ABA 问题

  1. 线程 A 读取值 A
  2. 线程 B 将其改为 B,然后又改回 A
  3. 线程 A 执行 CAS,认为没有变化,从而可能破坏数据。
  • 解决方案:带标记的指针(在指针中加入版本号)。

6.2 数据结构

  • 无锁栈:使用 CAS 更新 Head 指针。
  • RCU (读-复制-更新):在 Linux 内核中广泛使用。读者从不加锁;写者创建副本,更新,然后交换指针。旧副本只有在所有当前读者都完成后才删除。

7. 多线程性能陷阱

7.1 伪共享 (False Sharing)

当不同核心上的两个线程更新不同的变量,而这些变量恰好位于同一个缓存行(通常为 64 字节)时。

  • 结果:硬件必须不断在核心之间同步该缓存行,极大地破坏性能。
  • 修复:对变量进行填充 (Padding),使它们位于不同的缓存行。

7.2 上下文切换开销

线程切换需要:

  1. 保存寄存器。
  2. 切换栈。
  3. 刷新 CPU 流水线。
  • 经验法则:上下文切换很昂贵(~1-5 微秒)。使用线程池以避免频繁的创建和销毁。

8. 核心概念复习清单

  • 你能解释为什么 futex 比传统的内核互斥量快吗?
  • 自旋锁 (spinlock) 和互斥量 (mutex) 有什么区别?
  • “比较并交换” (CAS) 如何实现无锁编程?
  • 什么是“优先级反转”,以及“优先级继承”如何解决它?
  • 解释无锁栈场景下的 ABA 问题。

第 03 章结束。继续前往:第 04 章:内存管理