午市高峰,“幸福小馆”里坐满六桌客人,却只有一个服务员小张。你可能会想:他肯定手忙脚乱。可奇怪的是,小张从不发呆等任何一桌——A桌刚点完菜,他转身给B桌送水;B桌还没想好,他又顺路把C桌的账单递过去。客人眼里的小张仿佛有三头六臂,其实他只是懂得一个道理:菜在厨房里做的时候,站着等是最蠢的选择,不如先去服务别人。
这个道理,就是异步编程的灵魂。Python 里的 asyncio 就是那个“小张”,而它赖以工作的中枢,就是事件循环(Event Loop)。事件循环运行在单线程里,却能让成百上千个网络请求看起来在“同时进行”——秘诀不是分身,而是把等待的时间利用起来。这篇文章会用“小张的一天”贯穿始终,带你从零看懂事件循环到底怎么排队、怎么调度、怎么启动和关闭。
事件循环是 asyncio 的心脏,也是 Python 高并发服务的地基。理解它,你才能真正写出不阻塞、不出 Bug 的异步代码,也为排查线上诡异问题提供了关键视角。
可视化演示
展示事件循环四阶段轮转(就绪→执行→IO等待→回调)、任务队列与回调优先级调度、以及IO多路复用epoll机制三大核心维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Event Loop 循环流程:就绪队列→执行协程→IO等待→回调处理,四阶段持续轮转 队列优先级:就绪队列FIFO,回调按优先级排序,定时器按时间堆 epoll_wait(fd_set) → ready_fds IO多路复用:epoll监听多个fd,仅返回就绪事件,单线程高并发
循环本质
小张是“幸福小馆”唯一的服务员。午市高峰,六桌客人同时落座,他一个人点菜、端茶、结账、翻台。你可能会问:一个人怎么能同时服务三桌?其实原因很简单——他不会在一桌客人想菜谱的时候干站着。A桌刚报完菜名,他立刻去给B桌送免费的柠檬水;B桌说稍等,他又去把C桌的账单递上。在客人眼里,小张好像有三头六臂;实际上,他只是把“等待”的时间全用来“做别的事”了。
把餐厅换成进程,“小张的三头六臂”就是事件循环。它运行在单线程里,却能让许多任务都获得“被同时照顾”的感觉。实现这种错觉的机制,是一个永远在奔跑的循环:检查有没有新事件,有就处理,没有就继续转。正因为只有一颗“大脑”,同一瞬间它永远只做一件事,却能在极短的时间片里不断更换服务对象,让所有任务都觉得“服务员就在我身边”。
事件循环的本质是单线程 + 事件驱动的调度器。它区别于多线程:线程是雇二十个服务员,每个服务员盯一桌,哪怕那桌客人都在发呆也得站着;事件循环是把小张训练到极致,让他一个人利用每个客人的“等待间隙”穿插服务。编程上,它不需要操作系统做代价高昂的线程切换,只靠函数级的跳转就能完成换桌服务。这也是为什么 asyncio 能以单线程支撑成千上万个网络连接。
所以小张的秘诀从来不是分身术,而是“不等菜,去端水”。事件循环的价值也完全一样:它不创造更多 CPU,只是把原本要被空耗的 IO 等待时间,转变成了服务其他任务的有效时间。理解了这一点,你就握住了整个异步编程的总钥匙。
- 单个任务耗时一个协程从开始到结束,可以分为两段——真正消耗 CPU 的计算段,以及等待网络或磁盘返回的 IO 段。
- 串行总耗时如果用一个线程逐个执行任务,总时间就是每个任务的 IO 时间加 CPU 时间全部相加。
- IO 重叠事件循环把所有任务的 IO 等待重叠起来,这一部分只算最慢的那一个。
- CPU 不可重叠单核在某个瞬间只能执行一段计算,所以所有 CPU 时间必须累加。
- 加速比当 IO 时间占绝对主导时,事件循环的加速比约等于并发任务数。
任务排队
小张之所以能同时照顾六桌,除了腿快,更重要的是他手里有一个分类清晰的记事本。哪桌在等上菜,哪桌刚坐下还在翻菜单,哪桌预约了十五分钟后的甜品,他都记得一清二楚。餐厅里运转顺畅,靠的不是一股脑地瞎忙,而是合理的排队和分类。
事件循环里也有三个核心的“分类本”。就绪队列记录的是“现在就能服务”的任务——它们刚被唤醒,随时可以执行;定时器队列记录的是“到点才能叫”的任务,比如 asyncio.sleep(2) 就是给自己写了一张“两秒后请叫我”的便条;IO 等待队列则登记了所有正在等网络数据、等文件读取的任务,它们暂时和小张无关。
在 asyncio 的具体实现中,就绪队列是一个 FIFO 结构,loop.call_soon 注册的回调和被唤醒的 Task 都会进这里;定时器队列用最小堆按到期时间排序;IO 等待队列则托管给 selector(如 epoll),由操作系统内核帮忙盯着“哪个网络连接来数据了”。事件循环每一轮都先取就绪队列里最老的任务执行,再去看看定时器有没有到点、IO 有没有就绪。
记事本分得清,小张才能同时盯住六桌而不慌乱。同理,事件循环把任务划分成“可执行、定时、等 IO”三类之后,调度就有了明确依据。这是整个异步系统的地基——后面的所有机制,都是在这三个队列之上盖楼。
- 总待办集合事件循环管理的所有任务,最终都归入三个大抽屉。
- 就绪队列存放已经具备执行条件的 Task 和回调,先来先服务。
- 定时器队列存放
sleep、call_later创建的定时任务,未到点不出抽屉。 - IO 等待队列存放正在等 socket 或管道数据的任务,由内核帮忙守望。
- 合并调度三个队列合起来就是事件循环的全部工作来源,循环每轮都从它们当中挑选下一步动作。
调度规则
细心观察小张,你会发现他有个职业习惯:从不去抢客人手里的菜单。客人说“我再看看”,他就微笑着退开,先去忙别的桌;等客人抬头喊“点菜”,他才立刻出现。这种“你情我愿”的协作方式,就是事件循环最核心的调度规则——协作式调度。
在 asyncio 里,事件循环永远不会强行打断一个正在运行的协程。任务只有在执行到 await 语句时,才会主动让出控制权,把 CPU 交还给事件循环。如果某个协程里放着一段同步阻塞代码,比如 time.sleep(1),整个事件循环都会卡住——因为循环永远等不到那个协程“让位”的信号。这就是为什么在异步代码里,任何阻塞调用都被视为大忌。
协作式调度也有代价:每一次让出和恢复,都是一次函数调用链的跳转。虽然这个成本远小于线程切换,但积少成多。如果任务被切得过分零碎,大量的时间反而会消耗在切换本身。好的异步代码,会把握任务粒度:既能利用 await 让出空闲,又不会让切换开销喧宾夺主。
小张不会在客人犹豫时抢走菜单,事件循环也不会在协程忙碌时横插一脚。协作式调度让异步代码变得可控:只要每个人都遵守“await 让权”的约定,整个系统就能默契地运转。也正因为如此,一句不起眼的同步阻塞调用,就足以让整家“餐厅”陷入瘫痪。
- 任务运行时间每个任务真正消耗 CPU 执行代码的时间。
- 所有任务运行时间之和因为单线程无法并行,这部分必须累加。
- 切换次数每次 await 让出、IO 完成后恢复,就产生一次切换。
- 切换成本每一次从“任务 A”跳到“任务 B”的函数调用与恢复开销。
- 总耗时把干活的时间和切换的时间加起来,就是事件循环完成所有任务的实际时长。
回调机制
小张服务客人时有个法宝:给每桌发一个“叫号铃”。顾客点完菜不用守在厨房门口,铃响了再来端菜就行。这个铃铛,就是异步世界里的回调。它把“等”和“做”彻底分开,让客人(任务)该干嘛干嘛去,铃声一响,一切继续。
在 asyncio 底层,回调是无处不在的零件。loop.add_reader(fd, callback) 会把一个回调注册到 selector 上,意思是“这个文件描述符一旦可读,就帮我叫这个人”。当内核通知 selector“数据来了”,事件循环从 select 调用中返回,把对应的回调放入就绪队列,下一轮调度时执行。这些回调中,有一部分是 Future 的 set_result,它会唤醒正在等待该 Future 的协程。
回调机制还要特别注意错误传播。回调里抛出的异常如果没有被捕获,事件循环默认会把它记入日志之后继续运行,而不是中断整个循环。这在排查问题时极具迷惑性——你以为程序还在跑,其实某个角落已经悄悄坏了。所以在业务代码里,常常要在回调的外层套上 try/except,把异常显式捕获并分类处理。
小张的铃铛响起时,客人便起身端菜;事件循环的回调被触发时,挂起的协程便恢复执行。回调机制是事件循环的“触手”——它把内核的 IO 事件翻译成 Python 世界的动作。理解回调,你就能明白为什么异步程序看起来总是“突然跳到另一个地方”继续执行。
- 文件描述符网络 socket 或文件在系统里的编号,异步 IO 的“身份证”。
- 注册回调把“文件可读时要做的事”登记到 selector 上,相当于留下电话号码。
- 选择器就绪内核通知事件循环,某个 fd 有数据可读了。
- 循环执行回调事件循环从阻塞中返回,把回调放进执行流程。
- 恢复任务回调最终唤醒等待中的协程,让其继续往下执行。
协程协同
顾客在菜单上写下“糖醋排骨”,把它递给小张。菜单本身只是一张纸,不会自己跑进厨房;只有小张接过单、送到后厨,这张纸才真正变成“一道菜”的起点。在 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,协程此刻停在某个 await 处。
- 结果到位IO 回调或定时器回调调用 set_result,让 Task 变为 done。
- 状态切换Task 从“未完成”翻转为“已完成”,是一个关键触发点。
- 唤醒等待者事件循环发现 Task 完成,将等待它的协程放回就绪队列。
- 协程恢复await 表达式最终拿到结果,协程继续执行后面的代码。
时间管理
小张的记事本里,不光有“正在服务”和“等菜中”,还有一栏专门写预约:十分钟后给三号桌加水,二十分钟后提醒八号桌该结账了。这些事不用他现在就做,但他必须记住“什么时候做”。这,就是事件循环的时间管理。
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 是“灾难现场”。
- 当前时间事件循环用单调时钟记录现在的时刻,不受用户改系统时间影响。
- 延迟增量调用
sleep(2)时传入的 2 秒,就是希望等待的时长。 - 到期时间现在加上延迟,得到这个定时事件被唤醒的目标时刻。
- 定时器堆所有定时事件构成一个最小堆,堆顶是最早到期的那个。
- 最小到期值事件循环只需盯住堆顶的时间,那就是它下一次必须醒来的截止点。
启动关闭
小张开店有一整套流程:早晨开门迎客,中午忙碌运转,晚上打烊收尾。打烊不是把正在吃饭的客人轰出门,而是先告诉顾客“厨房即将停止接单”,等他们结账离场、桌面收拾干净,最后才熄灯锁门。事件循环的“一天”,同样要经历规范的启动、运行与关闭。
在日常开发中,asyncio.run(main()) 是标准入口。它会自动创建一个新的事件循环,运行 main 协程直到它结束,然后进入收尾阶段:取消所有尚未完成的任务,等待这些任务真正取消完毕,关闭异步生成器,最后关闭事件循环。这一条龙服务,让小张(开发者)不必手动操心打烊细节。
但如果你使用底层 API,就要自己负责收尾。loop.run_until_complete(future) 只会在主协程完成后停止循环,其他后台任务可能还“悬在半空中”;直接调用 loop.close() 前,如果不先把 pending task 全部取消并等待确认,Python 会抛出 Task was destroyed but it is pending 的警告。正确姿势是:把所有任务放入 gather,统一取消,await 它们确认结束,再关闭异步生成器与循环本身。
打烊的餐厅干干净净,第二天才能重新开张;关闭得体的循环,程序才能不报错、不泄漏地退出。事件循环的启动关闭看似只是工程细节,实际上承载了健壮性的底线。真正在生产环境跑过的人都会明白:会启动循环只是入门,能优雅关闭循环才是进阶。
- 运行状态事件循环正在轮转,服务于就绪队列和 IO 事件。
- 取消全部任务当主协程结束,循环向所有未完成任务发出取消信号。
- 停止中状态循环进入 stopping,等待任务们响应取消并善后。
- 资源清理关闭异步生成器、释放 selector 资源,收尾各种善后工作。
- 停止状态循环彻底归于平静,一切资源释放完毕。
AI 应用场景
高并发 AI 推理网关
阶段:模型服务部署与请求调度
解决的问题:在 AI 应用对外提供大模型推理接口时,如果每个请求都占一个线程,线程在等 GPU 或上游模型返回时会白白占着系统资源。事件循环单线程却能同时挂起成千上万个等待中的请求——谁的结果先回来了就继续处理谁。
具体案例:一家公司用 FastAPI 部署统一推理网关,所有业务线共享同一个大模型服务。网关内部用 asyncio 管理并发请求:用户发起调用后,协程将请求转发给推理后端,在等待响应期间让出控制权,事件循环立刻处理下一个请求。高峰期 2000 并发连接,应用进程的线程数却始终没有明显增长。
大规模数据爬虫
阶段:训练数据采集与语料获取
解决的问题:训练数据集往往需要从海量网页或公开 API 抓取。同步爬虫发起一个 HTTP 请求后只能干等网络回包,抓一千个网址就慢如蜗牛。事件循环把“发起请求”和“等待响应”拆开,循环在等待间隙同时发起其余请求,整体耗时从“所有请求时间累加”变为“最慢一次请求的时间”。
具体案例:数据团队要采集 10 万条商品评论用于训练情感分析模型。使用 aiohttp + asyncio 并发抓取,每个协程负责若干个 URL,事件循环统一调度。实测采集时长从同步方案的 2 小时缩短到不到 10 分钟,且整个进程只占几个线程。
实时训练监控告警
阶段:训练任务状态监测与异常上报
解决的问题:大模型训练动辄数天,需要实时观察 loss 变化和资源指标。传统轮询每几秒查询一次,既浪费资源又可能错过瞬时异常。事件循环的事件驱动机制,配合 WebSocket 推送,让监控系统在事件发生的瞬间就能触发回调及时告警。
具体案例:训练平台搭建了一个异步监控服务,通过 websockets 库维持与各训练节点的长连接。每个节点是一个协程,节点发来指标就更新,连接断开就立刻标记异常。事件循环同时管理数百个训练任务,节点状态几乎零延迟地汇聚到监控大屏。
Python 演示
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())
async def fetch_data(name, delay):定义协程函数;注意调用它并不会执行函数体,只是生成了一个协程对象。await asyncio.sleep(delay)主动挂起当前协程,把控制权交还给事件循环,这是让出 CPU 的关键动作。t1 = asyncio.create_task(fetch_data("任务A", 2))把协程包装成 Task 并注册到事件循环,Task 交由循环调度。results = await asyncio.gather(t1, t2, t3)同时等待三个任务完成,事件循环在这期间轮流服务每个任务。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 中,事件循环的主要职责是什么?
- A.创建多线程并行执行任务
- B.轮流调度协程和事件回调← 正确答案
- C.阻止任何任务被主动中断
- D.替代操作系统管理 CPU 指令
答案:B。事件循环是单线程内的调度中枢,它通过轮转把控制权交给不同的协程和回调。A 错在“多线程并行”,事件循环本身是单线程;C 错在它并不阻止中断,反而依赖 await 主动让权;D 错在它运行在操作系统之上,不能替代操作系统。好比小张再忙也只是一个人排班,不可能真分身成六个服务员。
Q2. 下列哪种方式能让协程被事件循环真正调度执行?
- A.调用协程函数并保存返回值
- B.直接把协程函数当作普通函数
- C.用 await 或 create_task 提交← 正确答案
- D.打印协程对象观察内部结构
答案:C。只有 await 一个协程,或者用 create_task 把它注册为 Task,事件循环才会调度它。A、B、D 都只是创建了协程对象,函数体不会执行。这就像菜单写好了但没有递给服务员,厨房永远不会收到订单。
Q3. 下列哪些场景适合用事件循环模型?
- A.海量网络 HTTP 请求并发← 正确答案
- B.复杂图像渲染的密集计算
- C.高并发爬虫抓取网页正文← 正确答案
- D.视频转码等 CPU 密集型工作
答案:AC。事件循环擅长 IO 密集型任务,网络请求、爬虫抓取都是典型的“等待 IO”场景,可以让等待重叠。B 和 D 是 CPU 密集型,单线程事件循环不仅没有并行优势,反而因切换而降低效率。这和小张“不等菜去端水”同理:只有存在等待,才有利用等待的价值。
Q4. 在 Python 异步编程中,Task 与协程的关系是什么?
- A.协程是 Task 的底层驱动实现
- B.Task 是协程的调度包装器← 正确答案
- C.两者彼此独立没有任何关联
- D.协程必须先转换为线程再运行
答案:B。create_task 把一个协程对象包装成 Task,Task 注册进事件循环并可以被取消、查询状态。本质上 Task 是协程与事件循环之间的“适配器”。A 说反了,协程是原始对象,Task 是包装;C 显然错误;D 错在协程不需要转成线程。
Q5. 关于事件循环中的阻塞调用,下列说法正确的是?
- A.只会阻塞当前协程,不影响其他
- B.只要时间够短就绝对没有危害
- C.会阻塞整个循环导致无法调度← 正确答案
- D.只有文件操作会阻塞事件循环
答案:C。事件循环是单线程协作式调度,任何一个协程里的同步阻塞都会卡住整个循环,其他任务全部停摆。时间短也只是程度问题,不是性质问题。D 错在阻塞来源远不止文件操作,网络同步库、 time.sleep 都会阻塞。就像小张被一位客人拉住聊十分钟,其他桌全部没人管。
Q6. 使用 asyncio.run() 时,它内部完成了哪些动作?
- A.创建新的事件循环实例← 正确答案
- B.运行传入的协程直到结束← 正确答案
- C.自动关闭系统所有线程池
- D.清理任务并关闭事件循环← 正确答案
答案:ABD。asyncio.run 是标准的“一条龙”入口:创建循环、跑完主协程、取消并等待剩余任务、关闭异步生成器和循环。它不会去关闭全局线程池,因为线程池可能在其他地方被共享。这就像小张开店:开门、迎客、打烊收尾一条龙,但不会去管隔壁餐厅的厨房。
Q7. 事件循环中定时任务底层采用什么数据结构组织?
- A.先进先出的普通链表队列
- B.按到期时间排序的最小堆← 正确答案
- C.以哈希表存储的字典结构
- D.后进先出的调用栈结构
答案:B。asyncio 的定时器用最小堆,保证堆顶永远是最早到期的事件,事件循环只需盯着堆顶就能确定下一次醒来的时间。A 的链表无法高效取出最小值;C 的哈希表适合查找不适合排序;D 的栈结构与定时先后顺序完全不符。就像小张的预约本,按时间先后排好才能不误事。
Q8. 下列哪些操作会让协程主动让出控制权?
- 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 状态的任务,会发生什么?
- A.任务自动转移到新循环继续执行
- B.任务被系统静默回收并丢弃
- C.会抛警告并遗留未处理资源← 正确答案
- D.事件循环自动等待全部任务完成
答案:C。pending task 在循环关闭后没有机会执行,asyncio 会输出 Task was destroyed but it is pending 的警告,任务关联的协程和 IO 资源可能没有被妥善释放。A 错在任务不会迁移;B 的“静默回收”恰恰是危险所在;D 错在关闭流程不会自动等待所有后台任务。
Q10. 关于事件循环与线程的区别,正确的说法有哪些?
- A.事件循环由协程在 await 处主动让权← 正确答案
- B.线程切换时机由操作系统决定← 正确答案
- C.事件循环可抢占正在执行的任务
- D.Python 多线程可并行执行 CPU 计算
答案:AB。事件循环是协作式,何时让权由 await 决定;线程是抢占式,切换时机由操作系统掌控。C 说反了,事件循环从不抢占;D 错在 CPython 的 GIL 限制了多线程并行执行 CPU 计算。比喻来说:小张等客人说“点菜”才过去服务,而操作系统则可能随时把某个服务员换去另一桌。