想象你是一家繁忙咖啡店的唯一咖啡师。手工滴滤咖啡需要 3 分钟“等水慢慢流”,意式浓缩只要 30 秒,而打奶泡又要 1 分钟。如果按顺序做:先做滴滤,盯着水流发呆 3 分钟,再做浓缩……那队伍早排到门外了。聪明的咖啡师会这样做:先启动滴滤(等待水流),转身去打奶泡,再回来萃取浓缩——一个人、一双手,却同时“进行”着三杯咖啡。这就是单核也能高并发的秘密:当一件事需要等待时,不傻等,而是切换去做另一件事。 asyncio 就是 Python 世界里的“咖啡师工作法”。它让你的程序在单线程内,通过不断切换任务,把等待网络、磁盘的时间利用起来,从而同时“服务”成百上千个请求。你不需要开一堆线程,也不需要担心锁竞争,只需要学会一种全新的协作方式:async/await。

岗位关联

对于 AI 从业者来说,无论是爬取海量训练数据、搭建模型服务网关,还是处理并发推理请求,asyncio 都是让单机吞吐量翻数倍的核心技能。它决定了你的服务能否从容面对高并发流量。

目录
  1. 可视化演示
  2. 协程核心概念
  3. async/await语法
  4. 事件循环机制
  5. Task与Future
  6. 协程调度原理
  7. aiohttp实战
  8. 与线程的对比
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 3KB)。

事件循环协程调度演示 事件循环维护就绪队列,协程遇到 await 时挂起并切换到下一个就绪任务 IO 等待期间自动切换其他协程执行,单线程实现高并发无需多线程锁 切换开销仅微秒级,同一时刻只有一个协程在执行,避免竞态条件 await 点切换实现单线程高并发,IO 等待不浪费 CPU

协程核心概念

咖啡师为什么能“一心三用”?因为每一项工作都分成了许多个“微步骤”:启动滴滤是个瞬间动作,之后是漫长的等待;打奶泡也是,打发的一瞬间需要盯着,蒸气打完就可以放着。咖啡师把每杯咖啡拆成“需要动手”和“只需等待”两种片段,动手时全神贯注,等待时绝不傻等。这正是协程的哲学:把任务切成无数小片段,遇到等待就让出控制权。

在 Python 中,协程(coroutine)是一个可以暂停执行、保存当前状态、稍后恢复的函数。普通函数一旦调用,就一路执行到底;协程函数则可以在中途停下,把 CPU 让给别的协程,等时机合适再回来继续往下走。这种“走走停停”的特性,恰好和 I/O 操作的节奏合拍——网络请求发出后,绝大多数时间都在等服务器回应,而这段时间 CPU 是空闲的。

技术上说,协程是比线程更小的执行单元。一个线程内可以包含成百上千个协程,由事件循环负责调度它们。协程的切换是协作式的:协程自己决定何时让出,而不是像线程那样被操作系统强制抢占。这意味着没有数据竞争和锁的需求,编程模型更简单。但代价是:如果某个协程长时间占用 CPU 不放,其他协程就会饿死。

咖啡师最终明白:同时段内,他动手的总时间没变,但因为三杯咖啡的等待时间重叠了,顾客等到的“第一杯”的时间大大提前。这就是高并发的真相——不是变出更多双手,而是把等待串成并行。

单线程总耗时=∑忙碌片段+max⁡(等待片段1,等待片段2,… )\text{单线程总耗时} = \sum \text{忙碌片段} + \max(\text{等待片段}_1, \text{等待片段}_2, \dots)
这个公式揭示了 asyncio 提速的本质:顺序执行时等待时间是累加的,比如 3 次网络请求各等 1 秒就要 3 秒;而协程并发时,3 个等待同时进行,总耗时约等于最长的那个等待——只有 1 秒。
  1. 忙碌片段之和协程真正占用 CPU 干活的时间总量,比如解析数据、计算特征。
    ∑忙碌片段\displaystyle \sum \text{忙碌片段}
  2. 取等待的最大值所有协程的 I/O 等待“同时进行”,所以等待总时长取决于最慢的那一个。
    max⁡(等待片段1,等待片段2,… )\displaystyle \max(\text{等待片段}_1, \text{等待片段}_2, \dots)
  3. 对比顺序执行如果逐个执行,总耗时是忙碌与等待的完全累加,远大于上式。
    T顺序=∑(忙碌片段i+等待片段i)\displaystyle T_{\text{顺序}} = \sum (\text{忙碌片段}_i + \text{等待片段}_i)

async/await语法

咖啡师随手在订单上画了两个符号:看到“☕“就知道要动手,看到”⏳”就知道该去干别的。Python 也用一对标点符号来完成这个约定:async 和 await。

用 async def 声明的函数会返回一个协程对象,调用时并不会立即执行函数体,而是返回一个可以交给事件循环调度的“任务单”。协程对象就像一杯“待制作的咖啡”,只有放进事件循环这个“工作台”,才会真正开始制作。而 await 是协程内部的暂停指令:遇到它,当前函数会挂起,让出执行权,直到等待的对象准备好再回来。

创建协程有三种常见方式。第一种:直接调用 async def 函数得到协程对象;第二种:用 asyncio.create_task() 将协程包装成任务,立刻加入调度;第三种:声明 async def main() 作为程序入口,用 asyncio.run(main()) 启动整个流程。其中 asyncio.run 会创建事件循环、运行 main 协程、结束后自动关闭循环——它是所有 asyncio 程序的“总开关”。

记住这个口诀:“async 定身份,await 让位置,run 来启动。” 咖啡师的菜单上,每一道饮品都清清楚楚写着哪些步骤需要等待、哪些步骤需要动手——而 await 就是那个“把订单插回排队架”的动作。

async def⏟声明为协程await 可等待对象⏟挂起点asyncio.run(main())⏟启动入口\underbrace{\text{async def}}_{\text{声明为协程}} \quad \underbrace{\text{await 可等待对象}}_{\text{挂起点}} \quad \underbrace{\text{asyncio.run(main())}}_{\text{启动入口}}
asyncio.run 相当于按下咖啡机的总电源;async def 是给函数贴上“可暂停”的标签;await 则是菜单上的“等待”按钮——按下后咖啡师转身去做别的单子。
  1. async 声明标记函数是一个协程函数,调用它得到协程对象而非执行结果。
    coroutine_obj=async_func()\displaystyle \text{coroutine\_obj} = \text{async\_func}()
  2. await 挂起在协程内部暂停执行,把控制权交还给事件循环。
    result=await awaitable\displaystyle \text{result} = \text{await } \text{awaitable}
  3. asyncio.run 启动创建事件循环并运行入口协程,相当于总的调度开关。
    asyncio.run(main())\displaystyle \text{asyncio.run}(\text{main}())

事件循环机制

咖啡店的核心不是咖啡机,而是咖啡师脑中那张“订单调度表”。他不断扫视:哪些订单需要动手了?哪些还在等待?这正对应 asyncio 的事件循环(event loop)——一个永不停歇的调度器。

事件循环维护两组角色:就绪队列和等待队列。就绪队列里是“可以立即干活”的协程;等待队列里是“正在等 I/O 完成”的协程。循环每一轮做三件事:从就绪队列取出一个协程运行到下一个 await;检查等待队列中的 I/O 是否完成,完成则移入就绪队列;重复以上过程。这个循环运行在单线程中,速度极快,每秒钟可以完成成千上万次“检查-调度-切换”。

asyncio.run() 是一个简化版的“开店流程”:开店(创建事件循环)→ 接待第一位顾客(运行 main 协程)→ 不断调度直到所有任务完成 → 打烊(关闭循环)。在事件循环内部,Python 通过 selectors 模块监听文件描述符:当某个 socket 可读可写时,对应的协程就被唤醒。这意味着成百上千个网络连接可以共用一个线程,却各自“感觉”自己在独享 CPU。

想象咖啡师在高峰期:永远有活儿干,因为每杯咖啡的等待间隙都穿插着别的咖啡的“动手时间”。只要订单够多,他就没有一秒闲下来——事件循环就是这位永远不累的咖啡师。

事件循环效率=忙碌时间忙碌时间+切换开销+空闲时间\text{事件循环效率} = \frac{\text{忙碌时间}}{\text{忙碌时间} + \text{切换开销} + \text{空闲时间}}
切换开销就是检查就绪队列、更新等待队列的微小成本。事件循环的优雅之处在于:空闲时间只出现在所有任务都真的无事可做时——这正是“等待最大化重叠”的结果。
  1. 忙碌时间占比事件循环真正用于执行用户代码的时间比例。
    忙碌时间\displaystyle \text{忙碌时间}
  2. 切换开销每次协程切换需要保存和恢复上下文,但这个开销极低(微秒级)。
    切换开销≈1μs\displaystyle \text{切换开销} \approx 1\mu s
  3. 空闲时间趋近于零只要还有协程在等待 I/O,切换就能填满空闲。
    lim⁡协程数→∞空闲时间T=0\displaystyle \lim_{\text{协程数} \to \infty} \frac{\text{空闲时间}}{T} = 0

Task与Future

咖啡师接到一张包含 5 杯咖啡的大订单,他不会傻傻地一杯接一杯做,而是把订单拆成 5 张小卡片插在调度板上,随时准备穿插完成。在 asyncio 中,Task 就是这张“小卡片”——它是协程的调度单元,代表一个正在被事件循环管理的任务。

创建任务的方式是 task = asyncio.create_task(coro),协程会立刻被注册到事件循环中,开始参与调度(但不一定立即执行)。之后你可以 await task 等待它完成,也可以同时创建多个任务,让它们一起“并发”运行。Future 是更底层的概念:它像一个“收货单”,表示一个未来才会有的结果。当协程里写下 await future 时,就是在等这个结果送达。Task 是 Future 的子类——每个 Task 内部都有一个 Future,用来容纳协程的返回值或异常。

实践中用得最多的是 asyncio.gather():它接受多个协程或任务,并发地运行它们,然后聚合所有结果。gather 的顺序与传入顺序一致,这意味着你可以放心地按位置取用结果。除此之外还有 asyncio.wait()(更灵活但易用性稍差)、asyncio.as_completed()(先完成先返回)等工具。

咖啡师把 5 张小卡片插上调度板后,心里有数了:只要最慢的那杯咖啡完成,整张订单就可以交付。其余几杯再快,也不用等他——它们早就在穿插中做完了。这正是 gather 带给程序员的确定性安心感。

T总=max⁡(TTask1,TTask2,…,TTaskn)+调度开销T_{\text{总}} = \max(T_{\text{Task}_1}, T_{\text{Task}_2}, \dots, T_{\text{Task}_n}) + \text{调度开销}
如果用 asyncio.gather() 并发执行多个协程,整体耗时约等于最慢的那个任务,而不是所有任务时间之和。这就是并发(concurrency)与并行(parallelism)的区别:并发是“交叉进行”,并行才是“同时进行”。
  1. 创建任务把协程包装成 Task,立刻注册进事件循环的调度表。
    task=asyncio.create_task(coro)\displaystyle \text{task} = \text{asyncio.create\_task}(\text{coro})
  2. 并发聚合gather 同时运行多个任务,等待全部完成并收集结果。
    results=await asyncio.gather(t1,t2,… )\displaystyle \text{results} = \text{await } \text{asyncio.gather}(t_1, t_2, \dots)
  3. 总耗时取最大值并发等待多个 I/O,总时长取决于最慢的那个任务。
    T总=max⁡iTTaski\displaystyle T_{\text{总}} = \max_i T_{\text{Task}_i}

协程调度原理

为什么咖啡师切换任务时不会“洒了奶泡”?因为他总是在一个步骤完整结束后才切换,绝不会做到一半扔掉。对应到 asyncio,调度是协作式的——协程只有在 await 处才会让出 CPU,这就是“主动让出”的含义。

协程调度的完整生命周期是:Runnable(就绪)→ Running(运行)→ Waiting(等待 I/O)→ Runnable(回到就绪)。当协程执行到 await some_io() 时,它登记一个回调到事件循环,然后进入等待队列;I/O 完成后,事件循环把它移回就绪队列,等待下一轮调度。整个过程不涉及操作系统内核切换,代价极低。

协作式调度有一个重要特性:没有抢占。如果某个协程写了一个死循环且不含 await,事件循环永远无法切走它,其他协程全部饿死——这就是“协作”二字的代价:每个人都必须自觉让路。好在,只要遵守“所有阻塞调用都必须用异步版本”这条规则,这种风险基本不会出现。

这家咖啡店没有“抢单”文化:每个咖啡师做完手头那个“小步骤”,才把位置让给下一个任务。正因为每个人都会主动让位,所以不需要复杂的锁机制——合作的默契,比强制规则更高效。

调度时延=t就绪→运行+t运行→等待+t等待→就绪\text{调度时延} = t_{\text{就绪→运行}} + t_{\text{运行→等待}} + t_{\text{等待→就绪}}
每一次协程切换都是纯用户态操作,没有内核陷阱、没有上下文切换的系统调用开销。所以哪怕在单个线程内切换上万次协程,性能也完全可接受。
  1. 就绪到运行事件循环从就绪队列取出协程,恢复它的执行上下文。
    Ready→Running\displaystyle \text{Ready} \rightarrow \text{Running}
  2. 运行到等待协程遇到 await,主动挂起并注册 I/O 回调。
    Running→Waiting\displaystyle \text{Running} \rightarrow \text{Waiting}
  3. 等待到就绪I/O 完成后回调触发,协程重新进入就绪队列。
    Waiting→Ready\displaystyle \text{Waiting} \rightarrow \text{Ready}

aiohttp实战

理论说了一堆,咖啡师终于要迎接真正的“早高峰大客流”了。aiohttp 就是为协程而生的异步 HTTP 客户端/服务端库,它的请求方法是协程,天然适配 asyncio 的调度节奏。

用 aiohttp 写并发爬虫,核心模式是:先定义异步请求函数,再用 asyncio.gather 汇聚多个请求。每个 await session.get(url) 都会触发一次网络 I/O 等待,而事件循环会利用这段时间去发起其他请求。对比同步 requests 库的逐个等待,aiohttp 在爬取 100 个网页时常常能带来 10~50 倍的提速,而且只用一个线程、极低的内存开销。

aiohttp 的适用场景绝不限于爬虫。API 网关、消息推送服务、实时数据管道——凡是“连接数多、每个连接大部分时间在等待”的服务,都是 asyncio 的用武之地。一个单核的 aiohttp 服务可以轻松承载数千个并发连接,而同等配置的同步服务可能几十个连接就开始阻塞。值得注意的是:aiohttp 服务端同样遵循协程规则,await request.json()、await response.write() 都是挂起点。

咖啡店高峰期,咖啡师不再按“先来后到”单线制作,而是所有订单并行推进。用户感觉“好像有好几个咖啡师”,其实只是一个擅长调度的高手。aiohttp 就是让 Python 变成这位高手的秘籍。

吞吐量=并发请求数平均响应时间≈max⁡(各请求等待时间)\text{吞吐量} = \frac{\text{并发请求数}}{\text{平均响应时间} \approx \max(\text{各请求等待时间})}
在高并发场景下,asyncio 的吞吐量不再受“单个请求耗时的线性累加”限制,而是取决于最慢请求的耗时。等待被重叠,吞吐被放大。
  1. 并发请求数同时发出的请求数量,比如用 gather 一次创建 100 个会话。
    N并发=100\displaystyle N_{\text{并发}} = 100
  2. 平均响应时间并发等待下,总耗时近似等于最慢请求的耗时。
    T总≈max⁡(t1,t2,…,t100)\displaystyle T_{\text{总}} \approx \max(t_1, t_2, \dots, t_100)
  3. 吞吐量定义单位时间能完成的请求数,是并发数与耗时的比值。
    QPS=N并发T总\displaystyle \text{QPS} = \frac{N_{\text{并发}}}{T_{\text{总}}}

与线程的对比

店里来了个新咖啡师,他主张“多雇几个人不就解决了?”——这就是多线程方案:每个线程独立处理一个请求,看似简单直接。但线程的代价是:操作系统要参与调度。每一次线程切换,内核都要保存和恢复寄存器、更新调度队列,这笔开销在大量并发时迅速累积。而且线程共享内存,修改共享数据需要加锁,否则会出现“两个人同时改同一张订单”的混乱。

协程则完全不同:它运行在一个线程内部,切换由事件循环在用户态完成,没有内核参与。启动和切换开销比线程低一个量级以上——Python 中线程切换大约需要 1~10 微秒,而协程切换通常不到 1 微秒。此外,协程天然不需要锁,因为单线程内同一时刻只有一个协程在运行,不存在真正的并行访问。

但协程不是银弹。如果是 CPU 密集任务(大量计算、矩阵运算),协程不仅没有提速,反而因为切换开销变得更慢。此时应该用多进程(multiprocessing)或调用 NumPy 这类底层释放 GIL 的库。选型口诀:IO 密集用协程,CPU 密集用多进程,需要真并行才用多线程。

咖啡店终究明白:再多的咖啡师也受限于厨房尺寸(GIL 和单核),而协程的思路是在有限空间内做到极致调度。当客流(IO 密度)足够大时,一位调度大师远比十个普通员工高效。

T线程=t切换×N切换⏟内核参与,开销大T协程=t切换×N切换⏟用户态完成,开销极小\underbrace{T_{\text{线程}} = t_{\text{切换}} \times N_{\text{切换}}}_{\text{内核参与,开销大}} \qquad \underbrace{T_{\text{协程}} = t_{\text{切换}} \times N_{\text{切换}}}_{\text{用户态完成,开销极小}}
线程切换是“店员跑到后厨拿货再跑回来”——路程远、耗时多;协程切换是“转身从手边货架拿”——一步到位。两者的功能相似,成本和频率却天差地别。
  1. 线程切换开销涉及内核态进入退出、寄存器保存恢复,约 1-10 微秒。
    t线程∈[1,10]μs\displaystyle t_{\text{线程}} \in [1, 10] \mu s
  2. 协程切换开销纯用户态栈切换,约 0.1-1 微秒,快一个量级。
    t协程≤1μs\displaystyle t_{\text{协程}} \le 1 \mu s
  3. GIL 的影响Python 全局解释器锁使得同一进程内线程无法真并行,进一步削弱多线程优势。
    GIL: 单进程内仅一个线程执行字节码\displaystyle \text{GIL: 单进程内仅一个线程执行字节码}

AI 应用场景

异步爬虫:批量抓取训练数据

阶段:数据采集(Data Collection)

解决的问题:训练 AI 模型需要海量文本、图片数据。用同步 requests 逐个下载 10,000 个 URL,假设每个耗时 0.5 秒,顺序执行要近 1.5 小时。asyncio + aiohttp 可以让这 10,000 个请求同时“排队等响应”,总耗时被压缩到最慢几个请求的量级,往往只需几分钟。

具体案例:某 NLP 团队需要抓取 50 万条新闻语料。他们用 asyncio.Semaphore(500) 控制并发度(避免被封 IP),配合 aiohttp 连接池复用 TCP 连接,最终在 2 小时内完成抓取,而同步方案预计需要 3 天。抓下来的数据直接进入数据清洗管道,为后续预训练提供语料。

AI 推理服务网关

阶段:模型部署与 Serving(推理服务)

解决的问题:AI 服务通常需要串联多个上游依赖:鉴权服务、特征存储、模型推理、日志上报。每一步都有网络 RTT。同步实现时,一次请求耗时是各依赖耗时之和;用 asyncio 并发调用相互独立的依赖(如鉴权 + 拉特征),单个请求的时延显著下降,网关在单核上就能支撑高并发。

具体案例:某推荐系统团队将模型服务的 Python 网关从多线程 Flask 迁移到 aiohttp + asyncio,在相同 2 核 4G 配置下,并发连接数从 300 提升到 5,000,P99 时延从 800ms 降到 350ms——因为等待特征服务的 200ms 被用来处理其他请求。

Python 演示

python40 行
import asyncioimport aiohttp URLS = [    "https://www.example.com",    "https://www.python.org",    "https://www.github.com",    "https://www.openai.com",] * 5  # 20 个 URL async def fetch(session, url):    async with session.get(url) as resp:        return url, resp.status, await resp.text() async def fetch_all():    async with aiohttp.ClientSession() as session:        tasks = [asyncio.create_task(fetch(session, url)) for url in URLS]        results = await asyncio.gather(*tasks)    for url, status, html in results:        print(f"{url} -> status={status}, len={len(html)}") async def demo_semaphore():    sem = asyncio.Semaphore(5)    async with aiohttp.ClientSession() as session:        async def limited(url):            async with sem:                return await fetch(session, url)        tasks = [asyncio.create_task(limited(url)) for url in URLS]        return await asyncio.gather(*tasks) async def main():    print("=== 全量并发 ===")    await fetch_all()    print("=== 信号量限流并发 ===")    results = await demo_semaphore()    for url, status, html in results:        print(f"{url} -> status={status}, len={len(html)}") if __name__ == "__main__":    asyncio.run(main())
目的:演示用 aiohttp + asyncio 并发抓取多个网页,并展示 Semaphore 限流控制并发度的标准写法。
  1. async def fetch(session, url)定义协程函数,返回请求的 URL、状态码和响应体。
  2. async with session.get(url) as resp异步发起 HTTP 请求,遇到网络 I/O 时自动挂起让出 CPU。
  3. await resp.text()异步读取响应体,同样是等待操作,会触发协程切换。
  4. asyncio.create_task(fetch(...))把协程包装成 Task 并注册进事件循环,立即参与调度。
  5. await asyncio.gather(*tasks)并发运行所有任务并收集结果,整体耗时约等于最慢的一个。
  6. asyncio.Semaphore(5)创建信号量,限制同时进行的协程数,防止对目标服务器压力过大。
  7. asyncio.run(main())启动事件循环并运行入口协程,所有任务结束后自动关闭循环。

常见误区

误区我用 asyncio 并发执行了 100 个任务,那这 100 个任务一定是同时在多核 CPU 上跑的,速度应该翻 100 倍。

正解asyncio 是单线程内的协作式调度,同一时刻只有一个协程在运行,绝不是并行。它的提速原理是“等待重叠”——把空闲等待时间用来跑别的任务。就像咖啡师只有一双手,但他能让 5 杯咖啡的等待时间重叠,从而更快交付每一杯。真正并行需要多进程或多线程(且受 GIL 限制)。

为何常见:理解“并发”和“并行”的区别需要一些底层知识。初学者看到关键字里带 async(异步),容易把它和“多任务同时跑”混淆。

误区我给所有函数都加上 async/await,程序肯定就变成高并发、性能爆表了。

正解asyncio 只对 IO 密集任务有效。如果任务是 CPU 密集型的——比如大量循环、矩阵乘、加密计算——协程切换只会增加开销,没有任何收益。更糟的是,如果有人在协程里调用 time.sleep(2) 这种同步阻塞函数,整个事件循环都会卡住 2 秒,所有协程全部停摆。正确做法是:CPU 密集用多进程,同步阻塞 IO 用线程池(asyncio.to_thread)。

为何常见:人们天然以为“新技术 = 更快”。asyncio 的适用边界(IO 等待主导)不是直觉能判断的,必须理解它的调度模型。

误区我定义了一个 `async def fetch_data()`,然后调用 `fetch_data()`,为什么网页没被抓到?难道不应该自动执行吗?

正解调用 async def 函数只会创建协程对象,函数体一行都不会执行。你必须让它进入事件循环才会运行——要么 await fetch_data(),要么 asyncio.create_task(fetch_data()),要么挂在 asyncio.gather 里。这就好比你把菜单递给咖啡师却一直握着不松手,咖啡当然不会开始做。

为何常见:协程的“惰性执行”与普通函数差异巨大。普通函数调用即执行,协程调用只返回对象——这个转变违反直觉,是 asyncio 最常见的“无声 bug”来源。

误区我在协程里用 `requests.get()` 代替 aiohttp 也可以吧?反正我写成 async 函数了。

正解绝对不行。requests.get() 是同步阻塞调用,在协程里调用它会独占整个线程——事件循环被卡住,所有并发任务全部暂停。这就像咖啡师在等滴滤时停下所有工作盯着水流,其他订单全被晾着。asyncio 世界要求“所有耗时操作都必须是异步版本”:HTTP 用 aiohttp、睡眠用 asyncio.sleep()、文件读写用 aiofiles 或 asyncio.to_thread()。

为何常见:很多人不知道同步库会阻塞事件循环。只要有一次 requests.get() 被塞进关键路径,整个服务的“高并发”就名存实亡。

误区以后我都用 asyncio 写服务,彻底抛弃多线程多进程。

正解asyncio 是 IO 密集场景的利器,但并非万能。CPU 密集任务用 multiprocessing,混合场景可能需要多进程 + 协程的组合;老代码依赖的同步库无法轻松改造成异步版本;调试 asyncio 的堆栈和资源释放也相对复杂。咖啡师再厉害,也需要烤箱(多进程)处理烘焙,需要柜台(GIL)管理空间。

为何常见:学完一个新技能后,人们容易过度推广它的适用范围。选型永远要回到“瓶颈是什么”来分析,而不是用某一种技术包打天下。

面试题

Q1. 关于 Python 中协程(coroutine)的描述,下列哪项正确?

单选题 · easy

  • A.协程可以在多个 CPU 核心上并行运行
  • B.协程可以在单线程内通过等待时切换实现高并发← 正确答案
  • C.协程必须依赖操作系统内核进行调度
  • D.协程之间必须使用锁来保护共享数据

答案:B。协程运行在单线程中,由事件循环在用户态完成调度,不涉及内核。它的高并发来自“等待时切换”,而非并行。选项 A 混淆了并发与并行;C 错在协程是纯用户态调度;D 错在单线程同一时刻只有一个协程运行,不需要锁。就像咖啡师一双手,靠穿插完成多杯咖啡,而不是真的同时动手。

Q2. 在 asyncio 中,await 的作用是什么?

单选题 · easy

  • A.强制程序单线程执行
  • B.启动事件循环
  • C.挂起当前协程,等待可等待对象完成后恢复← 正确答案
  • D.创建新的协程对象

答案:C。await 会暂停当前协程的执行,把控制权交还给事件循环,待等待对象(Task、Future、IO 等)完成后恢复。它不负责创建协程(那是 async def 调用和 create_task 的事),也不直接启动事件循环(那是 asyncio.run 的职责)。用咖啡店的比喻,await 就是“把订单插回排队架,转身去忙别的”。

Q3. 以下哪段代码能正确启动一个 asyncio 程序入口?

单选题 · easy

  • A.asyncio.run(main())← 正确答案
  • B.main().run()
  • C.await main()
  • D.async def run(main())

答案:A。asyncio.run(main()) 会创建事件循环、运行 main 协程、结束后自动关闭,是标准入口。await 必须在协程内部使用,不能直接在模块顶层执行;main().run() 不存在此方法;async def run(main()) 是语法错误。咖啡店必须按下“总电源开关”(asyncio.run)才能开始运转。

Q4. 关于 asyncio.create_task(),以下说法正确的是?

单选题 · mid

  • A.它会立即执行协程并等待结果
  • B.它将协程包装为 Task 并注册进事件循环,使其参与调度← 正确答案
  • C.它只能被调用一次,不能重复使用
  • D.它会在新线程中运行协程

答案:B。create_task 将协程包装成 Task 并注册到事件循环,协程成为调度对象,但不会立刻执行完毕,也不会阻塞调用方。重复调用 create_task 是完全合法的。它运行在同一个线程中,绝不创建新线程。就像咖啡师把新订单卡片插上调度板——具体何时做,要看整体调度安排。

Q5. 以下哪些场景适合使用 asyncio?

多选题 · mid

  • A.大规模网络爬虫,大量 HTTP 请求等待响应← 正确答案
  • B.高并发 API 网关,请求等待下游服务响应← 正确答案
  • C.纯 CPU 密集的矩阵运算
  • D.大量文件读取的 IO 密集任务← 正确答案

答案:ABD。asyncio 的核心优势在 IO 密集场景——网络请求、文件读取等“等待”占主导的任务。选项 A、B、D 都是等待占主导,协程能通过切换提升吞吐。选项 C 是 CPU 密集,协程不仅无效还会因切换开销变慢,应选择多进程。题目变体可反选“不适合用 asyncio 的场景”。

Q6. 事件循环中,“就绪队列”和“等待队列”的角色是什么?

单选题 · mid

  • A.就绪队列存放已完成的协程,等待队列存放未创建的协程
  • B.就绪队列存放可立即运行的协程,等待队列存放正在等 IO 的协程← 正确答案
  • C.两者没有区别,是同一个队列的两种叫法
  • D.就绪队列存放线程,等待队列存放协程

答案:B。事件循环每轮从就绪队列取出协程运行,同时检查等待队列的 IO 是否完成,完成则移入就绪队列。就绪=可以干活,等待=正在等待外部事件。这就好比咖啡师的“待做单”和“等咖啡机”两个区域——等机器完成的订单一旦好了,就移到待做区。

Q7. 在 async 函数中调用了同步库 requests.get(),会出现什么问题?

单选题 · mid

  • A.没有任何问题,正常并发执行
  • B.只在多线程中崩溃,单线程没问题
  • C.会阻塞整个事件循环,所有协程暂停直到请求完成← 正确答案
  • D.会启动一个新的子进程执行请求

答案:C。同步库的阻塞调用会独占线程,事件循环无法切换任务,所有协程全部被卡住。asyncio 要求所有耗时操作必须用异步版本(aiohttp、asyncio.sleep 等),或通过 to_thread 放入线程池。就像咖啡师停下来专心等一台机器,其他所有订单都停摆了——协作式调度的前提是每个人都要主动让位。

Q8. 关于 asyncio 与多线程的对比,哪些说法正确?

多选题 · hard

  • A.线程切换由操作系统调度,协程切换由用户态事件循环调度← 正确答案
  • B.协程切换开销通常远小于线程切换开销← 正确答案
  • C.多线程一定比 asyncio 快
  • D.因为 GIL,Python 多线程无法实现真并行← 正确答案

答案:ABD。A 正确,线程是内核级调度、协程是用户态调度;B 正确,协程切换通常不到 1 微秒而线程切换 1-10 微秒;D 正确,GIL 使得同一进程内多线程无法并行执行 Python 字节码。C 错误,IO 密集场景 asyncio 通常效率更高,多线程反而因切换开销而变慢。

Q9. 以下关于 asyncio.gather() 与 asyncio.create_task() 搭配使用的说法,哪个正确?

单选题 · hard

  • A.必须先用 create_task 再调用 gather,否则 gather 无效
  • B.gather 可以接收协程或 Task,并发运行后按传入顺序返回结果← 正确答案
  • C.gather 内部会自动创建新的事件循环
  • D.gather 只能接收一个协程参数

答案:B。gather 既能接收协程(会自动包装为 Task),也能直接接收 Task,并发运行后按传入顺序返回结果列表。它不需要配对 create_task,但使用 create_task 可以先拿到任务句柄做取消操作。gather 不会新建事件循环,它运行在当前循环中。咖啡师一次接多张订单卡,最后按点单顺序把饮品递给顾客。

Q10. 协作式调度下,以下哪些操作会导致其他协程被饿死?

多选题 · hard

  • A.协程内使用 while True 死循环且无 await← 正确答案
  • B.协程内调用 asyncio.sleep(0) 的无限循环
  • C.协程内执行耗时极长的同步 CPU 运算← 正确答案
  • D.协程内使用 time.sleep(0.01)← 正确答案

答案:ACD。协作式调度只在你主动让出 CPU 时才切换。死循环无 await(A)和长 CPU 运算(C)都会独占 CPU 不让出;time.sleep 是同步阻塞(D),同样卡死事件循环。而 asyncio.sleep(0) 会让出控制权,事件循环得以调度其他协程,所以 B 不会饿死别人——它相当于“假装忙碌但每次都让位”,别的协程有机会执行。咖啡师若一直埋头打磨一杯咖啡不放,其他订单自然全被饿死。