午市高峰,“幸福小馆”里坐满六桌客人,却只有一个服务员小张。你可能会想:他肯定手忙脚乱。可奇怪的是,小张从不发呆等任何一桌——A桌刚点完菜,他转身给B桌送水;B桌还没想好,他又顺路把C桌的账单递过去。客人眼里的小张仿佛有三头六臂,其实他只是懂得一个道理:菜在厨房里做的时候,站着等是最蠢的选择,不如先去服务别人。 这个道理,就是异步编程的灵魂。Python 里的 asyncio 就是那个“小张”,而它赖以工作的中枢,就是事件循环(Event Loop)。事件循环运行在单线程里,却能让成百上千个网络请求看起来在“同时进行”——秘诀不是分身,而是把等待的时间利用起来。这篇文章会用“小张的一天”贯穿始终,带你从零看懂事件循环到底怎么排队、怎么调度、怎么启动和关闭。

岗位关联

事件循环是 asyncio 的心脏,也是 Python 高并发服务的地基。理解它,你才能真正写出不阻塞、不出 Bug 的异步代码,也为排查线上诡异问题提供了关键视角。

目录
  1. 可视化演示
  2. 循环本质
  3. 任务排队
  4. 调度规则
  5. 回调机制
  6. 协程协同
  7. 时间管理
  8. 启动关闭
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示事件循环四阶段轮转(就绪→执行→IO等待→回调)、任务队列与回调优先级调度、以及IO多路复用epoll机制三大核心维度。

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

Event Loop 循环流程:就绪队列→执行协程→IO等待→回调处理,四阶段持续轮转 队列优先级:就绪队列FIFO,回调按优先级排序,定时器按时间堆 epoll_wait(fd_set) → ready_fds IO多路复用:epoll监听多个fd,仅返回就绪事件,单线程高并发

循环本质

小张是“幸福小馆”唯一的服务员。午市高峰,六桌客人同时落座,他一个人点菜、端茶、结账、翻台。你可能会问:一个人怎么能同时服务三桌?其实原因很简单——他不会在一桌客人想菜谱的时候干站着。A桌刚报完菜名,他立刻去给B桌送免费的柠檬水;B桌说稍等,他又去把C桌的账单递上。在客人眼里,小张好像有三头六臂;实际上,他只是把“等待”的时间全用来“做别的事”了。

把餐厅换成进程,“小张的三头六臂”就是事件循环。它运行在单线程里,却能让许多任务都获得“被同时照顾”的感觉。实现这种错觉的机制,是一个永远在奔跑的循环:检查有没有新事件,有就处理,没有就继续转。正因为只有一颗“大脑”,同一瞬间它永远只做一件事,却能在极短的时间片里不断更换服务对象,让所有任务都觉得“服务员就在我身边”。

事件循环的本质是单线程 + 事件驱动的调度器。它区别于多线程:线程是雇二十个服务员,每个服务员盯一桌,哪怕那桌客人都在发呆也得站着;事件循环是把小张训练到极致,让他一个人利用每个客人的“等待间隙”穿插服务。编程上,它不需要操作系统做代价高昂的线程切换,只靠函数级的跳转就能完成换桌服务。这也是为什么 asyncio 能以单线程支撑成千上万个网络连接。

所以小张的秘诀从来不是分身术,而是“不等菜,去端水”。事件循环的价值也完全一样:它不创造更多 CPU,只是把原本要被空耗的 IO 等待时间,转变成了服务其他任务的有效时间。理解了这一点,你就握住了整个异步编程的总钥匙。

S=TserialTloop=∑i=1n(tiIO+tiCPU)max⁡i tiIO+∑i=1ntiCPUS=\frac{T_{\text{serial}}}{T_{\text{loop}}}=\frac{\sum_{i=1}^{n}\left(t_i^{IO}+t_i^{CPU}\right)}{\max_{i}\,t_i^{IO}+\sum_{i=1}^{n}t_i^{CPU}}
通俗说明:串行跑任务是“把每桌客人的等待时间全加起来”;事件循环是“只等最慢的那一次等待,其余等待全被重叠掉”,所以总耗时大幅缩短。
  1. 单个任务耗时一个协程从开始到结束,可以分为两段——真正消耗 CPU 的计算段,以及等待网络或磁盘返回的 IO 段。
    titotal=tiIO+tiCPUt_i^{total}=t_i^{IO}+t_i^{CPU}
  2. 串行总耗时如果用一个线程逐个执行任务,总时间就是每个任务的 IO 时间加 CPU 时间全部相加。
    Tserial=∑i=1n(tiIO+tiCPU)T_{\text{serial}}=\sum_{i=1}^{n}\left(t_i^{IO}+t_i^{CPU}\right)
  3. IO 重叠事件循环把所有任务的 IO 等待重叠起来,这一部分只算最慢的那一个。
    max⁡i tiIO\max_{i}\,t_i^{IO}
  4. CPU 不可重叠单核在某个瞬间只能执行一段计算,所以所有 CPU 时间必须累加。
    ∑i=1ntiCPU\sum_{i=1}^{n}t_i^{CPU}
  5. 加速比当 IO 时间占绝对主导时,事件循环的加速比约等于并发任务数。
    S≈nS\approx n

任务排队

小张之所以能同时照顾六桌,除了腿快,更重要的是他手里有一个分类清晰的记事本。哪桌在等上菜,哪桌刚坐下还在翻菜单,哪桌预约了十五分钟后的甜品,他都记得一清二楚。餐厅里运转顺畅,靠的不是一股脑地瞎忙,而是合理的排队和分类。

事件循环里也有三个核心的“分类本”。就绪队列记录的是“现在就能服务”的任务——它们刚被唤醒,随时可以执行;定时器队列记录的是“到点才能叫”的任务,比如 asyncio.sleep(2) 就是给自己写了一张“两秒后请叫我”的便条;IO 等待队列则登记了所有正在等网络数据、等文件读取的任务,它们暂时和小张无关。

在 asyncio 的具体实现中,就绪队列是一个 FIFO 结构,loop.call_soon 注册的回调和被唤醒的 Task 都会进这里;定时器队列用最小堆按到期时间排序;IO 等待队列则托管给 selector(如 epoll),由操作系统内核帮忙盯着“哪个网络连接来数据了”。事件循环每一轮都先取就绪队列里最老的任务执行,再去看看定时器有没有到点、IO 有没有就绪。

记事本分得清,小张才能同时盯住六桌而不慌乱。同理,事件循环把任务划分成“可执行、定时、等 IO”三类之后,调度就有了明确依据。这是整个异步系统的地基——后面的所有机制,都是在这三个队列之上盖楼。

Qloop=Qready∪Qtimer∪Qio\mathcal{Q}_{\text{loop}}=\mathcal{Q}_{\text{ready}}\cup\mathcal{Q}_{\text{timer}}\cup\mathcal{Q}_{\text{io}}
通俗说明:事件循环的总待办,分成三本账:能立刻干的、到点再干的、在等别人回话的。三本账的排队规则各不相同,互相补充、互不覆盖。
  1. 总待办集合事件循环管理的所有任务,最终都归入三个大抽屉。
    Qloop\mathcal{Q}_{\text{loop}}
  2. 就绪队列存放已经具备执行条件的 Task 和回调,先来先服务。
    Qready\mathcal{Q}_{\text{ready}}
  3. 定时器队列存放 sleep、call_later 创建的定时任务,未到点不出抽屉。
    Qtimer\mathcal{Q}_{\text{timer}}
  4. IO 等待队列存放正在等 socket 或管道数据的任务,由内核帮忙守望。
    Qio\mathcal{Q}_{\text{io}}
  5. 合并调度三个队列合起来就是事件循环的全部工作来源,循环每轮都从它们当中挑选下一步动作。
    Qloop=Qready∪Qtimer∪Qio\mathcal{Q}_{\text{loop}}=\mathcal{Q}_{\text{ready}}\cup\mathcal{Q}_{\text{timer}}\cup\mathcal{Q}_{\text{io}}

调度规则

细心观察小张,你会发现他有个职业习惯:从不去抢客人手里的菜单。客人说“我再看看”,他就微笑着退开,先去忙别的桌;等客人抬头喊“点菜”,他才立刻出现。这种“你情我愿”的协作方式,就是事件循环最核心的调度规则——协作式调度。

在 asyncio 里,事件循环永远不会强行打断一个正在运行的协程。任务只有在执行到 await 语句时,才会主动让出控制权,把 CPU 交还给事件循环。如果某个协程里放着一段同步阻塞代码,比如 time.sleep(1),整个事件循环都会卡住——因为循环永远等不到那个协程“让位”的信号。这就是为什么在异步代码里,任何阻塞调用都被视为大忌。

协作式调度也有代价:每一次让出和恢复,都是一次函数调用链的跳转。虽然这个成本远小于线程切换,但积少成多。如果任务被切得过分零碎,大量的时间反而会消耗在切换本身。好的异步代码,会把握任务粒度:既能利用 await 让出空闲,又不会让切换开销喧宾夺主。

小张不会在客人犹豫时抢走菜单,事件循环也不会在协程忙碌时横插一脚。协作式调度让异步代码变得可控:只要每个人都遵守“await 让权”的约定,整个系统就能默契地运转。也正因为如此,一句不起眼的同步阻塞调用,就足以让整家“餐厅”陷入瘫痪。

Tloop=∑i=1ntitask+n⋅tswitchT_{\text{loop}}=\sum_{i=1}^{n}t_i^{\text{task}}+n\cdot t_{\text{switch}}
通俗说明:事件循环的总开销等于“所有任务真正干活的时间”加上“每次换桌服务跳动的成本”。任务切得越碎,换桌次数越多,成本越高。
  1. 任务运行时间每个任务真正消耗 CPU 执行代码的时间。
    titaskt_i^{\text{task}}
  2. 所有任务运行时间之和因为单线程无法并行,这部分必须累加。
    ∑i=1ntitask\sum_{i=1}^{n}t_i^{\text{task}}
  3. 切换次数每次 await 让出、IO 完成后恢复,就产生一次切换。
    nn
  4. 切换成本每一次从“任务 A”跳到“任务 B”的函数调用与恢复开销。
    tswitcht_{\text{switch}}
  5. 总耗时把干活的时间和切换的时间加起来,就是事件循环完成所有任务的实际时长。
    Tloop=∑i=1ntitask+n⋅tswitchT_{\text{loop}}=\sum_{i=1}^{n}t_i^{\text{task}}+n\cdot t_{\text{switch}}

回调机制

小张服务客人时有个法宝:给每桌发一个“叫号铃”。顾客点完菜不用守在厨房门口,铃响了再来端菜就行。这个铃铛,就是异步世界里的回调。它把“等”和“做”彻底分开,让客人(任务)该干嘛干嘛去,铃声一响,一切继续。

在 asyncio 底层,回调是无处不在的零件。loop.add_reader(fd, callback) 会把一个回调注册到 selector 上,意思是“这个文件描述符一旦可读,就帮我叫这个人”。当内核通知 selector“数据来了”,事件循环从 select 调用中返回,把对应的回调放入就绪队列,下一轮调度时执行。这些回调中,有一部分是 Future 的 set_result,它会唤醒正在等待该 Future 的协程。

回调机制还要特别注意错误传播。回调里抛出的异常如果没有被捕获,事件循环默认会把它记入日志之后继续运行,而不是中断整个循环。这在排查问题时极具迷惑性——你以为程序还在跑,其实某个角落已经悄悄坏了。所以在业务代码里,常常要在回调的外层套上 try/except,把异常显式捕获并分类处理。

小张的铃铛响起时,客人便起身端菜;事件循环的回调被触发时,挂起的协程便恢复执行。回调机制是事件循环的“触手”——它把内核的 IO 事件翻译成 Python 世界的动作。理解回调,你就能明白为什么异步程序看起来总是“突然跳到另一个地方”继续执行。

fd→add_readercallback→selectloop→invokerestore taskfd\xrightarrow{\text{add\_reader}}\text{callback}\xrightarrow{\text{select}}\text{loop}\xrightarrow{\text{invoke}}\text{restore task}
通俗说明:一个 IO 资源的编号,进入内核的“监视名单”;一旦有数据到达,事件循环把它对应的回调捡起来执行,最终让挂起的任务原地复活。
  1. 文件描述符网络 socket 或文件在系统里的编号,异步 IO 的“身份证”。
    fdfd
  2. 注册回调把“文件可读时要做的事”登记到 selector 上,相当于留下电话号码。
    add_reader\text{add\_reader}
  3. 选择器就绪内核通知事件循环,某个 fd 有数据可读了。
    select\text{select}
  4. 循环执行回调事件循环从阻塞中返回,把回调放进执行流程。
    invoke\text{invoke}
  5. 恢复任务回调最终唤醒等待中的协程,让其继续往下执行。
    restore task\text{restore task}

协程协同

顾客在菜单上写下“糖醋排骨”,把它递给小张。菜单本身只是一张纸,不会自己跑进厨房;只有小张接过单、送到后厨,这张纸才真正变成“一道菜”的起点。在 asyncio 里,协程对象就是那张菜单,而事件循环就是小张。

平常我们用 async def 定义函数,调用它只会得到一个协程对象,函数体一行都不会执行。要让协程真正跑起来,必须交给事件循环调度:asyncio.create_task(coro) 会把协程包装成一个 Task(任务),注册到事件循环中;或者直接 await 它,让当前协程把它挂在自己的执行链上。Task 内部包含一个 Future——Future 就像顾客拿到的一张小票,上面写着“将来你会得到一盘菜”。

当协程执行到 await asyncio.sleep(1) 时,会发生三件事:当前协程挂起、控制权交还事件循环、事件循环记下“一秒后叫醒它”。到点后,定时器回调把 Future 置为 done,事件循环检查到这个 Future 完成了,就把等待它的协程重新放回就绪队列。协程苏醒后,await 表达式返回结果,代码继续往下走。

顾客把菜单交给小张,小张把单据送进厨房,厨房做好菜再通过铃声传回来——协程、Task、Future 三者就是这样彼此配合的。协程负责表达“我要做什么”,Task 负责让事件循环调度它,Future 负责传达“结果好了没有”。这套协作关系,是异步编程中最日常也最关键的一环。

Task∈{pending,done},done⇒resume waiting coroutine\text{Task}\in\{\text{pending},\text{done}\},\quad\text{done}\Rightarrow \text{resume waiting coroutine}
通俗说明:Task 是一个状态盒,要么“还没好”,要么“好了”。一旦状态变为 done,事件循环就会把等待它的协程唤醒,让它从刚才暂停的那一行继续。
  1. 待定状态Task 刚创建时是 pending,协程此刻停在某个 await 处。
    pending\text{pending}
  2. 结果到位IO 回调或定时器回调调用 set_result,让 Task 变为 done。
    done\text{done}
  3. 状态切换Task 从“未完成”翻转为“已完成”,是一个关键触发点。
    pending→done\text{pending}\to\text{done}
  4. 唤醒等待者事件循环发现 Task 完成,将等待它的协程放回就绪队列。
    ⇒resume waiting coroutine\Rightarrow \text{resume waiting coroutine}
  5. 协程恢复await 表达式最终拿到结果,协程继续执行后面的代码。
    coroutine resumes\text{coroutine resumes}

时间管理

小张的记事本里,不光有“正在服务”和“等菜中”,还有一栏专门写预约:十分钟后给三号桌加水,二十分钟后提醒八号桌该结账了。这些事不用他现在就做,但他必须记住“什么时候做”。这,就是事件循环的时间管理。

asyncio 的定时器用最小堆来组织。每个定时任务都有一个到期时间(deadline),堆顶永远是“最早到期”的那个。asyncio.sleep(delay) 的本质,就是往定时器堆里插入一个“delay 秒后唤醒我”的事件;loop.call_later(delay, callback) 也是相同机制。事件循环每轮迭代时,都会先看一眼堆顶的到期时间,算出“最多还能睡多久”,再进入 select 等待——这样做保证 IO 事件和定时器都不会被彼此误事。

这里有个微妙点:事件循环的“睡眠”是配合 IO 等待一起进行的。如果定时器设在 0.5 秒后,而此刻没有任何 IO 事件,循环就会让 select 最多等 0.5 秒;期间一旦有 IO 到达,循环会提前醒来处理,处理完发现定时器还没到点,就继续等。时钟精度依赖系统的单调时钟 time.monotonic(),它不受系统时间拨动影响,保证调度的稳定性。

小张的预约记事本写得清清楚楚,才不至于同时忘记三桌客人的需求。事件循环的时间管理同样是它可靠性的关键——准确的到期时间、高效的最小堆、配合着 select 的等待策略,让定时器与 IO 事件和谐共处。这也解释了为什么 asyncio.sleep 在异步代码里是“标准等待姿势”,而 time.sleep 是“灾难现场”。

tdeadline=tnow+Δ,tmin⁡=min⁡h∈Hmin⁡h.deadlinet_{\text{deadline}}=t_{\text{now}}+\Delta,\qquad t_{\min}=\min_{h\in\mathcal{H}_{\min}}h.\text{deadline}
通俗说明:每一个定时事件都记着一个“到期绝对时间”;所有定时事件中,到期最早的被放在堆顶,事件循环就等着那个时间点到来。
  1. 当前时间事件循环用单调时钟记录现在的时刻,不受用户改系统时间影响。
    tnowt_{\text{now}}
  2. 延迟增量调用 sleep(2) 时传入的 2 秒,就是希望等待的时长。
    Δ\Delta
  3. 到期时间现在加上延迟,得到这个定时事件被唤醒的目标时刻。
    tdeadline=tnow+Δt_{\text{deadline}}=t_{\text{now}}+\Delta
  4. 定时器堆所有定时事件构成一个最小堆,堆顶是最早到期的那个。
    Hmin⁡\mathcal{H}_{\min}
  5. 最小到期值事件循环只需盯住堆顶的时间,那就是它下一次必须醒来的截止点。
    tmin⁡=min⁡h∈Hmin⁡h.deadlinet_{\min}=\min_{h\in\mathcal{H}_{\min}}h.\text{deadline}

启动关闭

小张开店有一整套流程:早晨开门迎客,中午忙碌运转,晚上打烊收尾。打烊不是把正在吃饭的客人轰出门,而是先告诉顾客“厨房即将停止接单”,等他们结账离场、桌面收拾干净,最后才熄灯锁门。事件循环的“一天”,同样要经历规范的启动、运行与关闭。

在日常开发中,asyncio.run(main()) 是标准入口。它会自动创建一个新的事件循环,运行 main 协程直到它结束,然后进入收尾阶段:取消所有尚未完成的任务,等待这些任务真正取消完毕,关闭异步生成器,最后关闭事件循环。这一条龙服务,让小张(开发者)不必手动操心打烊细节。

但如果你使用底层 API,就要自己负责收尾。loop.run_until_complete(future) 只会在主协程完成后停止循环,其他后台任务可能还“悬在半空中”;直接调用 loop.close() 前,如果不先把 pending task 全部取消并等待确认,Python 会抛出 Task was destroyed but it is pending 的警告。正确姿势是:把所有任务放入 gather,统一取消,await 它们确认结束,再关闭异步生成器与循环本身。

打烊的餐厅干干净净,第二天才能重新开张;关闭得体的循环,程序才能不报错、不泄漏地退出。事件循环的启动关闭看似只是工程细节,实际上承载了健壮性的底线。真正在生产环境跑过的人都会明白:会启动循环只是入门,能优雅关闭循环才是进阶。

Loop∈{running,stopping,stopped},running→cancel allstopping→cleanupstopped\text{Loop}\in\{\text{running},\text{stopping},\text{stopped}\},\quad\text{running}\xrightarrow{\text{cancel all}}\text{stopping}\xrightarrow{\text{cleanup}}\text{stopped}
通俗说明:事件循环有“运行中、正在停止、已停止”三种状态。从运行到停止不是断电,而是先取消任务、再清理资源,最后才拉到停止状态。
  1. 运行状态事件循环正在轮转,服务于就绪队列和 IO 事件。
    running\text{running}
  2. 取消全部任务当主协程结束,循环向所有未完成任务发出取消信号。
    →cancel all\xrightarrow{\text{cancel all}}
  3. 停止中状态循环进入 stopping,等待任务们响应取消并善后。
    stopping\text{stopping}
  4. 资源清理关闭异步生成器、释放 selector 资源,收尾各种善后工作。
    →cleanup\xrightarrow{\text{cleanup}}
  5. 停止状态循环彻底归于平静,一切资源释放完毕。
    stopped\text{stopped}

AI 应用场景

高并发 AI 推理网关

阶段:模型服务部署与请求调度

解决的问题:在 AI 应用对外提供大模型推理接口时,如果每个请求都占一个线程,线程在等 GPU 或上游模型返回时会白白占着系统资源。事件循环单线程却能同时挂起成千上万个等待中的请求——谁的结果先回来了就继续处理谁。

具体案例:一家公司用 FastAPI 部署统一推理网关,所有业务线共享同一个大模型服务。网关内部用 asyncio 管理并发请求:用户发起调用后,协程将请求转发给推理后端,在等待响应期间让出控制权,事件循环立刻处理下一个请求。高峰期 2000 并发连接,应用进程的线程数却始终没有明显增长。

大规模数据爬虫

阶段:训练数据采集与语料获取

解决的问题:训练数据集往往需要从海量网页或公开 API 抓取。同步爬虫发起一个 HTTP 请求后只能干等网络回包,抓一千个网址就慢如蜗牛。事件循环把“发起请求”和“等待响应”拆开,循环在等待间隙同时发起其余请求,整体耗时从“所有请求时间累加”变为“最慢一次请求的时间”。

具体案例:数据团队要采集 10 万条商品评论用于训练情感分析模型。使用 aiohttp + asyncio 并发抓取,每个协程负责若干个 URL,事件循环统一调度。实测采集时长从同步方案的 2 小时缩短到不到 10 分钟,且整个进程只占几个线程。

实时训练监控告警

阶段:训练任务状态监测与异常上报

解决的问题:大模型训练动辄数天,需要实时观察 loss 变化和资源指标。传统轮询每几秒查询一次,既浪费资源又可能错过瞬时异常。事件循环的事件驱动机制,配合 WebSocket 推送,让监控系统在事件发生的瞬间就能触发回调及时告警。

具体案例:训练平台搭建了一个异步监控服务,通过 websockets 库维持与各训练节点的长连接。每个节点是一个协程,节点发来指标就更新,连接断开就立刻标记异常。事件循环同时管理数百个训练任务,节点状态几乎零延迟地汇聚到监控大屏。

Python 演示

python21 行
import asyncioimport time async def fetch_data(name, delay):    # 模拟网络/IO等待, 这是协程让出控制权的契机    print(f"{time.strftime('%H:%M:%S')} [{name}] 发起请求, 需等待 {delay}s")    await asyncio.sleep(delay)    print(f"{time.strftime('%H:%M:%S')} [{name}] IO完成, 协程被恢复")    return f"{name}-data" async def main():    start = time.perf_counter()    t1 = asyncio.create_task(fetch_data("任务A", 2))    t2 = asyncio.create_task(fetch_data("任务B", 1))    t3 = asyncio.create_task(fetch_data("任务C", 3))    results = await asyncio.gather(t1, t2, t3)    print(f"总耗时: {time.perf_counter() - start:.2f}s")    print("结果:", results) if __name__ == "__main__":    asyncio.run(main())
目的:用三个模拟耗时任务演示事件循环的调度核心——总耗时不是三个任务耗时相加,而是近似等于最久任务的耗时,因为等待被重叠了。
  1. async def fetch_data(name, delay):定义协程函数;注意调用它并不会执行函数体,只是生成了一个协程对象。
  2. await asyncio.sleep(delay)主动挂起当前协程,把控制权交还给事件循环,这是让出 CPU 的关键动作。
  3. t1 = asyncio.create_task(fetch_data("任务A", 2))把协程包装成 Task 并注册到事件循环,Task 交由循环调度。
  4. results = await asyncio.gather(t1, t2, t3)同时等待三个任务完成,事件循环在这期间轮流服务每个任务。
  5. asyncio.run(main())创建事件循环、运行主协程、结束后自动取消剩余任务并清理关闭循环。

常见误区

误区用了 asyncio 就等于开了很多线程,任务会同时执行,所以异步一定比多线程快。

正解事件循环在单线程内轮转调度,同一瞬间永远只有一个任务在跑。它快在“等待重叠”而不是“计算并行”。对 IO 密集型任务,它能用极少的线程支撑大量并发;对 CPU 密集型计算,单线程的事件循环反而可能比多线程还慢,因为 GIL 本身就限制了 Python 线程并行。

为何常见:大家看到“超高并发”就联想到并行,误以为 asyncio 是自动打开了成百上千个线程,忽略了它其实只是“不等菜、去端水”。

误区`async def f(): ...` 定义了协程,然后我直接写 `f()` 就能执行它里面的代码,循环会自动调度。

正解直接调用 f() 只返回一个协程对象,函数体一行也不会执行。必须用 await f() 或 asyncio.create_task(f()) 把它交给事件循环,它才会真正开始运行。这就像菜单要递给小张才会送进厨房,放在口袋里永远只是张纸。

为何常见:普通函数调用即执行的习惯太根深蒂固,初学者很难意识到“协程对象”和“任务”之间需要一道调度注册的工序。

误区既然事件循环很聪明,那我在协程里用 `time.sleep(1)`、读写大文件、跑个 CPU 密集计算应该也没事吧?

正解事件循环是协作式调度,协程不主动让出控制权,循环就没机会服务别人。time.sleep(1) 会阻塞整个进程,让所有并发任务卡死 1 秒。正确的做法是使用 asyncio.sleep,或者用 asyncio.to_thread 把阻塞操作丢到线程池里,避免挡住循环。

为何常见:大家以为“异步”是运行时自动检测并转移控制权,根本没意识到异步的主动权其实握在协程自己手里。

误区主协程结束,程序退出,事件循环也关了,那些还没跑完的任务自然就被清掉了,不用特殊处理。

正解如果在循环关闭时还有 pending task,asyncio 会报警告 Task was destroyed but it is pending,任务背后的资源可能没有释放。正确做法是:主动取消所有剩余任务,await 它们确认取消完成,再关闭异步生成器,最后才关闭循环。这就像打烊时先把客人送走,再熄灯锁门。

为何常见:很多入门教程只用 asyncio.run,它内部已经完成了清理动作,导致大家忽略了手动关闭循环时的收尾细节。

面试题

Q1. 在 asyncio 中,事件循环的主要职责是什么?

单选题 · easy

  • A.创建多线程并行执行任务
  • B.轮流调度协程和事件回调← 正确答案
  • C.阻止任何任务被主动中断
  • D.替代操作系统管理 CPU 指令

答案:B。事件循环是单线程内的调度中枢,它通过轮转把控制权交给不同的协程和回调。A 错在“多线程并行”,事件循环本身是单线程;C 错在它并不阻止中断,反而依赖 await 主动让权;D 错在它运行在操作系统之上,不能替代操作系统。好比小张再忙也只是一个人排班,不可能真分身成六个服务员。

Q2. 下列哪种方式能让协程被事件循环真正调度执行?

单选题 · easy

  • A.调用协程函数并保存返回值
  • B.直接把协程函数当作普通函数
  • C.用 await 或 create_task 提交← 正确答案
  • D.打印协程对象观察内部结构

答案:C。只有 await 一个协程,或者用 create_task 把它注册为 Task,事件循环才会调度它。A、B、D 都只是创建了协程对象,函数体不会执行。这就像菜单写好了但没有递给服务员,厨房永远不会收到订单。

Q3. 下列哪些场景适合用事件循环模型?

多选题 · mid

  • A.海量网络 HTTP 请求并发← 正确答案
  • B.复杂图像渲染的密集计算
  • C.高并发爬虫抓取网页正文← 正确答案
  • D.视频转码等 CPU 密集型工作

答案:AC。事件循环擅长 IO 密集型任务,网络请求、爬虫抓取都是典型的“等待 IO”场景,可以让等待重叠。B 和 D 是 CPU 密集型,单线程事件循环不仅没有并行优势,反而因切换而降低效率。这和小张“不等菜去端水”同理:只有存在等待,才有利用等待的价值。

Q4. 在 Python 异步编程中,Task 与协程的关系是什么?

单选题 · mid

  • A.协程是 Task 的底层驱动实现
  • B.Task 是协程的调度包装器← 正确答案
  • C.两者彼此独立没有任何关联
  • D.协程必须先转换为线程再运行

答案:B。create_task 把一个协程对象包装成 Task,Task 注册进事件循环并可以被取消、查询状态。本质上 Task 是协程与事件循环之间的“适配器”。A 说反了,协程是原始对象,Task 是包装;C 显然错误;D 错在协程不需要转成线程。

Q5. 关于事件循环中的阻塞调用,下列说法正确的是?

单选题 · mid

  • A.只会阻塞当前协程,不影响其他
  • B.只要时间够短就绝对没有危害
  • C.会阻塞整个循环导致无法调度← 正确答案
  • D.只有文件操作会阻塞事件循环

答案:C。事件循环是单线程协作式调度,任何一个协程里的同步阻塞都会卡住整个循环,其他任务全部停摆。时间短也只是程度问题,不是性质问题。D 错在阻塞来源远不止文件操作,网络同步库、 time.sleep 都会阻塞。就像小张被一位客人拉住聊十分钟,其他桌全部没人管。

Q6. 使用 asyncio.run() 时,它内部完成了哪些动作?

多选题 · mid

  • A.创建新的事件循环实例← 正确答案
  • B.运行传入的协程直到结束← 正确答案
  • C.自动关闭系统所有线程池
  • D.清理任务并关闭事件循环← 正确答案

答案:ABD。asyncio.run 是标准的“一条龙”入口:创建循环、跑完主协程、取消并等待剩余任务、关闭异步生成器和循环。它不会去关闭全局线程池,因为线程池可能在其他地方被共享。这就像小张开店:开门、迎客、打烊收尾一条龙,但不会去管隔壁餐厅的厨房。

Q7. 事件循环中定时任务底层采用什么数据结构组织?

单选题 · hard

  • A.先进先出的普通链表队列
  • B.按到期时间排序的最小堆← 正确答案
  • C.以哈希表存储的字典结构
  • D.后进先出的调用栈结构

答案:B。asyncio 的定时器用最小堆,保证堆顶永远是最早到期的事件,事件循环只需盯着堆顶就能确定下一次醒来的时间。A 的链表无法高效取出最小值;C 的哈希表适合查找不适合排序;D 的栈结构与定时先后顺序完全不符。就像小张的预约本,按时间先后排好才能不误事。

Q8. 下列哪些操作会让协程主动让出控制权?

多选题 · hard

  • A.await asyncio.sleep(1)← 正确答案
  • B.await 一个未完成的 Future← 正确答案
  • C.在协程中调用 time.sleep(1)
  • D.执行一段密集的 for 循环计算

答案:AB。await 是让出控制权的唯一方式,无论是 await sleep 还是 await 未完成的 Future,协程都会挂起并交还控制权。C 是同步阻塞,不会“让权”,而是强制霸占整个循环;D 的 for 循环没有 await,同样不会让权。这就是协作式调度的关键差异。

Q9. 事件循环关闭时,若还有 pending 状态的任务,会发生什么?

单选题 · hard

  • A.任务自动转移到新循环继续执行
  • B.任务被系统静默回收并丢弃
  • C.会抛警告并遗留未处理资源← 正确答案
  • D.事件循环自动等待全部任务完成

答案:C。pending task 在循环关闭后没有机会执行,asyncio 会输出 Task was destroyed but it is pending 的警告,任务关联的协程和 IO 资源可能没有被妥善释放。A 错在任务不会迁移;B 的“静默回收”恰恰是危险所在;D 错在关闭流程不会自动等待所有后台任务。

Q10. 关于事件循环与线程的区别,正确的说法有哪些?

多选题 · extreme

  • A.事件循环由协程在 await 处主动让权← 正确答案
  • B.线程切换时机由操作系统决定← 正确答案
  • C.事件循环可抢占正在执行的任务
  • D.Python 多线程可并行执行 CPU 计算

答案:AB。事件循环是协作式,何时让权由 await 决定;线程是抢占式,切换时机由操作系统掌控。C 说反了,事件循环从不抢占;D 错在 CPython 的 GIL 限制了多线程并行执行 CPU 计算。比喻来说:小张等客人说“点菜”才过去服务,而操作系统则可能随时把某个服务员换去另一桌。