线程基础与内存模型:理解线程的本质
大家好,我是你们的老朋友。今天咱们聊聊多线程编程里最基础、也最容易被忽视的东西——线程的本质和内存模型。
说实话,我见过不少工程师,写多线程代码好几年了,问起「线程到底是什么」,回答得模棱两可。这其实挺危险的。你想想看,连手里拿的工具都不了解,怎么能用好它?
线程的本质:轻量级进程
线程,说白了就是「轻量级进程」。这个说法很经典,但到底轻在哪?
进程是操作系统分配资源的最小单位。每个进程有自己的地址空间、文件描述符、信号处理等等。而线程呢?它是CPU调度的最小单位。同一个进程里的多个线程,共享地址空间和大部分资源。
我打个比方:进程就像一家公司,有自己的办公楼、财务、人事。线程就是公司里的员工,大家共用办公室、打印机、茶水间。员工可以随时切换工作内容,但公司还是那个公司。
核心区别一句话:进程是资源分配单位,线程是CPU调度单位。
我在项目中遇到过一件事:有个同事为了隔离业务,把每个请求都开一个新进程来处理。结果服务器内存直接爆了。换成线程池后,同样的并发量,内存占用降了80%。这就是「轻量」的威力。
线程与进程的对比
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 切换开销 | 大(需要切换页表、刷新TLB) | 小(只需保存寄存器) |
| 通信方式 | IPC(管道、消息队列、共享内存) | 直接读写共享变量 |
| 创建速度 | 慢 | 快 |
| 健壮性 | 一个进程挂了不影响其他 | 一个线程挂了可能导致整个进程崩溃 |
嗯,这里要注意:线程虽然轻量,但共享地址空间是把双刃剑。好处是通信快,坏处是——你一不小心就把别人的数据改坏了。
现代CPU内存架构:从单核到多核
要理解多线程,光知道线程定义还不够。你得知道代码跑在什么样的硬件上。
早期的CPU是单核的,所有线程轮流在一个核心上跑。那时候写多线程程序,基本不用担心内存一致性问题。因为所有线程看到的都是同一份内存数据。
但现在的CPU不一样了。每个核心都有自己的L1、L2缓存,多个核心共享L3缓存。这就带来了一个问题:核心A修改了变量x,核心B怎么知道?
我画了一张图,帮你理清这个结构:
你看,每个核心有私有的L1和L2缓存,共享L3缓存。核心访问自己L1缓存的速度,比访问主存快两个数量级。但问题来了:如果核心0和核心1都缓存了变量x,核心0改了x,核心1不知道,怎么办?
缓存一致性协议:MESI
为了解决这个问题,CPU硬件工程师们发明了缓存一致性协议。最经典的就是MESI协议。
MESI是四个状态的缩写:
- M (Modified):已修改。缓存行被当前核心修改了,数据与主存不一致。其他核心不能缓存这个数据。
- E (Exclusive):独占。只有当前核心缓存了这个数据,且与主存一致。
- S (Shared):共享。多个核心都缓存了这个数据,且与主存一致。
- I (Invalid):无效。缓存行失效了,需要重新从主存或其他核心获取。
我举个例子,你感受一下这个协议是怎么工作的:
- 核心0要读取变量x。它发现L1缓存没有,于是从主存加载。此时状态为E(独占)。
- 核心1也要读取x。它发现L1缓存没有,于是通过总线嗅探发现核心0有。核心0把状态改为S(共享),核心1也缓存了x,状态也是S。
- 核心0要修改x。它先发一个「失效」消息给核心1。核心1收到后,把x的状态改为I(无效)。核心0把x改为新值,状态变为M(已修改)。
- 核心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协议,这些是理解多线程编程的基石。下一章我们会深入聊聊线程的创建、销毁和生命周期管理。