1. 线程基础与内存模型

大家好,我是你们的老朋友。今天咱们聊聊多线程编程里最基础、也最容易被忽视的东西——线程的本质和内存模型。

说实话,我见过不少工程师,写多线程代码好几年了,问起「线程到底是什么」,回答得模棱两可。这其实挺危险的。你想想看,连手里拿的工具都不了解,怎么能用好它?

线程的本质:轻量级进程

线程,说白了就是「轻量级进程」。这个说法很经典,但到底轻在哪?

进程是操作系统分配资源的最小单位。每个进程有自己的地址空间、文件描述符、信号处理等等。而线程呢?它是CPU调度的最小单位。同一个进程里的多个线程,共享地址空间和大部分资源。

我打个比方:进程就像一家公司,有自己的办公楼、财务、人事。线程就是公司里的员工,大家共用办公室、打印机、茶水间。员工可以随时切换工作内容,但公司还是那个公司。

核心区别一句话:进程是资源分配单位,线程是CPU调度单位。

我在项目中遇到过一件事:有个同事为了隔离业务,把每个请求都开一个新进程来处理。结果服务器内存直接爆了。换成线程池后,同样的并发量,内存占用降了80%。这就是「轻量」的威力。

线程与进程的对比

对比维度 进程 线程
地址空间 独立 共享
切换开销 大(需要切换页表、刷新TLB) 小(只需保存寄存器)
通信方式 IPC(管道、消息队列、共享内存) 直接读写共享变量
创建速度 慢 快
健壮性 一个进程挂了不影响其他 一个线程挂了可能导致整个进程崩溃

嗯,这里要注意:线程虽然轻量,但共享地址空间是把双刃剑。好处是通信快,坏处是——你一不小心就把别人的数据改坏了。

现代CPU内存架构:从单核到多核

要理解多线程,光知道线程定义还不够。你得知道代码跑在什么样的硬件上。

早期的CPU是单核的,所有线程轮流在一个核心上跑。那时候写多线程程序,基本不用担心内存一致性问题。因为所有线程看到的都是同一份内存数据。

但现在的CPU不一样了。每个核心都有自己的L1、L2缓存,多个核心共享L3缓存。这就带来了一个问题:核心A修改了变量x,核心B怎么知道?

我画了一张图,帮你理清这个结构:

现代CPU多级缓存架构 核心0 (Core 0) 核心1 (Core 1) 核心2 (Core 2) L1 指令 L1 数据 L1 指令 L1 数据 L1 指令 L1 数据 L2 缓存 (私有) L2 缓存 (私有) L2 缓存 (私有) L3 缓存 (共享) 主存 (Main Memory / RAM) 速度: 快 速度: 中 速度: 慢

你看,每个核心有私有的L1和L2缓存,共享L3缓存。核心访问自己L1缓存的速度,比访问主存快两个数量级。但问题来了:如果核心0和核心1都缓存了变量x,核心0改了x,核心1不知道,怎么办?

缓存一致性协议:MESI

为了解决这个问题,CPU硬件工程师们发明了缓存一致性协议。最经典的就是MESI协议。

MESI是四个状态的缩写:

  • M (Modified):已修改。缓存行被当前核心修改了,数据与主存不一致。其他核心不能缓存这个数据。
  • E (Exclusive):独占。只有当前核心缓存了这个数据,且与主存一致。
  • S (Shared):共享。多个核心都缓存了这个数据,且与主存一致。
  • I (Invalid):无效。缓存行失效了,需要重新从主存或其他核心获取。

我举个例子,你感受一下这个协议是怎么工作的:

  1. 核心0要读取变量x。它发现L1缓存没有,于是从主存加载。此时状态为E(独占)。
  2. 核心1也要读取x。它发现L1缓存没有,于是通过总线嗅探发现核心0有。核心0把状态改为S(共享),核心1也缓存了x,状态也是S。
  3. 核心0要修改x。它先发一个「失效」消息给核心1。核心1收到后,把x的状态改为I(无效)。核心0把x改为新值,状态变为M(已修改)。
  4. 核心1再次读取x。发现状态是I,于是重新从核心0获取最新值。

避坑指南:我曾经在项目中遇到过一个诡异的性能问题——多线程程序在8核机器上跑,性能反而比4核还差。查了半天,发现是「伪共享」(False Sharing)导致的。两个线程各自修改不同的变量,但这两个变量恰好在同一个缓存行里。MESI协议让它们不停地互相失效,缓存行在核心之间来回传输,性能直接崩了。

伪共享的典型场景是这样的:

// 两个线程分别修改 count1 和 count2
struct Counter {
    int count1;  // 线程A修改
    int count2;  // 线程B修改
};

// 如果 count1 和 count2 在同一个缓存行(64字节)内
// 线程A修改 count1 → 缓存行状态变为 M
// 线程B修改 count2 → 需要先让线程A的缓存行失效
// 来回折腾,性能惨不忍睹

解决办法也很简单:用填充(padding)让它们不在同一个缓存行里。

// 对齐到缓存行大小(64字节)
struct alignas(64) Counter {
    int count1;
    char padding[60];  // 填充到64字节
    int count2;
};

内存模型:你看到的不一定是真的

有了MESI协议,是不是就万事大吉了?不是的。MESI保证的是缓存一致性,但CPU还有另一个优化——指令重排序。

为了提升性能,CPU和编译器可能会改变指令的执行顺序。只要不影响单线程的执行结果,它们就敢乱排。但在多线程环境下,这就出问题了。

我举个经典的例子:

// 线程A
data = 42;
ready = true;

// 线程B
while (!ready);
print(data);  // 可能打印出0,而不是42

为什么会这样?因为CPU可能把 ready = true 提前执行了,而 data = 42 还没写回内存。线程B看到ready为true,就读取data,结果读到了旧值。

注意:这就是为什么我们需要内存屏障(Memory Barrier)和原子操作。C++11的 std::atomic 和 std::memory_order 就是用来解决这个问题的。别指望MESI能帮你搞定一切,它只管缓存一致性,不管指令重排序。

我个人习惯在写多线程代码时,遵循一个原则:能用原子变量就别用裸变量,能用锁就别指望运气。你想想看,硬件已经够复杂了,我们写代码就别再给自己挖坑了。

好了,这一章的内容就到这里。线程的本质、CPU内存架构、MESI协议,这些是理解多线程编程的基石。下一章我们会深入聊聊线程的创建、销毁和生命周期管理。