别让事件循环“假死”:Python Async 代码避免误用阻塞调用的完整指南

别让事件循环“假死”:Python Async 代码避免误用阻塞调用的完整指南

在 Python 编程世界里,asyncawait 给开发者带来了一种近乎优雅的并发体验:一个线程就能管理大量网络连接,在等待数据库、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/O100120 ms210 ms正常
每次阻塞 20 ms100980 ms1.8 s明显拥堵
每次阻塞 100 ms1005.1 s9.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

十三、超时必须覆盖整个调用链

将同步操作放进线程,并不代表它就不会拖垮系统。

应同时配置:

  1. 底层客户端超时;
  2. 外层协程超时;
  3. 并发数量上限;
  4. 重试次数上限;
  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 标准库可以通过 QueueHandlerQueueListener 将日志写入操作移出业务线程。

还应避免在高频路径中打印巨大的对象:

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 函数时,可以逐项检查:

  1. 是否调用了 time.sleep()
  2. 是否使用同步 HTTP、数据库或云服务客户端?
  3. 是否直接进行了大文件读写?
  4. 是否使用 subprocess.run()os.system()
  5. 是否存在大型循环、压缩、加密或序列化?
  6. 第三方函数内部是否可能进行网络或磁盘 I/O?
  7. 同步兼容代码是否通过 to_thread() 或执行器隔离?
  8. CPU 密集任务是否转移到进程池或独立 Worker?
  9. 同步调用是否设置底层超时?
  10. 线程池和进程池是否有明确容量?
  11. 是否使用信号量限制并发?
  12. 任务是否可能无限排队?
  13. 取消协程后,底层同步工作是否仍然运行?
  14. 是否监控事件循环延迟和尾部响应时间?
  15. 阻塞兼容逻辑是否集中在基础设施层?

只要其中任何一个问题无法明确回答,就值得进一步检查。


十九、总结:异步系统的关键不是 async,而是“主动让路”

Python 异步编程最核心的思想,并不是给函数加上 async,也不是尽可能多地创建协程。

真正的关键是:

当当前任务需要等待时,它必须及时把执行权交还给事件循环。

避免阻塞调用,可以遵循以下优先顺序:

第一选择:使用原生异步 API
    ↓
第二选择:将同步 I/O 放入 asyncio.to_thread
    ↓
需要精细控制:使用自定义线程池
    ↓
CPU 密集任务:使用进程池或独立计算服务
    ↓
长时间后台任务:使用任务队列与 Worker

与此同时,还要配套使用:

  • 超时;
  • 并发限制;
  • 有界队列;
  • 异常隔离;
  • 事件循环延迟监控;
  • 并发压测;
  • 清晰的同步边界。

优秀的异步系统并不是从来不调用同步代码,而是清楚地知道同步代码在哪里、可能阻塞多久、应该在哪个资源池执行,以及系统过载时该如何自我保护。

当你开始从完整调用链而不是单个 async def 判断代码是否异步安全时,你就已经迈过了 Python 异步编程中最重要的一道门槛。

你在实际项目中遇到过哪些“看起来是异步,实际上却阻塞”的问题?是同步数据库驱动、旧版 SDK,还是隐藏在日志与文件操作中的性能陷阱?欢迎在评论区分享你的排查过程和解决方案,让更多开发者少走一些弯路。


参考资料

SEO 关键词: Python编程、Python教程、Python异步编程、asyncio、async await、阻塞调用、Python实战、Python最佳实践、事件循环、异步高并发。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值