1. 进程和线程到底在解决什么问题——一个并发下载工具的“翻车”现场
前几天带一位实习生调一个批量下载工具,他用了Python的threading写了并发下载,跑起来CPU占用率一直上不去,网络也忽快忽慢,最要命的是偶尔还会下载出损坏的文件。查了半天代码没发现问题,最后发现是他对“进程”和“线程”这两个基本概念的边界没吃透,在需要隔离任务资源的地方用了共享内存,又在需要共享数据的地方起了多个进程,两头都别扭。
这就是“第8天“讲进程和线程时最值得先想清楚的问题:这两个东西到底是为了解决什么才出现的?答案是并发,但并发不是”同时开几个循环“那么简单,它背后涉及三件事——任务的并行执行、资源的隔离与共享、以及系统如何调度有限的计算资源。
随便打开一台运行中的电脑,操作系统里同时跑着几百上千个任务:浏览器在渲染页面,音乐软件在解码音频,编辑器在后台索引文件,这些任务各自需要独立的地址空间,一个崩溃了不应该把整个系统带走;但在一个任务内部,比如浏览器里同时加载几十个标签页,你又希望它们共享缓存、共享cookie、共享进程的公共资源,而不是每个标签页都开一个完整进程。
这就引出了两个不同粒度的设计:进程是资源分配的最小单位,线程是CPU调度的最小单位。进程像一整栋公寓楼,有自己的墙、自己的水电表;线程像楼里的房间住户,共享楼道、水电基础设施,但每户有自己的卧室和卫生间。如果你不理解“为什么需要进程”和“为什么还需要线程”,后面学锁、学同步、学调度、学IO模型都会是空中楼阁。
这一篇我按第8天的学习主线来写:先讲进程的全套家底,再讲线程与进程的差异,然后钻进调度器内部看时间片轮转的真实场景,最后聊并发编程里最容易踩的竞态条件和死锁问题,并附一个可以直接动手做的小作业。适合正在学操作系统的学生、刚接触服务端开发的新人,也适合那些“会用多线程但总出神秘Bug”的开发者。
2. 进程的“家底”:进程控制块、地址空间与状态流转
2.1 一个进程在系统里到底长什么样
操作系统要管理进程,手里必须有一张“信息卡”,这张卡叫进程控制块(PCB,Process Control Block)。你在教科书上看到的所有关于进程的描述,什么PID、状态、优先级、上下文数据、打开的文件列表、内存界限,全部存在这张卡里。操作系统不是在管理“正在运行的代码”,而是在管理这一张张卡,代码本身是静态的,躺在外存里,进程是动态的,是“加载到内存、登记到PCB、被调度器选中执行”之后才产生的。
PCB里最关键的几个字段我列一下:
- 进程标识符(PID):唯一标识一个进程,
ps命令看到的数字就是它。 - 进程状态:就绪、运行、阻塞、新建、终止,调度器全靠它做决策。
- CPU上下文:寄存器组的值、程序计数器、栈指针。进程被换下CPU时,这些值必须完整保存,下一次调度回来时才能无缝接上。
- 内存管理信息:代码段、数据段、堆栈段的基址和界限,页表指针。
- 资源清单:打开的文件描述符表、信号处理函数表、挂起的信号集合。
- 父子关系:父进程PID、子进程链表,这是
fork之后进程树能维护起来的基础。
如果你用ps -elf看一个正在运行的系统,会看到TTY列、STAT列、PID和PPID,这些字段绝大部分都来自PCB。
2.2 进程的地址空间:为什么进程之间不会互相踩到脚
每个进程都拥有自己独立的虚拟地址空间,这是进程隔离的根基。在32位系统上,用户空间通常占0到3GB,内核空间占3GB到4GB;在64位系统上,用户空间和内核空间各占一半左右(具体比例取决于配置)。进程看到的“地址”并不是物理内存的真实地址,而是虚拟地址,由CPU的MMU(内存管理单元)配合页表完成翻译。
这样设计带来的好处非常实际:一个进程因为野指针崩溃,段错误只发生在它自己的虚拟地址空间里,不会写坏别的进程的内存;一个进程读取属于自己的内存,也读不到别的进程的数据。进程间通信(IPC)所以麻烦,正是因为这种隔离天生就把“数据共享”的捷径封死了——要么用管道、要么用消息队列、共享内存、信号量,每种方案背后都有一层“跨越隔离墙”的代价。
我很多时候喜欢用一个比喻:进程像独立的单间公寓,有独立的门锁,邻居之间不能直接看到对方的家具。你要是想递东西给隔壁,得通过公共走廊(管道)、物业信箱(消息队列),或者干脆买一套两个房间打通的房子(共享内存),代价就是失去了隐私隔离。
2.3 状态转换里藏着“CPU为什么空转”的答案
进程从诞生到结束,会经历一系列状态变化:新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)、终止(Terminated)。几个状态之间的转换条件,是理解系统性能问题的关键:
- 就绪 -> 运行:调度器从就绪队列里挑一个进程上CPU。
- 运行 -> 阻塞:进程发起I/O操作(读磁盘、读网络)或等待某个事件,主动让出CPU。
- 运行 -> 就绪:时间片耗尽,或者被更高优先级的进程抢占。
- 阻塞 -> 就绪:I/O完成或等待的事件到达,进程回到就绪队列排队。
很多做后端开发的人问“为什么我的服务CPU不高但吞吐量上不去”,答案往往藏在第二类转换里:大量线程在等数据库I/O、等网络响应、等锁,Healthy状态看起来很多,但实际上全部处于阻塞状态,CPU当然利用不起来。反过来,如果你发现CPU一百个核全部打满,程序还是慢,那可能是计算密集任务被错误地拆分成了大量阻塞等待,或者干脆就是调度策略没匹配上负载特征。
2.4 实操验证:用系统命令观察“进程的家底”
纸上谈兵没用,开了终端直接看。在Linux系统里,ps -eo pid,ppid,stat,comm可以查看进程的基本状态与父子关系;top -H -p <PID>可以查看某个进程内部所有线程的CPU占用;pstree -p可以用树状视图查看进程的家族关系。
用一个简单的操作就能亲身感受“状态转换”:起一个sleep 100的进程,第一次用ps看它的状态,通常是S(可中断睡眠,也就是阻塞态),因为它在sleep等待超时事件,根本没占用CPU;然后对一个死循环程序执行同样操作,看到的状态往往是R(运行态或就绪态),在单核CPU的机器上它会把一个核吃满。这个对照实验非常简单,但对理解“阻塞态与运行态”的区别特别有效。
还需要注意僵尸进程(Z)和不可中断睡眠(D):僵尸进程是子进程已经终止,但父进程还没调用waitpid回收其PCB信息,所以它“死而不僵”,占着PCB不释放;D状态通常表示进程在内核态执行不可中断的I/O等待,不能轻易被杀掉,如果大量任务卡在D状态,大概率是底层存储出了问题。这两类状态在排查线上故障时几乎每天都要打交道。
3. 线程的实现方式与开销对比:用户态、内核态以及被误解的GIL
3.1 线程到底“轻”在哪
线程是进程内部的一条执行流。同一进程内的多个线程共享代码段、数据段、堆内存和打开的文件,只拥有自己独立的栈、寄存器上下文和程序计数器。正因为大部分资源是共享的,创建线程比创建进程便宜得多:进程创建要复制或写时复制地址空间、初始化页表、建立文件描述符表;线程创建只需要分配一个栈和一套上下文,通常快一个数量级。
进程和线程的取舍,我用一个表格帮你整理,这也是我教新人的第一份速查卡:
| 对比项 | 进程 | 线程 |
|---|---|---|
| 资源拥有的粒度 | 独立的地址空间、文件表、信号表 | 同进程内共享地址空间和文件表 |
| 创建/销毁开销 | 高,涉及内存空间复制与页表初始化 | 低,只分配栈与上下文 |
| 通信方式 | 进程间通信(管道、消息队列、共享内存等) | 直接读写共享变量(但要加锁) |
| 隔离性 | 强,一个进程崩溃不影响其他进程 | 弱,一个线程越界可能拖垮整个进程 |
| CPU调度单位 | 进程本身作为调度单位(传统模型) | 线程作为调度单位,进程成为资源容器 |
| 适用典型场景 | 多租户隔离、不可信插件、计算密集分布式任务 | IO密集型并发、高度共享数据的并发模型 |
这个表格值得收藏。真正开发的时候,你需要先问自己一个问题:这些任务之间要共享大量数据吗?如果要共享,线程大概率更合适;如果任务之间独立性强、崩溃隔离要求高、甚至需要部署到不同机器上,进程就是更好的选择。
3.2 三种线程模型,得能分清楚
教科书上把线程模型分为三层:用户级线程(ULT)、内核级线程(KLT)、混合模型。这三者的本质区别在于“线程的创建、调度、切换”是由谁完成的。
用户级线程完全在用户空间由线程库管理,内核完全感觉不到线程的存在,它只看到一个进程。好处是切换不需要陷入内核,特别快;坏处是一个线程发起阻塞的系统调用,如果线程库没有做好的调度,可能整个进程都被阻塞。Go语言的goroutine可以粗略理解成一种由运行时管理的用户级线程(但底层运行时还会把一批goroutine映射到若干内核线程上,属于混合模型)。
内核级线程由操作系统内核直接调度,每个线程都有独立的内核栈和上下文,阻塞一个线程不会影响同一进程的其他内核线程。代价是每次切换都要跨越用户态与内核态边界,开销大。Linux的pthread库创建出来的线程,默认就是内核级线程(实际映射到内核线程)。
混合模型结合两者:用户级线程负责高并发逻辑,内核线程负责真正跑在CPU上,用户线程与内核线程通过多路复用映射。像Go、Java的虚拟线程(Project Loom)、Erlang的进程,本质上都是混合模型的实践。面试的时候能把这三层讲清楚,比背再多的八股文都有说服力。
3.3 Python的GIL:为什么多线程常常“并发但并行不了”
提到Python多线程就绕不开GIL(全局解释器锁)。CPython解释器为了保证对共享内存对象操作的原子性,设置了GIL,一个进程内同一时刻只能有一个线程在执行Python字节码。多线程在CPU密集型计算场景下几乎不提升性能,有时甚至更差,因为线程切换本身有开销;但在IO密集型场景下效果显著,因为IO等待时线程会释放GIL,另一个线程可以继续执行。
GIL不是Python语言本身的属性,而是CPython解释器的实现细节。Jython、IronPython没有GIL;Python的multiprocessing模块本质上通过创建多个进程绕开GIL达到并行。如果你用Python做计算密集型任务,优先考虑multiprocessing或调用C扩展;做网络爬虫、消息处理这类IO密集型任务,threading、asyncio都是合理选择。
我见过太多人一写Python并发就上threading,跑了8个线程结果计算量翻倍性能反而下降。这里有一个判断口诀:CPU密集看并行,IO密集靠并发。前者要进程要并行,后者要线程要事件驱动,方向搞反了,后面全是坑。
3.4 一个小实验:亲眼看到线程共享与进程隔离
自己动手验证一次,比看十篇文章都管用。用Python写一个例子:
import threading import multiprocessing shared_list = [] def append_item(value): for _ in range(1000): shared_list.append(value) # 线程版:两个线程改同一个列表 t1 = threading.Thread(target=append_item, args=("A",)) t2 = threading.Thread(target=append_item, args=("B",)) t1.start() t2.start() t1.join() t2.join() print("threads result len:", len(shared_list)) # 进程版:两个进程各自有一份列表 def process_append(value): shared_list.append(value) print("process sees list:", shared_list) if __name__ == "__main__": p1 = multiprocessing.Process(target=process_append, args=("A",)) p2 = multiprocessing.Process(target=process_append, args=("B",)) p1.start() p2.start() p1.join() p2.join()运行结果中,线程版的两个线程操作的是同一个shared_list,最终长度接近2000;进程版的每个进程看到的是一个独立的列表,打印出来的内容各自只有一个元素。这个实验直观展示了共享与隔离的差异,也暗示了一个问题:共享虽然省事,但并发修改同一份数据时,不做同步控制,最终长度很可能不是2000,而是小于2000——这就是下一节要讲的竞态条件。
4. 调度器的工作现场:时间片耗尽之后发生了什么
4.1 多级反馈队列:教科书之外的真实调度模型
调度器是所有进程和线程能否“公平、高效”运行的裁判。现代操作系统的调度算法普遍采用多级反馈队列(MLFQ),思路很简单:设置多个不同优先级、不同时间片的队列,高优先级队列时间片短,低优先级队列时间片长;新任务进入最高优先级队列;任务在给定时间片内没执行完,就降级到下一级队列;低优先级队列的任务等待过久,会被提升回高优先级队列(防止饥饿)。
这套机制的精髓在于“反馈”:调度器不知道一个任务是CPU密集还是IO密集,只能通过行为去猜。一个任务频繁主动让出CPU(比如在等网络响应),说明它是IO密集,应该留在高优先级队列,让它快速响应;一个任务一直占用满时间片,说明它是CPU密集,应该调到低优先级队列,把CPU让给交互型任务。这就是为什么你的编辑器永远比后台编译任务更“跟手”。
4.2 上下文切换:并发系统的隐形开销
上下文切换是把当前任务的寄存器、程序计数器、内核栈保存下来,再恢复下一个任务的寄存器、程序计数器、内核栈。这个过程需要陷入内核态,做一趟完整的状态保存与恢复。如果系统里线程数量过多、切换过于频繁,CPU的很大一部分时间会花在“保存现场、恢复现场”上,而不是真正执行任务。
一组经验数据可以帮你直观感受:一次线程上下文切换大概花费几微秒到几十微秒。看起来不多,但如果一个进程有5000个线程,每个线程时间片只有几毫秒,高并发IO场景下每秒可能发生数万次切换,CPU时间的相当比例就被白白吃掉。很多服务端程序“调大线程数性能反而下降”,根源就在这里——线程数不是越多越好,线程数是需要匹配任务类型和CPU核数的。
4.3 Linux调度器的实际调参窗口
Linux的CFS(完全公平调度器)并不直接使用“时间片”概念,而是通过vruntime(虚拟运行时间)来保证公平:每个可运行线程挂在红黑树上,调度器总是选择vruntime最小的线程运行;线程运行时间越长,vruntime增长越快,被选中的概率就越低。这个设计本质上和MLFQ的精神一致:兼顾公平与响应速度。
你可以通过几个实际窗口感知调度行为:
cat /proc/cpuinfo的processor列表告诉你系统有多少逻辑核;绑核时根据这个编号设定CPU亲和性。nice -n 10 命令可以启动一个低优先级进程(nice值范围-20到19,数值越低优先级越高);renice可以调整一个运行中进程的优先级。- 检查线程数:
cat /proc/进程PID/status里的Threads字段,可以看进程起了多少线程,如果这个数字异常大,一般就意味着线程模型设计有问题。
之前有次在测试环境排查一个高CPU服务,发现2核机器上跑着800多个线程,大部分线程在等同一把锁,锁的持有者又把锁内的I/O操作做得很重。当时的处理思路不是简单“减少线程数”,而是把任务模型从“每请求一线程”改成有界线程池+队列异步化,线程数降到64,吞吐量反而翻了两三倍。这就是理解调度器之后能做出的合理决定:让可运行线程数量约等于处理器核数量,把等待型线程的占比降下来。
5. 并发Bug排查实录:从竞态条件到死锁的完整链路
5.1 竞态条件:共享数据写在错误的时间
竞态条件是指多个任务访问共享数据,且最终结果依赖于任务执行的精确时序。经典场景是“读改写”三步操作:线程A读到值n,线程B也读到值n,A把n加1写入,B把n加1写入,最终n只增加了1,而不是2。解决竞态条件的标准工具是锁(Mutex)、原子操作(Atomic),或者干脆不可变数据。加锁的粒度很重要:锁的范围太大会导致并发度下降,相当于把多线程退化成了串行;锁的范围太小又覆盖不住资源保护的边界,形成漏洞。
我见过最典型的错误是“双重检查”写错:先不加锁读一遍,发现需要更新,再进去加锁,结果在检查与加锁之间数据已经被别的线程改了。这个问题的标准解法是双重检查加锁(Double-Checked Locking),但前提是语言和硬件的内存模型允许这样安全发布对象;在Python这类有GIL的语言里做双重检查相对安全,但在C++里如果没处理好内存屏障,照样出错。
5.2 死锁:四个必要条件一起出现,就一定卡死
死锁指一组线程中每个线程都持有对方需要的资源,同时等待对方释放自己需要的资源,结果全部永远阻塞。教科书给出的死锁四条件是互斥、持有并等待、不可抢占、循环等待。实际排错时只要破坏其中一个条件就能解除死锁,最常见的手段是破坏循环等待——“所有线程按固定顺序加锁”。
看一个构造出死锁的例子:
import threading lock_a = threading.Lock() lock_b = threading.Lock() def task1(): lock_a.acquire() print("task1 got lock_a") lock_b.acquire() print("task1 got lock_b") lock_b.release() lock_a.release() def task2(): lock_b.acquire() print("task2 got lock_b") lock_a.acquire() print("task2 got lock_a") lock_a.release() lock_b.release() t1 = threading.Thread(target=task1) t2 = threading.Thread(target=task2) t1.start() t2.start() t1.join() t2.join()如果两个线程交错执行,task1拿到lock_a之后,task2拿到lock_b,两边再去拿对方手里的锁时就会一直等下去。解决办法是把两把锁的获取顺序统一成“先A后B”,或者用with lock_a: with lock_b:嵌套语法保证释放顺序,再或者使用Lock.acquire(timeout=...)加超时,超时后主动回滚而不是傻等。
5.3 一次线上问题:服务线程全部卡死的过程还原
某次给一个小型任务系统排查故障,现象是接口整体无响应,请求全部超时。第一件事是用jstack(Java场景)或py-spy dump(Python场景)抓线程栈,发现大量线程都停在同一句代码上——在等一个数据库连接池的获取操作。继续查,发现连接池被耗尽,而持有着连接的那几个线程正在等一个内存队列里的任务处理完成;那些任务又在等数据库连接释放。典型的“线程池耗尽+循环等待”组合式死锁,代码里任何单独一部分逻辑看起来都对,但组合在一起就出问题。
最终解法分两步:第一步,把数据库连接池的等待时间从无限等待改为5秒超时,超时后快速失败;第二步,调整任务处理架构,避免“处理线程”和“回调线程”互相等在共享资源上。这两步改完之后,系统再也没有出现过全线卡死。排查并发问题最重要的手段永远是抓线程栈:只靠看日志猜测是低效的,线程栈会直接告诉你每条线程停在哪一行代码,等待哪个资源,谁持有这个资源。
5.4 防呆清单:并发代码发布前先给自己过一遍
顺着上面的教训,我整理了一份实践清单,每次写并发代码都拿出来对照:
- 访问共享数据前是否明确加了锁?锁的作用域是否覆盖了读改写全过程?
- 是否存在获取多把锁的场景?获取顺序是否全局唯一?
- 等待外部资源(数据库连接、网络响应、队列容量)时是否有超时机制?超时后的回滚逻辑是否完整?
- 线程池的线程数是否远大于CPU核数?任务里是否大量出现阻塞等待?
- 是否存在一个线程在持锁状态下执行I/O操作?如果有,能否把I/O移出临界区?
- 进程退出时是否释放了所有资源?子线程是否都做了
join?
这六条不是空话,每一条背后都对应一个我踩过的或者帮别人踩过的坑。你能把这几条真正落实,并发代码的稳定性会上一大截。
6. 第8天作业:设计并实测一个多进程/多线程下载工具
6.1 作业目标与预期掌握的能力
作业本身不复杂,但能逼你把这一天的知识点串起来。目标:写一个命令行下载工具,批量下载一批文本文件或图片,要求支持多线程模式和通过multiprocessing实现的多进程模式,并统计对比两种模式的耗时。
掌握能力包括三块:第一块是创建和控制并发任务(线程池/进程池的创建、任务提交、结果收集);第二块是资源限制与同步(控制最大并发数、避免对共享输出文件造成写乱);第三块是性能分析(统计不同并发度下的耗时、CPU与IO占比,解释为什么线程和进程在不同场景下表现不同)。
6.2 参考实现步骤与核心代码
第一步,准备一批测试文件列表。假设你有150个小文件需要下载,用range(150)模拟即可。
第二步,分别用ThreadPoolExecutor和ProcessPoolExecutor写两个下载任务模块,每个任务模拟下载:随机sleep一段时间(模拟网络IO),把文件名写到共享日志时加锁。
第三步,固定测试环境:同一批文件,同样的网络条件,分别用1、4、8、16、32个并发工作者跑一遍,记录总耗时。
可以用下面这段结构快速搭起来(以线程版本为例):
import time import concurrent.futures def fetch_one(index): time.sleep(0.05) # 模拟网络等待 return index def run_thread_pool(workers): start = time.time() with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: list(executor.map(fetch_one, range(150))) return time.time() - start for w in [1, 4, 8, 16, 32]: used = run_thread_pool(w) print(f"thread workers={w}, time={used:.2f}s")把ThreadPoolExecutor换成ProcessPoolExecutor再跑一遍同样的循环,对比数据。
6.3 测试结果怎么解读
按我自己的实测数据,在模拟纯IO等待的场景下,多线程和多进程的提升都接近线性,从64个并发开始,提升幅度下降,原因是切换开销和等待唤醒的调度开销开始占主导;如果把这个任务改成纯计算(比如大量浮点运算),在多核机器上,多进程的加速比明显高于多线程,Python环境尤其明显,因为GIL直接限制了线程并行能力。
要求你在作业报告里必须回答三个问题:第一,为什么IO密集场景下线程能提升性能,哪怕Python有GIL?第二,为什么计算密集场景下多线程总是输给多进程?第三,在什么情况下并发数继续增加,性能反而下降?把这3个问题答清楚,说明你对进程线程的核心概念基本过关了。
6.4 验收标准与常见失败模式
你的作业需要提交三样东西:代码、测试数据表格、结论说明。验收看三个点:一是有没有用锁或者队列保证“写日志文件”不乱;二是测试数据是否包含至少5组不同并发度;三是结论是否解释了“为什么8个工作线程比1个线程快但32个线程反而慢”。
我这些年带人看这类作业,最常见的失败模式有两种。一种是所有耗时测试都是在同一台负载很高的机器上跑的,结果数据无规律,说服力几乎为零;另一种是只测了线程模式,没有做进程模式对照,交上来的结论断了一条腿。我把这两条列出来,就是提醒你取证是要讲环境一致性的,跑测试前先关掉多余的服务,把测试机空出来。
6.5 进阶挑战:有界线程池与队列异步化
作业做完之后,如果你还有余力,可以挑战一个进阶方向:重写下载模块,不使用固定大小的ThreadPoolExecutor,而是自定义一个有界任务队列,队列满了先让生产者阻塞,消费者固定在N个线程——体会一下“背压”对系统稳定性的价值。再往下走,可以引入asyncio实现单线程内的高并发IO,对比协程与线程的吞吐量差异。到这一步,“第8天进程和线程”的内容就彻底从概念层面延伸到了工程实践层面。
我个人的体会是:进程和线程这一章是操作系统里“离代码最近”的一章,但也是最容易被当成背概念跳过的一章。很多工作三年的人写并发程序靠搜索引擎拼凑,出了问题连线程栈都不会抓,就是当年没把PCB、状态机、调度、同步这几件事踏实吃过一遍。今天这篇的内容不少,但核心就一句话——先搞清楚你要的是隔离还是共享,再决定用进程还是线程;决定了之后,再考虑锁、队列和线程池的细节。这句话想明白了,比记住一百个API都值钱。