事件循环(Event Loop)是 Python 异步编程(asyncio)的核心,它让单线程内的并发成为可能——无需多线程,就能同时处理大量 I/O 密集型任务(如网络请求、文件操作)。
一、为什么需要事件循环?
在同步编程中,执行一个 I/O 操作(如网络请求)会阻塞整个线程,直到操作完成。这导致程序在等待时无法处理其他任务,资源利用率低下。
事件循环通过协作式多任务解决此问题:
将耗时操作交给操作系统后台执行(非阻塞 I/O)
循环监控哪些操作已完成
在操作等待期间,切换到其他可执行的任务继续运行
这就像一个人(线程)在煮水的同时切菜、准备食材,而不用一直盯着水壶。
二、核心概念
理解事件循环前,需掌握三个核心对象:
1. 协程(Coroutine)
用async def定义的函数,调用后返回一个协程对象。协程内部可以用await挂起自身,交出控制权。
python
async def fetch_data(): print("开始请求") await asyncio.sleep(1) # 模拟 I/O 等待,让出控制权 print("数据到达") return "data"2. 任务(Task)
将协程包装成Task对象后,事件循环会调度它执行。Task继承自Future,可以监控协程的状态(运行中、完成、取消等)。
python
task = asyncio.create_task(fetch_data())
3. Future
一个低层级的占位符,代表一个尚未完成的异步操作结果。Task是Future的子类。用户通常不直接创建Future,而是通过高层 API(如asyncio.ensure_future())操作。
三、事件循环的工作流程
事件循环本质是一个无限循环,不断从任务队列中取出就绪任务执行,直到没有任务可做。其大致步骤如下:
初始化:创建事件循环对象,注册一些内部处理(如信号、子进程监控)。
运行任务:取出一个可执行的协程/回调,执行它直到它
await一个未完成的Future或主动让出。等待 I/O:当所有当前任务都被阻塞(都在等待 I/O)时,调用操作系统的I/O 多路复用(如
epoll、kqueue)来监控注册的文件描述符。激活任务:一旦某个 I/O 就绪,唤醒对应的
Future,将其关联的任务放回可执行队列。循环:重复步骤 2–4,直到任务队列空且没有待处理的 I/O 回调。
用伪代码表示:
text
while 有待处理的任务或回调: 运行所有即时可用的任务/回调 如果没有可运行的任务,则: 计算最小的超时时间(如果有定时器) 调用 select/epoll 等待 I/O 事件 或 超时 将就绪的 I/O 事件对应的 Future 标记为完成(放入可运行队列)
实际实现(asyncio用纯 Python +selectors模块)更复杂,但基本逻辑如此。
四、获取和运行事件循环
asyncio提供多种方式获取和管理事件循环:
1. 获取当前事件循环
python
loop = asyncio.get_running_loop() # 必须在协程内部调用 # 或 loop = asyncio.get_event_loop() # 可能创建新循环(不推荐在协程内使用)
最佳实践:在协程内总是用get_running_loop(),因为它会抛异常如果不在协程内,避免意外创建新循环。
2. 运行事件循环
高层 API(推荐):
python
asyncio.run(main()) # 创建循环,运行 main 协程,结束后关闭循环
asyncio.run()在 Python 3.7+ 引入,自动处理循环生命周期,是启动异步程序的标准方式。
低层手动管理(仅用于特殊需求):
python
loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(main()) finally: loop.close()
五、调度任务和回调
1. 创建并等待任务
python
async def main(): # 创建任务(立即提交给当前循环调度) task1 = asyncio.create_task(coro1()) task2 = asyncio.create_task(coro2()) # 等待所有任务完成 results = await asyncio.gather(task1, task2) # 或单独等待 await task1
create_task()将协程包装为 Task 并调度执行,返回 Task 对象。asyncio.gather()并发运行多个可等待对象,返回结果列表或抛异常。没有
await的任务不会被等待完成,可能会在循环关闭时取消。
2. 在指定时间后执行回调
使用loop.call_soon()或loop.call_later()注册普通函数(非协程)作为回调,在下次循环迭代或延迟后执行。
python
loop = asyncio.get_running_loop() loop.call_soon(lambda: print("立即在下一次迭代执行")) loop.call_later(2.0, lambda: print("2 秒后执行"))注意:回调是同步执行且不应阻塞。如需异步工作,应使用asyncio.create_task()。
3. 在指定时间执行协程
asyncio没有直接的“延迟运行协程”,但可用asyncio.sleep()配合create_task:
python
async def delayed_coro(): await asyncio.sleep(2) print("2 秒后执行协程") asyncio.create_task(delayed_coro()) # 不等待,让它在后台运行六、与同步代码的交互
1. 在同步代码中调用异步函数
使用asyncio.run()启动一个临时事件循环:
python
result = asyncio.run(async_function()) # 同步代码中运行异步函数
但注意,这会阻塞直到完成,且不能在已有事件循环中调用。
2. 在异步代码中调用同步阻塞函数
用loop.run_in_executor()将阻塞操作放到线程池或进程池中运行,避免阻塞事件循环:
python
async def main(): loop = asyncio.get_running_loop() # 默认线程池执行器 result = await loop.run_in_executor(None, time.sleep, 2) # 或自定义线程池 with concurrent.futures.ThreadPoolExecutor() as pool: result = await loop.run_in_executor(pool, blocking_io_function)
这不会阻塞事件循环,其他协程可以继续运行。
七、定时器与超时控制
1. 等待超时
asyncio.wait_for()给一个协程设置超时时间,超时则抛出TimeoutError:
python
try: result = await asyncio.wait_for(long_running_task(), timeout=5.0) except asyncio.TimeoutError: print("操作超时")2. 等待多个任务,取第一个完成
asyncio.wait()可控制等待条件:
python
done, pending = await asyncio.wait( [task1, task2], return_when=asyncio.FIRST_COMPLETED ) for task in pending: task.cancel() # 取消未完成的任务
八、事件循环的内部实现细节
Python 事件循环的底层依赖于selectors模块,它是操作系统 I/O 多路复用的封装。根据平台自动选择最佳实现:
Linux →
EpollSelectormacOS/BSD →
KqueueSelectorWindows →
IocpSelector(ProactorEventLoop使用 IOCP)
默认的事件循环是SelectorEventLoop(Windows 上默认ProactorEventLoop以支持子进程)。其核心流程:
维护一个就绪队列
_ready,存放所有已激活的Handle(包装了回调或 Task 的下一步)。循环取出
_ready中的所有 Handle 逐个执行,直到队列空。如果没有可执行的 Handle,调用
_selector.select(timeout)等待 I/O 事件,将触发的事件对应的 callback 放入_ready。同时检查定时堆
_scheduled,将到期定时器也放入_ready。重复直到
_stopping标志为真且_ready空。
Task 的调度也是通过回调实现的:Task.__step每次执行协程的一步,直到遇到await挂起,将Future的完成回调注册为Task.__wakeup,这样 Future 完成时会把 Task 放回_ready。
九、事件循环的生命周期管理
一个典型的异步程序只使用一个事件循环(asyncio.run创建的),在main()返回后关闭循环。但有时(如测试、嵌入)需手动管理:
创建新循环:
loop = asyncio.new_event_loop()设置为当前线程的循环:
asyncio.set_event_loop(loop)运行直到完成:
loop.run_until_complete(coro)关闭循环:
loop.close()释放资源。关闭后无法再使用。
重要:不要在协程内调用run_until_complete()或asyncio.run()——这会导致嵌套事件循环错误。每个线程同一时间只能有一个运行中的循环。
十、常见陷阱与最佳实践
永远不要在协程中使用阻塞调用(如
time.sleep()),它会冻结整个事件循环。应使用await asyncio.sleep()。使用
asyncio.create_task()时务必保留引用,防止任务被垃圾回收意外取消。正确取消任务:当不再需要某个后台任务时,调用
task.cancel()并await task处理CancelledError,避免资源泄漏。不要混合使用线程和协程直接共享数据,需要用
asyncio.to_thread()(Python 3.9+)或loop.run_in_executor(),并注意线程安全。使用
asyncio.gather()时要处理异常,设置return_exceptions=True可收集异常而非立即抛出。在异步生成器、异步上下文管理器中,事件循环依然在背后工作,不必显式获取循环。
调试模式:设置
PYTHONASYNCIODEBUG=1环境变量或asyncio.run(main(), debug=True)可检测慢回调、未等待的协程等问题。
十一、完整示例
一个并发下载多个 URL 的例子:
python
import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as response: return await response.text() async def main(): urls = [ "http://example.com", "http://example.org", "http://example.net" ] async with aiohttp.ClientSession() as session: tasks = [asyncio.create_task(fetch(session, url)) for url in urls] # 并发执行,等待全部完成 pages = await asyncio.gather(*tasks) for url, content in zip(urls, pages): print(f"{url} 下载完成,长度 {len(content)}") if __name__ == "__main__": asyncio.run(main())执行过程:事件循环同时监控三个 HTTP 请求,当某个请求的 socket 可读时,唤醒对应任务继续处理数据。
事件循环是 Python 异步编程的发动机,理解它的调度方式、I/O 模型和任务管理,能帮助你编写高效、正确的并发程序,避免常见的阻塞和竞态陷阱。