平时在技术社区里泡着,经常能看到有人问“进程和线程到底啥区别”“并发和并行是一个意思吗”“协程是不是就是轻量级线程”这类问题。说实话,这些概念确实是计算机基础里的硬骨头,上学的时候死记硬背过,面试前突击复习过,可真到用的时候——比如让你说说线程池开多大、事件循环为啥能支撑高并发——又容易犯迷糊。我自己也是踩了不少坑,才慢慢把这些概念在脑子里串成了一张完整的图。
这篇博文就围绕“CPU、核心、进程、线程、协程、串行、并发、并行”这条主线展开,不整虚的,也不堆术语。我尽量用日常能接触到的场景把这些抽象概念讲透,把每层概念之间的关系、底层逻辑、以及实际项目里怎么选择搭配都掰开揉碎说清楚。不管你是刚入门的后端开发、做客户端还是搞运维,这篇内容都值得你花几分钟过一遍。
1. 为什么背熟了八股文,拿到实际问题还是懵
先聊个现象。很多人对这几个概念的记忆是“碎片化”的:知道进程是资源分配单位、线程是CPU调度单位,知道并发看起来是同时做、并行是真正同时做,知道协程比线程轻量。但一旦被问到“那我开10个线程和开10个协程,CPU到底在干嘛”就开始含糊了。本质原因在于,我们是在背名词定义,而不是沿着CPU的视角把整个执行链路过一遍。
换个角度想:CPU是个只认得机器指令的“工头”,它不关心你代码里写了什么花活,只关心当前要执行哪条指令、操作哪块内存。进程、线程、协程这些东西,只不过是从不同层面决定“工头”手里的活怎么派、派给谁、中途怎么换人。所以这篇文章我换个讲法——以指令执行和CPU调度为主线,把这几个概念从最底层的硬件往上捋。
第一个关键认知先放在这:无论概念多复杂,最终真正干活执行指令的物理单位只有CPU核心。其它一切机制,都是为了把一个程序要计算的事情切碎、排队、分派给这有限的几个核心。带着这个认知往下看,很多迷惑能少一大半。
2. CPU与核心:从“一个工头”到“一组工头”
2.1 单核CPU时代,哪有什么并行
最早的个人电脑讲CPU,通常指一个物理处理器封装,里面只有一个完整的计算核心。这个核心同一时刻只能执行一个线程的指令流。所谓多任务,全靠操作系统把CPU时间切成极小的片(Linux下典型时间片是几毫秒到几十毫秒),轮流分给不同的进程/线程。因为切换足够频繁,给人感觉是“同时”在跑好几个程序。
这里有个底层细节值得提一下:进程切换是有成本的。操作系统要保存当前进程的寄存器状态、程序计数器、堆栈指针、内存映射等信息到进程控制块(PCB),再加载下一个进程的上下文。每次切换还要经过内核态,高速缓存也可能失效。所以陷入“切换风暴”时,CPU有一大半时间是在做保存和恢复的动作,真正执行程序指令的时间反而很少。
我自己的一个直观经历:在一台旧的双核机器上,不开任何优化直接起几十个线程疯狂计算,观察CPU占用率其实没拉满,因为大部分时间耗在上下文切换上了。后面把线程数压到和核心数一个量级,吞吐反而翻了几倍。这就是单核时代的核心矛盾——时间有限,切换吞时间。
2.2 多核CPU登场,并行才成为可能
后来CPU越做越强,光靠提频遇到了功耗墙,于是厂商换了个思路:往一个物理封装里塞多个独立核心。比如四核CPU,物理上就是四个完整的计算单元,可以真正同时执行4条指令流。这时候谈论并行才有了硬件基础——多个进程/线程真的在同一时刻分别跑在不同的物理核心上。
需要区分物理核和逻辑核(超线程)。超线程技术是把一个物理核心伪装成两个逻辑核心,靠复用指令流水线和寄存器资源来提高吞吐。但注意,一物理核内的两个逻辑核共享大量执行资源,它们的“并行”是打折的,更准确说是提高了核心利用率;真要想并行计算,还是得看物理核心数。
2.3 一个最容易搞混的点:核数到底怎么影响并发
很多人写并发程序时有个惯性:线程越多越快。其实这里面关键瓶颈就是核心数。我梳理了一张表,把CPU核心数和同时执行能力之间的关系摆明白:
| 场景 | 核心数 | 同时真正运行的线程数上限 |
|---|---|---|
| 单核单线程 | 1 | 1 |
| 双核(每个核单线程) | 2 | 2 |
| 四核八线程(开超线程) | 4物理/8逻辑 | 最多4条指令流真正并行,逻辑竞争看资源 |
也就是说,如果你的机器是四核,你开了100个线程,同一瞬间真正在跑的也就4个。剩下96个都是在排队等CPU。所以核心数直接决定了系统的并行上限,而并发能力是另一回事,一会儿讲到协程时你会看到,单核也能玩出很高的并发,只是并行不了。
3. 进程与线程:资源容器与执行实体的分工
3.1 进程是“容器”,线程是“工人”
如果把操作系统比作一家餐厅,进程就像一张张承包合同,每个合同划定了一块独立的地盘(独立内存空间、文件句柄、信号处理等)。合同之间互不干扰,一家餐厅的黄焖鸡后厨和另一家餐厅的麻辣烫后厨,锅碗瓢盆不混用——这就是进程隔离。
线程则是在同一个合同下干活的具体工人。同一进程里的多个线程共享这张合同的地盘(共享堆内存、全局变量、文件描述符),所以线程之间通信成本低,直接读写共享数据就行。但共享也带来了新的问题:多个工人同时改同一份账本,就会把账改乱,所以需要加锁。这也就是为什么用多线程编程时,你要面对锁竞争、死锁、数据竞争这些麻烦。
3.2 为什么要有线程:进程太“重”了
如果只有进程而没有线程,操作系统要想做多任务,就得不断地整个进程切来切去。前面说过,每次切换要保存和恢复一整套内存映射和内核资源,代价很高。而且进程之间如果要交换数据,得走管道、消息队列、共享内存这些“跨合同”机制,麻烦且慢。
线程的出现把“资源”和“执行”解耦了:资源依然以进程为单位申请和隔离,但执行以线程为单位调度。切换线程时,同一进程内的内存映射不用换,只需要切换寄存器、栈指针这些轻量状态,代价比进程切换小一个量级。所以现代服务器程序普遍用多线程而非多进程来做并发,就是这个原因。
3.3 进程和线程使用场景怎么选
实际项目里怎么选,我总结几条经验:
- 需要强隔离、高稳定性的场景(如浏览器不同标签页、插件崩溃不能拖垮主程序)适合多进程。崩溃互不影响,坏了一个进程可以重启。
- 需要高频共享数据、低延迟通信的场景(如游戏引擎的渲染线程和物理线程)适合多线程。共享内存访问速度快,但要注意同步。
- 两者结合也很常见:比如主进程负责管理,内部开线程池干活;再通过进程池做横向扩展。很多中间件(数据库服务、Web服务器)都是这种混合结构。
线程虽然有共享内存的便利,但别忘了线程崩溃往往殃及整个进程。我记得某次在服务端程序里写了个野指针,把一个公共模块的内存踩坏了,整个进程直接挂,所有连接全断。排查了很久才定位到是个后台线程的问题。反正从那以后我对线程的“自由”敬畏多了几分:能不开裸线程就不开,尽量用线程池,并且每个线程的入口都包好异常捕获。
4. 线程到底怎么被调度:时间片、抢占与上下文切换
4.1 先搞清谁在排队、谁在跑
进程和线程在操作系统内部都是以“任务”形式存在的,内核的调度器维护着若干就绪队列。在Linux下,线程本质上是一种轻量级进程,数据结构里有一堆调度相关的字段。调度器按某种策略(CFS完全公平调度是Linux默认)给每个任务分配运行时间,一旦时间片用完、或者更高优先级的任务就绪、或者当前任务阻塞在IO上,就会触发调度,把CPU交出去。
这个过程我们一般叫抢占式调度——线程自己不需要主动让位,操作系统强制的。好处是单个线程死循环,其他线程也还有机会跑;坏处是你无法精确控制一个线程什么时候被切走。写实时系统的人最讨厌这种不确定性。
4.2 为什么说阻塞操作是并发的大敌
线程阻塞通常发生在等IO(网络读写、磁盘读写、等待锁)或者主动sleep时。一旦阻塞,这个线程就脱离CPU了,哪怕你开了100个线程,如果100个线程全都在等IO,CPU可能闲得要死。这个问题是理解协程价值的关键。
举个例子:你写一个爬虫,用线程池开20个线程,每个线程发一个HTTP请求等响应。这20个线程绝大多数时间在阻塞等待网络数据,只有数据处理那一点点时间在占用CPU。其实真正需要的不是20个线程,而是“20个同时等待的网络请求”,等待本身不消耗CPU。但如果用线程去等,每个线程都要占用一个内核调度实体,白白消耗线程栈内存、内核对象、上下文切换开销。
4.3 什么情况下线程还够用
多线程模型也不是一无是处。如果你的任务是CPU密集型的(把线程数开到接近核心数)、或者每个线程阻塞时间很短、或者并发规模不大(几百以内),直接用线程池就挺好。Go语言早期版本、Java的线程池,在普通业务场景下表现都很稳定。
但当你要支撑上万甚至数十万个并发连接时,一连接一线程的模式就崩了。一个线程默认栈大小8MB左右,就算只虚拟分配,上万个线程对内存也是巨大压力;更何况调度器要频繁切换这上万个上下文,CPU会忙死。这时候就该协程登场了。
5. 协程:让“等待”变得几乎免费
5.1 协程在用户态自己管调度
协程的核心思想是:把任务切换从内核态搬到用户态。线程的创建、调度、切换都要进内核,代价大;协程则由程序自己决定什么时候让出、什么时候恢复,切换只涉及保存恢复若干个寄存器和栈指针,不涉及内核态,所以单次切换开销可以做到极低(纳秒级到微秒级,比线程切换快几个数量级)。
用餐厅的例子来解释:进程是合同,线程是全职服务员。协程更像是这个服务员自己的“待办清单”——手头在等客户填单子的时候,不用傻站着,先跑去给另一桌倒水。等客户填完了再回来继续。一个服务员就能同时服务很多桌客人,因为这个服务员会自己找空档穿插干活,不需要每次都跑去经理那汇报换岗。
5.2 有栈协程与无栈协程
协程实现方式大致分两类,理解这个有助于看懂不同语言的语法:
- 有栈协程:每个协程有独立的调用栈。可以随时保存和恢复整个调用链上下文,灵活度高。Go的goroutine、Lua的协程、libco都属于这种。
- 无栈协程:协程不拥有独立调用栈,靠状态机和闭包保存局部变量。C++20的co_await、JavaScript的async/await、Python的asyncio本质上走的是这种模型。
无栈协程的调度依赖事件循环:你把协程理解成一个个可暂停的函数,碰到await/yeild就把控制权还给事件循环,事件循环再去跑别的协程,等IO响应到了再回头继续。
5.3 一个直观的体验代码
以Python的asyncio为例,虽然Python本身有GIL,多线程并行受限,但协程的高并发能力体现得淋漓尽致:
import asyncio import time async def fetch_one(url): # 模拟网络IO等待 await asyncio.sleep(1) return url async def main(): start = time.time() # 同时发起1000个“请求”,串行需要1000秒,这里大约1秒多 tasks = [fetch_one(f"https://example.com/{i}") for i in range(1000)] results = await asyncio.gather(*tasks) print(f"耗时 {time.time() - start:.2f} 秒,完成 {len(results)} 个任务") asyncio.run(main())关键就在await asyncio.sleep(1)这行:遇到await时,这个协程就把控制权交出来挂起,事件循环马上切换去执行下一个协程。1000个协程都在等那个1秒的定时器,但CPU只用极小的开销维护它们的状态。放到多线程场景,1000个线程去sleep要消耗多少资源,大家心里有数。
5.4 协程和线程怎么配合用
实际工程里,协程和线程常常混着用。原因很简单:协程跑在某个线程之上,它没法同时使用多核(同一时刻一个线程上的协程还是串行执行的)。所以你要多核并行,还是得开多个线程,每个线程内部跑一堆协程。
我常用的模型是:线程池 + 事件循环。比如用Netty的EventLoop、Go的GOMAXPROCS(默认情况下Go会把goroutine调度到多个线程上),或者Python里跑多个asyncio事件循环进程。这样既利用了多核的并行能力,又在单核内用协程扛高并发。
6. 串行、并发、并行:三个词说透
6.1 三者的层次关系
串行其实不是贬义词,它指任务一个接一个执行,严格有序。单核CPU上所有任务本质上都是串行交错的。并发和并行都是相对于“多个任务同时推进”而言的,区别在于:
- 串行:任务A跑完,任务B才开始。没有任何重叠。
- 并发:同一时间段内,多个任务都在推进,但任意时刻可能只有一个在执行(单核);它们是轮流用CPU的。比如Windows上一边听音乐一边聊QQ,单核时代就是并发。
- 并行:同一时刻,多个任务真正同时执行。必须有多核硬件和多条执行流(进程/线程/协程分布在多个核上)。
一个形象的对比:你一个人同时照看三锅菜(不断从这锅跑到那锅翻一下),这是并发;你请了三个厨师,一人盯一锅,这是并行。并发关注的是“能同时处理多少件事”,并行关注的是“同一时刻真正在跑几件事”。
6.2 并发结论不等于更快
这点太容易被误解了。并发强的系统未必“跑得快”,它只是“响应得快”、能在同样的时间窗内处理更多请求。如果你的计算是纯CPU密集的,比如算圆周率,你把一个任务切成100段在100个线程里跑,单核上只可能更慢(多了切换开销),多核机器上也许能提速,但提速上限就是核心数。而如果是IO密集型,比如文件读写、网络请求,并发模型能让CPU在等待IO的同时去处理别的任务,整体吞吐大幅提升。
写代码时先分清自己属于哪类:
| 类型 | 特点 | 推荐方案 |
|---|---|---|
| CPU密集型 | 大量计算,CPU长期跑满 | 线程/进程数≈核心数,多核并行 |
| IO密集型 | 大量等待网络/磁盘,CPU闲 | 协程或大量轻量线程,提高并发 |
| 混合型 | 既有计算又有IO | 线程池+事件循环组合 |
6.3 串行也分好坏
不少人觉得串行效率低,其实串行往往是最容易理解和调试的模型。处理顺序要求严格(比如转账先扣款再记账)、状态依赖强(下一个操作依赖上一个结果)的场景,串行最稳。很多并发问题反而是为了“推高吞吐”硬改出来的。我自己有一个小原则:能串行就别并行,必须并行就控制粒度,别为了炫技把简单问题复杂化。
7. 综合对照表:一张图理清所有概念
列一个概念对照表,方便随时查阅。每个维度的内容都是自己实践中总结出来的,不照抄教科书:
| 概念 | 本质 | 调度主体 | 切换成本 | 共享与隔离 | 适用场景 |
|---|---|---|---|---|---|
| CPU核心 | 物理执行单元 | 硬件 | 无 | 无 | 计算能力的上限 |
| 进程 | 资源分配容器 | 内核 | 高(切换地址空间/内核资源) | 进程间隔离,通信靠IPC | 应用隔离、崩溃隔离 |
| 线程 | 内核调度的执行实体 | 内核 | 中(切换寄存器/栈) | 同进程共享内存、文件句柄 | 常规并发、CPU密集型 |
| 协程 | 用户态轻量任务 | 用户态/事件循环 | 低(少量寄存器恢复) | 单线程内共享,调度协作式 | IO密集型高并发 |
| 串行 | 任务执行顺序 | 程序自身 | 最低 | 天然安全 | 逻辑强依赖 |
| 并发 | 交替执行的宏观效果 | 调度器 | 取决于单位 | 需要同步保护 | 提升吞吐/响应 |
| 并行 | 同时执行的物理事实 | 多核硬件 | 无额外切换 | 需要同步保护 | 榨取多核算力 |
8. 实战选择指南:遇到具体场景时怎么办
8.1 判断并发模型的三步法
每次设计并发架构,我习惯按三步走,这帮我避免了很多拍脑袋决定:
第一步,问自己:任务是偏IO还是偏CPU?拿不准就做个简单估算:单任务总共执行时间里,等待IO(网络、磁盘、锁)是不是占了大部分?如果是,协程或事件驱动模型优先。
第二步,问自己:并发规模大概多少?几百级别一下,用线程池完全够;几千乃至数万级别,再考虑协程模型。不要上来就上协程,团队学习成本和代码复杂度都是代价。
第三步,问自己:状态共享复杂度多高?如果大量共享可变状态,并发模型越高阶,锁管理越痛苦。这时候先想想能不能改成数据分区、消息传递,或者干脆串行化处理某个环节。
8.2 几个典型场景的落地配置
场景一:高并发网关(比如在线WebSocket消息推送)。我的选择是Netty这种事件循环加协程模型(Java虚拟线程也是新选择)。原因:单个连接大量时间空闲在等消息,适合少量线程跑海量连接;回调编程太反人类,所以用协程把回调拉平成顺序代码,可读性高很多。
场景二:数据处理批任务(比如每天跑一亿条日志做聚合)。大量时间花在读文件、写文件上,属于典型的IO密集型。我用多进程并行读分片+线程池处理+协程做结果写入,三层组合,吞吐比纯多线程大概提升了一倍多。注意瓶颈在磁盘IO时,并行度别开太高,否则磁盘寻道也顶不住。
场景三:游戏服务器逻辑。回合制或状态强同步的逻辑,其实串行处理起来最简单可靠。逻辑层用单线程加协程,数据访问走线程池——避免了大量的锁竞争,同时还能hold住几千并发连接。那些为了“多线程而多线程”设计的游戏服务器,调试多人在线同步时真的会让你怀疑人生。
8.3 性能调优时看什么指标
最后给点量化经验。优化并发性能时,最该盯的指标不是“线程数”“协程数”,而是这几个:
- CPU使用率:如果CPU没跑满,说明并发模型没能充分利用算力;如果长时间100%,说明要么计算密集、要么切换过多(怎么区分?看上下文切换频率)。
- 上下文切换次数:Linux下可以用
vmstat1或者pidstat -w看。如果每秒几十万次切换,通常意味着线程或进程数量远超合理值。 - 吞吐量和尾延迟:并发上去了,吞吐涨没涨,P99延迟有没有劣化。如果并发升高延迟反而升高,大概率是锁竞争或资源争用。
我自己曾经优化过一个接口,加了缓存后发现吞吐没怎么提升,后来一查是线程池被一个慢SQL拖住了,90%的线程都在阻塞等待数据库。后来把数据库查询改成异步,配合协程,接口的P99延迟从800ms降到了120ms。这个案例说明了一点:并发模型选得再好,如果底层有一个长阻塞,瓶颈还是会漏出来。
9. 梳理完以后,我个人实操中的几点体会
讲这么多,最后聊点接地气的经验。这些概念看起来是“面试题”,但它们直接决定了你写的程序能不能撑住流量、能不能在线上稳稳地运行。我现在的习惯是设计任何并发模块时先把这两行写清楚:瓶颈是CPU还是IO?并发规模预估多少?然后才去选模型、定参数。
还有个小技巧:写多线程代码时,尽量少手动开线程,优先用语言自带或成熟框架的线程池,让线程的生命周期可管理;写异步代码时,不管用的是Promise还是协程,脑子里始终留一根弦——所有回调或await的代码块都有可能并发执行,共享变量一定要防着点。
最后再分享一个排查线上问题的心法:遇到“性能不行”的case,不要急着调并发数,先看清楚CPU在忙什么、线程卡在哪、锁争用如何。很多时候答案不是“并发不够”,恰恰是“并发过度”或者“阻塞没消除”。把这些概念真正理顺了,排查起问题来,你自然知道该往哪个方向找。