一文读懂并发编程:进程、线程、协程与死锁的全面解析 并发编程对很多开发者来说属于那种“看了很多文章、背了不少概念但真到自己写代码时还是发懵”的知识板块。网上讲线程和进程区别的帖子一抓一大把但绝大多数要么停留在面试八股层面要么一上来就丢一堆晦涩的底层术语。这篇博文我不打算做成教科书式的概念汇总而是想站在实际编码和系统运行的角度把线程、进程、并发、并行这几个核心词彻底捋清楚。它们之间到底是什么关系为什么并发程序容易出诡异问题以及你在选型多进程、多线程还是协程时背后真正该考虑的因素是什么。适合正在学操作系统原理、准备后端开发面试或者写代码时遇到过数据错乱、死锁、CPU占用上不去这类问题的朋友参考。1. 并发编程的核心概念拆解1.1 先搞清楚进程到底是什么很多人对进程的第一印象是“运行中的程序”这个说法没有错但它只解释了一半。程序是静态的躺在磁盘上的一个文件进程是动态的是程序被操作系统加载到内存后正在执行的那个实体。进程拥有独立的内存空间包括代码段、数据段、堆、栈还独占文件描述符、打开的文件、信号处理器等系统资源。两个进程之间的内存天然隔离A进程里定义了一个变量B进程完全感知不到。这也是为什么进程崩溃一般不会直接拖垮另一个进程的原因。我当年在理解进程时踩过一个误区总觉得所谓“独立内存空间”不就是自己写代码时各自有一块内存么这有什么稀奇的。直到后来排查过一个线上问题某个后台服务的内存持续上涨重启之后恢复过几天又涨。一开始怀疑是线程泄漏后来用工具排查才发现是有个定时任务在不断创建新进程而这些进程因为某个bug没有被正确回收。当时就意识到进程不是一个概念而是一整套资源的集合。操作系统每创建一个进程就要给它在内存里划分地址空间初始化页表分配内核里的进程控制块PCB同时把代码段、数据段、堆栈都准备好。这套流程的代价相当高所以后来我在听到“进程创建开销大”这句话时才有了实感。进程还有一个容易被忽略的属性它是最小的资源分配单位。所谓“最小”意思是系统给资源内存、文件、信号量等做分配时都是以进程为粒度来操作的。线程需要的内存栈、寄存器状态虽然是从进程空间里划分出来的但系统的资源账本只记到进程这一层。1.2 线程是进程内部的执行单元如果进程是一个工厂那线程就是这个工厂里的工人。一个进程内可以有多个线程它们共享这块进程的内存空间包括堆和静态数据区但每个线程有自己独立的栈和寄存器上下文。共享内存带来的直接好处是线程间通信极其方便定义一个全局变量多个线程都能直接读写不需要像进程间通信那样走管道、消息队列或共享内存映射。但反过来这也是各种并发问题的根源所在多个线程同时读写同一个变量时如果没有任何保护机制数据就会错乱。线程也被称为“最小的执行单元”因为操作系统的调度器做任务切换时切换的就是线程。一个进程内的多个线程可以由操作系统的调度器分配到不同的CPU核心上并行执行也可以在一个核上快速交替执行。之前我做过一个图像处理程序一开始用单线程遍历所有像素处理一张大图要花掉好几秒。改成把图像切成多个区域每个线程处理一个区域后耗时明显下降。那是第一次直观感受到多线程带来的性能提升也是第一次遇到“为什么我的多线程版本结果跟单线程版本对不上”的诡异现象。原因是多个线程在更新一个公共的进度计数变量时发生了竞争最终数值比预期的少。这个教训让我从“会写多线程代码”变成了“开始理解并发访问为什么要加锁”。线程的创建代价比进程小得多因为不需要重新分配完整的地址空间和页表只需要分配栈空间、线程控制块TCB等一小组资源。但要注意这里所谓的“小”是相对进程而言在频繁创建销毁的场景下线程创建一样会成为性能瓶颈这也是后来线程池存在的核心原因。1.3 并发和并行到底差在哪里并发和并行是很容易被混用的概念面试时也常有候选人把两个词当成一个意思来答。但它们在计算机领域的含义差别非常大用一个比喻来说清楚并发是指一个人在同一时间段内处理多件事比如一边写代码一边听音乐听一会儿音乐又切回来写代码宏观上这两件事都在推进但微观上任何时刻只有一件事在进行。并行是指多个人各自处理一件事比如两个程序员同时在各自电脑上写代码两个任务实实在在同时发生。从底层看并发靠的是操作系统的任务调度。系统把CPU时间切成一个个很小的片段时间片分配给不同的任务。因为切换速度极快用户感知不到停顿觉得这些任务是同时运行的。而并行靠的是多核或分布式环境。只有系统拥有多个物理计算核心时真正的并行才可能发生。单核CPU上永远只能并发不可能并行。多核CPU上并发和并行可以同时存在你有8个核心运行的线程有20个那同一时刻最多8个线程在并行剩下12个靠调度器轮流排队。为什么我一直强调要把这两个概念区分清楚因为很多人优化程序时绕进一个思维误区以为把代码改成多线程就一定“并行加速”。如果机器是单核的改成多线程后性能不仅不会提升反而会因为额外的线程创建和上下文切换开销变得更慢。优化之前先搞清楚瓶颈是CPU密集型还是IO密集型再决定要不要上多线程这才是正确的思路。1.4 协程比线程更轻的并发方案协程是这十年里特别受关注的一个概念它本质上是一个“可以在任意位置暂停和恢复”的函数。线程的调度是操作系统内核干的而协程的调度是由程序自己控制的。运行协程时程序可以在一个线程内维护很多个协程执行到某个地方发现需要等待IO就把当前状态保存下来主动让出执行权切换到另一个协程。因为这个过程不经过操作系统内核所以切换成本比线程低一个数量级。有些人会觉得协程把线程替代了这种理解不准确。协程最终还是要运行在线程上只不过一个线程可以容纳成千上万个协程。IO密集的场景比如网络服务同时处理几万个连接请求如果每个连接一个线程线程数一高操作系统光切换调度就忙不过来内存开销也大。换成协程后一个线程就能扛住大量连接这就是Go语言里goroutine以及各语言异步框架性能优秀的根本原因。我自己的体会是协程把“并发编程”的视角从“如何管理更多的系统资源”转移到了“如何管理更多的任务状态”这个思维转变很重要。2. 操作系统底层视角并发为什么难写2.1 时间片与上下文切换并发的前提和代价操作系统实现并发的基础是调度器和时间片。调度器负责决定下一个该运行哪个任务而时间片是这个任务一次可以独占CPU的最长时长。这个时间片不能太长否则其他任务会等得很难受也不能太短否则切换太频繁大量时间浪费在保存和恢复现场上。常见的操作系统时间片在毫秒级范围比如10ms到100ms之间具体数值取决于系统配置和负载情况。上下文切换代价相当高。当前任务运行时CPU里的寄存器存着它的状态包括程序计数器、栈指针、各种通用寄存器。切换任务时需要把这些状态保存到内核空间再把下一个任务之前保存的状态装载到寄存器里。这个过程还要处理缓存失效的问题新任务的代码和数据可能不在当前CPU的缓存中需要从内存重新读取。到底有多贵一次上下文切换的开销通常在微秒级虽然单看不多但在高频切换场景下这个开销会被放大到一个不能忽视的程度。我自己有一次性能调优经历印象非常深刻。一个消息处理程序吞吐量一直上不去。开始怀疑是业务逻辑太慢后来用性能剖析工具一看发现系统大量的时间消耗在调度和上下文切换上。原因是开启了太多线程而且每个线程处理的业务量又很小。线程们频繁地让出CPU和抢占CPU整个进程都在“切换”而不是“干活”。把线程数量压下来改成适量线程加队列缓冲后吞吐量反而涨了一大截。这让我明白一个道理并发并不是“开的线程越多越好”并发是为了让CPU在等待外部资源时不要闲着而不是为了并发而并发。2.2 多核CPU与并行度的实际边界前几年“多核”还是宣传口号现在已经是服务器标配了普通笔记本电脑也有8核16线程。多核出现让并行真正成为可能但也引出了一个新问题你的程序到底能利用多少核这里需要理解阿姆达尔定律虽然原理简单但很有指导意义。一个程序里既有只能串行执行的部分也有可以并行执行的部分。整个程序的加速上限取决于串行部分所占的比例。假设一个程序的串行部分占比为5%可以并行的部分是95%那么哪怕是无限增加CPU核心数理论加速上限也不会超过20倍。后面的核心越来越多时提升曲线会迅速变得平坦。换句话说硬件的并行能力强不代表你的程序就能无限吃下这些能力代码逻辑本身的串行依赖才是瓶颈。怎么判断自己的程序有多大并行度我习惯先做一个简单评估程序运行时有没有一个共享的变量、一个公共的锁、或者一个全局的资源在串行化整个流程如果数据必须按顺序写入同一个文件或者所有计算依赖上一步结果那无论你怎么多线程最终都会阻塞在一个点上。分布式计算领域里的数据分区思想其实在这里同样适用不同线程处理不同分区的数据最后再做一次汇总聚合尽可能减少各线程之间的耦合。2.3 并发下的可见性现代CPU做过的“小动作”写并发代码时还有一个很诡异的坑叫“可见性问题”它可能是三个线程中写道我在多线程程序里设置了一个布尔标志位另一个线程循环检查这个标志来决定是否跳出结果等了半天它都没跳出。表面上看这个标志位已经被修改了但另一个线程就是看不到。我当年第一次碰到这种问题第一反应是“代码写错了吧”。找了半天没找到逻辑错误最后才明白是可见性的问题。现代CPU为了提升性能在硬件层面做了很多优化。每个CPU核心都有自己的缓存线程读取共享变量时优先从缓存里读而不是每次访问内存。一个线程修改了变量的值这个新值可能还停留在自己的缓存里没有马上刷新到主内存。另一个线程的CPU缓存里存的完全是旧值。这就是可见性问题一个线程对共享变量的修改对另一个线程来说可能是不可见的。为了解决这个问题编程语言提供了各种机制比如用锁、用volatile关键字、用原子操作类本质上都是保证跨线程的可见性和有序性。它们的底层依赖CPU的缓存一致性协议和内存屏障指令。所谓内存屏障可以理解成一道强制指令要求在此之前的读写操作必须先完成之后的读写操作才能执行改变CPU优化和内存访问的原有顺序。实际编码中我不建议自己去插入内存屏障那是极其底层且容易出错的活儿直接用语言提供的高级同步工具是更稳妥的选择。3. 并发访问共享资源的四大阵痛3.1 竞争条件最隐蔽的BUG来源并发程序里竞争条件Race Condition可以说是最经典也是最隐蔽的错误类型。它的定义很简单多个线程访问同一个共享数据且至少有一个线程在写又没有任何同步机制保证访问顺序最终结果依赖于线程的具体执行顺序。这就叫竞争条件。说到这个每次举例子我都会用那个最经典的计数器场景。设想两个线程同时对同一个变量i执行自增操作i当前值是100。单线程里一个自增结束后i变101。但多线程时i这行看上去简单在底层其实是三条指令读取i的值到寄存器寄存器加1把结果写回i。如果线程A刚读完i100还没把101写回去线程B也读了i仍然读到100然后两个线程各自加1最后都写回101。结果就是明明自增了两次i却只增加了1。这种bug特别难查因为它不是稳定复现的需要特定的时序才能触发有时跑一整天都没问题有时刚上线就炸。解决竞争条件的思路就两条要么让共享数据的访问变成原子操作通过底层的CAS指令让读改写一气呵成中间不可打断。要么给共享数据的访问加上锁把并发的访问变成串行。设计并发程序时提前梳理出“哪些数据是共享的”“哪些操作不能交叉执行”比出bug后再去排查要高效得多。3.2 锁、信号量与死锁锁是并发控制里最常用的工具。经典的有互斥锁同一时刻只允许一个线程持有锁其他线程尝试获取时会被阻塞直到锁被释放。读写锁则更精细允许多个线程同时读但写的时候只能有一个线程而且写期间不能有读。信号量本质上是一个计数器可以控制同时访问某个资源的线程数量比如限制同时只能有3个线程访问数据库连接池。锁用起来门槛不高但设计不好会给自己挖坑最大的坑就是死锁。一个经典的教科书案例线程A持有锁1等待锁2线程B持有锁2等待锁1。两者互相等待谁也不放手程序就永远卡死在这里。死锁的形成需要四个条件同时满足互斥、持有并等待、不可剥夺、循环等待。实际编码中四个条件并不容易同时出现但只要出现程序基本就是瘫痪状态。避免死锁最常见的方法是破坏循环等待条件比如给所有锁编号要求所有线程必须按固定的顺序去获取锁。线程如果想同时持有锁1和锁2必须先拿锁1再拿锁2这样就不会出现一个线程拿着锁1等锁2、另一个线程拿着锁2等锁1的情况。另外一个很实用的技巧是使用带超时的锁获取。改造代码时我经常用这个方案获取不到锁就等待几百毫秒超时后放弃打印日志或者做降级处理。这样即使真出了问题程序也不会永久卡死至少能发现问题。3.3 指令重排比你想象中更“不守规矩”的执行编译器为了优化CPU为了流水线效率都会对代码执行的顺序做调整前提条件是调整后的结果和原有逻辑在单线程视角下保持一致。问题来了这个前提只针对单线程。在多线程环境下指令重排就可能导致一个线程看到另一个线程的操作以非预期的顺序发生。举个例子线程A执行了两条语句先给变量x赋值再把标志位isReady设为true。线程B在检查到isReady为true后紧接着读取x它可能读到的是x的旧值因为isReady的写入和读取并没有让CPU保证x的写入已经完成。这种bug肉眼几乎不可能看出来因为它不依赖特殊的条件只需要正确的执行顺序被颠倒。处理方式上还是回到那些高级同步工具语言级锁、原子操作、volatile它们内部已经插入了足够的内存屏障保证代码在语义上不会出现重排导致的错误可见性。写业务代码时我从来没有手动去处理过指令重排但正因为知道它的存在才能理解为什么有些明明“逻辑正确”的代码运行起来会随机抽风。3.4 原子类与CAS无锁并发的一条捷径锁能解决问题但代价是线程阻塞和唤醒的开销。如果某个共享变量的操作本身很简单比如就做一个加法完全可以用原子类来替代锁。原子类底层依赖CASCompare And Swap比较并交换指令这个指令由硬件直接支持能在没有锁的情况下保证操作的原子性。CAS的逻辑是这样的判断当前内存值V是否等于期望值A如果相等就把值更新为B如果不相等就什么都不做。整个过程可以由操作用一条CPU指令完成不会被打断。但是在并发高的场景下CAS也会遇到ABA问题就是值在比较期间从A变成B再变回ACAS会认为变量没变过。实际工程里可以通过版本号或者带标记的原子引用来解决。无锁并发方案的优势是线程不会阻塞性能在高竞争场景下更稳定。但它的复杂度也比锁要高不少我个人的经验是优先用锁只有在热点代码被性能剖析确认是瓶颈而且操作足够简单时才考虑用CAS去优化。一上来就追求无锁容易把自己陷入调试的泥潭。4. 实际项目中的并发选型与实践4.1 多进程还是多线程这是个选择题写并发代码时最先要想清楚的就是这个问题你的场景更适合多进程还是多线程我把两者的特点整理成一个对照表方便直观对比维度多进程多线程内存空间完全独立进程间不共享共享进程内存空间数据通信管道、队列、共享内存、Socket等成本高直接读写共享变量成本低创建销毁开销高相对低故障隔离进程间隔离一个崩溃不易影响其他线程间共享地址空间一个崩溃可能导致整个进程退出适用场景计算密集、强隔离需求、跨语言任务IO密集、大量短任务、需要频繁共享数据如果任务是CPU密集型的比如视频编码、科学计算多进程通常更有优势。因为每个进程独立调度可以充分利用多核多CPU资源还能天然规避某些语言层面多线程的限制后文会提GIL。如果任务是IO密集型的比如网络服务、文件处理绝大部分时间都在等待IO完成多线程更合适线程数甚至可以达到一个比较大的数量让系统在等待期间去处理其他任务。我的经验是选型时先问三个问题任务主要消耗CPU还是IO数据共享的频繁程度有多高程序崩溃的容忍度有多高三个问题想清楚选型大体就定了。4.2 到底该开多少个线程很多新手第一反应是“线程数越多越好”这个观点在前面已经反驳过一次。线程数过多带来的问题是上下文切换开销暴增、内存占用量上升、缓存命中率下降甚至可能把系统拖垮。那么线程数该怎么定对于CPU密集型任务线程数一般设置为CPU核心数加1或加2。多的那一两个线程是为了处理偶发的内存缺页或其他原因导致的阻塞不至于让CPU空闲。对于IO密集型任务等待IO的时间远远大于执行计算的时间所以要开更多的线程来填满CPU的空闲窗口。经典公式大致是线程数 CPU核心数 × (1 IO等待时间 / CPU计算时间)。如果IO等待时间是计算时间的10倍那核心数为8时线程数可以开到88左右。工程上动态线程池和压测调整是更实际的做法公式的价值更多在于告诉我们线程数不是拍脑袋定的它跟任务特性和硬件资源直接相关。我开发一个网络抓取服务时一开始就把线程池配成500。结果系统的调度开销一下子飙升抓取速度反而下降了。后来改成线程数从32开始通过压测一步步往上调整找到那个吞吐量最优的平衡点。这个过程让我深刻体会到线程配置是一个需要实测调优的参数而不是一个固定值。4.3 脚本语言的特例GIL锁如果你的主力语言是Python这类带GILGlobal Interpreter Lock全局解释器锁的脚本语言那多线程的情况就有点特殊了。GIL是一把作用在解释器层面的大锁。因为CPython的内存管理并不完全线程安全为了避免多个线程同时操作对象内部的数据结构解释器强制同一时刻只能有一个线程执行Python字节码。有一个很典型的例子一个纯计算密集型的Python程序用多线程去跑结果发现性能不仅没有提升反而可能在多核CPU上甚至比单线程还慢。因为GIL的存在多线程根本没有真正在并行执行。那Python里的多线程就毫无意义了吗不是的。如果任务是IO密集型的比如网络请求、文件读写线程在执行IO操作时会把GIL释放掉其他线程就能趁机运行。这种场景下多线程仍然能提升整体效率。比如用Python的requests库同时发起多个HTTP请求多线程版本的效率明显高于单线程版本。想绕过GIL的限制有几个常用手段用multiprocessing模块做多进程让每个进程都有自己的GIL各跑各的可以充分利用多核或者把CPU密集的核心逻辑用C/C扩展实现在扩展内部释放GIL再或者直接选择其他没有GIL解释器的语言来跑那种重计算的任务。搞清楚GIL的作用边界才不会被“Python多线程性能差”这句话误导。4.4 通信模式并发任务之间如何处理数据多线程共享内存通信方便但同步问题多多进程内存隔离通信麻烦但可靠性更高。工程上我见过最常用且比较容易维护的进程间通信模式是消息队列。它的核心思路是让各个进程之间不直接打交道而是通过一个集中式的队列中转数据。生产者进程把数据放到队列消费者进程从队列里取数据双方解耦。线程间的“通信”则更多体现在如何安全地传递共享数据上。除了前面说的锁和原子类还有一种实践中很好用的模式是“不可变数据加新数据发布”。意思是不去修改已有的共享对象而是构造一个新的对象然后通过一个原子引用把新对象“发布”出去。其他线程在读取时看到的就是一个完整的新版本不会碰到数据写到一半的中间状态。这种思想的变体在很多框架里都能看到影子我用它解决过好几个状态同步的并发问题。工具层面语言自带的各种并发队列、线程池、异步框架是首选。自己手写队列来传递数据除非你是为了学习否则在生产环境里真的没必要一不小心就会写出复杂的死锁bug。5. 并发编程的两个高阶问题与优化手法5.1 伪共享一个看不见的性能杀手缓存系统中数据不是一字节一字节被加载的而是按“缓存行”为单位操作。一个缓存行通常是64字节包含了相邻地址的多段数据。如果两个变量刚好落在同一个缓存行里又分别被两个不同的线程频繁修改那这两个线程所在的CPU核心会不断因为缓存一致性协议去同步这个缓存行互相拖累。看起来两个线程互不干扰实际性能却差得一塌糊涂这种现象叫伪共享。我之前优化过一个计数器统计模块总感觉多线程性能不符合预期。用性能工具分析后发现时间大量消耗在缓存一致性的同步上。解决方法也简单把每个线程独立使用的计数器之间填充一些无用字节让它们落不到同一个缓存行里。这是一种典型的空间换时间策略优化效果非常明显。5.2 锁的粒度选择用最合适的粒度保护数据锁粒度太粗会让并发度很低明明可以并行执行的代码都排队等了锁粒度太细保护范围不完整又会留下竞争条件和并发bug的隐患。这是并发设计里特别需要斟酌的问题。我的经验是默认优先用“细锁”只在确实存在性能瓶颈时才考虑粗化。原因是细锁意味着能并行的部分更多性能上限更高。但细锁也意味着锁的获取次数更多潜在死锁的风险更大。工程实践上可以先用一个粗粒度锁把代码跑通保证逻辑正确再通过性能剖析确认热点决定要不要细化和优化。先求对再求快是我在并发优化上一贯的原则。另外锁的持有时间那就是另一个关键尽量缩短持锁时间把耗时的IO操作放到锁外。如果一个锁保护的是大段业务逻辑那这个锁基本就把并发的意义给抹掉了。6. 从概念到实战三步把它用起来6.1 先动手写一个“会出错”的程序纸上谈兵学并发效率最低。我的建议是先写一个肯定会出问题的并发程序亲眼看看数据错乱、死锁、最终结果不对是真实存在的。写个多线程程序每个线程同时给一个共享变量加10000次不加任何同步跑下来看最终结果是不是精确等于线程数乘以10000。几乎可以肯定不是。这个实验特别有价值它会让你对“并发问题不是理论而是现实”建立起牢固的认知比看十篇讲解都管用。6.2 学会用工具和方式调试并发bug排查的常规痛点在于“不稳定复现”。同一个程序这次跑错下次跑对让人怀疑人生。这时候一定要学会用工具和方式。线程转储、竞态检测器、性能剖析器这类工具能帮你从“看代码找逻辑错误”升级到“看系统和运行态找问题”。比如Java里的jstack可以抓线程状态直觉定位到死锁线程在哪把锁上等着Go的竞态检测器可以在测试阶段就抓出数据竞争Linux上的perf能看上下文切换频率和锁等待时间。这些工具没法解决所有并发bug但能把排查范围大幅缩小。6.3 从优秀源码中学习并发思想最后一步也是最容易被忽略的一部抄优秀代码的作业。很多知名开源项目里都藏着高质量的并发设计。比如数据库连接池是怎么控制并发获取连接的消息中间件是怎么处理生产者和消费者之间的并发关系任务调度框架是怎么避免死锁的这些问题的答案都在源码里。我自己每次读这类框架的并发模块收获最多的不是某一行代码炸不炸而是作者设计时对“哪些数据必须共享”“哪些必须隔离”“什么时候要锁”“什么时候用原子操作”的取舍思路。并发编程的能力很大程度是在这种取舍感觉中积累起来的。我个人在实际操作中的体会是掌握并发编程的核心不在于背住多少概念定义而在于建立一种“并发意识”写每一行涉及共享数据的代码时下意识都会想一想这里会不会有竞争要不要加大锁可见性有没有保证这个数据能不能设计成每个线程私有这种意识一旦建立起来再看那些“并发很难”的抱怨你会发现自己已经具备了一种完全不同的思维方式。想巩固的话找一段以前写过的单线程程序试着用学到的机制去优化它在真实场景里体会一次并发的收益和代价你会收获更多。