首页 / 资讯中心 / 文章详情

并发与并行:从CPU核心到线程协程的性能边界

并发与并行:从CPU核心到线程协程的性能边界 ★ FEATURED ARTICLE
说实话这几个词在面试里被问烂了在日常开发中也常年被混用。不少人嘴上挂着“并发”“并行”其实没真正理解它们的边界能用线程池但说不清为什么阻塞IO场景下协程更合适知道CPU有核心数但写代码时根本没把自己的并发数跟硬件算力挂钩。我前阵子在做某跨平台系统的性能优化和某个图像处理Demo的调度改造时正好把这条链路整个理了一遍从硬件到操作系统到应用层。这一篇我只讲一件事CPU、核心、进程、线程、协程、串行、并发、并行它们各自是什么、彼此什么关系、在真实代码里怎么决定你的性能上限。1. 从硬件到软件先画一条完整的抽象阶梯1.1 硬件层CPU和核心到底在算什么很多初学者的误区是把CPU直接当成一个“会干活的大脑”。实际上现代CPU早就不是单打独斗了一块物理CPU里面集成着多个物理核心每个核心才是真正独立执行指令的计算单元。你看到的“8核16线程”指的一般是8个物理核心加上超线程技术模拟出的16个逻辑处理器。超线程的原理很取巧。一个物理核心内部本来有大量执行资源但单条指令流往往用不满这些资源。超线程让一个物理核心同时维护两份线程上下文让空闲的执行单元也能被另一份指令流用起来。代价是两份逻辑线程共享核心内的缓存和执行端口所以它带来的加速通常在20%到30%之间不是线性翻倍。我在做图像处理Demo时验证过纯计算密集型任务在开启超线程的逻辑处理器上跑性能提升远低于预期有时候甚至因为缓存争抢反而略降。操作系统的任务管理器或Linux的top命令里看到的是逻辑处理器数量。真正决定计算密度的是物理核心数不是逻辑处理器数。这一点记不住的话下面所有关于并行的讨论都会跑偏。1.2 软件层进程和线程各管哪一段进程是操作系统分配资源的最小单位。内存空间、文件句柄、打开的网络连接这些统统挂在进程名下。两个进程之间默认谁也看不见谁的内存这就是隔离性。隔离带来的好处是稳定一个进程崩了通常不会拖垮另一个进程。坏处是切换成本高进程间通信要通过管道、共享内存、消息队列这类专门机制还要陷入内核态开销很大。线程是操作系统调度的最小单位。一个进程内部可以开很多线程它们共享进程的地址空间、堆内存和打开的资源但每个线程有独立的栈和寄存器上下文。因为共享内存线程之间沟通方便直接读写共享变量就行麻烦也随之而来竞态条件、死锁、内存可见性问题全都扎根在这份“共享”上。我用一个生活中的比喻来区分进程是独立的住宅单元每一套都有完整的厨房、水管、电路关上门各过各的日子想借个酱油得通过公共管道传递线程是一家人的厨房锅碗瓢盆共用商量着来做菜效率高但要是两个人同时抢一个水龙头就会出乱子。1.3 用户态的协程轻量级到极致协程不在操作系统的视野里它是用户态的程序自己管理的东西也叫纤程或绿色线程。协程是线程内部更小的执行单元由运行时调度不涉及内核态切换不依赖操作系统提供的调度器。拿Go里的goroutine打比方一个goroutine初始栈只有几KB而操作系统线程的默认栈普遍在MB级别。一台机器开几万个线程基本不现实但开几万个goroutine是常态。我在某跨平台系统里做网关改造时单机常驻了三万多协程内存占用也就比之前开几百个线程时高一点点。协程的关键优势不在计算速度快而在高并发场景下的资源效率。我想特别强调一点协程不是银弹。它需要配合异步IO使用才能最大化收益。如果协程内部全是同步阻塞调用那它底层的线程还是会被卡住数量照样受限。Python里的asyncio之所以对IO密集任务效果好恰恰因为事件循环把所有阻塞操作都换成了非阻塞挂起。2. 三种执行节奏串行、并发、并行2.1 串行最朴素但往往被低估串行就是“一件事做完再干下一件”。写代码很简单逻辑好推理没有竞态风险调试也不会莫名其妙地“时好时坏”。我在实际工作中发现很多时候串行方案被粗暴否定其实它才是最优解。典型场景是写小工具脚本。脚本里分三步抓页面、解析内容、写结果。每一步都不重整体跑完不到半秒。这种情况下强行上多线程、多进程纯粹是给自己找麻烦——线程池的初始化开销、任务切割的逻辑、结果汇总的复杂度加起来比那半秒的耗时更值钱。另一个串行被低估的场景是写入类操作。业务逻辑要追加日志、批量更新数据库如果对写入顺序有严格要求串行能保证严格的顺序性。而并发写入往往要引入锁或队列来重新协调顺序一不小心就弄巧成拙。我有一次在项目里看到同事用多线程并发写同一个SQLite库文件结果大量“database is locked”最后改成串行反而稳定又高效。2.2 并发是结构不是速度并发的本质是“多个任务在同一时间段内被交替推进”。再准确一点并发关心的是任务的分解和组织方式不承诺同一时刻真的在同时执行。经典的单核并发模型就是时间片轮转。操作系统把CPU时间切成小片线程A跑几毫秒挂起线程B跑几毫秒再切回A。因为切换速度快人感知上会觉得它们在“同时运行”。这种“伪同时”其实就是并发。它的价值不在于让任务完成得更快而在于防止某个任务独占资源让系统整体保持响应。从代码结构上说并发也是一种设计策略。拿一个人同时处理多件事来类比并发是切菜时烧水烧水时又去切葱来回切换着推进并行才是真的雇了两个帮工一个烧水一个切菜。理解了这一层你就不会再问“为什么并发没让程序更快”这种问题了。2.3 并行真正的同时执行并行要求有多份独立的计算资源在同一时刻分别执行不同任务。多核CPU出现之前并行只是并行计算领域的概念个人电脑上几乎没有真正的并行。今天我们写多线程程序如果恰好运行在4核机器上最多也就4个线程在真正地同时跑指令——这还取决于是否存在资源争抢和系统里其他进程的挤占。并行和并发不是互斥关系。并发是程序结构的属性并行是运行状态的属性。并发程序不一定并行执行并行执行的程序也不一定体现为并发结构。一个4核机器上跑一个无并发的单线程程序它在某一瞬刻只用一个核而一个精心设计的并发程序在4核机器上能让四个线程真正分驻四个核心同时推进这才叫发挥了并行能力。这里必须提到阿姆达尔定律的通俗版本一个程序里能被并行化的部分占比越高多核加速的效果才越好如果只有30%的代码能并行化剩下70%必须串行那就算给100个核心最终加速比也还是上不去。我见过不少团队把包含大量共享状态修改的模块强行并行化结果频繁加锁性能不升反降原因就出在这。3. 各自擅长的场景不要拿线程去硬扛文件IO也别拿协程做CPU密集运算3.1 线程池通用但需要心算容量线程池的作用是复用线程避免频繁创建和销毁的开销。Java里有现成的ThreadPoolExecutorGo里可以用协程池或直接开goroutinePython则有concurrent.futures。但我在一线团队里看到的通病是线程池的核心参数永远是一套默认值没人算过。最简单的估算办法把线程数分成IO密集和CPU密集两套公式。任务类型推荐线程数说明CPU密集型CPU核心数 1避免过多线程争抢计算资源多出的一个用于补偿缺页中断等损耗IO密集型CPU核心数 * 2 1简单经验值实际还要看阻塞比例IO密集型更准确CPU核心数 / (1 - 阻塞系数)阻塞系数是阻塞时间占总耗时比例越高线程数越多阻塞系数怎么量我在某跨平台系统的数据同步模块里用链路追踪统计过一次HTTP调用平均耗时大约300毫秒其中纯计算部分只有10毫秒阻塞系数约0.97。代入公式8核机器上线程数约在240多这跟压测结果比较接近。但这里有个隐性坑线程本身消耗内存。每个线程默认栈空间可能占到几百KB到1MB开200个线程就要预留200MB级别虚拟内存再加上上下文切换的开销一旦线程数超过某个阈值系统的有效吞吐量反而下降。所以线程池不是越大越好一定要结合机器配置和任务特征做压测。3.2 协程与IO密集如何正确识别阻塞协程最适合的场景是“大量任务在等待外界响应”。等待响应期间协程让出执行权运行时调度器把CPU让给其他协程用整体利用率自然就上去了。我拿Python写爬虫举例。如果开50个线程每个线程都去请求外部接口等待响应时这个线程是被阻塞挂起的操作系统要管理50个线程的调度。如果改用asyncio开一个事件循环注册50个等待中的IO事件循环发现哪个响应到了就去执行哪个。实际效果是我用协程跑同一批120个页面的抓取任务耗时比串行快了近40倍内存占用反而低了。协程能力范围内注意别把CPU密集操作塞进协程里。比如在asyncio协程里做图片的等比缩放、解析超大的JSON字符串、跑复杂的加密算法这些计算会让事件循环被占住其他协程全部排队用户体验就是卡顿。正确的做法是把这类计算任务放进线程池让协程和线程池配合。3.3 并行与真实多核什么代码才有资格并行不是所有代码都能并行。能让多个核心同时跑的前提是任务之间没有强依赖或者通过某些手段把依赖解耦。我把适合并行的特征归纳成三个任务可以拆成多个独立子任务每个子任务的输入输出清晰子任务之间的数据交换极少或者只发生在最后汇总阶段各子任务不依赖同一份可变共享状态的连续更新反过来不适合并行的特征也很明显多个线程需要频繁修改同一个计数器、同一个队列、同一棵树。这种场景不管拿什么锁性能都不会好看。我在优化某图像处理Demo时把单张图片的滤镜拆到多个核心并行处理效果很好因为每行像素之间的计算互不干扰但我在另一个项目里把共享日志列表直接开放给多个线程写就遇到了各种诡异问题。4. 实操解析一次完整的并发改造案例4.1 从串行到并发的三步改造法我以某跨平台系统里一个批量文件转换模块为例它最初是串行逻辑读文件、做格式转换、写磁盘、然后处理下一个文件。文件多的时候整个任务要跑十几分钟。我们做了三步改造第一步先量化瓶颈。用工具看CPU占用率很低磁盘IO也很空闲说明任务卡在某个等待环节上大概率是文件的读取和外部依赖服务响应。第二步按瓶颈选模型。既然瓶颈在等待考虑用并发而不是并行。确定用线程池加异步队列的结构不直接裸建线程。第三步把共享状态最小化。转换过程中的中间结果全部放进任务独立的数据块里避免多个线程同时操作同一个全局表。改造完之后十几分钟缩到两分钟出头。性能提升来自两个层面等待时间被重叠利用以及任务拆分成更小的粒度后调度更灵活。4.2 参数选择的完整推导那个批量文件转换模块跑在一台4核8线程的服务器上。压测后我们得到单文件转换耗时约80毫秒其中IO等待约65毫秒纯计算约15毫秒。阻塞系数算出来是0.8125。按公式计算线程数4 / (1 - 0.8125) ≈ 21.3。我把线程池核心线程数定为16最大线程数定为32队列容量设为128。为什么不是精确的21因为过大的队列会让任务积压又不能无限开线程打爆资源。实际压测结果16个线程时CPU占用率在65%左右再调到20CPU占用率到了85%延迟没有明显劣化就维持在了20。这个调试过程提醒我公式只是起点现场压测才是决定最终参数的关键。如果你用Gogoroutine本身没有池化概念你可以直接起对应数量的goroutine配合带缓冲的channel做流控。Go的做法更简洁但相应的你也要手动保证任务量不会被无限放大。4.3 改造后的调试与测试重点我自己调试并发程序时最讨厌遇到的是“偶发故障”。它上午不出错下午出现重启后消失真查起来又抓不到现场。这类问题绝大多数是数据竞争。用Go可以直接跑竞态检测器我写了并发测试代码在压测环境下胜率大幅度提高因为检测器会在每次内存访问时捕捉冲突。还有一类问题是死锁。模拟项目X里有个支付对账模块两个协程互相等待对方的信号跑了几百次之后卡死。排查思路是先抓goroutine dump看清每个协程阻塞在哪一行画出等待关系链几步就定位出来了。这几条经验我觉得最实用并发代码里每一处共享变量的读写都要问一句“需不需要原子操作或锁”线程池任务里一定要有超时控制杜绝任务永久挂起导致线程池耗干监控和日志要带上任务ID才可能在线上问题出现时回溯执行路径5. 高频踩坑与排查技巧实录5.1 问题速查表问题现象可能原因排查思路解决方式CPU占用极高但任务吞吐没涨并发数远超核心数上下文切换过重检查线程数、系统切换开销指标降低线程数或改用协程多线程程序越来越慢共享资源锁竞争严重采集线程BLOCKED状态、锁等待时间缩小锁粒度、用无锁结构、拆分热点协程版本卡顿协程内混入CPU密集计算观察事件循环耗时分布计算密集任务挪到线程池程序偶发数据错乱数据竞争开竞态检测器、code review共享变量加原子操作或锁线程池任务堆积任务量超过池处理能力看队列长度和拒绝策略日志调大池容量、更快消费、削峰限流IO密集程序线程很多但利用率低所有线程都在同源阻塞点等待分析阻塞耗时来源改异步IO、或合并IO请求5.2 一个印象深刻的线上排查有一回线上系统某模块夜间跑批时CPU飘到90%以上但业务量并没有波动。我先用性能分析工具抓进程内线程状态发现大量线程卡在锁等待上再抓锁信息发现是“缓存日志列表”这个全局对象被频繁追加锁竞争爆炸。复现之后把日志追加改成每100条批量提交一次再用线程本地缓冲存储待提交数据问题直接消失。这类问题的共性是开发者在写代码时默认锁竞争开销很低但一旦线程数上来锁的等待时间会被放大到足以拖垮系统的程度。判断的标准也很简单看有效工作时间占比如果大部分时间都在等待锁、等待IO就要立刻怀疑资源争抢环节。5.3 性能验证的完整打法性能改造不是“改完看变快了”就结束。我会固定一套压测流程先记录改造前的基线数据包括吞吐量、响应时间P95、CPU占用率、内存占用改造后跑同样的压测对照每项指标。如果一个方案让吞吐量涨了但内存翻了三倍那就要权衡是否划算。压测时特别注意预热。JVM有即时编译Go有GC预热复杂场景下第一波请求往往很慢真正能反映稳态性能的数据要看几分钟后的平均值。还要避开其他任务干扰压测机上的监控、日志、备份服务如果还在跑数据根本不准。6. 一点个人建议经常有同事问我这些问题到底学并发编程该从哪入手我的建议是先把“进程”“线程”“协程”这三个词在操作系统层面的差别亲手验证一遍。用Python或Go写一个小程序分别用线程和协程去跑一万个睡眠型任务观察内存和耗时的差异。这一步做扎实了后面再学锁、队列、线程池、异步框架都不会飘。概念层面的东西光看书是真的会忘的跑一次代码以后面试和写代码时都能有底气。最后提一个容易被忽略的点并发相关的性能问题很多时候不是语言或框架的问题而是你对资源边界没有清晰的预估。一台机器有多少核心、能支撑多少线程、IO等待占比多少这些数字应该在写代码之前就心里有数。这套思维远比背住某个函数名值钱得多。
阅读完成 · 觉得有帮助?
咨询建站