查看: 67|回复: 0

深入解析Python事件循环:asyncio核心调度与run_forev

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
事件循环(Event Loop)是asyncio的调度核心。很多人初学Python异步时被回调、协程、队列绕晕,其实用“厨师做菜”来类比就能快速掌握它的设计骨架。本文不展开async/await语法,只聚焦事件循环本身:它的角色、两个队列、run_forever循环结构,以及如何注册回调并驱动执行。

一、为什么需要事件循环

假设你同时收到三个订单:煮汤需要30分钟,切菜需要5分钟,炒菜需要10分钟。同步写法是先把汤炖好,期间站在灶台前干等,再去做下一个任务。代码层面就像这样:
  1. import requests
  2. def task1():
  3.     # 模拟阻塞请求,比如提交订单
  4.     requests.post("http://example.com/submit")
  5. def task2():
  6.     print("do something")
  7. task1()  # 这里阻塞一段时间,task2只能排队
  8. task2()
复制代码

问题很明显:等待I/O时线程被白白占用,CPU没有充分运转,后续任务全部被堵住。异步的思路则是:把汤放上灶,设定一个闹钟,然后继续切菜、炒菜;闹钟响时再回来处理汤。同样的一个人,不再傻等,就能同时推进多个任务。事件循环就是那个“厨师”,它负责安排“什么时候做什么事”。

对应关系如下:

- 厨师:事件循环(Event Loop)
- 订单:任务/回调函数
- 设闹钟:call_later(秒数, 回调)
- 马上做:call_soon(回调)
- 闹钟响了,汤好了:任务进入就绪(ready)队列,等待被执行

因此事件循环的核心逻辑可以用一句话概括:反复检查有没有该做的事,有就做,没有就等。

二、两个队列:就绪队列与延时队列

事件循环内部维护两个“篮子”:

- ready队列:存放马上可以执行的回调
- 延时队列:存放还没到时刻的回调,每个项都有时间信息

每一轮循环做的事情很简单:

1. 查看延时队列中是否有到期任务,有就移动到ready队列
2. 把ready队列中的任务全部执行完
3. 回到第1步,直到有人调用stop()

延时队列通常按到期时间排序,队首就是最接近到期的任务。当没有I/O事件要监听且延时队列非空时,事件循环会算出需要等待的时间,而不是空转浪费CPU。

三、Python中事件循环对象的基本操作

在asyncio中,事件循环是AbstractEventLoop的实例。常用操作包括创建、绑定、获取和停止:
  1. import asyncio
  2. # 创建一个新的事件循环对象
  3. loop = asyncio.new_event_loop()
  4. # 绑定事件循环到当前线程
  5. asyncio.set_event_loop(loop)
  6. # 获取当前线程的事件循环
  7. current_loop = asyncio.get_event_loop()
  8. print("当前事件循环:", current_loop)
  9. # 移除绑定
  10. asyncio.set_event_loop(None)
  11. # 运行事件循环,会陷入循环,直到内部调用stop()或手动停止
  12. current_loop.run_forever()
  13. # 停止事件循环
  14. current_loop.stop()
复制代码

需要特别注意:run_forever()除非被stop()中断,否则不会返回。也就是说在run_forever之后写代码需要确保已有回调能触发停止逻辑,否则会一直卡住。

四、run_forever的内部结构

事件循环本质上是一个while True往返循环。源码级别的逻辑等价于:
  1. def run_forever(self):
  2.     """Run until stop() is called."""
  3.     while True:
  4.         self._run_once()
  5.         if self._stopping:
  6.             break
复制代码

真正的工作在_run_once()中完成。每跑一圈,它负责以下几件事:

1. 检查延时队列,把到期的回调移入ready队列
2. 计算本轮阻塞等待I/O的超时时间:
   - ready队列已有任务:timeout为0,立刻处理,避免延迟
   - 延时队列还有任务:timeout = 队首到期时间 - 当前时间
   - 两个队列都为空:timeout为None,即一直等待I/O就绪
3. 用这个timeout去等待I/O事件;期间某个socket文件描述符就绪,就把对应回调加入ready队列
4. 如果等到超时仍没有I/O,则再处理一次延时队列,保证定时任务不会因无I/O而“失约”
5. 复制一份ready队列,然后依此执行这些回调

这样一轮循环就结束了。由此可见事件循环的设计目标不是“多线程并行”,而是用单线程配合事件通知机制,在合适的时机做合适的任务,避免无意义的忙等。

五、亲手验证call_soon与call_later

通过两个简单回调,可以直观看到事件循环如何调度“马上做”和“延后做”:
  1. import asyncio
  2. loop = asyncio.new_event_loop()
  3. # 这个函数会直接进入ready队列,下一轮循环就执行
  4. def my_ready_callback():
  5.     print("这是一个直接进入ready队列的回调函数")
  6. # 这个函数进入延时队列,1秒后到期才执行
  7. def my_scheduled_callback():
  8.     print("这是一个延迟1秒后进入ready队列的回调函数")
  9.     loop.stop()  # 执行完毕,停止事件循环
  10. loop.call_soon(my_ready_callback)
  11. loop.call_later(1, my_scheduled_callback)
  12. loop.run_forever()
  13. print("事件循环已停止")
复制代码

执行顺序预期是:先打印ready回调,然后等待约1秒打印延时回调,再调用loop.stop()使run_forever退出。如果不调用stop,run_forever会一直运行。

这个例子虽然简单,但它揭示了事件循环的两条基本API线:call_soon用于把可立即执行的任务放入ready队列;call_later用于注册延迟任务。两者的底层都会将回调封装成带有时间信息或句柄的事件,最终交由_run_once调度。

六、理解事件循环对调试异步代码的意义

在实际开发中,很多人遇到“asyncio.run()中途报错”、“回调不执行”、“协程卡死”等问题,往往就是因为对事件循环的运转机制不清楚。例如,在事件循环运行时,主线程去调用同步阻塞I/O操作,会阻塞整个轮子,导致所有定时任务和网络回调无法准时触发。再比如,多个线程各自创建事件循环时,必须在每个线程中分别获取和设置对应的事件循环,不能跨线程直接操作。

掌握事件循环的两队列模型后,再学习async/await语法、Executor、传输层协议等知识,就会顺畅很多。底层无论包了多少层,最终都是围绕“反复检查该做的事”这个永不停止的轮子在转。

七、小结

- Python事件循环是一个AbstractEventLoop对象,负责调度回调任务
- 就绪队列存放立即执行的任务,延时队列存放尚未到期的任务
- run_forever的本质是while True调用_run_once,直到被stop
- _run_once负责迁移到期任务、计算等待时间、处理I/O事件、执行就绪回调
- call_soon将任务放入就绪队列,call_later将任务放入延时队列

理解了这些,再回头去看asyncio源码:事件循环的调度会越来越清晰。它不依赖复杂的线程锁,只靠“队列+时间检查+selector等待”就完成了高并发的I/O模型,这也是Python异步编程最核心的底层逻辑。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-9-3 12:00 , Processed in 0.023803 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部