ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

进程线程协程对比:从资源开销到并发选型实战

进程线程协程对比:从资源开销到并发选型实战 先说我自己的一个经历。几年前我接手一个文档转码服务最初每个任务都开一个进程去处理跑到百路并发时服务器物理内存直接被打满光是创建进程的开销就吃掉大量资源。后来改成线程池内存问题缓解了但任务里有大量等待外部服务响应的场景线程都被“等”住了眼睁睁看着几百个线程挂着不动。最后用协程重写同一台机器并发量翻了差不多二十倍。从进程到线程再到协程我是真刀真枪体会到了“重量级”到“轻量级”到底差在哪。这篇文章不打算重复教科书定义我想从资源开销、切换成本和工程选型三条线把进程、线程、协程这条演进脉络说透。无论你平时写Java、Python、Go还是C这套底层逻辑都是通用的尤其是看到“进程池”“线程池”“协程”这些词不再发怵能直接判断自己的项目该用哪一种。1. 演进主线为什么并发模型从进程一路走向协程1.1 进程是“一套房子”资源重但在物理隔离上很可靠早期网络服务最朴素的并发方案是“一个连接一个进程”。每来一个客户端请求服务端就fork出一个子进程去处理处理完就退出。这种方式好处非常明显进程和进程之间拥有独立的虚拟地址空间一个进程崩了大多数情况下不会影响其他进程。Linux的1号进程也就是init/systemd就是所有进程的老祖宗pstree一眼就能看到整棵进程树。但它的问题也致命。进程的创建和销毁成本很高创建一个进程需要分配独立的地址空间、页表、文件描述符表、内核栈和用户栈还要经历系统调用。想象成每次来客人你都给他租一套独立房子住家具、水电、钥匙、门锁全部重新配齐。房子多了物业操作系统管理成本直线上升几千个连接就是几千套房子内存和CPU都扛不住。更麻烦的是跨进程通信简称IPC。两套房子里的人想协作得通过管道、共享内存、消息队列或者Socket传话路上还有序列化、拷贝的开销。C10K问题就是这么来的一台服务器想同时撑住一万个连接如果用“一连接一进程”内存早就爆了。所以进程本身不是错错在它太重。它的存在价值是“隔离”和“稳定”直到今天依然关键。Nginx的master进程和worker进程Electron的主进程和渲染进程都是多进程模型的优秀实践。它解决的问题是其他模型给不了的单点崩溃不扩散。1.2 线程的诞生房价太贵于是合租既然每个人分一套房太贵能不能让大家合租在一套房子里每人一个房间这就是线程的思路。线程共享进程的地址空间、文件描述符和大部分全局资源只需要独立的栈、寄存器上下文和少量线程控制块。创建线程的成本比进程低一个量级同一进程内的线程通信也简单太多直接通过共享变量就能交换数据不需要走内核IPC。代价是合租房里没有隐私了。两个线程同时改同一个变量就会出现竞争条件。经典场景是Java里多个线程同时执行count 1这条看起来简单的语句在底层其实是“读-改-写”三步没有同步保护时结果一定不对。所以线程模型离不开锁、原子类、并发容器这些配套工具Java里常说“类是否线程安全”本质上就是在回答这个类在多个线程同时访问时内部状态还能不能保持一致。线程的劣势还没完。线程之间切换依然发生在内核态每次切换都要陷入内核保存和恢复寄存器、程序计数器、栈指针虽然不用换页表但系统调用的开销省不掉。线程一多调度器压力也大常见的Java服务端线程池配几百个线程就差不多了再往上加线程上下文切换本身就能吃掉大量CPU。很多线上服务CPU不高但响应很慢一查线程全在BLOCKED或者WAITING状态就是典型的“线程太多都在等”。线程解决的是“共享状态下的中等并发”。到今天它依然是最通用的并发方案因为操作系统原生支持、语言支持成熟、调试工具多。只是当并发规模再上一个台阶时线程的栈内存和切换成本又会成为瓶颈。1.3 协程再进一步不搬家也不换房只需要把“椅子”让出来线程解决了多任务并行的问题但没解决“等待”的问题。举个例子线程A发起一个网络请求后需要等响应回来才能继续干活等待期间线程只能挂起白白占用内核调度资源。高并发I/O场景下大量线程不是在计算而是在等网络、等磁盘、等数据库。协程的思路很巧妙既然任务本来就是在等那就主动把CPU让出来让其他任务先跑。协程是用户态调度的在不同语言里实现方式差异很大但本质都类似“协作式调度”。协程主动yield或者await调度器就去执行下一个协程等I/O就绪了再回到原来的地方继续。这就像合租房里不再按“谁占房间”来分配资源而是按“谁在说话”来调度。一个人问完问题就去旁边坐着等答案把椅子让给下一个人问。不用搬房子不用换房间只需在椅子上让个位。所以协程能达到极高的并发量级一个线程内可以轻松挂几万甚至几十万个协程内存占用远小于线程栈。用户态调度是协程“轻量”的关键切换协程只涉及保存少量寄存器和栈帧完全不触发系统调用既不陷入内核也不需要锁成本比线程切换低一两个数量级。Go的goroutine是协程的超集带工作窃取的M:N调度器Python的asyncio是事件循环驱动的协程Kotlin协程是编译器加运行时在库层做调度C20也原生支持无栈协程用编译器生成状态机来调度。2. 把账算清楚进程、线程、协程的资源开销对比2.1 一张表直接看清差异下面这张表我从创建成本、切换成本、调度主体、内存、隔离性、通信方式和典型应用几个维度做了对比基本覆盖了选型时需要考虑的全部关键点。对比项进程线程协程创建成本高需要分配地址空间/页表/文件描述符中共享地址空间只需栈和控制块极低用户态创建几KB初始栈即可切换成本最高涉及页表切换、TLB失效中内核态切换需陷入内核极低用户态完成只存少量寄存器和栈帧调度主体操作系统内核操作系统内核用户态运行库/事件循环/运行时内存空间独立虚拟地址空间互相隔离共享进程地址空间每个协程有独立栈但同属于一个线程地址空间通信方式IPC管道/共享内存/Socket成本高共享内存加锁成本低但有竞态代码内直接通信/共享变量简单直接故障影响进程崩溃互不影响一个线程崩溃可能拖垮整个进程协程异常需要显式捕获避免传导并发上限受内存影响通常几千以内通常几千到几万会吃紧同线程内几万到几十万很轻松典型代表nginx worker、Electron主/渲染进程Java线程池、Tomcat请求线程Go goroutine、Python asyncio、Kotlin协程、C20协程这张表里最值得关注的不是内存大小而是“调度主体”这一行。进程和线程的调度权都交给操作系统内核协程则把调度权拿回用户态。我们在写代码时感受不到这个差异但它直接决定了切换成本也决定了并发上限能拉到多高。2.2 上下文切换的“贵”核心是内核态穿越为什么进程切换比线程切换更贵进程切换不只是换栈和寄存器还要切换整个虚拟地址空间也就是要刷新页表、重载CR3寄存器TLB全部失效。这意味着进程切换后新进程访问内存时大概率TLB不命中CPU冷缓存性能一落千丈。因此同样是切换进程比线程重得多。线程切换虽然不需要换地址空间但线程切换仍然在内核态完成。只要线程调用阻塞操作或者时间片耗尽CPU就要陷入内核由内核调度器决定下一个执行哪个线程然后保存当前线程上下文、装载下一个线程上下文。这整个过程涉及用户态到内核态的穿越是昂贵的系统调用级别操作。协程则完全不同。协程的调度逻辑在用户态切换只是把一个协程的局部变量、寄存器、程序计数器保存到自己的栈或上下文中然后恢复另一个协程的上下文完全不需要陷入内核也不需要锁整个切换等价于一次普通函数调用的开销。量级上进程切换通常是数微秒级线程切换是微秒级协程切换能做到几百纳秒。虽然单次差异不大但在每秒几十万次切换的高并发场景下差距就会天差地别。这也解释了为什么Go语言能轻松跑起上百万个goroutineJava传统线程模型跑几十万线程就会因为栈内存和切换开销直接崩盘。不是线程做不到高并发而是内核态调度的成本摆在那里数量一高就成瓶颈。JDK 21引入虚拟线程本质也是为了绕过这个瓶颈。2.3 内存模型的不同隔离、共享与动态栈进程的内存隔离是重量级的根源。每个进程有自己的虚拟地址空间也就是每个进程都有一套独立的“映射表”牺牲了通信便利换来了崩溃隔离。适合多租户、需要高稳定性的场景比如浏览器里一个标签页崩了不影响整个浏览器靠的就是多进程架构。线程共享进程地址空间好处是通信快坏处是安全性差。一个线程越界写坏了堆内存整个进程都可能崩溃。所以Java里大量的面试题都在讲线程安全和并发容器本质上就是在解决“共享内存导致的状态混乱”。避免这类问题的主要手段用不可变对象、无状态设计、ThreadLocal、原子类和并发集合把共享降到最低。协程的内存模型又不一样。每个协程都有一块独立栈但栈不初始分配很大空间而是可以动态增长初值栈只要几KB就够远比线程默认的MB级别栈小。所以同样的内存进程能开几百个线程能开几千个协程能开几十万个。再加上协程无需内核对象创建和销毁几乎零成本才能支撑起大规模连接并发。3. 实操演示用Python和Java把三种并发形态跑起来3.1 Python实测I/O密集任务三种方案差异明显我用一个简单但能说明问题的场景100个任务每个任务模拟一次50毫秒的I/O等待。分别用进程池、线程池、协程跑看实际完成时间。代码很直白可以直接复制跑。import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor import asyncio def work(n): time.sleep(0.05) return n async def awork(n): await asyncio.sleep(0.05) return n def run_thread(count): start time.perf_counter() with ThreadPoolExecutor(max_workers10) as pool: list(pool.map(work, range(count))) return time.perf_counter() - start def run_process(count): start time.perf_counter() with ProcessPoolExecutor(max_workers4) as pool: list(pool.map(work, range(count))) return time.perf_counter() - start async def run_coro(count): start time.perf_counter() await asyncio.gather(*(awork(i) for i in range(count))) return time.perf_counter() - start if __name__ __main__: n 100 print(thread:, run_thread(n)) print(process:, run_process(n)) print(coro:, asyncio.run(run_coro(n)))我这边实测的结果大概是进程方案2到3秒线程方案0.5到0.6秒协程方案约0.05秒。协程为什么能做到接近理论极限因为100个异步任务全都并发挂起5毫秒等待几乎是并行发生的。线程方案其实也在并发执行但线程调度和上下文切换的痕迹很明显而且线程数量有限10个线程需轮流处理100个任务必然慢一些。进程方案最慢主要成本花在创建进程、fork出独立地址空间以及Python进程池本身的任务分发上。你需要知道这里有个特例Python的time.sleep会释放GIL所以线程方案没有被全局锁拖垮。如果换成纯CPU计算比如大量整数运算Python多线程会被GIL卡成一个线程的效果那时候进程池才是正解。这也引出Python社区的一个重要动态官方在实验性的free-threaded模式下逐步去GIL但生产上稳妥的做法依然是I/O密集用协程或线程CPU密集用ProcessPoolExecutor。3.2 线程池和进程池的核心参数先干活还是先排队Java里最常用的是ThreadPoolExecutor很多老项目用的是Executors.newFixedThreadPool封装但封装会隐藏底层的队列选择问题。直接掌握底层参数才是王道。ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 30L, TimeUnit.SECONDS, // 空闲线程回收时间 new ArrayBlockingQueue(1000), // 有界阻塞队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );核心线程数和最大线程数怎么定经验公式CPU密集任务用CPU核数或者核数加1因为CPU密集任务几乎不会让出计算资源线程数超过核心数只会增加无谓的切换I/O密集任务建议配置为CPU核数的两倍左右因为大量线程在等待I/OCPU有空闲可以切换执行其他任务。更精确的公式是“核心数 / (1 - 阻塞系数)”阻塞系数越高线程数就可以越多。任务进来后的执行顺序很多人理解反了不是先创建到最大线程数再排入队列而是先让核心线程干活核心线程满了就排队队列满了才会创建额外线程直到最大线程数队列和最大线程数都满了才触发拒绝策略。也就是说队列的核心地位超乎想象。阻塞队列选型上ArrayBlockingQueue有界能保护内存不被无限任务撑爆但队列满了会触发拒绝策略业务上要考虑拒绝后的补偿LinkedBlockingQueue默认无界任务来多少收多少好处是不丢任务坏处是任务无限堆积时内存会被拖垮SynchronousQueue不缓存任务每个任务都直接转给新线程适合任务量小但要求快速响应的场景。Python的ThreadPoolExecutor和ProcessPoolExecutor参数相对简单只有max_workers底层用队列实现但对同步阻塞任务和CPU密集任务的区分逻辑跟Java一样。线程池和进程池的核心价值只有一个复用资源降低反复创建销毁的开销让并发上限可控可预测。3.3 协程的典型坑阻塞调用、忘写await和CPU密集协程的好处明显但坑也不少。最常见的是在async函数里写了同步阻塞代码直接把整个事件循环卡死。async def bad_worker(): time.sleep(1) # 错误示范这里会阻塞整个事件循环 async def good_worker(): await asyncio.sleep(1) # 正确写法挂起协程让出控制权time.sleep是同步阻塞的调用它时当前线程就卡住了其他协程就算就绪也得不到执行机会。真正上线时如果协程里要访问数据库却用了同步的MySQL驱动就会出同样的问题。正确做法是换异步驱动或者把同步阻塞操作丢到线程池里执行。第二个坑是创建协程但忘记await。async def函数被调用时不会真的执行函数体而是返回一个协程对象必须await或者交给事件循环去调度。新手经常写出这种代码async def fetch(url): return url fetch(https://example.com) # 错误示范协程对象被创建但从未执行Python运行时会给出RuntimeWarning: coroutine never awaited警告但很多人没注意。排查思路很直接字符串搜索哪里调用了async函数却没写await。第三个坑是协程里跑CPU密集计算。事件循环本质上还是单线程一个协程在纯计算上霸占CPU其他协程也会一起等。要解决就把计算丢给asyncio.to_thread或run_in_executor让CPU密集任务在线程进程池里跑算完再回到事件循环。Kotlin协程也有类似的坑但它对线程池的封装更平滑Dispatchers.Default、Dispatchers.IO和Dispatchers.Main分别映射到不同线程池协程只是代码结构上的“轻量”调度器底层还是线程池。C20的协程则走得更底层直接生成状态机完全无栈性能极好但对程序员要求极高。不管哪种语言协程的适用边界都是“大量并发等待”不是“大量并发计算”。4. 真实排障记录死锁、误杀进程与IPC通信问题4.1 线程死锁的定位从服务卡死到jstack与火焰图线上服务最典型的故障之一就是“卡死不响应”CPU不高线程全吊在那不动。十有八九是死锁或者线程饿死。线程死锁要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。说白了就是线程A拿了锁1还想拿锁2线程B拿了锁2还想拿锁1两边都不放手就谁也动不了。排查第一步是拿到Java进程的线程dump。JVM进程用jps找PID然后jstack输出所有线程状态。jps -l jstack pid输出里最显眼的关键词是deadlock后面会跟着两个线程互相等待的详细栈信息。Found one Java-level deadlock: pool-1-thread-1: waiting to lock monitor 0x00007f... (object 0x...) pool-1-thread-2: waiting to lock monitor 0x00007f... (object 0x...)定位到锁的持有者和等待者之后解决方案并不复杂按固定顺序获取锁让所有线程都先锁A再锁B循环等待就打破了或者用tryLock加超时获取失败就释放已有锁重试再或者缩小同步块的范围把不相关操作移出锁区。Python里也可以用faulthandler.dump_traceback_later定期打印线程栈C/C则用gdb挂进程后thread apply all bt看调用栈。线程状态监控也值得做成日常习惯。常用命令top -Hp pid看进程内所有线程的CPU占用ps -eLf统计线程数量jstat看JVM GC线程状态jconsole和VisualVM可以实时看线程状态分布。针对Spark这类大数据场景executor本身是JVM进程通过Spark Web UI加jstat配合能同时看到线程和内存GC的联动情况。QNX这类嵌入式系统也有对应的pidin threads命令查看单个线程状态。工具五花八门核心思路只有一个先把异常线程找出来再顺着它的调用栈找锁和资源。4.2 不随便杀进程黑屏事件、nginx和“进程关不掉”的真相杀进程是日常运维操作但乱杀野生进程的教训太多了。Windows下发生过很多次“误删一个进程电脑黑屏”的事件多半是结束了explorer.exe。explorer.exe一旦结束桌面、任务栏、文件管理器全部消失但系统其实还活着。救命方案是CtrlShiftEsc打开任务管理器通过“文件 - 运行新任务”输入explorer.exe回车桌面就回来了。这件事告诉我们动手杀任何进程前先确认它是什么进程、父进程是谁、有没有子进程牵连。Linux下同样如此。杀进程前至少要看两样东西进程的PID和父进程PPID。ps -fp PID能看属主和父进程信息pstree -p能画出父子链。init进程是PID 1它是所有进程的祖先普通用户不能杀也不应该杀这是基本常识。nginx的多进程架构是另一个经典案例。nginx启动后有一个master进程和多个worker进程。master进程负责监听信号、平滑重载配置worker进程才真正处理请求。直接kill -9某个worker进程不可怕master会自动拉起新的worker但直接杀master时worker也会跟着退出。所以优雅停服的正确姿势是kill -QUIT master或者nginx -s quit热更新配置用nginx -s reload让master先接收完新配置再平滑切换worker。“进程无法关闭”这个问题也很常见。WPS卡死了任务管理器里结束wps.exe半天没反应真相往往是WPS是一个多进程应用办公套件里有wps.exe、wpp.exe、et.exe还有云服务进程wpscloudsvr.exe你不把云服务进程或者父进程一起结束主进程会被重新拉起来。Windows下处理这种顽固进程先tasklist | findstr wps看清全部相关进程再taskkill /F /T /PID pid把父子进程一次性干掉。Linux下则先用kill -TERM给进程优雅退出的机会它实在不配合再上kill -9。另外有一种伪“无法关闭”其实是进程变成了僵尸进程父进程没有回收这时候该处理的是父进程而不是僵尸本身。4.3 进程间通信的工程案例Electron主进程与渲染进程Electron是理解多进程通信特别好的素材。很多人打开Electron写的应用后任务管理器里密密麻麻一堆进程就觉得不正常。其实这是Chromium多进程架构的正常形态主进程负责窗口管理和原生系统能力渲染进程负责页面渲染还有GPU进程、网络服务进程、工具进程等辅助进程。每个页面都可能独立渲染进程所以微信等应用一开就是好多进程一点都不奇怪。多进程带来的核心问题是进程之间不能直接调用函数只能通过IPC消息通信。Electron里最推荐的通信模式是主进程用ipcMain.handle注册处理器渲染进程通过ipcRenderer.invoke发起调用中间用preload脚本里的contextBridge.exposeInMainWorld把安全的API暴露给页面。// 主进程 import { ipcMain } from electron ipcMain.handle(get-file, async (_event, path: string) { return await readFile(path) }) // preload脚本 import { contextBridge, ipcRenderer } from electron contextBridge.exposeInMainWorld(electronAPI, { getFile: (path: string) ipcRenderer.invoke(get-file, path) }) // 渲染进程 const content await window.electronAPI.getFile(/path/to/file)这套模式的核心价值在于隔离页面Web代码拿不到Node.js原生能力只能通过白名单API调用安全边界清晰。同时主进程是唯一可以访问系统资源的地方渲染进程不能直接操作文件系统或者窗口。“进程间通信IPC”在通用场景下有非常经典的选型路线通信数据量小、跨主机用TCP或UDP Socket同机低延迟、大数据量用共享内存控制信号和简单状态同步用信号量或消息队列需要跨安全域、可靠传输再考虑Unix Domain Socket。总的来说IPC越重越安全越轻越高效选型时可以按极客的经典权衡来取舍要效率就共享内存加锁要隔离和可靠性就加一层消息协议。Akka这种actor模型本质上也是一种工程化的IPC抽象把并发单位从线程转移到消息和Actor调度交给Dispatcher是“更轻量并发模型”的另一种路径。5. 怎么选工程并发模型的取舍建议5.1 按场景快速判断该用哪个我不会直接说“优先用协程”这种话因为选型要回到业务本质。判断流程基本是四步走。第一步看任务之间的失败隔离要求。如果一个任务崩溃会污染其他任务的数据比如处理外部用户上传的不可信文件就必须用进程隔离。进程虽然重但在不可信代码或内存不安全语言C/C下它是可靠性的最后防线。第二步看并发规模。单机几百上千的并发线程池足够了引入协程反而增加学习和调试成本。单机几万甚至几十万的并发连接比如网关、消息推送、代理转发这类服务协程或事件驱动是必然选择。Go的goroutine、Java虚拟线程、Python asyncio、Kotlin协程都能支撑这个量级看团队语言栈挑一种就好。第三步看任务性质。任务是CPU密集还是I/O密集决定了关键路径是“算得快”还是“等得少”。CPU密集场景多进程加进程池更直接每个进程利用一个物理核心绕开锁和GILI/O密集场景协程可以最大化利用等待间隙配合线程池处理同步阻塞部分属于一套非常务实的混合方案。第四步看团队掌握度和排查成本。协程写起来爽但出问题时的排查比线程难不少尤其是事件循环阻塞和协程泄漏往往需要火焰图配合才能定位。如果团队对新模型不熟悉先在非核心服务验证再逐步推广更稳妥。5.2 长期实践后的几条经验我个人在实际项目里已经形成了几个固定偏好不一定适合所有人但供参考。进程是我装“隔离墙”的首选凡是有崩溃风险、安全隔离需求的任务默认放进程里。线程是我处理“共享状态”的首选多线程共享内存并发访问的场景下线程池是性价比最高的工具但一定要把线程数量的上限当作对外承诺来管理宁可让队列有界并触发拒绝策略也不要让线程无界膨胀撑垮服务。协程是我处理“高并发等待”的首选大量网络I/O、消息推送、代理转发这类任务协程能把并发上限拉高一个量级但必须小心阻塞调用和CPU密集代码混入事件循环。还有一个容易被忽略的思路CPU亲和性设置。Linux可以用taskset把关键线程绑到固定核心减少调度跳动带来的延迟抖动Windows任务管理器里的“设置相关性”也是同一件事。高并发场景下让高频线程稳定待在同一颗核心上配合进程、线程、协程的合理搭配往往能得到超出预期的稳定性。从进程到线程再到协程本质是在回答同一个问题如何用尽量少的资源支撑尽量多的并发任务。下次写代码前先想清楚你的任务到底是在“等”还是在“算”哪一种并发模型正在解决你真正的瓶颈。这个判断比背再多概念都有用。
返回列表