前几天有位开发者跑来和我聊一个问题他写了个图像批处理脚本要在十六核的服务器上给上万张图片做滤镜处理。他想着线程池越大越快一上来就配了100个线程结果整体耗时不但没降反而从半小时拖到了快一小时。top里CPU占用不到五成每个线程都在那儿排队等锁。这个现象正好把CPU密集型、内存密集型、IO密集型任务这三类任务的区别摆在了台面上。搞懂这个分类不是应付面试用的它直接决定你的并发模型、线程池大小、异步框架和缓存策略选得对不对。今天这篇我不打算讲教科书上的定义而是从真实观测指标和调优经验出发把三类任务分别长什么样、怎么判断、怎么优化一次说透。1. 判定问题的第一步你到底在等谁1.1 先给三类任务一个生活版定义拿一场晚市的餐厅来说。后厨有位大厨CPU他炒菜的速度决定出餐上限这就是CPU密集但厨房的操作台和冰箱大小有限内存食材堆不下、取料太费事大厨再快也快不起来这是内存密集还有一种情况大厨本身没闲着但传菜服务员去外面买食材、洗碗一等就是好几分钟大厨只能停火等着这是IO密集。所以判断一个任务属于哪类核心问题只有一个你到底是在算、在存还是在等CPU密集型任务时间主要花在CPU的计算指令上比如循环、排序、图像滤镜、矩阵运算。内存密集型任务时间耗在数据的存取、分配、释放上计算本身占比不大但数据规模大到让内存成为短板。IO密集型任务时间耗在等待外部资源上——磁盘读写、网络请求、下游服务的响应。为什么这个区分极其重要因为三类任务的药方完全不同。CPU密集加线程可能更慢IO密集加线程却常常立竿见影内存密集则可能压根不该用内存去硬扛而要改数据流。1.2 分类决定选型而不是凭感觉我刚入行时有个坏毛病服务一慢第一反应就是开大线程池。后来被现实教育了几次才发现选型必须跟着任务类型走。先对照一下常见的并发模型任务类型推荐方向反面例子CPU密集型进程池、线程数接近核心数开几百个线程抢CPU时间片内存密集型流式处理、紧凑数据结构、分批加载一次性把大文件全读进内存IO密集型线程池、异步协程、连接池串行一个连接等到天荒地老这套对照不是理论推演而是无数个线上事故换来的直觉。比如公司里的某个定时任务原来是IO瓶颈结果有人把机器的CPU加到几十核耗时一点没变因为磁盘写入还是那个速度反过来如果是纯计算任务你往机器里狂加内存也救不了等待时间。1.3 不看指标就开药的代价有一个特别常见的场景一个功能慢了第一反应是加缓存。但如果这个任务本身是CPU密集型比如对几万条日志做正则匹配加个Redis缓存没有任何意义——你缓存的是结果可每次处理的数据都不一样命中率趋近于零。有个朋友做过一个数据清洗任务跑一次要四五个小时他认为是CPU不够申请了更高配置的机器结果只快了5%。后来我一查瓶颈在磁盘随机写——每一行都单独open、flush一次。优化后他把写入改成批量拼接时间直接缩到半小时。这些教训说明一件事分类不是靠猜是靠观测。观测工具不复杂无非是CPU、内存、IO这几类指标关键是你要知道看什么数字、怎么对号入座。下面三个章节就挨个拆开讲。2. CPU密集型核心已经烧满线程再多也没用2.1 典型场景和真实感觉CPU密集型任务的特征是任务的总耗时约等于CPU干活的时间。程序在飞快计算但你观察它的等待时间很少。常见场景包括图片滤镜、视频转码、音频处理3D渲染、物理模拟、游戏AI计算大规模日志的正则匹配和字段抽取模型推理、加密哈希、数据压缩矩阵运算、数值计算比如求和、积分、求解方程拿一段很朴素的Python代码举例result 0 for i in range(10_000_000): result i * i这段代码跑起来CPU使用率能冲到100%耗时完全由循环和算术指令决定。如果你的项目里有这种量级很大的计算段它基本就是CPU密集型。还有一个更隐蔽的变种任务里计算量不大但调用次数极多比如对一亿行数据逐行做字符串处理每行运算很短但总计算量巨大。这种同样属于CPU密集不是IO因为数据都在内存里瓶颈是CPU指令周期。2.2 用三个指标把CPU瓶颈钉死判断任务是不是CPU密集别听人描述看指标。我最常用的三个入口第一个是top里的CPU状态行。us用户态和sy内核态加起来长期在70%以上waIO等待接近0同时id空闲很低说明机器确实在忙着算东西。反之如果us不高、wa很高那多半是磁盘或网络在拖后腿。第二个是vmstat的进程运行队列。跑一段vmstat 1观察r列。r表示正在运行和等待CPU的进程数。如果r一直大于逻辑核心数说明CPU已经成瓶颈了请求都在排队。第三个是进程内的CPU占用。top里按P排序看某个进程的CPU%是否接近100%。如果多个线程各占一部分但总和已经顶满所有核也说明瓶颈在CPU。三条对上两条基本就可以断定是CPU密集型。这时你会发现一个反直觉的事实线程加的越多系统反而越慢。因为大量线程在争抢有限的核心上下文切换、缓存失效、锁竞争全部变成额外开销。指标现象判断方向ussy高、id低、wa≈0CPU算力瓶颈us低、wa高磁盘IO等待内存占用大、swap读写持续内存压力2.3 并发数不是越多越好CPU密集任务的并发设计核心原则很简单worker数量接近物理核心数即可。有个流传很广的线程数公式N C × (1 W/C)。其中C是CPU核心数W是等待时间C是计算时间。对于纯CPU任务等待时间为0公式简化为N C。所以十六核机器开16到32个worker就够了如果你开100个线程跑纯Python计算反而会因为争抢时间片和锁而变慢。这里还要特别提一下超线程。超线程在操作系统中显示的逻辑核心数往往是物理核心的两倍但它的本质是一套执行单元轮流处理两组上下文并不能带来两倍计算能力。做容量规划时按逻辑核数来定worker数量可以但期望值别按倍数算。不同语言的处理方式也有差异Python纯Python计算受GIL影响线程无法真正并行要提速就用ProcessPoolExecutor或多进程。只有numpy这类会释放GIL的库线程在某些场景下才有意义。Gogoroutine调度开销小但大量CPU密集goroutine依然受GOMAXPROCS限制想跑满多核一般不需要自己管。Java线程池大小同样按核心数设计不要盲目调到几百上千。2.4 让单任务变快才是CPU型优化的大头很多人面对CPU密集任务第一反应是上多核、加并发但我多年的体感是并发带来的收益有上限单任务性能提升往往更夸张。曾经有个任务要对上千个文件做关键词匹配最初用Python嵌套循环跑一遍要三分钟。后来发现可以用numpy的向量化操作替代内层循环同一批数据只要0.3秒。三分钟到0.3秒是600倍的提升而加并发最多只能带来核数的倍数通常也就4到16倍。所以CPU密集任务的优化优先级应该是算法层面把O(n²)改成O(n log n)往往是几十倍的差距。向量化和JITnumpy、SIMD、Numba、Cython把Python循环翻译成底层高效指令。减少重复分配循环里反复创建临时对象会拖累CPU和缓存提前分配好buffer。缓存友好按顺序访问连续内存比到处跳指针快得多。最后才是并发在单任务优化到瓶颈后再用多核扩展。这个顺序每次都能省下大量机器资源。CPU密集型任务的本质是算把每次运算本身做快远比把一堆慢运算并行起来有意义。3. 内存密集型不是内存不够是数据放法不对3.1 这类瓶颈的现场长什么样内存密集型任务在技术里常被忽略因为它的症状不直接。CPU使用率中等偏高但忽高忽低程序没在疯狂计算却明显感觉到卡顿或者随着运行时间越来越慢最后进程被系统直接杀掉。典型场景有一次加载几百万行数据做group by或join内存中构建超大字典、图结构、索引缓存系统堆了大量数据GC频繁启动科学计算里创建了巨大的数组或中间结果内存密集型和CPU密集型很容易混。比如一个任务在处理一亿行数据CPU也在跑但真正的短板是内存带宽数据太大CPU从内存取数要等几十个周期计算单元反而在空转。这种情况叫内存带宽受限它依然属于内存密集型。3.2 用哪些指标把内存问题抓出来判断内存瓶颈我一般从系统层和进程层两个维度下手。系统层free -h看总内存和swap使用量vmstat 1看si和so两列。如果si和so持续不为0说明系统正在频繁换页内存严重不足连带着所有进程都变慢。进程层ps aux --sort-rss | head可以列出内存占用最高的进程。RSS是实际占用的物理内存VSZ是虚拟内存大小判断问题时优先看RSS。代码层Python项目可以用tracemalloc找出哪些代码在分配大块内存Java项目看GC日志和堆转储Go项目用pprof heap。重点是区分两件事是数据量本来就超过可用内存还是代码产生了大量垃圾没有及时回收。如果换页多、swap活跃、GC频繁那大概率是内存密集型或内存泄漏而不是CPU问题。3.3 内存慢的本质命中和垃圾要优化内存密集型任务先理解它为什么慢。CPU访问速度差异其实非常大L1缓存约1纳秒L2缓存约4纳秒主内存RAM约60到100纳秒磁盘毫秒级别比内存慢几个数量级所以当你的数据超过CPU缓存容量CPU每次取数都要跑到主内存等待时间从几个周期变成几十上百个周期。如果用了一种很散的数据结构比如Python里均匀分布的天然引用对象CPU还要反复跳指针缓存命中率低到吓人程序看起来在算其实都在等内存。另一个隐形杀手是垃圾回收。代码里循环创建大量临时对象内存不断分配释放GC不停扫描回收这对性能的拖累甚至不亚于磁盘IO。好比厨房操作台太小大厨炒一道菜要去一趟储物间搬料大部分时间和精力都花在来回跑路上。3.4 六条随手可用的优化路径内存密集任务的优化思路不是简单粗暴地加内存而是让同样的数据量变得更容易处理流式加载用pandas.read_csv(..., chunksize10000)这类分块读取而不是一次性load全量。数据可以从所有都在内存里变成处理完一批就释放一批。紧凑数据结构Python list装一千万个浮点数每个对象都有额外开销换成array.array或numpy数组内存占用可能下降十几倍。用mmap映射文件大文件不需要全部读进堆里用内存映射让操作系统按需加载尤其适合只读扫描。批量与复用避免在循环里反复分配大对象提前分配好buffer循环里填值即可。限制缓存体积进程内缓存要设容量上限和淘汰策略否则缓存救CPU会变成内存拖垮整机。常见的LRU、TTL方案都能用。处理不了就分流当单个机器内存确实装不下数据集时把任务拆成多个分区按分区处理、中间结果落盘这是比买更大机器更稳的做法。我踩过一个特别典型的坑看到一个进程内存飙升第一反应是程序泄漏了ps一看RSS确实大。仔细查才发现是用了list(map(...))把生成器转成了完整列表一次存了一亿个元素。改成生成器逐个处理后内存占用直接降了九成。有时候不是内存不够是数据放法不对。4. IO密集型CPU在空转线程全在睡觉4.1 IO 不是只有磁盘IO密集型任务有两个特征CPU占用不高时间却哗哗地流走你观察进程状态大量线程阻塞在系统调用上像一群等着放学的小学生。IO也分好几种来源定位时必须分开想磁盘IO读文件、写日志、数据库引擎刷盘。网络IOHTTP请求、消息队列收发、远程调用。外部服务等待调用下游接口对方处理慢你的线程只能挂着等。锁和信号量等待另一个线程释放资源本质上也属于等。典型场景包括网页爬虫批量抓取、接口网关转发请求、日志采集器不断读新文件、ETL任务逐行处理、数据库的N1查询、消费消息队列里的消息。IO密集任务最让人困惑的是top里CPU明明很低wa也不算高可任务就是慢。因为网络等待不会让wa飙升你看到的是大量线程处于睡眠状态时间全耗在外部系统的往返上。4.2 为什么它适合高并发和异步IO密集和CPU密集在并发策略上几乎是相反的。阻塞式IO调用会让线程进入睡眠操作系统趁机切到别的线程执行。你开的线程越多同一个时刻能发起的IO请求就越多整体吞吐就上去了。只要线程数量不超过系统承载极限加线程对IO任务确实有效。这一点在Python里也能成立CPython的GIL虽然限制了计算的并行但IO等待时GIL会释放所以多线程写爬虫、写网络服务性能提升是实打实的。IO密集任务可以选择的三种方案方案最大并发切换代价适合场景多线程几百到上千中等阻塞式IO、已有同步代码协程/异步上万低纯网络IO、高并发长连接多进程受核数和内存限制高需要隔离或混有CPU计算我的建议是如果项目里IO密集但伴随一些CPU计算优先用异步主框架线程池执行CPU计算的混搭而不是只用一个模型硬扛。4.3 定位 IO 瓶颈等的时间去哪了判断IO瓶颈常用命令是iostat -x 1。看磁盘的%util和await如果%util长期接近100%且await明显升高就是磁盘饱和。iotop能看具体是哪个进程在读写。网络IO定位稍微复杂点。我会先看ss -s整体连接状态再通过代码打点统计一次外部调用的时间分布DNS解析多久、建立连接多久、等待响应多久。多数慢请求问题最后都指向这几种情况没复用连接每次请求都新建TCP连接握手开销叠加。DNS解析慢调用外部API前有个慢DNS查询。串行等待一个请求拿到响应后才发下一个白白浪掉了IO重叠的机会。N1查询查了1000条订单又循环发起1000次用户查询每次都是独立网络往返。有一次我处理一个报表接口原来对100个用户逐个查详情总耗时2秒多改成一条带IN条件的批量查询后耗时掉到70毫秒。这说明IO密集任务优化最划算的方向之一就是减少往返次数而不是单纯加大并发。4.4 一个爬虫任务的改造复盘拿一个很常见的爬虫任务举例需求是抓取一万个网页并保存到本地。最初的写法是单线程循环每个网页要经历完整的DNS解析、TCP握手、请求、响应、落盘。假设每个网页耗300毫秒一万个下来约3000秒将近一个小时。IO密集的串行就是这种效果。改成线程池后呢代码很短from concurrent.futures import ThreadPoolExecutor URLS [https://example.com/page/1, ...] def fetch_and_save(url): html http_get(url) append_to_file(f{url}\t{html}) with ThreadPoolExecutor(max_workers32) as pool: pool.map(fetch_and_save, URLS)32个worker意味着同一时刻有32个网络请求在跑等待时间被重叠起来总耗时从一小时缩到几分钟。如果再进一步把落盘操作丢到一个单独的writer线程避免磁盘写和网络请求互相抢时间效果还会更好。但要注意如果抓下来后要做深度学习推理CPU计算占比变高这任务就从IO密集变成了混合任务需要拆成两段分别优化。任务类型会随着优化移动这是非常正常的现象。5. 混合任务与常见误区现实服务很少只有一种压力5.1 一个请求里可能三类压力都撞齐了真实业务基本不会只有单一压力。拿一个支付回调接口举例接收请求、反序列化JSON是CPU工作验签计算是CPU工作查订单和余额要读数据库这是IO如果各家下游接口响应慢又变成了等待外部服务最后还可能写日志、发MQ。单个请求里CPU、内存、IO全占齐了。所以给整个服务贴我们是IO密集型的标签是不准确的。**更准确的说法是这条代码路径的这个阶段瓶颈是什么。**同一台机器上有的线程在等数据库有的线程在跑业务逻辑系统层面的指标是它们混合后的结果会掩盖真实瓶颈。5.2 按阶段分流不要按整机归类处理混合任务我习惯把它们拆成流水线阶段每个阶段单独配并发策略下载/读取阶段IO密集开线程池或异步数量可以大。解析/清洗阶段CPU密集用进程池或限制worker数为核心数。写入/推送阶段又回到IO密集开writer线程批量落盘或批量发送。阶段之间用队列连接。队列长度就是天然的监控指标哪个阶段的队列在持续积压瓶颈就在哪个阶段。流水线的最慢环节决定了整条流水线的速度这和工厂产能的道理一样。一个典型的处理管道可能是抓取(IO32线程) - 解析(CPU8进程) - 写入(IO4线程批量写)这种分层设计比全任务扔进一个大线程池要稳得多。大线程池的问题在于CPU计算和IO等待混在一起worker数量没法同时满足两种需求要么IO并发不足要么CPU争抢严重。5.3 三个反复出现的认知误区误区一加线程一定更快。如果任务本身是纯CPU计算加线程超过核心数后不仅不快还会因为上下文切换和锁竞争变慢。那个开头提到的100线程图像批处理脚本就是经典案例。误区二异步万能。异步协程适合IO等待但如果在同一个协程里混入一段长时间CPU循环整个事件循环都会被卡住其他所有并发任务跟着遭殃。这类CPU计算要放到executor或者独立进程里。误区三缓存能解决所有慢。缓存只对重复读有效。一次性数据处理、写多读少场景缓存带来的收益很小反而增加内存占用和数据一致性的复杂度。我发现不少团队遇到性能问题就上缓存最后缓存本身成了新的内存压力源。5.4 给不同代码路径的选型参考代码路径特征推荐方案典型反面计算量大、循环密集进程池、线程数等于核数大量线程抢CPU高频小IO线程池或异步连接复用每请求新建进程大数组、大DataFrame流式处理、紧凑结构一次性load全量混合IOCPU异步为主executor卸计算全部塞进同一线程这个表是我这几年规划服务时反复用的参考。说白了选型不是大家都用异步我也用的跟风而是看你这一阶段在等什么。6. 一套能直接落地的性能定位流程6.1 五步定位法面对一个性能问题我的动作永远是固定的五步每步都围绕当前瓶颈是CPU、内存、还是IO定目标把很慢翻译成具体指标。多慢算慢p95超过500毫秒还是每天定时任务跑不完抓系统指标连续采样3到5分钟记录CPU各状态、负载、内存、swap、磁盘util形成基线。对号入座按前面几章的对应关系判断——us高是CPUwa高是磁盘swap活跃是内存。代码级定位用profiler找到具体函数。Python用cProfile、py-spyGo用pprofJava用火焰图。小步实验一次只改一个变量比如线程数从32调到64或者写入改成批量改了之后立刻用第2步的指标验证不灵就回滚。这套流程看起来很朴素但能拦住90%的错误优化。我见过太多人上来就重写架构结果性能没变还引入一堆新Bug。6.2 常用命令速查表先把最常用的一组命令列出来出了问题十分钟内就能把现场抓到命令看什么什么时候用topCPU百分比、load、内存第一步快速感知vmstat 1运行队列、swap、IO等待判断CPU还是IOiostat -x 1%util、await怀疑磁盘瓶颈free -h总内存、swap怀疑内存不足ps aux --sort-rss哪个进程吃内存找内存大户dmesg | grep -i oomOOM killer记录进程莫名消失后perf top内核态热点函数CPU热点找不到时strace -c系统调用统计看每秒多少IO调用抓完这些数据任务类型基本就浮出水面了。剩下的是针对类型的优化动作。6.3 一个日志清洗任务的完整排查过程最后分享一个完整的排查案例这个案例最能说明任务类型是动态的。某天有个定时任务需要清洗五百万行访问日志提取字段后输出到一个清洗文件。原来的写法是逐行读文件、正则匹配、open(..., a)逐行追加写入。跑了一半发现两小时都没结束大家都以为是数据量大准备加机器。我先跑了top发现CPU只有30%左右再看iostat -x 1磁盘%util不高但await明显偏高。问题出在写入模式每行都做一次open和写入五百万行等于五百万次系统调用大量时间耗在文件打开关和内核态切换上。这其实是IO密集型不是数据量大的问题。修复方式很简单攒够一千行再批量写一次系统调用数量变成原来的千分之一。任务从两小时缩到十五分钟。但故事还没结束。写完速度上来之后再跑topCPU使用率涨到了90%以上。这时瓶颈从IO移动到了CPU——正则匹配成了最大消耗。于是我把正则预先compile再配合多进程按行数分片处理十五分钟又缩到了三分多钟。同一个任务从IO密集变成CPU密集再变成CPU多核扩展每一步都是先定位再动手。最后说个我自己的习惯。我把上面这些命令打包成了一个简单的性能采集脚本遇到问题先跑一遍把CPU、内存、磁盘、负载全部存成日志再做任何代码改动都有数据底稿。方法听起来土但非常管用。这三类任务的分类不是终点而是一个起点先搞清楚你的程序在等CPU、等内存还是等IO后续的线程池大小、异步框架、批量处理和缓存策略自然就有了方向。
阅读完成 · 觉得有帮助?