别让事件循环“假死”:Python Async 代码避免误用阻塞调用的完整指南
在 Python 编程世界里,async 和 await 给开发者带来了一种近乎优雅的并发体验:一个线程就能管理大量网络连接,在等待数据库、HTTP 接口或消息队列时,程序仍然可以继续处理其他任务。
然而,异步代码里隐藏着一个非常常见、也非常危险的问题:
函数明明写成了
async def,服务却仍然会突然卡顿,甚至像“死机”一样停止响应。
罪魁祸首往往不是 asyncio,而是我们在协程中误用了阻塞调用。
一行不起眼的 time.sleep(3)、一次同步 HTTP 请求、一段普通文件读写,甚至一条耗时的日志,都可能占住整个事件循环。此时,其他协程即使早已准备就绪,也得不到执行机会。
本文将从事件循环原理出发,系统讲解如何识别、替换、隔离和监控阻塞调用。无论你正在学习 Python 异步编程,还是已经在维护 FastAPI、爬虫、实时数据处理或消息消费系统,这些方法都值得纳入你的 Python 最佳实践清单。
一、先理解:async 不等于自动异步
很多初学者容易产生这样的误解:
async def process():
do_something()
只要函数前面加了 async,里面的代码就会自动变成非阻塞操作。
事实并非如此。
async def 只是把函数定义为协程函数。协程能否释放事件循环,取决于它执行过程中是否遇到了能够真正挂起当前任务的 await。
例如:
import asyncio
async def task_a() -> None:
print("A 开始")
await asyncio.sleep(2)
print("A 结束")
async def task_b() -> None:
print("B 开始")
await asyncio.sleep(1)
print("B 结束")
async def main() -> None:
await asyncio.gather(task_a(), task_b())
asyncio.run(main())
asyncio.sleep() 不会让当前线程真的睡眠。它会挂起当前协程,把控制权交还给事件循环。于是,当任务 A 等待时,任务 B 可以继续运行。
执行过程大致如下:
任务 A 开始
↓
A 遇到 await,主动让出控制权
↓
任务 B 开始
↓
B 遇到 await,主动让出控制权
↓
事件循环继续调度已就绪的任务
事件循环通常在一个线程中依次执行回调和协程代码。一个任务只有运行到 await 并真正挂起后,其他任务才有机会在同一线程中执行。(Python documentation)
这意味着,如果协程调用了一个长时间不返回、内部也不会让出控制权的同步函数,事件循环就会被堵住。
二、一个 time.sleep,为什么能拖住所有协程?
来看一段看似简单的代码:
import asyncio
import time
async def heartbeat() -> None:
while True:
print("心跳正常")
await asyncio.sleep(0.5)
async def bad_task() -> None:
print("阻塞任务开始")
time.sleep(3)
print("阻塞任务结束")
async def main() -> None:
heartbeat_task = asyncio.create_task(heartbeat())
await asyncio.sleep(1)
await bad_task()
heartbeat_task.cancel()
await asyncio.gather(
heartbeat_task,
return_exceptions=True,
)
asyncio.run(main())
你可能期待 heartbeat() 每隔 0.5 秒输出一次。
但执行到:
time.sleep(3)
后,心跳会暂停整整 3 秒。
原因是 time.sleep() 会阻塞当前操作系统线程。事件循环恰好运行在这个线程中,所以它无法继续调度其他协程。
正确写法是:
async def good_task() -> None:
print("异步任务开始")
await asyncio.sleep(3)
print("异步任务结束")
二者表面上都“等待三秒”,内部语义却完全不同:
| 调用 | 当前协程 | 事件循环线程 | 其他任务 |
|---|---|---|---|
time.sleep(3) | 持续占用线程 | 被阻塞 | 无法执行 |
await asyncio.sleep(3) | 被挂起 | 可以继续工作 | 正常执行 |
记住一个非常实用的判断原则:
在协程里,耗时操作如果既没有
await,也没有被转移到其他线程或进程,就很可能正在阻塞事件循环。
三、异步代码中常见的阻塞调用
阻塞调用并不只有 time.sleep()。实际项目里,问题通常隐藏得更深。
1. 同步休眠
错误:
import time
async def retry() -> None:
time.sleep(2)
正确:
import asyncio
async def retry() -> None:
await asyncio.sleep(2)
2. 同步 HTTP 客户端
下面的调用会在请求完成前占用事件循环线程:
import requests
async def fetch_user(user_id: int) -> dict:
response = requests.get(
f"https://example.com/users/{user_id}",
timeout=5,
)
response.raise_for_status()
return response.json()
问题不在于 requests 不优秀,而在于它是同步客户端,不会与 asyncio 事件循环协作。
在异步应用中,应优先选择支持 await 的异步 HTTP 客户端;如果暂时无法替换,则需要将同步调用移出事件循环线程。
3. 普通文件读写
async def read_config() -> str:
with open("config.json", encoding="utf-8") as file:
return file.read()
本地小文件可能瞬间完成,因此问题不明显。但当文件较大、磁盘繁忙、文件位于网络挂载目录,或者多个请求同时读写时,阻塞就会迅速放大。
4. 同步数据库驱动
async def load_orders(connection):
cursor = connection.cursor()
cursor.execute("SELECT * FROM orders")
return cursor.fetchall()
即使外层函数是 async def,同步数据库驱动仍然会阻塞。
真正异步的数据库操作通常会表现为:
rows = await connection.fetch(query)
不过,不能只根据函数名判断。必须阅读所使用驱动或 SDK 的文档,确认它是否真正支持异步 I/O。
5. CPU 密集型计算
async def calculate() -> int:
return sum(
number * number
for number in range(100_000_000)
)
这段代码没有网络等待,却会长时间占用 CPU。
事件循环最适合调度大量 I/O 等待任务,不适合直接执行长时间的纯 Python 密集计算。即使函数没有任何传统意义上的“阻塞 I/O”,持续占用 CPU 同样会阻止其他协程运行。
6. 同步子进程调用
import subprocess
async def convert_video() -> None:
subprocess.run(
["ffmpeg", "-i", "input.mp4", "output.mp4"],
check=True,
)
subprocess.run() 会一直等待子进程结束。
异步应用应优先使用 asyncio.create_subprocess_exec() 或 asyncio.create_subprocess_shell()。Python 官方为子进程提供了高层异步 API,可以异步启动、读取和监控多个子进程。(Python documentation)
7. 隐藏在第三方 SDK 中的同步调用
这是工程中最难发现的一类问题,例如:
async def send_notification(client, message: str) -> None:
client.send(message)
client.send() 可能执行了:
- HTTP 请求;
- DNS 查询;
- 文件操作;
- 加密计算;
- 阻塞式重试;
- 同步数据库访问。
判断一个函数是否阻塞,不能只看调用形式。普通函数既可能瞬间返回,也可能在内部等待十秒。
四、解决方案一:优先使用原生异步 API
最理想的解决方式,不是给同步函数“打补丁”,而是从调用链开始使用真正支持异步的组件。
例如,一个异步 Web 服务的调用链应尽量保持一致:
异步请求处理器
↓ await
异步业务服务
↓ await
异步 HTTP / 数据库客户端
↓
操作系统非阻塞 I/O
理想代码:
async def get_user(
user_id: int,
repository,
) -> dict:
user = await repository.find_by_id(user_id)
if user is None:
raise LookupError("用户不存在")
return user
而不是:
async def get_user(
user_id: int,
repository,
) -> dict:
return repository.find_by_id(user_id)
在选择库时,可以检查以下信号:
- 网络操作是否需要
await; - 是否提供异步连接池;
- 是否支持异步上下文管理器;
- 官方是否明确说明与
asyncio兼容; - 超时和取消是否能够沿调用链传播;
- 是否仍在内部调用同步驱动。
Python 的 asyncio 是大量异步 Web 服务器、数据库库和分布式任务组件的基础设施,但具体业务库是否异步,仍需逐一确认。(Python documentation)
五、解决方案二:用 asyncio.to_thread 隔离同步 I/O
现实项目中,我们经常无法立即替换所有同步代码。
例如:
- 历史 SDK 只有同步版本;
- 某个文件处理库不支持异步;
- 团队维护着大量同步业务函数;
- 第三方云服务客户端没有异步接口。
这时可以使用:
await asyncio.to_thread(function, *args, **kwargs)
读取文件的改造
阻塞版本:
from pathlib import Path
async def load_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
改造后:
import asyncio
from pathlib import Path
async def load_text(path: Path) -> str:
return await asyncio.to_thread(
path.read_text,
encoding="utf-8",
)
包装同步 SDK
import asyncio
from typing import Any
def send_with_sync_sdk(
client: Any,
message: str,
) -> str:
return client.send(message)
async def send_message(
client: Any,
message: str,
) -> str:
return await asyncio.to_thread(
send_with_sync_sdk,
client,
message,
)
asyncio.to_thread() 会在线程中运行指定函数,并返回一个可以等待的协程。它主要用于把可能阻塞事件循环的同步 I/O 操作移到线程中;当前的 contextvars.Context 也会被传播到工作线程。(Python documentation)
这比直接操作事件循环和执行器更简洁,适合大多数同步 I/O 兼容场景。
六、to_thread 不是万能解药
asyncio.to_thread() 很好用,但不能不加控制地使用。
1. 不要为每个微小操作切换线程
下面的写法会产生没有必要的调度成本:
async def build_name(
first_name: str,
last_name: str,
) -> str:
return await asyncio.to_thread(
lambda: f"{first_name} {last_name}"
)
字符串拼接非常快,直接执行即可:
async def build_name(
first_name: str,
last_name: str,
) -> str:
return f"{first_name} {last_name}"
线程隔离适用于可能明显等待的同步 I/O,而不是所有普通函数。
2. 取消协程不等于终止线程
看下面的代码:
import asyncio
import time
def blocking_work() -> None:
time.sleep(30)
print("同步工作完成")
async def main() -> None:
task = asyncio.create_task(
asyncio.to_thread(blocking_work)
)
await asyncio.sleep(1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("等待任务已取消")
asyncio.run(main())
外层异步任务可以被取消,但已经在线程中运行的同步函数通常不会因此自动停止。
所以,对于可能长时间运行的同步函数,应当:
- 在函数内部设置超时;
- 分段执行并检查停止标志;
- 避免无限阻塞;
- 为底层网络客户端设置连接和读取超时;
- 必要时使用能够强制终止的独立进程。
3. 不要让线程池变成新的无限队列
假设一个接口每秒收到几千个请求,每个请求都调用:
await asyncio.to_thread(blocking_operation)
如果底层操作非常慢,工作会在线程池等待队列中持续积压。
更稳妥的方式是增加并发限制:
import asyncio
from collections.abc import Callable
from typing import TypeVar
T = TypeVar("T")
SYNC_IO_LIMIT = asyncio.Semaphore(16)
async def run_blocking_io(
function: Callable[..., T],
*args,
**kwargs,
) -> T:
async with SYNC_IO_LIMIT:
return await asyncio.to_thread(
function,
*args,
**kwargs,
)
使用:
result = await run_blocking_io(
legacy_client.query,
"python",
)
这样最多只有 16 个同步操作同时进入线程执行,避免线程和下游资源失控。
七、解决方案三:使用 run_in_executor 精细管理执行器
当你需要自定义线程数、区分工作负载或使用进程池时,可以使用:
loop.run_in_executor()
自定义线程池
import asyncio
from concurrent.futures import ThreadPoolExecutor
from functools import partial
IO_EXECUTOR = ThreadPoolExecutor(
max_workers=12,
thread_name_prefix="legacy-io",
)
def blocking_query(
client,
keyword: str,
limit: int,
):
return client.query(
keyword=keyword,
limit=limit,
)
async def query_async(
client,
keyword: str,
limit: int = 20,
):
loop = asyncio.get_running_loop()
call = partial(
blocking_query,
client,
keyword,
limit,
)
return await loop.run_in_executor(
IO_EXECUTOR,
call,
)
相比 to_thread(),自定义执行器的优势是:
- 可以明确限制线程数;
- 可以给线程命名;
- 可以隔离不同类型的阻塞任务;
- 可以监控不同工作池;
- 可以使用
ProcessPoolExecutor。
不要在每个请求中创建新的线程池。线程池通常应在应用启动时创建,在关闭时统一释放。
八、CPU 密集任务:不要简单扔进普通线程池
对于以下任务,问题不是等待 I/O,而是长期占用 CPU:
- 图像编码;
- 大规模 JSON 转换;
- 数据压缩;
- 密码哈希;
- 复杂数学计算;
- 大量纯 Python 循环;
- 机器学习前后处理。
传统 CPython 构建中,纯 Python CPU 密集任务放入线程池,通常不能像多进程那样有效利用多个 CPU 核心。官方文档也说明,to_thread() 主要面向 I/O 密集函数;对于会释放 GIL 的扩展模块或不受 GIL 限制的实现,才可能适用于 CPU 密集任务。(Python documentation)
更稳妥的方案是进程池:
import asyncio
from concurrent.futures import ProcessPoolExecutor
CPU_EXECUTOR = ProcessPoolExecutor(max_workers=4)
def calculate_score(data: list[int]) -> int:
total = 0
for value in data:
total += value * value
return total
async def calculate_score_async(
data: list[int],
) -> int:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
CPU_EXECUTOR,
calculate_score,
data,
)
架构可以设计为:
asyncio 事件循环
│
├── 异步网络 I/O
├── 异步数据库 I/O
├── 少量同步 I/O → 线程池
└── CPU 密集计算 → 进程池或独立计算服务
对于持续时间很长、资源消耗很大的任务,更推荐放入任务队列或独立服务,而不是让 Web 请求一直等待。
九、异步执行外部命令
错误方式:
import subprocess
async def generate_report() -> None:
subprocess.run(
["python", "build_report.py"],
check=True,
)
正确方式:
import asyncio
async def generate_report() -> str:
process = await asyncio.create_subprocess_exec(
"python",
"build_report.py",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
message = stderr.decode(
"utf-8",
errors="replace",
)
raise RuntimeError(
f"生成报告失败:{message}"
)
return stdout.decode(
"utf-8",
errors="replace",
)
能使用 create_subprocess_exec() 时,应优先于拼接 shell 命令。
如果必须使用 shell,必须正确处理参数转义,避免 shell 注入。Python 官方文档明确提醒,使用异步 shell 子进程接口时,应用需要负责正确转义空格和特殊字符。(Python documentation)
十、真实案例:FastAPI 接口为何偶尔卡住?
假设我们有一个异步接口:
from fastapi import FastAPI
import time
app = FastAPI()
@app.get("/report")
async def create_report() -> dict[str, str]:
time.sleep(5)
return {"status": "done"}
虽然路由函数使用了 async def,但 time.sleep(5) 会阻塞负责运行事件循环的线程。
在这五秒内,同一事件循环上的其他请求也可能无法得到及时调度。
方案一:本质上是异步等待
import asyncio
from fastapi import FastAPI
app = FastAPI()
@app.get("/report")
async def create_report() -> dict[str, str]:
await asyncio.sleep(5)
return {"status": "done"}
方案二:必须调用同步报表函数
import asyncio
from fastapi import FastAPI
app = FastAPI()
def build_report_sync() -> str:
# 调用旧系统、读取文件或同步 SDK
return "report-2026.pdf"
@app.get("/report")
async def create_report() -> dict[str, str]:
filename = await asyncio.to_thread(
build_report_sync
)
return {"file": filename}
方案三:CPU 密集或持续时间很长
不要直接在请求中执行。更合理的流程是:
客户端提交任务
↓
Web 接口校验参数
↓
将任务写入队列
↓
立即返回 task_id
↓
后台 Worker 处理
↓
客户端查询结果或接收通知
这种设计既保护了事件循环,也能更好地支持重试、超时、进度记录和故障恢复。
十一、如何主动发现阻塞调用?
阻塞问题在开发环境中可能毫无征兆,因为本地流量低、磁盘快、数据库也很近。到了生产环境,并发量增加后,它才突然爆发。
因此,不能只靠肉眼检查。
方法一:开启 asyncio 调试模式
可以通过环境变量启用:
PYTHONASYNCIODEBUG=1 python app.py
也可以在代码中启用:
import asyncio
async def main() -> None:
loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.05
# 应用逻辑
asyncio.run(main())
调试模式可以记录耗时过长的回调。默认情况下,超过一定时长的回调会被视为慢回调,也可以通过 loop.slow_callback_duration 调整阈值。(Python documentation)
开发和压测环境可以设置得更敏感,例如 50 毫秒:
loop.slow_callback_duration = 0.05
不建议在没有评估日志量和性能成本的情况下,直接在高流量生产环境长期启用完整调试模式。
方法二:监控事件循环延迟
可以运行一个轻量级协程,定期估算事件循环被延迟了多久:
import asyncio
import logging
logger = logging.getLogger(__name__)
async def monitor_event_loop(
interval: float = 0.1,
warning_threshold: float = 0.05,
) -> None:
loop = asyncio.get_running_loop()
while True:
started_at = loop.time()
await asyncio.sleep(interval)
elapsed = loop.time() - started_at
lag = max(0.0, elapsed - interval)
if lag >= warning_threshold:
logger.warning(
"检测到事件循环延迟:%.3f 秒",
lag,
)
启动监控:
async def main() -> None:
monitor = asyncio.create_task(
monitor_event_loop()
)
try:
await run_application()
finally:
monitor.cancel()
await asyncio.gather(
monitor,
return_exceptions=True,
)
需要关注的指标包括:
- 事件循环延迟;
- 请求 P95、P99 延迟;
- 超时率;
- 活跃任务数;
- 线程池排队量;
- 数据库连接池等待时间;
- CPU 使用率;
- 单次阻塞函数耗时;
- 任务队列长度。
方法三:在代码审查中搜索高风险调用
可以重点检查异步函数内是否出现:
time.sleep
requests.get / post
subprocess.run
os.system
socket 的同步接口
普通 open/read/write
同步数据库 cursor.execute
threading.Lock.acquire
同步云服务 SDK
大型压缩、解析、加密和循环计算
这份名单不是“禁止使用列表”,而是“需要确认执行语义列表”。
方法四:在并发压测中观察吞吐与尾延迟
一个阻塞函数在单请求测试中可能只增加 100 毫秒延迟,但在异步单事件循环服务中,它可能让许多并发请求一起等待。
示意数据如下:
| 场景 | 并发请求 | 平均延迟 | P99 延迟 | 事件循环状态 |
|---|---|---|---|---|
| 原生异步 I/O | 100 | 120 ms | 210 ms | 正常 |
| 每次阻塞 20 ms | 100 | 980 ms | 1.8 s | 明显拥堵 |
| 每次阻塞 100 ms | 100 | 5.1 s | 9.4 s | 接近失去响应 |
这是一组用于解释趋势的示意数据,不代表固定性能结果。真实表现取决于机器、并发模型、Worker 数量和调用链。
关键不是只看平均值,而是观察 P95、P99 等尾部延迟。
十二、用测试守住异步边界
除了功能测试,还应验证代码是否阻塞事件循环。
下面是一种简单的测试思路:
import asyncio
async def count_ticks(
duration: float,
interval: float = 0.01,
) -> int:
loop = asyncio.get_running_loop()
deadline = loop.time() + duration
ticks = 0
while loop.time() < deadline:
await asyncio.sleep(interval)
ticks += 1
return ticks
async def suspicious_operation() -> None:
await asyncio.sleep(0.2)
async def test_operation_does_not_block_loop() -> None:
ticker = asyncio.create_task(
count_ticks(0.25)
)
await suspicious_operation()
ticks = await ticker
assert ticks >= 10
如果 suspicious_operation() 内部误用了:
time.sleep(0.2)
计数协程在这段时间无法执行,测试就可能失败。
这类时间测试会受持续集成机器负载影响,所以阈值不要设置得过于苛刻。它更适合作为回归保护,而不是纳秒级性能基准。
还可以为同步兼容层单独测试:
async def test_sync_sdk_is_offloaded() -> None:
result, ticks = await asyncio.gather(
call_legacy_sdk_async(),
count_ticks(0.3),
)
assert result is not None
assert ticks > 5
十三、超时必须覆盖整个调用链
将同步操作放进线程,并不代表它就不会拖垮系统。
应同时配置:
- 底层客户端超时;
- 外层协程超时;
- 并发数量上限;
- 重试次数上限;
- 失败后的熔断或降级策略。
示例:
import asyncio
SYNC_LIMIT = asyncio.Semaphore(8)
def call_sync_service(client, payload):
return client.send(
payload,
connect_timeout=1,
read_timeout=3,
)
async def call_service(client, payload):
async with SYNC_LIMIT:
try:
return await asyncio.wait_for(
asyncio.to_thread(
call_sync_service,
client,
payload,
),
timeout=4,
)
except TimeoutError as error:
raise RuntimeError(
"下游服务响应超时"
) from error
需要再次强调:
外层等待超时后,后台线程里的同步函数不一定立即停止。
真正的安全边界仍然依赖底层客户端自身的超时设置。外层超时主要用于避免协程无限等待和及时释放上层业务流程。
十四、日志也可能阻塞事件循环
日志经常被忽视。
下面的调用看起来很普通:
logger.info("订单处理完成")
但日志处理器可能在执行:
- 文件写入;
- 网络发送;
- 日志轮转;
- 文本格式化;
- 堆栈信息收集;
- DNS 查询;
- 远程日志服务重试。
高吞吐异步服务中,可以采用:
业务协程
↓
线程安全日志队列
↓
独立日志线程
↓
文件或远程日志系统
Python 标准库可以通过 QueueHandler 和 QueueListener 将日志写入操作移出业务线程。
还应避免在高频路径中打印巨大的对象:
logger.debug("完整响应:%r", huge_response)
即使日志级别关闭,也要注意是否提前执行了昂贵的字符串转换:
# 不推荐:可能先进行昂贵的格式化
logger.debug(f"完整响应:{expensive_format(data)}")
可以先判断:
import logging
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"完整响应:%s",
expensive_format(data),
)
十五、建立清晰的“同步边界”
成熟项目不应在业务代码的任何角落随意调用 to_thread()。
更好的方式是建立统一兼容层:
# infrastructure/legacy_storage.py
import asyncio
from pathlib import Path
class LegacyStorage:
async def read(self, path: Path) -> bytes:
return await asyncio.to_thread(
path.read_bytes
)
async def write(
self,
path: Path,
content: bytes,
) -> None:
await asyncio.to_thread(
path.write_bytes,
content,
)
业务层只面对异步接口:
async def archive_document(
storage: LegacyStorage,
source: Path,
target: Path,
) -> None:
content = await storage.read(source)
await storage.write(target, content)
这样做的价值包括:
- 阻塞调用集中管理;
- 容易设置并发限制;
- 容易统一添加超时;
- 容易替换成真正的异步实现;
- 业务代码不需要了解底层兼容细节;
- 测试时更容易注入替身对象。
这也是非常重要的 Python 实战经验:技术债可以暂时存在,但必须有清晰边界,不能无声地扩散。
十六、一次完整的异步任务处理案例
下面实现一个简化的数据处理管道:
- 异步接收任务;
- 使用有界队列形成背压;
- 固定数量的 Worker;
- 将旧版同步 SDK 放进线程;
- 限制同步调用并发;
- 设置超时;
- 记录失败但不中断整个系统。
import asyncio
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class Job:
job_id: str
payload: dict[str, Any]
class LegacyClient:
def send(
self,
payload: dict[str, Any],
) -> str:
# 此处模拟同步网络 SDK
return f"result-{payload['value']}"
class AsyncLegacyGateway:
def __init__(
self,
client: LegacyClient,
concurrency: int = 8,
) -> None:
self._client = client
self._semaphore = asyncio.Semaphore(
concurrency
)
async def send(
self,
payload: dict[str, Any],
) -> str:
async with self._semaphore:
return await asyncio.wait_for(
asyncio.to_thread(
self._client.send,
payload,
),
timeout=3,
)
async def producer(
queue: asyncio.Queue[Job | None],
jobs: list[Job],
worker_count: int,
) -> None:
for job in jobs:
await queue.put(job)
for _ in range(worker_count):
await queue.put(None)
async def worker(
name: str,
queue: asyncio.Queue[Job | None],
gateway: AsyncLegacyGateway,
) -> None:
while True:
job = await queue.get()
try:
if job is None:
return
try:
result = await gateway.send(
job.payload
)
logger.info(
"%s 完成任务 %s:%s",
name,
job.job_id,
result,
)
except TimeoutError:
logger.error(
"%s 处理任务 %s 超时",
name,
job.job_id,
)
except Exception:
logger.exception(
"%s 处理任务 %s 失败",
name,
job.job_id,
)
finally:
queue.task_done()
async def main() -> None:
worker_count = 12
queue: asyncio.Queue[Job | None] = (
asyncio.Queue(maxsize=100)
)
gateway = AsyncLegacyGateway(
LegacyClient(),
concurrency=6,
)
jobs = [
Job(
job_id=f"job-{index}",
payload={"value": index},
)
for index in range(1000)
]
workers = [
asyncio.create_task(
worker(
f"worker-{index}",
queue,
gateway,
)
)
for index in range(worker_count)
]
await producer(
queue,
jobs,
worker_count,
)
await queue.join()
await asyncio.gather(*workers)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())
这套设计拥有多层保护:
有界队列
↓
防止任务无限积压
固定 Worker 数
↓
控制活跃任务规模
Semaphore
↓
限制同步 SDK 并发
to_thread
↓
避免阻塞事件循环
wait_for
↓
限制上层等待时间
异常隔离
↓
单个任务失败不拖垮整个管道
这比简单地对一万个任务执行 asyncio.gather() 更适合真实生产环境。
十七、常见误区与修正
误区一:函数很快,所以阻塞没关系
今天运行需要 2 毫秒,不代表生产环境也只需要 2 毫秒。
磁盘、网络、DNS、数据库和第三方服务都可能发生抖动。判断依据不应是“本地感觉很快”,而应是“这个调用在最坏情况下是否会等待”。
误区二:多启动几个 Web Worker 就能解决
多 Worker 可以降低单个事件循环阻塞带来的影响,但不能消除问题。
如果每个 Worker 都在阻塞:
- 吞吐量依然下降;
- 延迟依然升高;
- 需要更多内存;
- 下游压力更大;
- 服务扩容成本增加。
扩容可以提高容量,却不能代替正确的并发模型。
误区三:所有同步函数都应该放进线程
普通字典操作、简单校验和短小字符串处理没有必要进入线程。
线程切换本身存在成本,而且线程池也是有限资源。
误区四:使用 await 就一定不会阻塞
下面的代码有 await:
await wrapper()
但如果 wrapper() 内部先执行五秒同步计算,然后才遇到第一个 await,前五秒仍会阻塞。
异步安全必须检查完整调用链,而不是只看最外层语法。
误区五:线程池适合所有 CPU 任务
线程池适合同步 I/O 兼容。纯 Python CPU 密集任务通常应考虑进程池、原生扩展、向量化计算或独立服务。
十八、异步代码审查检查表
每次评审 async def 函数时,可以逐项检查:
- 是否调用了
time.sleep()? - 是否使用同步 HTTP、数据库或云服务客户端?
- 是否直接进行了大文件读写?
- 是否使用
subprocess.run()或os.system()? - 是否存在大型循环、压缩、加密或序列化?
- 第三方函数内部是否可能进行网络或磁盘 I/O?
- 同步兼容代码是否通过
to_thread()或执行器隔离? - CPU 密集任务是否转移到进程池或独立 Worker?
- 同步调用是否设置底层超时?
- 线程池和进程池是否有明确容量?
- 是否使用信号量限制并发?
- 任务是否可能无限排队?
- 取消协程后,底层同步工作是否仍然运行?
- 是否监控事件循环延迟和尾部响应时间?
- 阻塞兼容逻辑是否集中在基础设施层?
只要其中任何一个问题无法明确回答,就值得进一步检查。
十九、总结:异步系统的关键不是 async,而是“主动让路”
Python 异步编程最核心的思想,并不是给函数加上 async,也不是尽可能多地创建协程。
真正的关键是:
当当前任务需要等待时,它必须及时把执行权交还给事件循环。
避免阻塞调用,可以遵循以下优先顺序:
第一选择:使用原生异步 API
↓
第二选择:将同步 I/O 放入 asyncio.to_thread
↓
需要精细控制:使用自定义线程池
↓
CPU 密集任务:使用进程池或独立计算服务
↓
长时间后台任务:使用任务队列与 Worker
与此同时,还要配套使用:
- 超时;
- 并发限制;
- 有界队列;
- 异常隔离;
- 事件循环延迟监控;
- 并发压测;
- 清晰的同步边界。
优秀的异步系统并不是从来不调用同步代码,而是清楚地知道同步代码在哪里、可能阻塞多久、应该在哪个资源池执行,以及系统过载时该如何自我保护。
当你开始从完整调用链而不是单个 async def 判断代码是否异步安全时,你就已经迈过了 Python 异步编程中最重要的一道门槛。
你在实际项目中遇到过哪些“看起来是异步,实际上却阻塞”的问题?是同步数据库驱动、旧版 SDK,还是隐藏在日志与文件操作中的性能陷阱?欢迎在评论区分享你的排查过程和解决方案,让更多开发者少走一些弯路。
参考资料
- Python
asyncio官方概览与高层 API。(Python documentation) - Python 协程、任务与
asyncio.to_thread()文档。(Python documentation) - Python asyncio 开发与调试模式说明。(Python documentation)
- Python 异步子进程官方文档。(Python documentation)
SEO 关键词: Python编程、Python教程、Python异步编程、asyncio、async await、阻塞调用、Python实战、Python最佳实践、事件循环、异步高并发。

2547

被折叠的 条评论
为什么被折叠?



