ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

进程、线程、协程怎么选?从原理到实战彻底讲透

进程、线程、协程怎么选?从原理到实战彻底讲透 不少刚接触并发编程的同学第一次看到进程、线程、协程这三个概念时很容易陷入一种“好像懂了又好像没懂”的状态。背概念的时候都明白一写代码就不知道该用哪个。我在带新人或者做技术分享时经常被问到同一个问题它们到底有什么区别实际项目里我应该优先选哪个这篇文章不打算照搬教科书定义我尽量用实战中容易理解的方式把进程、线程、协程这三个东西的底层逻辑、核心区别、选型思路一次讲透。看完之后至少你在设计系统时能清楚地知道某个场景为什么用线程而不是协程或者为什么用进程隔离比线程共享更合适。1. 从一段最简单的代码说起它们到底在管理什么我们先不急着下定义。想象你写了一个非常简单的单线程程序比如读取一个文件然后打印内容。当这个程序跑起来之后操作系统会为它分配内存、打开文件描述符、记录它的运行状态。这时候这个正在运行的程序实例就是进程。进程是操作系统进行资源分配的基本单位它拥有一套独立的地址空间、文件描述符表、信号处理器等。线程是操作系统进行任务调度的基本单位它是进程内部的一个执行流。同一个进程内的线程共享进程的内存空间。但这两个定义有一个问题它们都是从资源归属的角度来看的没有解释“为什么需要它们”。我自己的理解更倾向于把它们看作不同层级的任务管理容器。打个比方你可以把进程想象成一家独立的餐厅。餐厅有自己的地址虚拟内存、自己的厨师团队、自己的食材库房。而线程就是餐厅里的厨师每个厨师可以独立完成做菜任务但他们共享同一个库房和菜单。如果某个厨师打翻了油锅线程崩溃整个餐厅都可能面临停业整顿进程崩溃。而协程则是厨师之间互相协作的一种方式不用叫停整个厨房而是某个厨师在等菜炖熟的间隙主动去帮另一个厨师切配菜等炖菜好了再回来继续。概念先放一边更关键的问题是这三个层级分别解决了什么问题只有理解了这一点你才能在做技术选型的时候有清晰的判断依据。2. 进程资源隔离的“重型武器”进程的核心特征是隔离这是它的生命线。每个进程有独立的地址空间一个进程崩溃不会直接影响另一个进程。这种隔离性带来两个直接好处一是稳定性强二是安全边界清晰。2.1 进程做了什么线程做了什么操作系统在创建进程时需要完成的事情比创建线程多得多。它要分配新的虚拟地址空间建立页表映射初始化进程控制块分配内核栈。这些操作的代价都很高。相比之下创建线程只需要分配一个线程控制块和一个内核栈因为线程直接复用进程已经建立好的地址空间。这也解释了为什么进程间通信比线程间通信麻烦。进程因为地址空间隔离必须借助管道、消息队列、共享内存、Socket等机制来交换数据。而线程因为共享地址空间直接读写共享变量就行效率高得多但代价是需要自己处理同步互斥问题。我在实际项目里见过不少滥用进程的场景。比如某团队做了一个爬虫系统为了抓取效率每来一个任务就开启一个新进程。任务多的时候系统里飘着上百个进程内存占用直线飙升还经常因为文件描述符耗尽导致整个服务不可用。最后改造为进程池加协程资源占用降了60%吞吐量反而上去了。2.2 进程的适合场景所以进程最适合的场景通常是这些需要强隔离性的服务比如浏览器的标签页一个标签页崩溃不影响其他标签页。多租户场景需要严格隔离不同用户的数据和资源。需要利用多核CPU能力且各任务之间关联度低的场景比如分布式计算框架里的worker进程。需要使用第三方不可靠库但又不想因为它的崩溃影响主业务的场景比如通过子进程调用某个不稳定的图像处理库。进程的缺点是切换开销大。上下文切换时需要保存CPU寄存器、程序计数器、内核栈还要刷新TLB快表代价比线程切换高一个数量级。如果你只是需要并发执行IO密集任务比如同时处理几万个网络连接那么为每个连接创建一个进程是不现实的。3. 线程轻量级但还是有“上下文切换税”线程的诞生是为了更轻量地实现并发。它共享进程的地址空间所以创建成本远低于进程切换成本也相对更低。但线程带来的最大挑战是并发控制。3.1 线程切换到底贵在哪里很多人以为线程切换只是从线程A跳到线程B其实内核态需要做不少事情。保存当前线程的寄存器状态、程序计数器更新线程控制块然后加载新线程的状态。如果这两个线程属于不同进程还得切换地址空间、刷新TLB这种场景下切换代价会暴增。即使是同进程内的线程切换也需要经过内核态存在用户态到内核态的陷阱这部分开销在极高并发下会被放大。线程并发模型还有一个经典的敌人竞态条件。多个线程同时修改共享变量时如果不用锁保护轻则数据错误重则死锁。我见过一个支付系统的真实故障两个线程同时扣减同一个用户余额因为没加锁结果余额被扣成了负数。表面上看是业务逻辑问题本质上就是线程共享地址空间带来的后果。加了锁之后问题并没有消失而是变成了锁竞争。当并发量超过一定程度线程的大量时间都花在等待锁上CPU利用率上不去反而比单线程性能还差。这也是为什么很多高性能系统比如Redis虽然严格说是单线程事件循环但它设计的核心思路是避免多线程锁竞争、Nginx早期版本以单线程多路复用为主打宁可不用多线程也要避免锁竞争带来的不确定性。3.2 线程的适用场景线程适合那些需要共享大量状态、任务间通信频繁的场景。比如一个业务服务内部不同模块需要访问同一个缓存、同一个配置中心用多线程可以方便地共享数据。但要注意共享是有成本的在设计时就应该尽量把共享区域缩小尽量用不可变数据替代可变共享状态。线程池是管理线程的常用手段它解决的是频繁创建和销毁线程的开销问题。但线程池也有坑比如线程数设置得过大导致上下文切换频繁或者任务队列积压导致响应延迟飙升这些都需要靠压测和监控来验证调整。4. 协程用户态调度的“轻骑兵”协程的出现本质上是为了解决线程在高并发IO场景下的两个痛点内存开销大、切换代价高。协程最大的特点是它的调度不依赖操作系统内核而是由应用程序自己控制。4.1 协程的调度是怎么实现的线程是抢占式调度操作系统随时可能把CPU从当前线程切走应用程序无法精确控制切换时机。而协程是协作式调度一个协程主动让出CPUyield或者被调度器挂起通常是遇到异步IO等待时才会切换切换完全发生在用户态。正因为切换不需要操作系统参与所以协程切换的开销非常小通常比线程切换快一两个数量级。同时协程的内存占用也低得多一个线程默认栈大小往往是1MB到8MB而一个协程的栈往往只有几十KB甚至可以通过动态增长来进一步压缩。这意味着在内存有限的条件下协程可以创建成千上万个而线程只能创建几百个。我用一个实际项目来说明协程的价值。之前参与过一个IM网关服务单机需要维持数万条长连接。如果为每个连接分配一个线程按每个线程1MB栈空间算五万个连接就需要50GB内存完全不可行。换用协程之后每个连接分配一个协程内存占用控制在几百MB以内同样的机器可以支撑更大的连接数。但协程也不是银弹。它对CPU密集型的任务没有帮助因为协程本质上是单线程内的并发利用不了多核。而且协程代码写起来虽然直观但调试起来比线程麻烦得多。协程的调用栈在异步调度时会片断化出错时的日志信息往往难以定位。4.2 各种语言对协程的实现差异不同语言的协程实现差异很大这一点选型时要注意Go语言里的goroutine它其实是混合调度有内核线程池多个goroutine会映射到多个线程上可以充分利用多核只是调度逻辑在用户态。所以Go的并发模型严格说不是纯协程更准确说是“协程化调度”加“多线程绑定”这也是Go能在高并发场景表现出色的原因之一。Python里的asyncio这是典型的单线程事件循环加协程其实是单核并发的。Python的GIL全局解释器锁也限制了多线程执行CPU密集任务的效率所以asyncio主要适用于IO密集场景比如大量网络请求、文件IO。Python协程比较适合写异步爬虫、异步API服务但跑重计算任务就不推荐了绕不开GIL。C#里的async/await和Java的虚拟线程它们的理念是从语言层面提供简洁的异步编程模型尽量降低并发编程的心智负担。Java虚拟线程是JVM管理的用户态线程设计目标就是让高并发应用可以像编写普通线程一样编写但底层由JVM调度。5. 核心区别对照三张表理清本质概念讲完了我把它们放到一个框架里对比看起来更直观。下面这几张表按不同维度拆开了。对比维度进程线程协程资源拥有者独立地址空间、独立文件表共享进程地址空间和其他资源共享线程栈和上下文由线程驱动调度者操作系统内核操作系统内核应用程序或语言运行时切换方式抢占式抢占式协作式主动让出上下文切换代价高涉及地址空间切换、TLB刷新中涉及内核态切换低纯用户态切换创建和销毁代价高中低并发模型多进程并发多线程并发单线程内多路复用并发数据共享方式需要IPC机制管道、消息队列等直接共享内存需同步机制直接共享变量同一线程内无需锁崩溃影响进程间隔离互不影响线程崩溃可能导致整个进程崩溃协程异常会传导到所属线程适用偏向CPU密集、需要隔离、稳定优先IO密集且任务较复杂、共享状态多IO密集、海量连接、高并发低内存压力从表里能看出选择哪种模型不只是看“哪个更高级”而是要看对稳定性的要求、对内存的预算、对并发的规模。另一个维度的对比是“并发单元数量级”。一个8GB内存的服务器可以用大约几百个线程上万个协程几十个进程。这个数据虽然没有固定标准但我实测下来线程数量过千后上下文切换开销会明显增多而协程数量过万时只要IO等待可控、调度器足够健壮系统依然能保持不错的响应。还有一点值得补充协程并不替代线程更不替代进程。如果你的业务里既有CPU密集计算又有大量网络请求比较典型的设计是“线程池加协程”。线程池里的每个线程当作协程的“载体”或者说调度器多个协程在同一个线程上交错执行。这样做的好处是既充分利用多核又能处理高并发IO。Go运行时其实就是这样设计的。6. 应用场景选型指南到底怎么选这一节应该是很多人最关心的。我根据自己的项目经验把选型思路整理成一套可以快速参考的决策流程。遇到一个新的并发场景先问自己几个问题。6.1 先判断任务的类型CPU密集还是IO密集CPU密集型的任务比如视频编码、数据处理、科学计算核心目标是榨干CPU的多核算力。这种情况下优先考虑多进程因为进程之间隔离性好可以各自跑在不同核心上不存在GIL这种限制也不受锁竞争拖累。如果一定要用多线程要确保计算过程中不会频繁争抢锁。IO密集型的任务比如网络服务、消息队列消费、数据库访问瓶颈在等待IO完成CPU大部分时间在空转。这时候进程和线程的切换开销就成了纯浪费协程的优势能得到最大发挥。在支持良好协程的语言里比如Go、Python的asyncio、Kotlin的协程直接选协程。有一种任务比较棘手比如流式计算既有大量IO读取又有复杂的聚合计算。这时不要试图只用一种模型可以采用“多进程加多协程”的组合每个CPU核心分配一个进程进程内部用协程处理IO密集操作CPU密集操作通过调用专门的计算线程池来执行。架构上复杂一些但性能和稳定性都远好于单一模型。6.2 再考虑你要不要隔离隔离不是免费午餐但有些场景必须付出这个代价。比如你正在做一个插件系统第三方插件可能是不受信任的如果通过线程加载插件插件崩溃很可能带崩整个宿主进程。我见过一个编辑器工具因为某个第三方插件用了C扩展库直接段错误整个应用崩溃用户未保存的文档全丢了。如果把插件放在独立进程里运行通过本地Socket通信即使插件崩溃也能拉起重启用户体验就好很多。相反如果各个任务之间需要频繁交换数据而你又希望延迟尽量低那进程间通信带来的序列化拷贝开销是不能接受的。这时候用线程加锁或读写共享状态是合理的但要精心设计锁粒度尽量用原子操作和无锁数据结构。比如一个实时金融行情推送系统多个柜台线程不断更新行情快照其他线程读取快照这个场景用进程还是线程要慎重衡量但很多人最终选择了线程加读写锁或内存屏障为的是获得更低的延迟。6.3 还要看你的团队熟悉什么语言这一点往往是最务实的。Go语言天生有goroutine加持写高并发服务非常顺手Java生态最近几年虚拟线程也越来越成熟Python的asyncio虽然可以处理高并发IO但要小心编写异步代码一旦混入同步阻塞调用整个事件循环会被卡住这在不少项目里都造成过线上事故。如果是C项目你既可以用系统线程也可以用协程库。但C协程对开发者要求偏高涉及内存生命周期管理一旦掌控不好容易出现悬垂引用和内存泄漏。每个团队的能力禀赋不同能维护好哪种模型往往比“哪种模型理论上更好”更重要。7. 代码层面的直观对照同样并发三种写法为了更直观地体现三者的差异我用一个非常常见的场景来看并发发起N个网络请求。你可以把它想象成爬虫批量抓取网页或者微服务调用批量查询数据。这里只做示意级代码不追求完整的可运行工程。7.1 多进程并发的写法多进程方式适合那种子任务之间完全独立、不需要共享状态的场景。比如通过Python的multiprocessing来并发下载文件from multiprocessing import Pool def download(url): # 模拟网络下载 return fdownloaded: {url} if __name__ __main__: urls [fhttp://example.com/{i} for i in range(10)] with Pool(processes4) as pool: results pool.map(download, urls) print(results)这种写法的好处是每个下载进程之间隔离干净任何一个崩溃不会影响其他。但缺点也很明显启动多个Python进程的开销不小如果只是做几十个轻量请求完全没必要上进程。而且进程内如果再用锁的话跨进程的锁不像线程锁那么简单信号量和共享内存机制都更复杂。多进程真正有优势的场景是计算密集且可以天然拆分成独立子任务的算法比如图像批量处理一个进程处理一批图片充分利用多核。7.2 多线程并发的写法多线程是很多Java服务端工程师最熟悉的模型。用线程池来执行一批请求ExecutorService executor Executors.newFixedThreadPool(10); ListFutureString futures new ArrayList(); for (String url : urls) { futures.add(executor.submit(() - fetch(url))); } for (FutureString future : futures) { System.out.println(future.get()); } executor.shutdown();用线程池看起来很简单但这里有一个隐含的问题如果阻塞任务很多线程池里的线程一直占用着池子很容易被打满。想象你同时发起1000个请求线程池核心线程数是10最大线程数是50那么绝大多数请求都会在队列里排队。如果每个请求平均耗时1秒队列排到几十秒甚至几分钟用户体验瞬间崩溃。这时候可以通过配置队列策略、调整拒绝策略、设置超时来处理但本质上即便你用线程面对海量阻塞IO时依然会面临资源天花板。线程不是为“成千上万同时阻塞”这种场景设计的。7.3 协程并发的写法Go的写法最能体现协程的价值var wg sync.WaitGroup for _, url : range urls { wg.Add(1) go func(u string) { defer wg.Done() resp, err : http.Get(u) if err ! nil { log.Printf(fetch %s error: %v, u, err) return } resp.Body.Close() }(url) } wg.Wait()这段代码里goroutine之间几乎没有显式共享状态每个goroutine只是发一个网络请求等待响应回来。Go调度器会把这些goroutine合理分配到多个线程上执行遇到IO阻塞就挂起转去执行其他就绪的goroutine线程数不需要太多CPU利用率却可以维持高位。从这三种写法可以总结出一个规律进程和线程的数量都受系统资源硬约束而协程数量可以轻松达到上百万级别前提是你的调度器写得好语言运行时足够健壮。8. 常见问题与排查技巧实录这部分内容来自我这些年看过的真实故障和调试经验。很多问题不是概念理解有误而是对性能和调度机制理解不足而踩坑。8.1 线程池太大还是太小怎么判断线程池完全是“设了就好”的想法往往导致问题。线程数设置太大上下文切换开销超过计算时间系统吞吐量反而下降设置太小CPU利用率不足任务排队延迟严重。经验公式首先看任务是CPU密集还是IO密集。CPU密集型的建议线程数等于CPU核心数或核心数加一IO密集型的可以粗略使用“核心数乘2加1”作为起点但真正的标准是靠压测调优。你可以在压测环境里从低线程数逐步递增观察系统吞吐量和P99延迟的拐点。如果线程数加到某个值之后吞吐量不再明显提升说明已经到顶了。还有一点容易忽略线程栈大小。有些系统默认给线程分配1MB的栈一个应用如果创建了500个线程光栈内存就占500MB。如果能在创建线程时显式指定较小栈大小可以显著降低内存占用但要注意递归深层调用可能栈溢出。8.2 协程泄漏怎么定位协程泄漏是指协程数量只增不减像内存泄漏一样最终拖垮整个进程。常见原因是协程进入了阻塞等待比如在协程里调用了同步IO操作或者等待某个channel但一直没人写入。我的排查经验是先看指标如果你的服务暴露了协程数量指标正常情况下应该是平稳的一旦出现持续上升就该警告了。接着抓一下协程栈看它们卡在什么地方。Go项目可以用runtime/pprof里面的GoroutineProfile来分析Python的asyncio可以用asyncio.all_tasks()来查看当前所有未完成任务。定位到是某个协程永远在等一个channel或者等一个Future就要检查是谁在往这个任务发送结果。如果发送方因为异常没执行接受方就会一直等。解决思路是同步引入超时控制异步引入看门狗定时重启异常链路。8.3 多进程还是多线程导致的死锁死锁最常见的原因是锁顺序不一致。两个线程各自持有一把锁又同时去获取对方手中的锁双方僵持不下。排查时先抓线程栈看看每个线程持有什么锁、在等什么锁。然后画一个锁依赖图看是否存在循环依赖。这里有一个不太起眼但很实用的技巧统一加锁顺序。比如要求所有代码必须先加A锁再加B锁禁止倒序。如果代码里已经存在问题代码一时改不完可以在锁外面加超时获取机制获取不到就释放自己的锁并重试打破死锁的僵持。另外除了锁顺序还有一个隐形原因是线程池中任务嵌套等待。比如线程A向线程池提交任务B然后A等待B完成而B又要等线程池里有空闲线程才能执行结果池永远是满的B永远排不上队。这类死锁往往出现在复杂的任务协作逻辑中排查时要特别注意任务依赖图和池大小的关系。8.4 IPC通信的坑多进程通信比大家想象中更复杂。我用消息队列做进程间通信时踩过一个坑一旦消费者进程处理变慢消息队列里的消息越积越多内存占用持续上升。更危险的是某些消息队列实现中如果消息体里含有大量二进制数据队列的内存拷贝会进一步放大问题。解决方案是尽量控制消息体积不要传输大块数据。如果必须传大文件或大数据块走共享内存或本地文件系统不要全部塞进IPC通道。还有多个进程同时写同一个队列时要确认队列实现本身是否线程安全不安全的队列被并发写入会导致数据错乱这种错误特别难排查因为偶发且没有规律。9. 一些真实的心得体会写了这么多最后聊聊我自己的感受。进程、线程、协程这三层不是替代关系而是互补关系。它们分别在不同层次上解决了效率和隔离的矛盾。初学者很容易陷入“协程是最厉害、什么都要用协程”的误区但真实的高质量系统里往往是三者混用的。进程负责顶层应用隔离线程负责利用CPU多核并承载任务执行协程负责海量IO并发的调度效率。我自己在做系统设计时最优先考虑的往往不是性能极限而是稳定性和可维护性。并发模型选型如果过于复杂会导致团队后续维护成本暴增线上排查问题也像大海捞针。所以在满足业务需求的前提下尽量选团队最熟悉、心智负担最小的模型。如果在协程和线程之间犹豫我的建议是先估算一下并发规模。单机并发连接数在几千以下线程模型完全扛得住如果到了几万甚至几十万协程模型基本是必要选择。而CPU密集型高并发计算几乎没有争议直接上多进程配合调度框架把数据分发下去。最后一个小技巧无论最终选择哪种模型一定要有完善的监控图标。进程数量、线程数量、协程数量、任务队列长度、锁等待时间、协程阻塞时间这些都是最基础也最关键的指标。没有监控你连出问题时都不知道该看哪里。很多线上诡异故障最后追溯下来都是因为一开始没有埋好指标观察点。这一套组合拳打下来并发这块的基本盘算是稳稳地拿住了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进