📚 本文收录于「流浪」的系列专栏
| 🐧Linux系统 | ⚙️C++ |
| 📊数据结构与算法 | 🐍Python |
| 🔗LangChain & LangGraph | 🗄️MySQL 数据库 |
| 🌿Git 工具 | 🌐计算机网络 |
| 🤖AI | 💯大厂面试、八股 |
| 📚学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言:篇36 讲线程概念时,留了两个坑:私有清单怎么来的、切换开销小为什么。篇37 拆完页表,CR3 和 TLB 也都认识了。线程(三)回来算账——线程值不值得用、要用多少、切换到底便宜在哪、私有的到底是什么。优缺点起手,单进程收尾。
一、线程的优点和缺点
1.1 优点四条
先说结论:线程的便宜,全部便宜在「共享」两个字上。清单收成四条,每条讲清为什么。
1 创建代价小
进程创建要搭一整套家当:地址空间、页表、文件描述符表,样样从头来。线程呢?地址空间是现成的,只是在里面多登记一条执行流。
一条线程的诞生,实际就三步:
- glibc 先取或建一个线程控制块
- 再用mmap 映射一块栈给它
- 最后带着「共享地址空间」的标志调clone陷入内核,内核建一个轻量级进程(LWP)——篇36 讲过的那套,这里全用上了
三步里没有一步在「复制整个进程」。
fork 的写时拷贝,救的是物理页那一层——页面上先共享,写了再复制。但页表本身、mm_struct、文件描述符表这些管理结构的副本,fork 还是一套一套建。线程连这些都不建,指针共享,一步到位。
对照着说:fork 出的子进程拿到的是自己的一份家当,哪怕按需复制;线程拿到的是原家的钥匙——住的是同一个屋子。
2 切换代价小
进程切换连地址空间一起换,线程切换地址空间都不碰。
3占用资源少
线程不另起炉灶,私有的只有栈、寄存器那几样(篇36 对比表列过)。同样是「多干活」,多开线程比多开进程轻得多——一个子线程的主要开销就是一块栈,后面第四章会给它的具体尺寸。
算全一点:每个执行流还有内核里的 task_struct 和配套的内核栈——这是固定开销,但和整套进程家当比,还是轻一个量级。
3 真并行,还能 IO 与计算重叠
多个线程分布到多个核上,是物理意义上的同时跑;一个线程等 IO 的时候,别的线程的计算照常推进,CPU 不空转。篇36 那个下载器的例子放到这里就通了:收数据的线程阻塞在网络上,刷进度条的线程照常在算。
1.2 缺点四条
代价也得摆清楚,还是四条——而且每一条的根源同样是共享:
1 性能损失
线程开得比核数还多,多出来的不产生算力,只产生排队和切换开销。而且排队的线程越多,切换越频繁,开销雪上加霜。
2 健壮性降低
一个线程崩,整个进程跟着崩。除零、野指针触发信号,信号终止的是进程,所有线程陪葬——信号那套递达机制,信号系列已经讲透了。篇36 讲过线程没有隔离性,那一条的代价面就在这里。
举个具体场面:子线程除零,SIGFPE 的默认动作是终止进程——不是终止那个线程。崩溃的锅是一个线程的,代价是全家买单。多进程模型里崩一个进程,兄弟进程毫发无损,这就是健壮性差距的直观版。
3 缺乏访问控制
进程之间有隔离墙,谁也进不了谁的地盘;线程之间地址空间全共享,没有权限边界可言——全局变量谁都能改,别人的堆谁都能踩。方便和危险是同一件事的两面。
一个线程把全局链表头改坏,所有用它的线程一起完蛋——共享让「别人的 bug」变成「自己的 bug」。
3 编程难度
难不在写代码,难在共享带来的时序问题。最典型的:两个线程同时对同一个计数器减一,「读出来、减一、写回去」三步之间随时可能被切走,两个线程各减一次,结果可能只减了一次。这种竞态怎么防,是线程同步要解决的问题,先把问题的根子记在这里。
还有一层难在排查。线程 A 数组越界,踩坏的是整个进程的堆——事后崩的可能是不相干的线程 B,案发现场和作案现场隔着十万八千里。共享内存的程序难调,这是经典原因。
1.3 用途,什么场景值得用线程
一句话:提高 CPU 密集型任务的效率,提高 IO 密集型任务的体验。
1 CPU密集型
CPU 密集型的场景,比如视频编码、加解密、批量计算——多线程把任务拆到多个核上并行。单线程只能占一个核,四核机器上有三个空座;线程把核填满,四核理论上就能快接近四倍。
2 IO密集型
IO 密集型的场景,比如爬虫、服务器、数据库访问——多线程让「等网络」和「算业务」重叠起来,CPU 不空转,体验和吞吐一起上去。
对照一下:单线程想把 IO 的等待时间填满,只能走事件驱动那套异步机制;多线程是最直白的解法——C++ 标准库都给到了 std::thread,语言层面就站在这条路线上。
线程不是万能药——它的好处和坏处都来自共享,1.2 那四条代价别忘了。值不值,看完这一篇自己就能算。
这两类场景也直接决定了线程怎么开——往下看。
二、线程并不是越多越好
2.1 计算密集型,线程数贴着核数走
计算密集型:任务的绝大部分时间在与 CPU 打交道——加解密、压缩、纯数值计算,几乎不等 IO。
判断一个任务是不是计算型,就看它等待 IO 的时间占比——占比趋近于零,就是计算型。
这种任务,线程数贴着 CPU 核数走。拿 4 核举例:同一时刻最多 4 个线程真正在算。开 8 个纯计算线程,多出来的 4 个不产生任何算力,只产生排队和切换——账面更忙,结果更慢。
通行经验是核数 +1:多出的那一个,专门顶某个线程偶尔缺页、短暂阻塞的空档,让核不至于闲着。
注意 +1 是保险,不是乘数——偶发阻塞是意外;要是常态阻塞,说明任务根本是 IO 型。道理记一条:计算型线程的天花板是核数。
2.2 IO 密集型,可以适当多开
IO 密集型:线程大部分时间在等磁盘、等网络——阻塞着,不占 CPU。
判断方法对称:等待占比高,就是 IO 型。同一套账,两个方向。
给个直觉账:一个请求 200ms 里 180ms 在等数据库、只有 20ms 真在算,单线程的 CPU 利用率就是一成。等待占比九成,意味着每 10ms 只有 1ms 在算——线程在这里就是「填空器」。
开上十来个这样的线程,一个线程等 IO 的空档全被别人的计算填上,CPU 才算吃饱。
这个账还能再算实一步:100 个线程、每个计算占比一成,任意时刻「同时在算」的就约 10 个——4 核的机器会有轻量排队。想把核喂饱又不至于排得太长,线程数就围绕这个比例去调。
当然不是无限多。每个线程一块 8MB 的栈预留(4.2 给尺寸),乘上线程数就是实打实的内存压力;线程本身还有调度成本,多开一份就多一份账单。
| 任务类型 | 线程大部分时间在 | 线程数怎么定 |
|---|---|---|
| 计算密集型 | 占着 CPU 算 | ≈ 核数(通行经验 +1) |
| IO 密集型 | 阻塞等 IO | 适当多开,等待占比越高开得越多 |
2.3 两个反例
反例一:4 核机器跑 100 个计算线程。每个线程拿到的 CPU 份额被摊薄,时间片变小、切换变频繁;切换一次,cache 和 TLB 就被冲刷一次——越忙越慢,越慢越忙。算力没多一瓦,开销先翻倍。
反例二:IO 服务只开 1 个线程。九成时间在等网络,CPU 长期空转,请求排队排到天荒地老——等待时间没被用起来,机器是白的。
反例二的正确姿势也不是加线程就完事——等 IO 的时间本身能不能省(连接池、批量请求)是另一个优化方向。线程数解决「填空」,解决不了「空太多」。
一个吃满核靠并行,一个喂饱核靠填空,方向完全相反——线程数不存在万能数字,只存在贴合场景的数字。完整版是:线程数跟着场景走——计算型贴核数,IO 型看等待占比。
三、线程切换比进程切换,便宜在哪
3.1 切换切的是上下文
上下文切换这个词,篇12 讲进程切换时展开过:时钟中断进来,内核保存当前执行流的寄存器现场,恢复下一个执行流的现场,接着跑。线程时代,切的对象换成了 task_struct——骨架一模一样,省的东西不一样。
先澄清一个常见误解:线程切换照样要进内核——调度器在内核里,保存现场、挑选下一个执行流都是内核的活。线程切换省的不是「进内核」这一步,省的是进内核之后要换的东西少。
所以准确的说法是:线程切换和进程切换都要进内核,都要保存恢复寄存器——差距全部在「之后」。
要换的东西,先数一遍:通用寄存器、程序计数器、状态寄存器、浮点和向量寄存器——这一套执行现场几十个寄存器,进程和线程都要保存恢复,谁也免不了。差距在后面两层。
这几十个寄存器里最要紧的是程序计数器——它决定「从哪继续跑」,其余寄存器决定「带着什么状态跑」。
内核视角再看一眼调度队列:排队的全是 task_struct,线程和进程在这里长得一样。切换时内核做一个判断——下一个 task_struct 的 mm 指针和我相同吗?相同(同进程线程),地址空间整套跳过;不同(跨进程),才走换 CR3 那一串。线程切换的便宜,就落在这个判断上。
这个判断便宜到什么程度——一次指针比较。但它背后省掉的,是整套地址空间切换的连锁动作。
3.2 地址空间不用换
第一层差距在地址空间。进程切换必须换页表——CR3 一写,整套映射体系全换(篇37 讲过,换 CR3 还会连带翻译缓存作废)。
写 CR3 是一条指令的事,但作废的是成千上万条 TLB 缓存——一条指令的代价,不在指令本身。
同进程的线程共享 mm_struct、共享页表,CR3 根本不动。CR3 不动,TLB 就不用作废——地址翻译的缓存原样有效,切过去的线程接着用,一页都不用重查。
为什么 CR3 一动 TLB 就得作废——TLB 缓存的是「虚拟页号到物理页框」的翻译结果,它的有效前提是页表没换过;页表一换,旧翻译全部失去信用,只能清掉重来。
现代 CPU 的 PCID 能给进程切换的 TLB 全刷缓解一点——PCID 给 TLB 条目打上地址空间编号,换进程时可以不清、靠编号区分。但它救不了换地址空间要重新热 cache 这件事——差距依然在。
3.3 cache 还是热的
第二层差距在cache。线程 A 刚执行过的代码、刚碰过的数据,和线程 B 用的是同一套地址空间——B 上场时,这些内容大概率还留在 cache 里。
直接命中,不用去内存重新搬——这就是省的部分。
能接住这件事的另一个帮手是局部性原理(篇37 讲低 12 位时用过):程序刚访问过的地址,很快还会再访问。局部性有两副面孔——刚访问过的地址很快还会再来,是时间局部性;刚访问过的地址旁边很快被摸到,是空间局部性。线程切换的短间隔,正好让时间局部性把热数据接住。
进程切换就完全是另一番景象:地址空间整个换掉,旧 cache 内容对新进程基本作废,新进程头一段时间的访存都在往 cache 里重新灌数据——冷启动。
量级上感受一下:cache 命中的访问和访问内存,差着一到两个数量级;冷启动错过的每一笔,都在付这个差距——切换一频繁,差距直接吃进总时间。
更细一层:进程切换后的头一小段时间,TLB miss 和 cache miss 会集中爆发——旧翻译全作废,热数据全不在。这段窗口就是换地址空间交的税,同进程线程切换一分不交。
多核上还有一层硬件兜底:线程分布在不同核,各自有各自的 cache,同一地址的修改靠硬件一致性协议对齐,对程序透明。但协议对齐的是单次访问,两步操作之间的中间态它不负责——count-- 那种竞态,依然要在软件层解决。
补一句严谨的:cache 热这个优势的前提是两个线程干的活相近、用的数据相近;两个线程八竿子打不着,cache 照样互相冲刷。便宜的基础是共享,不是切换本身。
3.4 一笔完整的账
把两层差距并成一张表,进程切换和线程切换的账目一目了然:
| 动作 | 进程切换 | 同进程线程切换 |
|---|---|---|
| 保存/恢复寄存器现场 | 要 | 要(一样不少) |
| 换页表(写 CR3) | 要 | 不用 |
| TLB 作废 | 要(PCID 可缓解) | 不用 |
| cache | 大面积冷 | 基本还热 |
| 换 fd 表、信号处理表等资源映射 | 随地址空间一起换 | 共享,不用换 |
结论一句话:寄存器现场谁都躲不掉,线程切换省掉的是它后面那一串。
下次有人问线程切换为什么便宜,按这张表从上往下数——第一行谁都躲不掉,往下每一行都是省出来的。
四、线程私有的到底是什么
篇36 列过私有清单:栈、上下文、线程 ID 这几样,还留了句话——清单怎么来的?
4.1 从「调度单位」推出来的寄存器
先立一个视角:线程是一个动态的概念。task_struct、地址空间、代码,躺在那里不动,都是静态的描述;线程是正在被调度执行的那条控制序列——它「活着」,是因为调度器在保存它、恢复它、运行它。
进程的视角是「资源怎么分」,线程的视角是「活怎么干」——分配资源的时候想进程,指派任务的时候想线程。两个词各管一半,单进程是两半的重合。
篇36 的结论:线程是调度的基本单位。调度的基本单位,就是被保存和恢复的基本单位——每个线程执行到哪条指令、寄存器里是什么值,各不相同。所以一组寄存器(执行现场)必须私有。私有清单的第一样,从「调度」两个字直接推出来。
推到底层也说得通:调度器保存恢复的就是寄存器组,而这些现场在内核里就挂在执行流自己的 task_struct 上——私有的本质,是这些字段本来就在线程自己的结构里。
4.2 独立的栈,主线程和子线程还不一样
第二样是栈。线程的执行体现为一条条函数调用链:返回地址、局部变量、被调用方要用的寄存器保存区,全压在栈上。
两条执行流共用一个栈,两串调用链会互相覆盖。而且递归深的线程会把栈撑得很大,共用的另一条直接遭殃。所以工作台必须独享,即栈必须一人一份
Linux 里,主线程和子线程的栈来源还不一样:
- 主线程栈:进程一创建就有,在地址空间的栈区,按需增长,涨到上限为止
- 子线程栈:由 pthread 库用 mmap 预先映射好一块,大小预先固定——glibc 默认 8MB(受 ulimit -s 影响),用尽即尽,不能动态增长
为什么子线程栈要预先固定?因为库要在虚拟地址空间里给它挑一个确定的位置,mmap 一次到位——它没有内核那种「按需加页」的待遇。
位置上也有讲究:多个子线程的栈由库在 mmap 区域挨个安排,彼此之间还隔着 guard page——一家着火,不至于烧到隔壁的栈。
子线程栈的末尾还垫着一页guard page(守护页),权限设成不可读写。栈溢出一碰到它,立刻段错误——宁可当场崩,也不让越界写悄悄弄脏别的内存。
8MB 是虚拟地址空间的一次性预留,真用到才分配物理页(篇37 讲过的按需分配)。但它乘上线程数就不再是小数——线程不是免费的,这是「不是越多越好」的又一层原因。
顺带一个细节:线程退出后,它的栈不会立刻还给内核——glibc 会把这块栈缓存起来,下一个新线程直接复用,省掉反复 mmap 的开销。栈是线程最贵的家当,库替你省着用。
4.3 errno、信号屏蔽字、调度优先级
篇36 那份清单没点全,剩下三样这里补齐,各一句话:
- errno:库函数出错不设全局 errno,错误码走返回值——如果 errno 是全局的,一个线程刚查完就被另一个线程覆盖,错误码全乱套,所以它必须线程私有
- 信号屏蔽字:每个线程可以各自屏蔽不同的信号,A 屏蔽了不代表 B 也屏蔽
- 调度优先级:调度按执行流给,优先级是线程自己的属性
errno 再拆开看一眼:glibc 里它根本不是全局变量,宏展开是一次函数调用,返回当前线程自己那份错误码的地址。每线程一份,靠的机制叫线程局部存储——errno 是这套「每线程一份」机制的招牌应用。
信号屏蔽字也展开一点:信号的处理方式是进程一份、全体共享;屏蔽字是线程一份、各自设置——一全局一私有,正好对上 4.4 那张表的划分。同一个信号,线程 A 屏蔽了、线程 B 没屏蔽,它照样能打断 B——屏蔽是各人的伞,不是全家的伞。
调度优先级为什么跟线程走——3.1 说过,调度队列里排的全是 task_struct,优先级是排队时的权重,自然属于执行流自己。
再加上线程 ID(篇36 讲过的 TID),私有清单齐了。每一样的「为什么」,都能从「线程是调度的基本单位」推出来——这就是篇36 留的坑的答案。
4.4 共享与私有,一张表收拢
私有的五样之外,全是共享的。把两侧并成一张表:
| 归属 | 内容 |
|---|---|
| 共享(跟着进程走) | 地址空间(代码、全局数据、堆)、页表、文件描述符表、信号处理方式、当前工作目录、用户 ID |
| 私有(跟着执行流走) | 一组寄存器(现场)、栈、errno、信号屏蔽字、调度优先级、线程 ID |
判断标准就一条:跟着执行流走的必须私有,跟着容器走的全部共享。
fd 表为什么归在共享侧——fd 是「进程打开的文件」的编号,跟着容器走。一个线程 accept 返回的 fd,另一个线程拿来直接读写,毫无障碍——线程写服务器的根基就在这。
进程之间 fd 各归各的表,想共享得走专门的传递机制;线程这里一个整数就够。
拿 errno 和 fd 表把判断标准自测一遍:errno 跟着执行流的报错走,私有;fd 表跟着容器走,共享——两秒钟判断完,清单不用背。
五、单进程就是只有一个执行流的进程
最后收个尾巴,两句看似平淡的话。
一切进程至少有一个执行线程。进程是资源容器,容器里总得有一条执行流在跑代码——没有执行流的容器,就是个躺在那不动的空壳。
另一面也成立:最后一条线程退出,进程就结束——exit 那一刻,走的是容器里最后那条流。
这也解释了「主线程」是什么:进程创建时自带的那条执行流,main 函数就跑在它的栈上(4.2 说的主线程栈)。
也就是说,从学 C 语言写下的第一行 main 开始,你就一直跑在一条线程上——线程不是新东西,是你一直在用的东西的正式名字。
单进程,就是只有一个执行流的进程。它是线程数等于 1 的特例:容器没变,只是里面只跑一条流。多线程进程,不过是往同一个容器里多加了执行流。
到这里,线程和进程缝上了。Linux 里每个执行流都是一个 task_struct(篇36 讲的 LWP)——「进程」和「线程」在这套模型里本来就是同一种东西的不同叫法,单进程就是两者之间的桥。
篇36 讲过,Linux 不给线程单独设计结构,直接用进程的 task_struct 模拟。当时是当实现选择讲的;现在能看清更深一层——容器和执行流本来就是两个正交的维度:资源指针共享就是同容器,调度实体独立就是各执行流,一个结构把两个维度全装下了。
单进程,就是两个维度重合的特例。
编号上也有印证:单线程进程的 TGID 和 PID 相等(篇36 讲过的那组编号)——组里只有一号,就是它自己。多线程进程,组里才有了二三四号。
这个视角落到写代码上就是选型直觉:要隔离、要稳定,多进程,崩一个不连坐;要共享、要效率,多线程——把第二章的账和这里的账合起来看。第二章管「开多少」,这里管「要不要开」。
还有一个角度是通信成本:多进程交换数据得走通信系列那一整套 IPC;多线程读的是同一块内存,变量赋值就完事。共享是线程的天然红利,也是它的天然风险——怎么防住风险,线程同步后面专门处理。
六、全篇总结
四笔账收拢:
- 值不值得用:优点(创建/切换代价小、占用资源少、真并行、IO 与计算重叠)对缺点(性能损失、健壮性差、缺乏访问控制、编程难度高)——共享带来效率,也带走隔离,优缺点同一个根源,count-- 那三步就是风险标本
- 用多少:计算密集型贴核数(通行经验 +1),IO 密集型看等待占比适当多开——越多越好是误会,8MB 的栈乘上线程数也不是小数
- 切换为什么便宜:寄存器现场谁都躲不掉,省的是换页表(CR3 不动、TLB 不作废)和 cache 冷启动——内核里一个 mm 指针的判断就见分晓,便宜在共享
- 私有的是什么:寄存器、栈、errno、信号屏蔽字、调度优先级、线程 ID——全从「线程是调度的基本单位」推出来;子线程栈 mmap 预固定 8MB,guard page 兜底
单进程收尾:进程是容器,执行流至少一条。这篇没有一行代码,但每一条结论都能在下一篇找到着落——pthread_create 怎么把执行流造出来、怎么等它、怎么终止它,一一兑现。
七、文末面试题
7.1 推导题(按本讲知识点,附答案)
先自己想,再看答案——答案都用本章的账推,不引入新知识。
【推导】线程的优点和缺点各有哪些?
答:优点——创建/切换代价小、占用资源少、可真并行、IO 与计算重叠;缺点——线程过多有性能损失、健壮性差(一个线程异常整个进程退出)、缺乏访问控制、编程难度高。优缺点根源相同:共享地址空间。【推导】线程私有的资源有哪些?为什么是这些?
答:一组寄存器(调度单位要保存/恢复现场)、独立的栈(函数调用链私有)、errno、信号屏蔽字、调度优先级(各线程可各自设置)。【推导】线程共享的资源有哪些?
答:地址空间(代码、全局数据、堆)、页表、文件描述符表、信号处理方式、当前工作目录、用户 ID——跟着容器走的全部共享。判断标准:跟执行流走的私有,跟容器走的共享。【推导】主线程栈和子线程栈有什么区别?
答:主线程栈在栈区、按需增长有上限;子线程栈由 pthread 库 mmap 预先固定(glibc 默认 8MB、受 ulimit -s 影响)、不能动态增长,末尾 guard page 让溢出当场段错误。【推导】为什么说一个进程至少有一个线程?
答:进程是资源容器,代码总要有一条执行流来跑;主线程是进程自带的,单进程就是「线程数=1」的特例,多线程只是往同一容器里加执行流。【推导】为什么线程创建比进程创建快得多?
答:进程要建地址空间、页表、fd 表一整套家当;线程的地址空间现成,只需建控制块、mmap 一块栈、带共享标志调 clone——不复制任何「家当」。【推导】为什么一个线程出异常,整个进程都会退出?
答:异常触发信号(如除零的 SIGFPE、野指针的 SIGSEGV),信号处理主体是进程,默认动作终止整个进程,所有线程陪葬——地址空间共享,没有隔离墙可挡。【推导】线程和进程怎么选?
答:要隔离、要稳定选多进程,崩一个不连坐,代价是创建/切换/通信开销大;要共享、要效率选多线程,通信零成本,代价是没有隔离、要靠同步互斥兜底。IO 与计算重叠、计算并行化是线程的典型收益场景。【推导】子线程的栈为什么不能像主线程栈那样按需增长?
答:主线程栈由内核按缺页机制增长;子线程栈是 pthread 库用 mmap 一次映射到位,库要在地址空间里给它挑定位置、垫好 guard page,依赖不了内核按需加页——8MB 是虚拟预留,真用到才给物理页。【推导】「线程是一个动态的概念」怎么理解?
答:task_struct、地址空间、代码都是静态的描述;线程是正在被调度执行的那条控制序列,它的「活着」体现为调度器对它保存、恢复、运行。私有清单全部从「动态、被调度」这个属性推出来。
7.2 真题(来源已核实,转述注明)
合适的线程数量是多少?CPU 核心数和线程数的关系?
答:CPU 密集型 ≈ 核数(通行经验核数+1,防某个线程偶发阻塞时 CPU 空转),开多了只剩切换开销反而变慢;IO 密集型可远大于核数(等待时不占 CPU),通行公式 Ncpu×(1+等待时间/计算时间),工程上最终靠压测定。
【真题·转述自 51CTO 博客《面试题:合适的线程数量是多少?CPU 核心数和线程数的关系?》(题库型,未标注具体公司)】多线程一定比单线程快吗?
答:不一定。IO 密集型任务,多线程能把等待时间填上,通常更快;CPU 密集型任务,线程数超过核数后只剩切换开销,反而更慢;多线程共享数据还要加锁,锁竞争本身也是成本。先分清任务是计算型还是 IO 型,再谈快不快。
【真题·转述自 CSDN 博客《吃透进程与线程:从概念到实战,破解并发编程核心难题》
💬 优缺点的账、线程数的账、切换的账、私有的账——四笔账算完,线程的性价比心里有数了。单进程那句话,把进程和线程缝成了一种东西的两个名字。你之前写的代码里踩过哪个坑,评论区聊聊。觉得有收获,点个赞再走。