线程与进程模型
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),
g++ -std=c++17 -O2 -static。CPU 是 i5-11400H:6 个物理核 / 12 个逻辑处理器,拓扑由GetLogicalProcessorInformationEx读出来,不是猜的。所有时间都是本机跑出来的,重复测量的组会写明区间。
一、先分清三个词
很多争论源于混用这三个词。按可验证的定义:
| 概念 | 定义 | 判据 |
|---|---|---|
| 并发 concurrency | 多个任务在同一时间段内都可推进 | 一个线程也能并发(轮流推进) |
| 并行 parallelism | 多个任务在同一时刻都在执行 | 需要 ≥2 个硬件执行单元 |
| 异步 async | 等待期间不占用执行单元,完成后再接手 | 看等待时 CPU 被吃掉没有 |
同一件”等两个各 150ms 的事”,四种写法实测:
A 单线程串行阻塞 墙钟 311.9 ms 本线程 CPU 0 ms
B 单线程忙等轮询 墙钟 150.0 ms 本线程 CPU 156 ms ← 并发达成,但没有并行,且烧核
C 两个线程各自阻塞 墙钟 156.2 ms 两个线程 CPU 各约 0 ms ← 并发 + 等待不花钱
B 是”单线程并发”的活标本:一个循环同时盯两个到期时间,墙钟从 312ms 压到 150ms,靠的是重叠等待,不是多核。代价是这一个核 100% 空转了 156ms。
把等待换成真正的计算,才轮到并行出场。固定总工作量、只变线程数(每个元素固定做 512 次取模,保证单元素代价与数值无关):
1 线程 318.0 ms 基准
2 线程 165.7 ms 1.92x
3 线程 106.9 ms 2.97x
4 线程 100.6 ms 3.16x
6 线程 82.4 ms 3.86x (= 物理核数)
8 线程 68.2 ms 4.66x ← 本机实测的最优点
12 线程 77.7 ms 4.09x ← 等于逻辑处理器数,反而退步
16 线程 68.8 ms 4.62x
24 线程 69.0 ms 4.61x
6 个核的机器,加速比卡在 4.7x 上下,12 线程比 8 线程还慢。这不是线程池的错,是频率和 SMT 的错,下一节量化它。
阻塞型任务的收益则干净得多(6 个 150ms 的任务):
1 线程 934.7 ms 2 线程 459.0 ms 3 线程 318.5 ms 6 线程 173.6 ms
几乎线性,因为等待时间不吃执行单元——I/O 密集场景加线程见效,CPU 密集场景加线程很快撞墙,这是选并发度时最重要的一条经验。
二、线程共享什么、隔离什么
std::thread 的每个线程实测:
main tid=86592 &g_data=00007ff691b78020 局部变量=000000d9639ff7c0
child#0 tid=74892 &g_data=00007ff691b78020 局部变量=000000d963dffc9c
child#1 tid=78824 &g_data=00007ff691b78020 局部变量=000000d963fffbbc
child#2 tid=49048 &g_data=00007ff691b78020 局部变量=000000d9641ff67c
child#3 tid=63876 &g_data=00007ff691b78020 局部变量=000000d9643ffd8c
&g_data 五个线程一模一样,局部变量地址各不相同。这就是线程模型的全部要害:地址空间、代码段、数据段、堆、打开的文件、当前进程 id 是共享的;栈、寄存器组、程序计数器、线程 id、thread_local、调度优先级、CPU 亲和性、信号掩码是各自的。
thread_local 的实测(每个线程自己累加):
线程 0 看到自己的 tls=1(地址 000001e6014b6a48)
线程 1 看到自己的 tls=2(地址 000001e6011527c8)
线程 2 看到自己的 tls=3(地址 000001e6014b6268)
名字相同、值互不影响、地址也不同。CRT 的 errno、strerror 的静态缓冲、rand() 的状态在 Windows/MinGW 上都是这类 TLS——这解释了为什么”errno 是线程安全的”而”全局计数器不是”。
进程是另一回事:地址相同,内容不同
同一个可执行文件既能当父进程也能当子进程跑。父进程把 g_data 改成 99,然后各叫一个线程和一个进程来看:
[线 程 tid=11616] 看到 g_data = 99,地址 00007ff64ab97000
[主线程 pid=37436] 我自己看到 g_data = 99
[子进程 pid=107696] g_data = 42,地址 00007ff64ab97000
虚拟地址完全相同,值却是初始的 42。 虚拟地址是进程内部的坐标,两个进程各自有独立的页表把同一个坐标映射到不同物理页。这个实验比任何定义都直观:线程改的是”同一个东西”,进程改的是”自己的副本”。
顺带一个可观测的副作用:那次实验的输出文件里,[子进程] 那一行排在父进程第一行之前。父子进程的 stdio 缓冲区是各自独立的,谁先 fflush 谁先落盘——进程之间连 stdout 的缓冲顺序都保证不了,更别说内存。
代价对比:37KB 与 3.83MB
创建前 WorkingSet= 4.33 MB 私有提交=0.72 MB 句柄= 95
200 线程空闲 WorkingSet= 8.65 MB 私有提交=8.17 MB 句柄=496
全部回收 WorkingSet= 4.55 MB 私有提交=0.94 MB 句柄= 96
200 个空闲线程多花 7.45 MB 提交内存 = 每线程约 37 KB,多 401 个句柄 = 每线程 2 个(线程句柄 + TLS/堆块)。注意线程栈默认预留 1 MB 但只按需提交,所以”37 KB”远小于 1 MB;我把 20000 个线程全开起来时,私有提交是 744.8 MB(约 37 KB × 20000),而且一个都没创建失败——所以”线程开太多会创建失败”在 x64 上很难触发,真正的问题是调度和内存提交。
进程侧:
子进程 WorkingSet=3.83 MB 私有提交=0.67 MB 句柄数=84
串行「创建进程 → 等它退出」30 次:502.2 ms ⇒ 16.74 ms/个
串行「创建线程 → join」 30 次: 3.6 ms ⇒ 0.120 ms/个(差 139 倍)
8 个单元各干 30ms 的活:8 线程 = 44.5 ms,8 进程 = 124.6 ms
| 维度 | 线程 | 进程 |
|---|---|---|
| 创建/销毁 | 0.12 ms | 16.7 ms(139 倍) |
| 空闲常驻内存 | ≈37 KB 提交 | 3.83 MB working set |
| 内核句柄 | 2 个 | 84 个 |
| 数据可见性 | 直接共享,需加锁 | 必须 IPC/序列化 |
| 崩溃边界 | 一个线程写坏内存,全进程死 | 隔离 |
结论:要”频繁创建 + 大量共享数据”就选线程;要”故障隔离 + 独立发布/重启”就选进程;既要隔离又要快,就用常驻进程/工作进程池(Chrome、nginx、PostgreSQL 都是这条路)。
顺便,MinGW 上根本没有 POSIX 的 fork():
c9fork.c:5:15: warning: implicit declaration of function 'fork' [-Wimplicit-function-declaration]
ld.exe: undefined reference to `fork'
Windows 侧只有 CreateProcess——它创建的是一个全新映像,不是父进程的写时复制副本。所以任何”C++ 调 fork 起子进程”的代码在 MinGW 下都编不过,必须换成 CreateProcess 或者干脆上 Linux。
三、上下文切换的真实代价
先把同一台机器上几种操作的单价排出来(都实测过):
steady_clock::now() 0.02 µs (20 ns)
本线程 atomic fetch_add(relaxed) 5.35 ns
依赖载入延迟:2 KB 工作集 1.17 ns/次
32 KB 1.68 ns/次
1 MB(L2/L3) 12.03 ns/次
32 MB(内存) 113.73 ns/次
Sleep(0) / yield() 0.08 µs
两线程原子自旋交接 0.061 µs (61 ns)
两线程条件变量唤醒交接 11.35 µs
创建并回收一个线程 120.8 µs
创建并等待一个进程 16 740 µs
数量级关系是:一次唤醒 ≈ 2100 次原子操作 ≈ 943 次 1MB 工作集的依赖载入 ≈ 100 次 32MB 工作集的依赖载入。同一个 ping-pong 实验把 condition_variable 换成原子自旋,交接单价从 11.35µs 掉到 61ns,186 倍。
为什么唤醒这么贵:Sleep/wait 要陷入内核 → 调度器把线程挂起并从就绪队列摘掉 → 通知方置状态并唤醒 → 调度器给目标线程选核 → 上下文切换保存/恢复寄存器 → 目标线程的栈和状态缓存在被别的线程用过的那颗核上是冷的,要重新装载 → 才是你的代码。自旋交接把这整段都换成了”一次缓存行所有权迁移”(约 60–100ns),所以快两个数量级。
代价写在上表里看不见:自旋的 200 万次交接是两个核全程 100% 占用换来的。判据很简单——等待时长和忙等成本谁大。等一个 3ms 的磁盘操作,自旋烧掉两个核 6ms 的 CPU;等一次 200ns 的锁交接,唤醒的 11µs 开销是它的 50 倍,自旋完胜。介于两者之间就得混合:先自旋 N 次再阻塞(_mm_pause + 有界自旋),这是很多真实锁的做法。
线程数超过可运行硬件线程数,切换不产生吞吐
把线程绑到指定的逻辑处理器上,跑同样一段串行依赖链(LCG,1000 次一组),每组重复 5 次取中位数:
A 1 线程,绑核0 的逻辑0 896.0 M iter/s(5 次区间 768.5–909.2)= 基准
B 2 线程,同一个逻辑处理器 905.9 M iter/s(区间 833.1–948.3)= 1.01x ← 时间分片,吞吐不涨
C 2 线程,同物理核的两个超线程 1788.6 M iter/s(区间 1584.1–1848.4)= 2.00x ← SMT
D 2 线程,两个不同物理核 1613.4 M iter/s(区间 457.7–1732.4)= 1.80x ← 真并行
E 6 线程,6 个物理核各 1 3929.0 M iter/s(区间 3617.9–4228.1)= 4.38x
F 12 线程,12 个逻辑口各 1 7496.7 M iter/s(区间 6871.7–7735.1)= 8.37x
G 20 线程不绑核抢 12 个逻辑口 6686.5 M iter/s(区间 5868.9–7530.3)= 7.46x ← 超额订阅
四条能直接当结论用的事实:
- B = 1.01x:两个线程挤在同一个逻辑处理器上,只是被时间分片来回切,吞吐一点没涨。调度器切换是”免费”的错觉就在这里——它切换得很勤,但切换本身不产出。
- E = 4.38x 而不是 6x:6 个核满载只有 4.4 倍。差距主要来自频率——基准 A 是”1 核忙、其余 5 核睡”,能顶到最高睿频;6 核全忙时整包频率掉下来。加速比的天花板是功耗墙,不是核数。
- G < F:20 个线程抢 12 个逻辑口,吞吐从 8.37x 掉到 7.46x。超额订阅不只”没好处”,它有实际损失(每次抢占都要走上面那段 11µs 级别的路)。
- C 与 D 的关系不能推广:这个负载是乘法延迟受限的依赖链,发射槽大量空闲,SMT 正好用另一个线程填满,所以同核两线程能到 2.00x,甚至略高于跨核的 1.80x(跨核那组还吃了睿频下降)。换成执行单元饱和的负载(向量加法、纯整数吞吐),SMT 收益通常只有 10–30%。别把一次实测当普适常数,要当”这台机器、这个负载”的实测。
Sleep 的粒度是个陷阱
Sleep(1) 八次实测:15.19 15.11 15.87 14.85 15.23 14.73 15.11 15.42 ms
timeGetDevCaps:wPeriodMin=1 wPeriodMax=1000000
timeBeginPeriod(1) 之后 Sleep(1)=1.88 ms;关掉之后 Sleep(1)=13.72 ms
Sleep(1) 睡了 15ms,不是 1ms:Windows 默认的定时器分辨率就是约 15.6ms。想让 Sleep(1) 真的接近 1ms 要调 timeBeginPeriod(1),而这是全系统的设置(影响所有进程和电池寿命,用完必须 timeEndPeriod)。写”每 1ms 采样一次”的循环前,先确认这个数——否则你的采样频率是名义值的 1/15。
四、并行度会被什么吃掉
前面说加速比撞墙是频率问题,这里把”线程自己变慢”也测出来。每个线程都做同样完整的一份活(不切分),于是 CPU 时间合计可以直接和线程数对照:
1 线程:墙钟 104.2 ms,CPU 合计 93 ms
2 线程:墙钟 106.2 ms,CPU 合计 218 ms → 每份活慢了 1.17 倍
4 线程:墙钟 117.0 ms,CPU 合计 433 ms → 1.16
6 线程:墙钟 114.0 ms,CPU 合计 650 ms → 1.16
8 线程:墙钟 122.6 ms,CPU 合计 837 ms → 1.13
12 线程:墙钟 186.0 ms,CPU 合计 1365 ms → 1.22
理想情况下 12 线程的 CPU 合计应是 12 × 93 = 1116ms,实测 1365ms。同样的代码、同样的数据、没有任何同步,每个线程也慢了 15–22%:共享 L3、内存带宽、以及 SMT 抢占同一组执行单元。这就是”并行效率不是 100%“的物理来源,和锁没关系,别去调锁参数。
再看任务切分粒度。上一节那个 for (d=1; d*d<=n; d++) 的试除负载,单个元素的代价随 n 增长——静态均分 12 段时,我以为的”等分”其实严重不等分:
worker0 耗时 300.6 ms ← 它分到 [0, 250000),最便宜
worker5 耗时 992.9 ms
worker11 耗时 1227.7 ms ← 它分到 [2750000, 3000000),最贵
总耗时 1228.1 ms,最快 300.6 / 最慢 1227.7 ⇒ 快的核提前干完,白等 927.5 ms
改成动态领块(线程做完一块再领下一块):
静态均分 12 段 705.8 ms
动态领块,块数=线程数 635.9 ms
动态领块,块数=120 568.0 ms ← 最好
动态领块,块数=1200 590.7 ms ← 块太碎,领取本身的原子开销显现
“块数 = 线程数”的动态分配等于没有动态分配(还是 12 块,只是发牌顺序变了)。甜点在 20 倍线程数左右,再碎就要付原子竞争的利息。这条留到本专栏第六节《任务队列与线程池》继续算。
五、测量本身有坑:本节踩到的四个
写这一节时我推翻了三个自己的初步结论,都源于测量工具的语义:
clock()不是 CPU 时间。MinGW/MSVCRT 的clock()增量跟着墙钟走:Sleep(100)期间主线程什么都没算,clock()照样走了 115ms(墙钟 115.0ms)。用它统计并行度,会得出”8 线程并行效率 600%“这种荒唐数。测 CPU 时间要用GetThreadTimes/GetProcessTimes。GetThreadTimes对短线程返回 0。第一版每线程只跑 2–3ms,读回来的 CPU 时间不是 0 就是全进程的历史累计,7 个配置里 5 个给出 0ms。把工作单位放大到 ~100ms 后数据才稳定。任何”多线程总 CPU 时间”的测量,先确认单个线程活够久。GetProcessTimes的结算时机:线程的时间要在线程退出时才汇入进程值。我一度在 8 个 2ms 的线程退出前取样,读到 109.4ms 的无厘头数字。- 绑核实验会被睿频干扰。D 组(两个不同物理核)单次最低值只有 457.7 M iter/s,最高 1732.4——差 3.8 倍,因为有别的进程在抢核。所以我改成每组 5 次取中位数并把区间一起写出来,读者才能判断这个数能不能拿来比。
六、小结
- 并发是逻辑结构,并行是物理事实。单线程可以并发(重叠等待),不能并行。
- 线程共享整个地址空间(实测
&g_data五个线程一模一样),只隔离栈/TLS/寄存器/调度属性;进程连虚拟地址都一样,内容却是各自的副本。 - 单价表记住三个数:原子操作 5ns、一次唤醒交接 11µs、一次自旋交接 61ns。要不要阻塞,就看你等的时间有没有跨过这个数量级。
- 线程数不是越多越好,也不是等于核数就好:本机 8 线程最优(4.66x),12 线程(等于逻辑处理器数)反而回落到 4.09x;20 线程抢 12 个逻辑口吞吐再降 11%。
- 加速比达不到核数,一半原因是频率墙(6 核只有 4.38x),另一半是每线程自己变慢 15–22%(共享缓存/带宽/SMT),这两项都跟锁无关。
- 选线程还是进程,看两点:要不要崩溃隔离、要不要频繁创建。要隔离又要快,就上常驻进程池,别指望 Windows 上有
fork()——MinGW 连符号都没有。