别被 `run_in_threadpool` 骗了,它只是个“背锅侠”!
如果你在写 FastAPI 或者基于 Starlette 的应用那你一定遇到过这种进退两难的时刻你手里有一段祖传的同步阻塞代码比如老旧的requests.get或者某个不支持异步的数据库驱动但你的路由是被async def定义的“纯血异步”函数。这时候如果你直接把同步代码塞进去整个异步事件循环就会像被施了定身法一样瞬间卡死吞吐量直接清零。于是你开始疯狂查阅文档终于找到了那个金光闪闪的 API——run_in_threadpool。你兴奋地把它包在同步代码外面一跑果然不卡了你长舒一口气以为自己完美解决了并发问题。但真相是你并没有解决阻塞你只是花钱雇了个“背锅侠”。而且如果不了解它的底细这个背锅侠迟早会把你的服务器搞垮。今天我们就来扒一扒run_in_threadpool的底裤看看这玩意儿到底是个啥。第一幕微波炉与傻站着的厨师要彻底懂这其中的奥秘我们先来复习一下“同步Sync”和“异步Async”到底有什么区别。把我们的程序想象成一家高档餐厅而服务器的 CPU 和主事件循环就是这家餐厅里唯一的超级服务员。真正的异步比如httpx.AsyncClient微波炉模式服务员接到客人的点单把菜放进微波炉按下 10 分钟倒计时。然后立刻转身去招呼其他几百个客人。微波炉“叮”一声响了I/O 完成通知服务员再去端菜。结果单核一个服务员轻松应对千万并发没有任何人闲着。同步阻塞比如requests.get傻站着模式服务员接到点单把菜下锅然后死死盯着这口锅看 10 分钟。这 10 分钟内门外排队的几百个客人全都在骂街因为服务员被卡住了。结果吞吐量暴跌服务器“假死”。第二幕run_in_threadpool登场背锅侠就位为了防止超级服务员被这口锅卡死FastAPI 祭出了run_in_threadpool这块创可贴。当你使用它时到底发生了什么这段代码奇迹般地变成微波炉了吗绝对没有这道菜依然需要人站在锅边死等run_in_threadpool只是做了一个障眼法超级服务员一看这道菜要死等为了不让自己被骂他立刻跑到后厨花钱雇了一个临时工开辟了一个子线程。服务员对临时工说“老哥你帮我站在这口锅前死等 10 分钟好了叫我哈”随后超级服务员一身轻松立刻转身回大堂继续接客了。这就是run_in_threadpool的真相它并没有把同步变异步它只是把“阻塞卡顿”的这口锅甩给了后台新建的子线程。对于主程序超级服务员来说他不卡了但对于这段代码本身它依然是阻塞的。第三幕定时炸弹——“临时工”是有限的既然能雇临时工那我把所有同步代码都用run_in_threadpool包起来不就天下太平了如果你敢这么干你的服务器离崩溃就不远了。因为这种“假异步”有一个致命的弱点线程池的数量是有上限的。在 FastAPI底层依赖 anyio中默认的线程池大小通常是40 个。这意味着你的后厨最多只能容纳 40 个临时工。假设你同时来了 40 个很慢的请求超级服务员雇了 40 个临时工大家都在后厨死等。当第41 个请求来的时候灾难发生了。服务员回头一看“卧槽雇不到人了” 此时这第 41 个请求只能在大堂苦苦排队。从用户的角度来看你的服务器依然卡死了不管你怎么刷新都在转圈。而如果是真正的异步微波炉模式哪怕同时来 10000 个请求服务员只需要把 10000 盘菜放进 10000 个微波炉按一下按钮就行了单线程就能搞定根本不需要雇佣临时工。第四幕终极禁忌——千万别让临时工干“体力活”如果你觉得线程池耗尽已经够惨了那run_in_threadpool还有一个能让 Python 直接吐血的禁忌用它来执行 CPU 密集型任务纯粹的数值计算、图像处理等。这又回到了 Python 的祖传大坑——GIL全局解释器锁。在同一间厨房进程里有且只有一把“炒菜用的铁铲GIL”。如果是 I/O 阻塞比如等网络请求临时工是在“傻等”不需要拿铁铲会释放 GIL超级服务员可以继续拿铁铲干活两不相干。如果是重度计算临时工必须死死握住这把铁铲疯狂干活。这时候超级服务员如果想去大堂端盘子执行 Python 异步逻辑就会发现铁铲被临时工抢走了两人开始疯狂争夺同一把铁铲。最终的结果是你的代码不仅依然只能在一个 CPU 核心上跑而且因为主线程和子线程疯狂抢夺 GIL整体性能反而比不加还要慢总结法则拿捏 FastAPI 并发的正确姿势写到这里是时候总结一波避坑口诀了原汤化原食在异步框架里永远首选原生支持 async 的第三方库如httpx,asyncpg,aiofiles。这是真理是王道。创可贴用法如果迫不得已必须调用同步 I/O 库并且确定并发量不高可以用run_in_threadpool或者直接写普通的def路由FastAPI 底层也是放进线程池。它能救急但别当饭吃。计算任务靠边站如果你有消耗大量 CPU 的任务视频处理、大矩阵运算请绝对远离run_in_threadpool正确做法是开辟全新的厨房使用ProcessPoolExecutor或外部任务队列如 Celery Redis让它们在其他多核 CPU 上奔跑。别再被run_in_threadpool骗了它只是个尽职尽责的背锅侠。善待它别把它累死。