在 asyncio 的世界里,loop.run_in_executor(None, func) 几乎是所有人接触异步编程时最早用到的 API 之一。它的作用很直白:把一个同步阻塞的函数丢到线程里执行,避免阻塞事件循环。但不少人对它的理解停留在“能用就行”,却忽略了一个关键问题——当第二个参数传 None 时,这个函数到底用的是哪个线程池?是每次调用都临时创建一个线程,还是所有调用共享同一个池?这个问题的答案直接影响高并发场景下的性能表现。

默认执行器从哪里来:源码层面的追踪
当你在调用 run_in_executor 时把 executor 参数留成 None,事件循环并不会现场创建一个新的线程池。翻看 asyncio 的源码可以发现,BaseEventLoop 内部维护了一个 _default_executor 属性,初始值为 None。第一次调用 run_in_executor(None, ...) 时,事件循环会执行一段懒加载逻辑:
def run_in_executor(self, executor, func, *args):
self._check_closed()
if self._debug:
self._check_callback(func, 'run_in_executor')
if executor is None:
executor = self._default_executor
if executor is None:
executor = concurrent.futures.ThreadPoolExecutor(
thread_name_prefix='asyncio_'
)
self._default_executor = executor
return futures.wrap_future(executor.submit(func, *args), loop=self)这段代码清楚地展示了默认执行器的生命周期:首次调用时创建一个 ThreadPoolExecutor,实例化后挂到 self._default_executor 上,此后所有传 None 的调用都会复用这同一个池。也就是说,默认线程池是事件循环级别的单例,一个 loop 对应一个池,而不是一次调用对应一个线程。
另外值得注意的是线程名前缀被设置成了 asyncio_。如果你在调试时用 threading.enumerate() 打印所有线程,看到名为 asyncio_0、asyncio_1 这样的线程,那就是默认执行器的工作线程。通过这个特征可以快速判断某段阻塞代码是否真的被丢进了默认池。
线程数上限与排队阻塞:默认配置的隐患
自 Python 3.8 起,ThreadPoolExecutor 在未显式指定 max_workers 时的默认值是 min(32, os.cpu_count() + 4)。假设你的机器是 8 核,默认池最多只有 12 个工作线程。这对纯回调式的小任务来说够用,但一旦你把大量阻塞型 IO 任务(比如 requests 请求、数据库驱动调用)全部塞进默认池,超出线程数的任务就会在池内部的队列里排队,等待有线程空闲出来。
排队本身不算错误,它是一种背压机制,但问题在于这种阻塞是隐性的。调用方拿到的是一个 asyncio Future,任务实际上还躺在 ThreadPoolExecutor 的队列里没开始执行。如果你为任务设置了超时时间,很容易出现“任务还没开始跑就被超时取消”的怪现象。来看一个能复现问题的例子:
import asyncio
import time
def blocking_task(n):
time.sleep(2) # 模拟阻塞 IO
return n
async def main():
loop = asyncio.get_running_loop()
tasks = [
loop.run_in_executor(None, blocking_task, i)
for i in range(50)
]
start = time.perf_counter()
results = await asyncio.gather(*tasks)
print(f"耗时 {time.perf_counter() - start:.1f} 秒")
# 8 核机器上默认池只有 12 个线程
# 50 个任务需要分约 5 批执行,耗时约 10 秒
asyncio.run(main())50 个每次睡 2 秒的任务,理论上并发执行只需 2 秒多,实际却要跑 10 秒左右。这就是默认线程数上限带来的隐性串行化。更隐蔽的风险是 CPU 密集型任务:如果你把一个纯计算函数丢进默认池并占满了所有线程,其他本该快速完成的 IO 型 executor 任务也会被卡住,整个事件循环的“逃生通道”就被堵死了。所以一个经验法则是:默认池只留给短小、轻量的兜底任务,重活儿务必用独立执行器承载。
自定义执行器与隔离策略:正确的工程做法
解决上述问题的核心思路是按任务类型划分执行器。网络请求类、磁盘 IO 类、CPU 计算类分别使用独立的线程池,互不干扰。具体有两种实现方式:一种是在调用时直接传入 executor 实例,另一种是用 loop.set_default_executor() 替换整个默认池。
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
def io_task(n):
time.sleep(1)
return f"io-{n}"
async def main():
loop = asyncio.get_running_loop()
# 为 IO 任务创建独立的高并发线程池
io_pool = ThreadPoolExecutor(max_workers=64, thread_name_prefix="io_")
try:
tasks = [loop.run_in_executor(io_pool, io_task, i) for i in range(64)]
results = await asyncio.gather(*tasks)
print(f"完成 {len(results)} 个任务")
finally:
io_pool.shutdown(wait=True)
asyncio.run(main())传入自定义池后,线程数、线程命名、队列策略都由你掌控,排除了默认上限的干扰。如果希望全局统一替换默认行为,可以用 set_default_executor,但要注意它必须在事件循环运行之前或之内调用,且替换后所有传 None 的调用都走新池,影响面较大,需要谨慎评估。
还有几个容易被忽略的细节值得强调。第一,线程池的清理责任:asyncio.run() 结束时会自动关闭它自己创建的默认执行器,但你自己创建的池需要手动调用 shutdown(),用 try/finally 或上下文管理器保证不泄漏线程。第二,Python 3.9 之后可以直接用高层接口 asyncio.to_thread(),它内部就是调 run_in_executor(None, ...),同样受默认池约束,不要因为换了 API 就以为绕开了线程数限制。第三,如果你使用的是较新的 Python 版本,还可以通过 asyncio.get_running_loop().set_default_executor(pool) 在协程内部动态调整,配合 atexit 或 finally 块统一回收。
总结一下:run_in_executor(None, ...) 的默认线程池在同一个事件循环内是严格复用的,这个设计避免了频繁创建销毁线程的开销,但也带来了线程数上限和任务类型混杂的风险。理解它的单例本质,再根据业务场景显式地划分执行器,才能让 asyncio 程序在高并发下保持稳定的吞吐能力。
run_in_executorasyncio线程池修改时间:2026-09-13 12:12:34