Sunday 的面试指南

Python 的 async/await 是怎么工作的?为什么用了异步,接口还是会卡住?

🧑‍💻 面试官:AI 接口要调用几个外部服务,你准备用什么方式减少等待?

🙋‍♂️ 我:把函数改成 async,再用 await 调用,等待接口的时候就不会阻塞了。

🧑‍💻 面试官:函数里仍然使用同步的 HTTP 客户端,也能不阻塞吗?

🙋‍♂️ 我:那客户端也需要支持异步。

🧑‍💻 面试官:两个异步查询,你先 await 第一个,再 await 第二个,它们就会同时执行吗?

这道题的关键是:「让出执行机会」。async 不是把函数自动放到另一个线程;用了 await,也不代表两项任务已经同时开始。

面试速答(60 秒版)

Python 的 async/await 主要用于协作式并发。多个任务可以交给事件循环调度,一个任务遇到需要等待的异步操作时,可以暂停,让其他任务继续执行。

但是,把函数写成 async,并不会自动把里面的同步操作变成异步。如果仍然直接使用同步 HTTP 请求、time.sleep,或者长时间执行计算,就可能占住事件循环,让其他请求一起等待。

同时,先 await 一个查询,再开始另一个查询,通常还是顺序执行。对于已经确认互不依赖的异步查询,可以用 asyncio.gather 等方式一起调度。

因此,需要等待外部服务的任务,优先使用异步客户端;旧的阻塞调用可以考虑放到线程中执行。计算密集任务则需要另外安排进程或工作服务,不能指望加上 async 就自动变快。

async包装不会把同步阻塞变成异步I/O,等待任务让出执行机会才调度其他任务

知识点详解:await 的时候,程序究竟在做什么?

一个请求在等,为什么另一个请求还能执行?

假设一个接口需要查询订单服务和物流服务。两个服务都要过一会儿才返回,等待期间,本地程序没有多少事情需要计算。

如果当前请求一直占住执行线程,其他任务就没有机会运行。异步 I/O 则可以让程序先登记“我正在等这个结果”,把执行机会交给事件循环,等结果准备好了,再继续当前任务。

事件循环负责检查哪些任务已经可以继续,并安排它们执行。这里的多个任务并发推进,不等于多个任务的 Python 代码正在同一线程里同时计算。

Python 的开发说明指出,同一个事件循环线程中,一个 Task 执行代码时,其他 Task 不会同时执行;当前任务在合适的等待点暂停,其他任务才有机会推进。

也不是见到 await 就一定切换任务。如果等待对象已经准备好了,当前任务可能直接继续。所以,不能用 await 的数量判断程序是不是“足够异步”。

async 只声明函数,不替你改掉同步操作

在 Python 中,调用 async 函数,会得到一个协程对象。需要进一步 await 它,或者把它调度成任务,它里面的过程才会执行。

但开始执行以后,函数体里仍然可能出现阻塞。例如:

time.sleep(2) 会让当前线程直接停下来,事件循环也没法使用这个线程处理其他任务。同步 HTTP 客户端等待网络响应时,也可能产生同样的问题。

如果把它换成 await asyncio.sleep(2),当前任务等待期间,事件循环就可以推进别的任务。但实际业务里的 HTTP 调用,还需要使用支持异步 I/O 的客户端,不能只在同步函数外面加一个 await。

已有的同步库暂时不能替换时,可以考虑 asyncio.to_thread,让阻塞调用在另一个线程里执行,事件循环等待它的结果。Python 的 Task 文档给出了这种用法。

不过,线程池也有容量限制。调用量很大时,任务可能改为等待线程空闲,并没有凭空消除工作量。

两个 await,为什么还可能是串行的?

咱们再看订单和物流查询。假设参数都已准备好,两项查询互不依赖。

如果先写“等待订单查询完成”,再写“开始物流查询并等待”,那么第二个查询仍然要等第一个结束以后才开始。

下面两个版本都可以独立运行,用短暂等待模拟外部查询。不需要模型密钥,也不表示真实接口耗时。

Python

import asyncio

async def query(name):
    # 模拟可以让出执行机会的 I/O 等待
    await asyncio.sleep(0.05)
    return name

async def main():
    results = await asyncio.gather(
        query("订单"), query("物流")
    )
    print(results)

asyncio.run(main())

gather 会把两个协程一起调度,并等待结果。对于这个模拟,二者的等待可以重叠;如果改成先 await query("订单"),再 await query("物流"),就没有这样的重叠。

TypeScript

async function query(name: string): Promise<string> {
  // 模拟异步等待,不在等待期间占住执行线程
  await new Promise<void>((resolve) => {
    setTimeout(resolve, 50);
  });
  return name;
}

async function main() {
  const results = await Promise.all([
    query("订单"), query("物流"),
  ]);
  console.log(results);
}

main().catch(console.error);

TypeScript 这里沿用 JavaScript 的执行机制。调用 async 函数会开始执行函数体,并返回 Promise;这一点和 Python 调用后先得到协程对象不同。MDN 的 async 文档说明了这个行为。

两个例子要说明的是同一个问题:先把互不依赖的异步工作安排起来,再等待结果。Promise.all 或 gather 本身不会把同步计算自动变成并行计算。

先await订单再开始物流仍串行,两个独立异步任务先启动可以重叠等待

接口还是卡,就继续检查谁没有让出执行机会

假设程序用了异步 HTTP 客户端,但返回结果以后,需要在接口里处理一份很大的文件。如果这段处理持续占用事件循环线程,其他请求仍然可能被拖慢。

这时候,需要区分等待外部 I/O 和本地计算。阻塞 I/O 可以考虑线程;耗时计算可以安排到进程池或独立工作服务,再由接口异步等待结果。

对于常见启用 GIL 的 CPython,不能把 to_thread 当成让普通 Python 计算自动多核并行的方案。某些释放 GIL 的扩展,以及不同运行时配置,需要另行判断。

此外,还要检查连接池、线程池、下游限流和任务队列。异步减少的是不必要的占用,不代表资源无限。大量任务一起等待同一个下游,也可能让排队、超时和重试增加。

面试官继续追问

使用异步以后,耗时就一定减半吗?

不会。两个互不依赖的等待可以重叠,但请求启动、结果处理和其他步骤仍然需要时间。

如果第二步必须使用第一步的结果,就不能为了并发提前执行。能省多少,需要看实际依赖和测量结果。

CPU 使用率不高,为什么接口还慢?

可能在等连接、等线程、等下游,也可能被某个同步操作占住事件循环。

需要记录任务从排队到执行的过程,检查事件循环是否长时间无法调度,而不是只看 CPU 使用率。

用 gather,一项失败后其他项就取消了吗?

默认情况下,gather 把异常传播给等待方,不会仅因一个任务出错,就取消其他任务。

Promise.all 也不会自动取消已经发出的请求。因此,异常后需要按调用规则处理其他任务,不能认为收到错误就等于所有工作停止了。

面试速记卡

  • async:声明异步函数,不会自动改造函数体里的同步操作。
  • await:等待可等待对象,必要时让出当前任务的执行机会。
  • 事件循环:在同一线程中调度任务,不等于代码同时多核执行。
  • 并发等待:先安排互不依赖的任务,再一起等待结果。
  • 阻塞处理:使用异步客户端,或把合适的同步 I/O 放到线程。
  • 计算任务:另外安排执行资源,不靠添加 async 自动提速。
简历汪永久免费在线制作简历,模板直接套用、导出无水印,永久免费、下载免费,不需要付费解锁任何功能。去写简历