ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

IO密集任务还在用线程池?协程改造实战与避坑指南

IO密集任务还在用线程池?协程改造实战与避坑指南

还在用线程池处理一次拉取几百个外部接口的批量任务?每次看监控里线程上下文切换的数字都觉得不对劲?如果你最近正被回调式异步代码折磨得翻来覆去,那我劝你把协程这事认真安排上。这篇内容不讲课本理论,就讲我实际用协程把一批IO密集任务改造后的变化,以及你在迁移时大概率会踩的坑。标题说“还在等什么”,真不是营销话术,是我自己把同步代码改成协程之后,心里冒出的第一句话——改晚了。

1. 一次IO密集风暴:我为什么把协程调来当主力

1.1 现场还原:同步循环带来的连锁雪崩

之前有一个定时抓取任务,每隔5分钟从几十个第三方接口拉数据,每个接口下面又要分页拉详情。最初版本写得非常直接:同步的 for 循环,一个请求等完了再发下一个。单次任务跑下来三四分钟还算能忍,后来接口数量翻了几倍,单次任务直接逼近 20 分钟,定时器还没跑完,下一轮又启动了,任务重叠得越来越厉害。

我把任务打印出来看,发现绝大多数时间并不是在解析数据,而是卡在resp = requests.get(url)这一行上。网络往返占掉了整个任务生命周期的 90% 以上,真正干活的CPU时间基本可以忽略。这就是最典型的IO密集型任务:代码不是在“算”,而是在“等”。同步写法的“等”是串行的,一个请求等了 500 毫秒,后面一百个请求就依次跟着等,总时长自然被放大。

后来我想过用线程池,但当时最直接的反应是:这不就是“多开几个同时等”吗?于是先改了ThreadPoolExecutor(max_workers=20)版本,任务总时长确实压到了两三分钟。不过上线没多久就暴露出新问题,线程一多,所有请求同时打到外部接口,连接数瞬间飙高,再加上需要写数据库,连接池也被拖到极限。那一周我反复在看监控:线程数、数据库连接数、外部接口响应时间,三个指标轮流报警。

1.2 线程不是越多越好:线程的隐性成本

线程池方案看着简单,但它的成本是隐性的。操作系统线程不是免费的,每个线程都有自己的栈空间,Python 的线程还得承受 GIL 的约束。IO 密集场景下 GIL 虽然会释放,线程本身却仍然需要被调度器管理,线程一多,上下文切换的开销就上来了。上下文切换的代价不只是CPU多花几个时钟周期,更重要的是它会抢占缓存、打断热点代码执行,让整个进程的表现变得不稳定。

在线程池里,并发度只能靠max_workers硬编码。如果外部接口变慢,每个线程都阻塞在requests.get上,线程池里的线程被占满,新任务只能排队,整个任务时间又变得不可控。反过来,如果把线程数调大,数据库连接池、外部服务连接数又会跟着吃紧。这种“调参数”式的伸缩方式,本质上还是用“堆资源”来对抗等待。

而且线程池在控制流上非常别扭。我要等一百个请求的结果都回来,用as_completed遍历 Future,还得自己维护一个字典把 Future 和原始任务对应起来;某一个请求失败要重试,得在包装函数里塞各种逻辑。代码一旦复杂起来,线程池里来回传递异常、状态、结果,阅读成本直线上升。

1.3 协程能解决的问题:让线程等待时腾出手

协程恰恰把“等待”这件事单独拆了出来。一个协程在await处挂起时,事件循环可以把CPU让给另一个已经就绪的协程。同样是等网络响应,同步代码是让一个人站在那里等一百次快递,协程是这个人把所有快递单号登记好,快递到了谁的就处理谁的,中间还能接着干别的。

我当时的判断逻辑其实很简单:任务里到处是网络IO等待,等待期间不需要参与运算,那就应该用协程把等待集中管理起来。并不是说线程池完全不行,而是协程在同样IO密集的场景下,能用更少的系统资源挂起更多的任务,还能用同步代码的写法表达异步流程。这是它对我最大的吸引力。

用上协程之后,任务数量从一百个变成几千个也不慌,因为协程任务本身很轻量,内存开销远小于线程。你可以在单线程里创建成千上万个协程任务,而线程做到几十上百个就已经开始有调度压力了。

2. 完全看懂async/await:事件循环里到底发生了什么

2.1 async函数与await的本质

很多人一看到async def就觉得里面肯定有魔法,其实拆开看完全没有。async def定义出来的函数,调用时并不会执行函数体,而是返回一个协程对象。真正的执行发生在这个协程对象被事件循环调度之后。

举个例子:

async def hello(): print("hello") await asyncio.sleep(1) print("world")

调用hello()之后,屏幕上不会立刻打印 “hello”。这个协程对象就像一个待办事项,得等asyncio.run(hello())或者await hello()把它推进事件循环,才会打印第一行。这就是“惰性执行”:函数体不是立即跑,而是准备好了等你调度。

await是协程里最核心的关键字,它表示“我要等一个操作完成,在这期间我先让出控制权”。等的时候,事件循环去跑别的协程;那个操作完成之后,事件循环再回来继续往下执行。所以await不只是一个语法标记,它同时是“挂起点”和“恢复点”,本质上是把程序的控制流切成了很多小片段,由事件循环来安排执行顺序。

有一点容易被新手忽略:只有异步函数里才能使用await。同步函数里写await,Python 解释器会直接报语法错误。反过来,协程函数里的代码如果全部是同步调用、没有任何await,那它也只是一个被推迟执行的普通函数,不会产生并发效果。

2.2 事件循环:一个人的调度团队

事件循环(Event Loop)是整个协程体系的大脑。你可以把它理解成一个只有一个人的调度中心:一个人同时管理着几万张订单,谁的就绪条件满足了就先处理谁,处理完一个立刻切到下一个。

底层机制上,事件循环依赖操作系统提供的IO多路复用能力,比如epoll、select这些机制。底层库把这些能力封装起来,事件循环只需要注册“这个socket有数据可读了” “那个socket可写了”,然后阻塞等待内核告诉自己哪些事件发生了。某个协程等待的网络响应一到,内核触发可读事件,事件循环再把对应的协程恢复执行。

这里有个非常容易踩的坑:事件循环只有一个线程在跑,所有协程都在这个线程里切换。如果某个协程里写了time.sleep(2),它不会“挂起自己让给别人”,而是会真的把线程阻塞住两秒钟。在这两秒里,整个事件循环停摆,所有其它协程全部停滞。协程内部要等待,应该用await asyncio.sleep(),它会主动让出控制权,而不是霸占线程。

我见过不少线上事故的根因就是这条:业务代码里不小心出现一个time.sleep(),表现为外部接口偶发超时,排查半天发现是某个老模块里用同步sleep拖住了整个事件循环。所以在异步环境里,凡是等待类的操作,都要问一句:这是否会让出线程?

2.3 并发不是并行:把“等待”切出来

这两年在技术社区里见过很多误解,最典型的就是“协程能提高计算速度”。这句话放在CPU密集型任务上是错的。协程做的是并发(concurrency),让多个任务交替推进,而不是并行(parallelism),让多个任务同时执行。事件循环在同一时刻只跑一个协程,只是切换得非常快,宏观上像是同时在进行。

用餐饮来类比可能更清楚:并发是一个服务员同时服务多桌客人,这桌点完菜就去服务下一桌,再回来上菜;并行是同时请了五个服务员,每桌一个。协程是前者,它不要求多核CPU,也不需要额外线程,只凭“谁在等就让出、谁就绪就上”的调度逻辑,把大量等待时间压缩掉。

所以协程真正适合的场景,是等待占大头、计算占小头的IO任务。如果任务本身是纯CPU运算,比如图像处理、视频编码、数据压缩、复杂数学计算,你用协程改写不但不会变快,反而会因为切换逻辑带来额外开销。这种场景应该上多进程,利用多核CPU做并行。

明白了“并发不是并行”,你就知道什么时候该用协程,什么时候该用别的工具了。

3. 协程的适用边界:什么场景赶紧用,什么场景千万别碰

3.1 最适合协程的四类任务

我日常接触的项目里,下面这四类任务使用协程时收益最明显:

网络请求批量抓取。无论是爬虫、批量拉取外部API、还是做接口连通性检查,每个请求都是发出去之后长时间等待响应。用协程可以把几百上千个请求的等待重叠起来,写出来的代码还保持清晰的顺序感。

网关和代理转发服务。一个Web服务收到请求后,需要把请求转发给后面的多个服务,再把结果聚合返回。这种服务天然是IO等待密集型的,协程可以在同一个工作线程里同时处理大量转发请求,省下来的线程资源可以留给慢查询场景。

长连接和消息推送。比如WebSocket服务,大量连接并不是时刻都有消息往来,大多数时间都处于空闲等待状态。用协程挂起这些空闲连接,内存占用比线程模型低得多,单个节点能支撑的连接数大幅提升。

文件IO批处理。读写大量小文件、日志轮转、批量压缩前的读取阶段,这些操作同样有大量时间耗费在IO等待上。Python 标准库里的aiofiles可以提供异步文件操作接口,不过它本质上还是通过线程池模拟出来的,适合处理阻塞文件操作,压榨空间略小于网络IO,但对于大批量文件场景依然有改善。

判断一个场景适不适合协程,我的经验是看一个比例:任务主动等待IO的时间占总生命周期的比例。如果超过七成,协程基本是首选;如果任务一直在做CPU计算,那协程就不是正确答案。

3.2 不适合协程的场景与判断标准

纯CPU密集型任务不适合协程。比如一个图像滤镜批量处理程序,核心操作全是像素计算,没有外部等待,协程调度对计算本身毫无帮助。这类任务应该用ProcessPoolExecutor或者直接上多进程,让每个CPU核真正并行动手。

大型同步库难以替换的场景也要慎重。如果项目的核心依赖是一堆同步库,而且这些库没有对应的异步版本,硬上协程会面临两难:要么用run_in_executor把同步调用丢到线程池里,但这只是绕道,本质上仍然在用线程兜底;要么强行换库改写业务逻辑,改动面太大。这种情况下,协程的价值会被大幅削弱。

迁移成本高、系统又稳定运行多年的场景,不要听风就是雨。一个线程池方案已经跑得很稳的任务,虽然监控里能看到上下文切换开销,但只要指标都在可接受范围,就没有必要为了“技术新潮”动刀。技术选型永远要看投入产出比。

还有一点容易忽视:协程配合数据库时,事务边界和连接管理会更复杂。如果业务里有大量跨表事务、依赖同一个连接的上下文状态,使用异步驱动(比如 asyncpg、aiomysql)时必须时刻关注连接是否泄漏、事务是否正确关闭。对于事务逻辑复杂的老系统,迁移成本远高于收益。

3.3 协程与线程、进程的搭配使用

成熟的项目里,协程、线程、进程往往不是互斥关系,而是混着用。我现在的习惯是:IO密集任务用协程统一调度,个别必须调用的同步库用run_in_executor丢到线程池避免阻塞事件循环,CPU密集任务单独走进程池。

举个例子,一个数据采集系统里,主流程是一个 async 任务池,负责并发拉取接口和切换页面。但某个环节需要对抓到的文本做关键词匹配,这个匹配算法是CPU密集的,我就把它放到ProcessPoolExecutor里,再把结果通过 await 拿回来,两边的优势都保住了。

这种混合模型用多了之后你会发现,协程更像是“调度骨架”,负责把IO等待的部分串联起来;线程池和进程池是其中专门的执行单元,各管一摊。这样架构下的代码,能清楚区分“哪些任务在等待IO”和“哪些任务在消耗CPU”,排障和调优都会轻松很多。

4. 实操:把批量下载工具从同步迁移到异步IO

4.1 第一轮:同步for循环,简单但代价高

为了把迁移过程说清楚,我拿一个批处理工具当例子:从某个接口批量下载 100 个 JSON 文件。假设每个请求的网络耗时在 300 毫秒左右,不设置超时上限,最初的版本长这样:

import requests def download_all(urls): results = [] for url in urls: resp = requests.get(url, timeout=5) resp.raise_for_status() results.append(resp.json()) return results

这段代码的好处是直观,坏处也很直观:100 个文件全部下载完,总耗时大约是 100 × 0.3 秒,也就是 30 秒起步。中间任何一个请求慢一点,总时间还会继续涨。任务串行等待,所有时间都被白白消磨在等网络响应上。

如果只是几个文件倒无所谓,但一旦要抓几百上千个,这种写法在工程上就无法接受了。第一次压测的时候,看着日志一条一条慢慢打出来,我心里只有一个想法:必须改。

4.2 第二轮:线程池,效果提升但问题不少

上面提到我一开始用的是线程池,这里把当时的主要写法放出来,方便对比:

from concurrent.futures import ThreadPoolExecutor, as_completed def download_all(urls): results = [None] * len(urls) with ThreadPoolExecutor(max_workers=10) as pool: future_map = { pool.submit(requests.get, url, timeout=5): index for index, url in enumerate(urls) } for future in as_completed(future_map): index = future_map[future] resp = future.result() results[index] = resp.json() return results

10 个线程并发,理论上耗时可以压到同步版本的十分之一附近,也就是三秒左右。实际跑下来也确实快了很多,但代码明显复杂了:我要手动维护 Future 和原始坐标的映射关系,还要在取结果时处理异常。如果中间有失败重试,还要在包一层函数,代码会越来越厚。

线程池的另一个问题在前面也提过,并发量被max_workers死死绑定。你想控制某个接口的并发上限,只能靠调整线程数,但线程数同时影响所有任务,没法针对某个目标单独限流。遇到外部接口响应变慢,线程池里的线程全部阻塞在等待中,后续任务全都堵在队列里,又回到了等待失控的状态。

4.3 第三轮:aiohttp + 信号量,干净又可控

改用协程之后,这个工具最核心的代码变成了下面这样:

import asyncio import aiohttp async def fetch_one(session, sem, url): async with sem: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: resp.raise_for_status() return await resp.json() async def download_all(urls, concurrency=10): async with aiohttp.ClientSession() as session: sem = asyncio.Semaphore(concurrency) tasks = [ asyncio.create_task(fetch_one(session, sem, url)) for url in urls ] results = await asyncio.gather(*tasks) return results

asyncio.create_task负责把协程包装成任务并加入事件循环。每个fetch_one协程执行到await时主动挂起,事件循环继续调度其它协程。100 个任务被全部创建之后,最多同时只有 10 个协程真正在发请求,因为信号量Semaphore(10)把并发闸门卡住了。

asyncio.gather会等待所有任务结束后一起返回结果,返回顺序与传入的任务顺序一致。这个特性在处理批量场景时非常省心,一个任务对应的原始URL和返回值天然对齐。

用协程版本最大的感受是:我希望做到什么并发度,直接写在Semaphore(concurrency)里,不用去调整线程数,也不用担心线程栈资源。即使任务从 100 个变成 10000 个,代码不用变,只需要调整并发参数。

4.4 迁移过程中的三个大坑

第一个坑就是在 async 函数里直接调用同步版 requests。requests 是对底层 socket 的阻塞封装,调用它会锁死当前线程。本来事件循环可以在你await的时候去处理其它协程,可一个 requests 库的调用把所有切换机会都堵死了。要么换成 aiohttp、httpx.AsyncClient 这类异步客户端,要么用await loop.run_in_executor(None, requests.get, url)把同步调用放到线程池里执行。

第二个坑是忘记给批量任务设置整体超时。单个请求有超时,不代表整个 gather 不会卡死。如果一个任务因为底层连接异常迟迟不结束,事件循环会一直挂着等它。我后来会在外层再加一层整体超时保护,这点到下一部分细说。

第三个坑是异常处理太粗糙。asyncio.gather(*tasks)只要有一个任务抛出异常,整个 gather 就会抛错,其它任务的结果也拿不到了。批量抓取场景里,个别请求失败是家常便饭,常见做法是用gather(return_exceptions=True)把异常当作结果返回,或者用 TaskGroup 精细化处理异常。

代码结构本身的差异其实不大,真正改变的是你对“等待”的理解。写完协程版再回头看同步版,绝大多数时间都是白等,这种感觉特别强烈。

5. 线上协程代码的工程化细节:超时、取消与可观测性

5.1 超时控制:防止任务“挂死”

协程代码最大的隐性风险就是“挂死”:一个任务因为底层资源迟迟没有响应,挂在await上不动。事件循环本身不会主动处理这种情况,必须靠代码层设置超时。

单个请求的超时可以直接放在异步客户端里,比如 aiohttp 用aiohttp.ClientTimeout(total=5)表示整个请求最多等 5 秒。但光这样还不够,批量任务应该再加一个全局超时,保证最坏情况下,整个任务不会无限期拖下去。Python 3.11 之后可以用asyncio.timeout,新老项目里常用的是asyncio.wait_for。一个典型写法:

async def run_with_global_timeout(urls): tasks = [asyncio.create_task(fetch_one(...)) for url in urls] async with asyncio.timeout(30): results = await asyncio.gather(*tasks) return results

如果全局超时触发,事件循环会尝试取消 gather 下的所有任务。这里要注意:不要在取消时留下大量“Task was destroyed but it is pending”的告警。理想的做法是先取消任务,再用asyncio.gather(..., return_exceptions=True)统一消费取消事件,把状态清理干净。

我自己的原则是:凡是在 async 代码里创建 Task,就必须明确它能结束。要么正常完成,要么被超时取消,要么被显式取消,不存在第三个状态。写代码的时候多问一句“这个任务要是永远不返回,系统会怎样”,能避掉很多线上问题。

5.2 任务取消与异常管理

协程任务的生命周期一旦理解透彻,取消就不难。asyncio.Task.cancel()会向任务内部投递一个CancelledError,任务可以通过 try/finally 做清理工作,比如关闭连接、释放信号量。默认情况下,取消会中止整个协程的执行。

如果项目跑在 Python 3.11 及以上,我推荐直接用asyncio.TaskGroup。它提供了结构化的并发模型,所有子任务在async with块内创建,块内任意任务异常都会触发取消,退出时统一等待所有任务结束。代码写起来很像同步语言里的作用域,非常清晰。

async def download_all(urls): async with aiohttp.ClientSession() as session: sem = asyncio.Semaphore(10) async with asyncio.TaskGroup() as tg: tasks = [ tg.create_task(fetch_one(session, sem, url)) for url in urls ] # 到这里说明所有任务都已结束

TaskGroup 的怪异之处在于,正常路径下不会显式收集任务返回值,你得把返回值塞到外部容器里保存。但这换来的是异常管理上的省心:兄弟任务异常、自身被取消时,不再需要你手动清理。使用老版本 Python 时,至少要严格维护任务引用,不要创建任务后不 await 就丢到一边,否则容易出现任务泄漏。

批量任务里我倾向于对异常做分级处理:不可重试的异常直接记录并跳过;可重试的异常(比如网络超时)做两三次退避重试;关键任务失败后在日志里标成大红色,而不是静默处理。

5.3 协程调试:让我看清任务在等谁

async 代码之所以调试起来费力,是因为调用栈被切成了碎片。一个任务刚执行到一半,切到另一个任务,等它回来,栈信息早就变了。Python 的 asyncio 为此提供了调试模式:设置环境变量PYTHONASYNCIODEBUG=1,或者在调用asyncio.run前执行loop.set_debug(True)。开启后,事件循环会输出更详细的信息,包括哪些协程被调度、哪些任务一直未被等待等。

我在实际调试中很依赖给任务命名。创建任务时写清楚标签,日志会好查得多:

task = asyncio.create_task(fetch_one(...)) task.set_name(f"fetch-index-{index}")

这样一旦任务异常,日志里会直接显示task name: fetch-index-23,比单纯看到一串数据舒服太多。对批量任务,我还会在协程里手动记录开始时间和结束时间,用一条日志打印每次下载耗时。遇到外部接口整体变慢,把这批耗时数据导出来拉个分布图,基本就能定位问题。

有些奇怪现象,比如明明开了协程,任务总耗时却没有下降,这时候先检查是不是有同步阻塞操作混在事件循环里。最容易出现的就是time.sleep、requests.get、os.system这类会卡住线程的调用。用py-spy之类的工具 dump 一下线程栈,看看事件循环线程到底卡在哪个函数,基本能找到元凶。

5.4 与同步代码共处的技巧

完全重写是不可能的,尤其是大项目里历史代码遍布。我的方案是“隔离同步,保住循环”:事件循环只负责异步代码,同步的阻塞调用一律通过asyncio.to_thread或loop.run_in_executor丢进线程池,避免阻塞整个循环。

比如项目里有一个第三方 SDK 只提供了同步接口,而我又希望整体流程保持异步,就封装一层适配器:

async def call_sync_sdk(arg): result = await asyncio.to_thread(sync_sdk.do_work, arg) return result

asyncio.to_thread在 Python 3.9 之后可用,它本质上是把函数扔到默认线程池里执行。这样虽然还是会用到线程,但调用点变成了一个 await,事件循环不会被长期占用,其它协程依然可以正常运行。

数据库访问也是同样的思路。如果团队没有异步驱动经验,默认用asyncio.to_thread把同步查询包装一下,先把流程跑通再逐步优化。等到业务真正吃紧,再评估是换异步驱动还是继续扩大线程池兜底。工程上讲究渐进,不能为了纯异步把整个团队拖入泥潭。

6. 一点个人经验:我现在是怎么取舍的

6.1 一个简化的决策参考

现在拿到一个并发任务,我第一反应不是追技术热点,而是按等待比例来做判断。网络IO和磁盘IO等待时间占比高,协程就是第一选择,并发量用信号量控制,超时用asyncio.timeout兜底,异常用 TaskGroup 或者return_exceptions=True管理。纯计算类任务直接进进程池,不跟他们绕弯子。任务少、生命周期简单,就老老实实用同步代码,不折腾。

这套判断方法帮我避免了很多没必要的重构。协程不是万能药,但它对IO密集任务的改造效果,在多数项目里都是肉眼可见的提升。尤其是那些“一个个慢慢等外部接口”的场景,改成协程之后,耗时会从指数级下降,代码却不会变复杂多少。

6.2 给刚开始用协程的人三个小建议

第一,先找一个功能独立的IO小工具下手,比如批量下载脚本、接口探活、日志收集器,把整个流程从同步改成 asyncio,跑一边对比耗时。不要一上来就重构核心交易链路,风险太大。

第二,强制记住一个规则:任何阻塞型同步操作,都不能直接出现在协程函数里。time.sleep、requests.get、同步文件的读写,碰到这些就要考虑替换成异步版本或者丢到线程池。这一条能挡住七成以上线上事故。

第三,写协程代码之前,把事件循环的“单线程调度”思路梳理清楚。每写一个await,就想象一下事件循环现在会去处理哪个就绪任务。这个思维模型建立起来之后,你会发现协程写起来和同步代码一样顺手,甚至更自然。异步IO的绝大多数困惑,都来自没有想清楚当前线程到底被谁占用。

协程处理异步IO这件事,真正难的根本不是语法,而是把“等待时让出控制权”变成一种条件反射。一旦你适应了这种控制流切换的方式,再回头看线程池里密密麻麻的 Future 映射和回调嵌套,就能理解标题里那句“还在等什么”到底在催什么了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进