十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

异步编程为何只对IO密集有效?CPU密集任务加速实战与避坑指南

异步编程为何只对IO密集有效?CPU密集任务加速实战与避坑指南 1. 先搞清楚两个概念IO操作和CPU密集操作到底指什么1.1 IO操作等待多、干活少IO操作指的是输入输出操作。网络请求、磁盘读写、数据库查询、消息队列投递与消费这些都是典型的IO操作。这类操作有个共同点你发起一个请求然后大部分时间都在等待结果返回真正CPU参与计算的部分微乎其微。比如你用requests库请求一个REST API整个过程大致分解为DNS解析域名、TCP三次握手、发送HTTP请求、服务器处理后返回响应、你读取响应体。这里面除了本地拼接请求头和解析响应头之外剩下的全都是等。等DNS服务器回包等TCP握手完成等服务器处理完业务逻辑等网络传回数据。一次接口调用本地CPU忙碌的时间可能不到1毫秒但整体耗时可能高达300到500毫秒。这里有个非常关键的事实在你等待的这500毫秒里CPU并不是在帮你计算什么而是彻底闲着。如果程序是同步执行的那么线程会卡在recv或read调用上什么也不干虚度光阴。这才是IO操作最大的浪费点。1.2 CPU密集操作一直在干活CPU密集操作或者叫计算密集型操作指的是那些需要CPU持续运转才能完成的工作。典型例子包括视频编码压缩、图像滤镜处理、大数组排序、矩阵运算、哈希计算、数据压缩解压、模拟仿真计算等等。这类操作的特征是CPU忙得不可开交几乎不需要等待外部设备。拿视频转码举例FFmpeg把一个1080p视频转成H.264编码器需要逐帧进行运动估计、离散余弦变换、熵编码这个过程中CPU一直满载运行几乎没有闲置。再比如Python里做高精度数值运算算100万个数的方差CPU要一条条执行指令这期间也没有任何空闲等待。所以CPU密集操作的核心特征是计算时间 CPU工作时间。你没法通过不等待来加速因为根本没得等。1.3 一个类比点外卖和做数学题为了把这两个概念说透我习惯用一个生活类比。IO操作就像点外卖你下单之后真正花在点这个动作上的时间只有几十秒剩下四五十分钟都在等商家做饭、等骑手送货。这段时间你是自由的可以看书、打游戏、干别的活。异步编程就是下单之后先干别的等外卖快到了再下楼去拿。CPU密集操作就像做一套高难度数学题解题的每一分钟都需要你坐在书桌前动脑子你没法先干别的等题自己做完。如果硬要中途去打个游戏再回来只会增加进入状态、重新审题的开销实际总时长反而更长。当然类比终归是类比下面我们看工程层面真正的原理。2. 异步的本质不是快而是不傻等2.1 事件循环和协程到底在干什么异步编程的核心是事件循环Event Loop。以Python的asyncio为例事件循环就是一个无尽的循环它维护着一个任务队列每次从队列里取出一个协程任务执行当任务遇到await也就是等待某个IO事件完成时就把控制权交还给事件循环事件循环再去执行队列里的下一个任务。当之前等待的IO事件完成时回调函数会被触发把对应的协程任务重新放回队列继续执行。Node.js的Event Loop本质一样libuv库实现了基于epollLinux或kqueuemacOS的IO多路复用事件循环在等待多个IO描述符时任何一个变得可读可写就立刻回调对应的JavaScript函数。这个机制最关键的地方在于等待IO的这段时间被让出来了。原来同步模型下线程阻塞在IO上的死等时间被事件循环拿去执行其他任务了。所以异步编程不是让单个IO操作变快了而是让多个IO操作的总耗时从叠加变成了重叠。2.2 异步真正解决的问题是等待我们做个小学数学题。假设一个IO请求需要500毫秒其中CPU执行代码只需要5毫秒等待响应需要495毫秒。同步执行10个这样的请求耗时是10 × 500 5000毫秒也就是5秒。如果用异步方式把这10个请求同时发出去每个请求的等待时间互相重叠。瓶颈不再是10 × 500而是最长那个请求的耗时大约就是500毫秒左右。5秒变成0.5秒这不是快了一点点是快了10倍。而且请求数量越多优势越明显100个请求同步要50秒异步还是1秒左右。这就是异步对IO操作的意义把一个个嵌套的等待时间改成并行的等待时间让CPU在那5毫秒的干活时间里尽可能多地处理完所有请求的发起和响应解析。2.3 为什么CPU密集操作享受不到这个红利现在假设我们要执行10个CPU密集计算任务每个任务需要500毫秒纯计算中间没有等待、没有外部依赖。异步事件循环里跑这10个协程会发生什么因为事件循环是单线程的任何时候只能执行一个计算任务。task_1跑500毫秒跑完再跑task_2再跑500毫秒。10个任务的总耗时就是 10 × 500 5000毫秒和同步执行完全一样。更严重的是asyncio每次协程切换都要保存和恢复执行上下文多出来的这些切换开销还会让总时间略微超过5000毫秒。有人会问异步提供了await能不能在计算任务里主动await让出CPU让多个计算任务交替运行可以但交替运行不等于并行运行。10个计算任务轮流用同一个CPU核心每个任务跑20毫秒就切换最终总完成时间还是约等于5000毫秒甚至因为切换开销变得更慢。CPU密集操作想真正加速只能靠并行——让多个计算任务同时占用多个CPU核心。异步是并发模型不是并行模型它在单核上调度协作式任务能力边界就在这里。3. 用代码验证同样用异步结果完全不一样3.1 场景A并发HTTP请求IO密集实测先说实验环境我自己写了一个简单的HTTP接口故意让它sleep 200毫秒再返回模拟真实的网络延迟。然后用同步和异步分别请求10次记录总耗时。同步版本import time import requests URL http://localhost:8000/api/test def sync_fetch(): start time.perf_counter() for i in range(10): resp requests.get(URL) resp.json() return time.perf_counter() - start print(f同步耗时: {sync_fetch():.2f}s)异步版本import asyncio import aiohttp URL http://localhost:8000/api/test async def async_fetch(): start time.perf_counter() async with aiohttp.ClientSession() as session: tasks [] for i in range(10): tasks.append(session.get(URL)) responses await asyncio.gather(*tasks) for resp in responses: await resp.json() return time.perf_counter() - start print(f异步耗时: {asyncio.run(async_fetch()):.2f}s)这里要提醒一下session.get返回的是协程对象我们把这10个协程都收集到tasks列表里再通过asyncio.gather一次性调度这样10个请求才能同时发出。如果你在一个循环里await一个再发起下一个那就又变成同步的了。在实际代码里还应该注意及时close响应对象避免连接池泄漏。我这边测试环境比较简单就直接用这个简版了。说下结果同步代码跑下来大约2.1秒异步代码大约0.23秒差了将近10倍。这就是IO等待被重叠的效果。3.2 场景BCPU密集计算纯计算实测再看一个CPU密集场景对一个长度为2000万的列表执行平方和求和。这个计算单次大概需要0.5秒左右。同步版本import time def sync_calc(): data list(range(20_000_000)) start time.perf_counter() total sum(i * i for i in data) return time.perf_counter() - start print(f同步耗时: {sync_calc():.2f}s)异步版本import asyncio import time async def cpu_task(data): return sum(i * i for i in data) async def async_calc(): data list(range(20_000_000)) start time.perf_counter() tasks [cpu_task(data) for _ in range(10)] results await asyncio.gather(*tasks) return time.perf_counter() - start print(f异步耗时: {asyncio.run(async_calc()):.2f}s)这里注意一个细节同步版本只算1次耗时约0.5秒。异步版本我故意让10个同样的计算任务排队结果大约在5秒左右。也就是说同一个列表的同一个计算异步跑10遍就是同步跑10遍的时间总和完全没有节省一丁点。如果你把同步版本也循环执行10次两个版本的耗时基本一致。3.3 结果对比差距的根源在哪里两组实验的差距根源用一句话说IO密集的等待开销可以被并发掩盖掉而CPU密集的计算开销只能被并行摊薄。async/await的异步模型提供了并发调度的能力但调度不等于并行。在并发IO场景里事件循环把等待网络响应的空窗期留给其他任务单位时间内完成的工作量大幅提升。在CPU密集场景里任务之间没有空窗期事件循环没有碎片时间可以利用所有任务只能挤在同一个CPU核心上排队异步的调度能力完全派不上用场。这里还要提一个额外的坑异步代码里如果存在一个耗时很长的CPU计算它会阻塞事件循环导致所有其他IO协程全部卡住。这就是为什么网上常说asyncio里不能写CPU密集型代码不是不能写而是写了会让整个事件循环失去响应。4. CPU密集操作的正确打开方式多进程与真并行4.1 多进程是如何解决CPU密集问题的既然异步在CPU密集操作上不给力我们就需要用并行手段来解决。主流做法是多进程multiprocessing或进程池。每个进程由操作系统调度到独立的CPU核心上多个计算任务真正同时执行。刚才的2000万数据求平方和用4个进程分别计算500万个数的部分和最后合并结果在4核机器上能把耗时压到原来的四分之一左右。Python的ProcessPoolExecutor用起来非常直接from concurrent.futures import ProcessPoolExecutor import time def partial_sum(data): return sum(i * i for i in data) def parallel_calc(data, workers4): size len(data) // workers chunks [data[i * size:(i 1) * size] for i in range(workers)] with ProcessPoolExecutor(max_workersworkers) as pool: results list(pool.map(partial_sum, chunks)) return sum(results)注意多进程并不是免费的午餐。进程之间不共享内存传入传出的数据要经过序列化pickle这会产生额外开销。如果待处理的数据量巨大序列化时间甚至可能超过并行计算节省的时间。所以多进程适合那些数据量可控、计算量很大的任务比如图片处理、视频帧编码。4.2 为什么Python多线程对CPU密集操作也不太行这里必须提一下Python的GIL全局解释器锁。在标准CPython实现中同一时刻只能有一个线程执行Python字节码。这意味着纯Python计算的多线程并不能利用多核本质还是在一个核上轮流执行而且线程切换开销甚至可能比多进程更大。很多人写爬虫或服务端时感知不到GIL的存在是因为操作中还涉及了C扩展或系统调用。一旦IO套接字进入等待GIL会被释放其他线程就有机会执行。所以Python多线程对IO密集操作是有效的对CPU密集操作效果很差。因此在Python生态里做CPU密集加速我的建议排序是优先用向量化库NumPy、Pandas等让底层C代码绕过GIL跑满多核其次用多进程最后才考虑用异步配合线程池或进程池。4.3 什么时候CPU密集操作也可以用异步思路有人可能会问是不是CPU密集操作就完全不能和异步沾边也不是。如果你的业务是以IO为主但夹杂少量轻量计算那用异步是没问题的。比如一个HTTP接口拿到参数后要临时算一下签名这个计算只有几十毫秒对整体性能影响很小可以直接在协程里算。但如果你想在asyncio里调用一个真正的CPU密集任务标准的做法是用 asyncio.to_thread 或 asyncio.get_running_loop().run_in_executor把任务丢给线程池或进程池执行池子执行完毕后通过回调把结果交回事件循环。这样既保证了CPU密集任务不阻塞事件循环又不影响其他IO协程的调度。import asyncio import concurrent.futures def heavy_calc(n): return sum(i * i for i in range(n)) async def main(): loop asyncio.get_running_loop() with concurrent.futures.ProcessPoolExecutor() as pool: result await loop.run_in_executor(pool, heavy_calc, 10_000_000) print(result)这种异步进程池的方式本质上是把CPU密集任务外包给其他进程让事件循环继续保持快速响应。这是工程上常用的混合方案。5. 实际工程中的常见误区与避坑指南5.1 误区async化之后就会快这个想法非常普遍。很多人把同步代码里的函数改成async def加上几个await就以为性能自动起飞了。实际上如果函数内部没有真正的IO等待async化除了增加协程开销什么都改变不了。我之前见过一个项目把一系列字典解析和格式转换的纯计算函数全部改成async处理速度不升反降同时还引入了协程泄漏的问题。异步必须有可等待的事件才有价值没有等待就没有重叠空间。判断标准很简单函数内部有没有网络请求、磁盘读写、sleep等待、锁等待这类阻塞点如果没有就别async。5.2 伪异步阻塞在async函数里的典型场景写asyncio代码最危险的是在协程内部调用同步阻塞库。比如在async函数里用requests.post发送HTTP请求这个调用是同步阻塞的它会直接卡住整个事件循环其他协程全部等待。正确的做法是使用aiohttp、httpx的异步客户端或者用 await asyncio.to_thread(requests.post, url) 把它丢到线程池。同理在异步代码里做文件读写除非用aiofiles这类异步文件库否则open和read也是阻塞的。的sleep也要用await asyncio.sleep()不能直接用time.sleep()因为time.sleep会阻塞当前线程而不会让出事件循环。这些都是伪异步的常见来源排查时第一先看是不是有同步阻塞调用混在事件循环里。5.3 遇到IO和CPU混合的场景应该如何设计实际业务往往不是单纯的IO或单纯的CPU而是两者混合。比如一个数据服务从数据库查一批原始数据IO然后做复杂的特征计算CPU再写回缓存IO。这种场景的正确设计是IO部分用异步协程并发调度CPU部分用进程池并行计算两者通过队列或者回调节点衔接。一种简化但实用的做法是把整个过程拆成拉数据—计算—存数据三段每段内部各自用最优模型。拉数据用异步并发请求计算用ProcessPoolExecutor并行处理存数据再回到异步批次写入。混合设计虽然工程复杂度更高但确实是高吞吐服务的核心思路。5.4 如何快速判断一个任务该不该用异步我个人的经验是问三个问题第一这个任务是否存在明显的等待时间比如网络请求、磁盘读写、锁等待、跨服务调用。存在等待异步大概率有收益。第二等待时间是否占总耗时的比例很高如果等待占比超过60%异步收益非常明显如果占比只有10%异步收益基本可以忽略。第三并行度是否大于1如果并发请求数小于等于1异步只是徒增复杂度。三个问题都符合才值得用异步。否则还是老老实实按同步写CPU密集部分用多进程按需上别为了高级而异步。6. 关于异步选型的一点个人体会在真实的团队协作和项目维护里我观察到一个规律异步代码写起来爽调试成本高性能收益集中在IO密集场景。所以我会建议团队先把IO密集的边界划清楚比如外部API调用、消息消费、数据库批量操作这些地方统一使用异步模型统一封装并发调度框架。CPU密集计算则单独抽出服务或者放到进程池中绝不让它进入事件循环。从语言层面看Node.js天然就是异步友好型适合网关、代理、实时推送这类IO密集场景。Python则更适合做数据分析和机器学习遇到高并发IO时用asyncio遇到重计算时用多进程或扩展库。选型的时候不要把异步当成银弹而是把它当作并发工具库中的一种和线程、进程、协程放在一起通盘考虑。另外再说一个容易被忽略的细节异步框架的排障工具和同步栈完全不一样。协程上下文、回调链、事件循环任务状态都需要专门调试。一旦线上出现任务悬挂或协程泄漏排查过程通常比同步代码更痛苦。所以引入异步时一定要提前把日志框架、超时控制、并发限流、异常捕获方案都做齐否则上线后再补坑成本翻倍。如果你刚开始接触异步编程我建议先不要在业务里大规模替换。找一两个明确的IO密集场景比如日志上传、消息推送、文件转码的IO部分小范围试点用数据对比收益再逐步铺开。踩过几次坑之后你对什么时候该异步、什么时候不该异步的理解会比读十篇文章都深。
返回列表