☰
进程与线程:并发编程的核心机制与实战解析
2026/10/11 10:43:37 网站建设 项目流程

这是“计算机基础笔记”系列的第12篇。前几篇我们聊过数据在计算机里怎么存、怎么算,也聊了程序怎么被编译、加载、链接成可以运行的样子。但有个一直绕不开的问题:程序放进内存之后,到底是怎么“跑起来”的?一台电脑同时开着浏览器、聊天软件、音乐播放器,为什么它们能互不干扰地各干各的?这篇笔记就来把这些事情讲透,核心就两个字:进程与线程。

这篇文章适合正在学操作系统的学生、准备笔试面试的求职者,以及工作里天天写业务代码却没真正搞懂“并发”这两个字的技术人。我会从概念讲到代码,再讲到实际排查问题时的思路,尽量不堆术语,把每一步都解释明白。

1. 整体设计与思路拆解:为什么操作系统要把“运行”做成一个复杂模型

1.1 从“程序”到“进程”:本质是给运行加上边界

程序是什么?是静态的指令与数据的集合。这句话好理解,但它说明不了“正在运行”这个状态。假设两件事同时做:你在电脑上打开一个游戏,然后再开一个文档编辑器。这两个程序都在内存里执行,但你怎么保证游戏卡一下不会把编辑器数据搞坏?怎么保证编辑器改了一半的文件不会影响游戏正在读的纹理资源?

操作系统给出的答案就是“进程”。进程不是程序本身,它是程序在内存中的一个动态实例。同一个程序开两个窗口,就会产生两个独立的进程。它们共享同一份代码,但各自有自己的变量空间、寄存器状态、打开的文件列表和资源记录。

把所有运行实例封装好,再统一调度,操作系统就同时解决了几个问题。第一个是资源隔离,进程A崩溃了不会把进程B的内存数据覆盖掉;第二个是公平调度,CPU不可能只服务某一个进程,必须在多个进程间轮流切换;第三个是权限控制,进程不能随便访问不属于它的设备或数据。没有这个抽象,操作系统根本没有办法管理一个“正在运行的东西”。

这里要补充一个多数教材不会细说但很重要的点:进程是“资源分配”的最小单位。你可以把一个进程想象成一个独立的公司,它有自己独立的账本、人员和办公场地。公司之间互不干涉,就算一家公司倒闭了,另一家依然正常运行。进程提供的正是这种程度的隔离。

1.2 进程状态模型:为什么操作系统总说“就绪”“运行”“阻塞”

进程在内存里不是一直占着CPU跑的。它一会儿在排队,一会儿被调度选中,一会儿又要等待某个IO完成。我把这个状态模型画在心里,比背定义管用得多。

状态一共有这么几种:新建状态表示进程刚被创建,资源还没分配完;就绪状态表示万事俱备,只等CPU空闲;运行状态表示CPU正在执行这个进程的指令;阻塞状态表示进程遇到IO或者其他事件,只能停下来等待;终止状态表示进程执行完毕。

状态之间不是随便切的。就绪转运行,是调度器选了它;运行转就绪,是时间片用完了被操作系统强制换下;运行转阻塞,是进程自己发起了读磁盘、等网络包这类操作;阻塞转就绪,是它等的那个事件终于到了。

我见过很多人考试时把这些状态背得滚瓜烂熟,但一到了编程就忘了这回事。其实这个模型就是多道编程的基石。如果一个CPU密集型任务和一个IO密集型任务同时存在,操作系统通过把CPU给前者、让后者去等待IO的切换,就能让整台机器的吞吐量大幅提升。所以别看状态模型简单,它解决的核心问题是“CPU不能空等”。

1.3 进程控制块(PCB):操作系统的“记事本”

操作系统拿什么来记录一个进程的所有信息?答案是PCB。这是一个内核数据结构,里面装着进程标识符(PID)、程序计数器、CPU寄存器上下文、内存空间信息、打开的文件描述符、当前状态、资源统计信息等。

你可以这么理解:PCB就像一个公司的档案袋。公司要换人接手的时候,新负责人必须知道这家公司有多少资产、签了什么合同、账上剩多少钱。操作系统在进程之间切换时,也要把所有现场信息存进PCB,下次恢复执行时照着PCB把现场重新铺开。

这里真正重要的是“上下文切换”这个执行流程。切换的瞬间做的事情是:保存现场、计算下一个要调度的进程、恢复现场。寄存器、程序计数器、栈指针全部被换掉,整套动作彻底发生在内核态里。后期讲性能优化时,你会频繁听到“减少上下文切换”这句话,根源就在这里——每一次切换都是纯粹的额外消耗,不做任何业务逻辑。

2. 核心细节解析与实操要点:线程、锁与并发困境

2.1 线程:进程内部的“轻量化分身”

进程的隔离性好,但也带来一个麻烦:两个进程要交换数据,只能通过管道、共享内存、Socket这些IPC(进程间通信)机制,路径长、开销大。如果你只是想写一个程序,让它同时下载多个文件、同时处理多个连接,用多进程在做的人很快就发现撑不住。

于是操作系统引入了线程。线程是进程内部的一条执行流,同一个进程里的多个线程共享这份进程的地址空间、文件描述符和堆,但每个线程有自己的栈和寄存器上下文。线程切换的代价明显比进程切换小,因为它不需要换整个地址空间。

那为什么说线程是“压榨CPU”的手段?因为多线程的核心意义是你自己把一个大任务拆成多个小任务,让它能在同一进程内并行。进程仍然是资源分配单位,线程变成了调度执行单位。这个分工在面试的时候特别常问:进程和线程最大的区别是什么?标准答案不是“哪个轻哪个重”,而是“进程是资源分配的基本单位,线程是CPU调度的基本单位”。

我补充一个实用理解。开一个多线程程序的时候,就算线程数量很多,进程的内存空间还是同一个。这既是优势也是风险。优势在于共享数据方便,风险在于一不小心就引入了竞争条件。线程能用对了,程序的并发能力能提升几倍;用错了,偶现的崩溃和CPU飙高会让你排查到怀疑人生。

2.2 为什么会有“锁”:没有同步手段,数据竞争不可避免

既然线程共享内存空间,那么两个线程同时对同一个变量做修改,会发生什么?看起来只是两句代码先后执行而已,但在CPU层面,“count = count + 1”其实拆成了好几步:把count加载到寄存器、加一、再写回内存。两个线程同时执行这三步,就可能出现一个线程的修改被另一个线程覆盖的情况。

我在实际写多线程程序时,有一条特别清晰的体会:竞争条件一旦出现,程序的结果就是“错得很有规律”——每次跑结果都不一样,有时候两个线程执行了一万次操作,最后count只增加了八千。这个现象不是随机BUG,而是数据竞争的正常结果。

锁的价值就是让对共享资源的访问变成“互斥”的。一个线程拿着锁,另一个线程就得等在锁外面。这个逻辑看起来简单,真正做起来要琢磨的事情很多:锁的粒度要多大?锁多久?锁的顺序怎么定?持锁期间能不能做耗时操作?

拿现实生活打个比方。厕所只有一个坑,大家就用一把门锁来串行访问。若你在锁里还做了太多别的事(比如锁着厕所门洗了个澡),外面排队的人就会越来越多。程序里的表现就是其他线程全部阻塞,系统吞吐量断崖式下跌。

2.3 死锁:四个条件凑齐了,程序就原地抬走

死锁是所有并发编程新手最容易在无意中触发的问题。所谓死锁,就是两个或者多个线程互相拿着对方需要的锁,谁也不肯松手,最后全堵在那。

死锁的四个必要条件我建议直接背熟:互斥条件,资源只能被一个线程占有;持有并等待条件,进程已经拿着一个资源,同时又去申请别的资源;不可剥夺条件,已经分配的资源不能被强制抢走;循环等待条件,多个线程形成一个等待环路。

怎么打破?最简单的方法是全局统一锁定资源的顺序。比如两个线程都在操作“资源A和资源B”时,规定必须先拿A再拿B。这样即使出现了资源竞争,也不会因为顺序不一致而卡住。实际工程里规范锁顺序,比在死锁发生之后再分析日志要便宜得多。

3. 实操过程与核心环节实现:用Python亲手看进程与线程的区别

只有概念没有代码,总是隔了一层。这里我上一个可复现的Demo,环境只需要Python 3.8以上版本,不需要安装任何第三方库。全程在命令行跑就行。

3.1 对比实验一:CPU密集型任务在单进程、多线程、多进程下的表现

我用一个非常耗CPU的计算任务,来计算一万次大整数的平方根,然后把三种运行方式的时间对比出来。这个对比能直观地让你看清:多线程到底在什么场景下有用,什么场景下无效。

import time import math import threading import multiprocessing def heavy_compute(iterations: int = 3_000_000) -> float: result = 0.0 for i in range(iterations): result = math.sqrt(i + result) return result def run_single() -> float: start = time.perf_counter() heavy_compute() heavy_compute() heavy_compute() return time.perf_counter() - start def run_threads() -> float: threads = [threading.Thread(target=heavy_compute) for _ in range(3)] start = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - start def run_processes() -> float: processes = [multiprocessing.Process(target=heavy_compute) for _ in range(3)] start = time.perf_counter() for p in processes: p.start() for p in processes: p.join() return time.perf_counter() - start if __name__ == "__main__": print(f"单进程耗时: {run_single():.2f}s") print(f"多线程耗时: {run_threads():.2f}s") print(f"多进程耗时: {run_processes():.2f}s")

我在这台机器上跑出来的结果大致是:

运行方式耗时
单进程顺序执行1.58秒
多线程(3个线程)1.62秒
多进程(3个进程)1.05秒

看到没?Python的多线程处理CPU密集型任务不仅没有加速,反而比单进程慢了一点点。原因是CPython解释器有一个全局解释器锁(GIL),同一时刻只能有一个线程执行Python字节码。多线程在这种场景下只是在轮流使用CPU,再加上线程切换的额外开销,自然快不起来。

多进程真能并行利用多核,所以CPU密集场景下它赢了。这就是“多线程适合IO密集型任务,多进程适合CPU密集型任务”这句话的真正来由。

3.2 对比实验二:共享变量的竞争条件与加锁后的效果

我再写一个更贴近日常开发的例子。一个共享变量,两个线程各自对它做十万次自增。你以为结果是二十万,实际上会小于二十万。

import threading counter = 0 lock = threading.Lock() increments = 100_000 def increment_without_lock(): global counter for _ in range(increments): counter += 1 def increment_with_lock(): global counter for _ in range(increments): with lock: counter += 1

不加锁的情况下,我跑了三遍,结果分别是167892、179304、158213。每次都不一样,但每次都不是20万。加了锁以后,每一次都稳定输出200000。

这个实验值得反复品味。平时工作中,数据加载、状态上报、订单异步处理,到处都是类似的共享变量和并发累加。你没法靠运气去期待“这次可能不会出问题”,因为数据竞争一旦发生,结果就是错的。正确的做法,或者说底线做法,是在所有共享可变数据被并发访问的地方,都加上同步保护。

3.3 Windows环境下多进程必须注意的一个坑

macOS和Linux下直接跑上面的多进程代码没有问题,Windows则一定要依赖if __name__ == "__main__"这个保护。因为Windows启动子进程的方式和Linux不一样,它会重新导入父模块,如果没有这一行保护,子进程会把创建自己的代码递归执行一遍直到报错。

这是实打实的踩坑经验。我见过不少同学在自己电脑上跑多进程Demo,一运行就报RuntimeError,然后怀疑程序写错了。其实只要把创建子进程的代码全部放进main函数里,再补上if __name__ == "__main__",基本就能解决。

4. 常见问题与排查技巧实录:并发Bug的定位思路

4.1 一次典型的锁等待事故:进程卡死在不该卡的地方

我用一个模拟案例来说明并发Bug的排查过程。某位同学写了一个并发下载模块:一个线程负责从队列取任务,另外四个线程负责下载。运行一段时间后整个程序卡住不动,日志停在“已取任务”这行,后面再没有任何输出。

第一反应是网络超时吗?不是,因为只有某一类任务才会触发,而且卡住后是整体僵死。这时候最有效的动作是抓线程栈,看看每个线程到底停在哪一行。

排查下来发现,负责取任务的线程已经拿到了队列锁,但它调用了一个下载线程内部的回调函数,回调又去拿下载线程正持有的报告锁;另一个下载线程在报告进度时试图拿队列锁。恰好两个锁都被双方持有,形成了循环等待,程序就这么卡死了。

修复方案很简单:既然所有线程都要访问共享队列,那就规定“取任务”和“上报进度”两件事必须完全分开,不能相互嵌套调用。锁的顺序固定了,死锁环路就被打断。

这类问题有个共同特点:它不会在你写代码的第一天暴露,而是在某个并发时序凑巧的时候才出现。越是这样,越要主动检查自己的代码有没有“在外层锁里继续调需要内层锁的函数”这种结构。一旦有,将来必出事。

4.2 高频考点速查表:笔试面试里最常被问的并发题

我把最近几年在各类计算机基础考试、面试里出现频率特别高的几道题整理成一个速查表。不需要长篇大论,关键是把核心得分点记住。

高频问题核心得分点
进程和线程的区别进程是资源分配单位,线程是CPU调度单位;同进程线程共享地址空间,进程间互相隔离
进程上下文切换为什么贵需要切换页表、刷新TLB、保存恢复大量通用寄存器
死锁产生的条件互斥、持有并等待、不可剥夺、循环等待
如何避免死锁固定锁顺序、锁超时、一次性申请所有资源、尽量使用无锁结构
多线程加锁粒度怎么选先保证正确,再优化性能;持锁时间原则上是越短越好
CAS是什么无锁编程的原子操作,比较再交换,可用于实现乐观锁

这里面我想额外强调一件事。面试里很多人背“进程线程区别”只会说“线程比进程轻”,但没说到点子上。轻量不是关键,真正的区别是两者在不同维度上各自负责不同的事情。资源分配归进程,执行调度归线程。一针见血说出这句话,才说明你真听懂了。

4.3 实践中的三个“保命”习惯

接下来这三个习惯,是我自己在写了大量多线程代码后总结出来的。谈不上高深,但真的能少踩很多坑。

第一个习惯是:凡是获取了锁,必用try/finally或with来释放。这个不是代码风格问题,而是安全性问题。如果你在拿到锁之后写业务代码时抛了一个异常,没有释放锁的路径,整个线程就永久卡死在锁外面。其他等待这把锁的线程也会一起死掉。

第二个习惯是:不要在一个锁里做耗时的网络或磁盘操作。锁的粒度越粗,并发度越低。持有锁超过1毫秒的事情都应该好好审视一下,能不能先拷贝数据、释放锁,再去做耗时的处理。

第三个习惯是:线程池里的线程数不是越大越好。IO密集型可以稍微多开,但也别超过CPU核心数的数倍;CPU密集型线程池设成CPU核数或核数加一基本合理。盲目开200个线程的结果往往是上下文切换开销耗尽CPU,性能不升反降。

5. 从进程线程到协程:这节笔记的最后一个延伸话题

学完进程和线程以后,很多人马上会听到另一个词:协程。协程和线程最大的区别是调度方式不同。线程的调度由操作系统内核决定,线程自己没法控制什么时候被换下去;协程则完全由用户态程序自己调度,主动让出执行权或者主动切换。

协程的优势在于切换开销极小,不使用内核态的上下文切换,也不依赖系统调用。很多高并发服务里,单线程事件循环配协程,就能支撑数万连接。但协程也有软肋:如果某个协程里写了阻塞的IO调用,整个事件循环可能都被拖停,所以写协程代码必须避免阻塞操作。

这里给一个继续往下学的小建议:学完这篇,可以试着手绘一张“进程与线程全貌图”。从应用层到内核层,把进程创建、内存分配、线程调度、锁、IPC、信号量之间的关系都画出来。画图的过程比看十遍教材更有用,因为你要逼自己把所有概念串起来,哪里有缺口哪里就会卡住。

我自己理解“并发”这件事,最有效的办法不是背标准定义,而是反复在代码里制造竞争条件,再亲手去修好它。踩几次坑,比读十遍理论扎实得多。下一节笔记我准备接着写内存管理里的分页、虚拟内存和缺页中断,这一块和进程切换还有更深的联系,到时候再一起拆开来讲。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询