理解Python中的协程,我们需从其底层原理开始,逐步深入。协程的核心在于控制流的非阻塞式管理,它允许在单一线程内实现并发处理,通过事件循环和协作式多任务来提高效率。
生成器基础
生成器简介
协程的前身是生成器(Generator)。 生成器是Python的一种特殊函数,它允许在执行过程中暂停并保留状态,以便稍后从同一位置继续执行。
生成器通过yield关键字实现,它在函数体内作为返回值使用,而不是return。当yield被执行时,生成器会暂停执行,并将当前状态(包括局部变量和执行位置)保存起来。 当再次调用next()或send()时,生成器会从上次暂停的地方继续执行。
生成器的这一特性为协程提供了基础。
# 例:
def simple_generator():
print("Starting")
yield 1
print("Continuing")
yield 2
gen = simple_generator()
print(next(gen)) # 输出 "Starting" 和 1
print(next(gen)) # 输出 "Continuing" 和 2
上下文切换
生成器的上下文切换是通过Python解释器内部的实现来完成的。简化流程如下:
1、生成器创建
当调用一个包含yield的函数时,Python不会立即执行函数体,而是返回一个生成器对象。
2、第一次调用next()
当首次调用next(generator)或通过for循环迭代生成器时,解释器会开始执行生成器函数,直到遇到第一个yield语句。此时,生成器函数暂停执行,yield表达式的右侧值被返回给调用者,生成器的状态被保存下来。
3、上下文切换
生成器的上下文包括局部变量、执行堆栈和程序计数器(指示执行位置)。当生成器暂停时,这些信息都被保存在生成器对象中。 当再次调用next()或send()时,Python解释器会恢复生成器的上下文,就像从未离开过yield一样。
4、send()方法
除了通过next()恢复执行,还可以使用generator.send(value)来传递一个值给生成器。当生成器暂停在yield表达式时,send()方法可以将传入的值设置为yield表达式的值,然后继续执行。
5、生成器结束
当生成器函数执行完毕或遇到return语句时,会引发StopIteration异常,表示生成器完成。 如果在yield之后没有更多的代码,或者return语句没有返回值,StopIteration的value属性为None。如果return语句有返回值,StopIteration的value属性将是该返回值。
生成器小结
生成器的上下文切换是Python解释器在内部自动处理的,程序员不需要直接操作。这种机制使得生成器能够有效地处理复杂的迭代逻辑,同时保持较低的内存开销,因为它只在需要时才计算值。在实现协程时,生成器扮演了关键角色,通过asyncio库将生成器转化为异步操作的协程。
协程
协程的定义
Python 3.5引入了async和await关键字,使得协程的定义和使用更加直观。一个使用async def定义的函数是一个协程函数,它返回的是一个协程对象,而不是直接执行。
# 例:
async def coroutine_example():
print("Coroutine started")
await asyncio.sleep(1) # 模拟异步操作
print("Coroutine finished")
# 使用asyncio.run或事件循环来执行
# asyncio.run(coroutine_example())
异步I/O与事件循环
事件循环(Event Loop): 是协程执行的核心,它负责调度任务,监听和处理事件(如I/O完成事件)。
await关键字: 当遇到await关键字时,协程会暂停,将控制权交还给事件循环,直到等待的异步操作完成。
协程的执行流程
1、定义协程函数: 使用async def关键字定义一个协程函数。这个函数可以包含await表达式,用来挂起协程的执行。
2、创建协程对象: 调用协程函数不会立即执行函数体,而是返回一个协程对象。
# 例:
async def my_coroutine():
# 协程体
pass
coro_obj = my_coroutine()
3、启动协程: 协程对象需要被事件循环调度才能开始执行。这通常通过将协程对象转换为任务(Task) 并提交给事件循环完成
# 例:
loop = asyncio.get_event_loop()
task = asyncio.create_task(coro_obj)
loop.run_until_complete(task)
或者使用asyncio.run()函数(Python 3.7+):
asyncio.run(my_coroutine())
3、挂起与恢复、
(1)当协程执行到await表达式时,它会暂停执行并将控制权返回给事件循环。
(2)事件循环可以在此期间调度其他协程或处理其他任务。
(3)当await等待的异步操作完成时,事件循环会恢复协程的执行。
4、结束协程
(1)协程执行完毕后,会自动返回。如果协程函数没有return语句,返回值默认为None。
(2)如果协程是作为任务运行的,事件循环会检测到任务完成,并更新相关状态。
5、错误处理: 如果协程中发生异常,事件循环可以捕获并处理这些异常,或者根据配置将异常传播给调用者。
整个流程的关键在于asyncio的事件循环,它负责调度协程的执行,确保在等待I/O或其他异步操作时,可以执行其他任务, 从而实现非阻塞的异步执行。
内部实现原理
1、协程对象
实际上是一个可迭代的对象,但具有额外的调度信息,使得事件循环可以管理它的执行。要执行这个协程,需要使用事件循环,比如通过asyncio.create_task()或直接在事件循环中运行。
2、堆栈帧
堆栈帧是Python执行模型中的一个重要组成部分,它描述了函数调用的上下文,包括局部变量、调用参数、返回地址等。在同步执行中,每当函数调用发生时,一个新的堆栈帧会被压入调用栈;函数返回时,对应的堆栈帧被弹出。对于协程,情况稍微复杂一些,因为协程可以暂停执行(通过await),此时它的状态(包括局部变量)会被保存在堆栈帧中,直到被事件循环再次激活。
3、协程调度
协程调度指的是事件循环如何管理协程的执行流程。Python的asyncio库维护了一个事件循环,这个循环负责监控和调度所有的协程任务。当协程遇到await表达式时,它会暂停当前的执行上下文(保存堆栈帧的状态),释放CPU控制权给事件循环。事件循环会检查是否有其他就绪的协程(即不需要等待I/O或其他外部事件的协程),如果有,就会切换到那个协程继续执行。这一过程被称为上下文切换,使得多个协程可以在单一线程中并发执行,提高了效率。
简而言之,协程对象是异步操作的表示,堆栈帧存储了协程执行的上下文信息,而协程调度则是事件循环根据协程的状态 自动进行的控制流管理,使得异步操作能够高效、有序地执行。
进程、线程和协程
1、进程(Process)
(1)资源分配单位: 进程是操作系统分配资源的基本单位,每个进程都有自己独立的内存空间(称为地址空间),包括代码、数据、堆和栈等。
(2)安全性: 进程间的数据是隔离的,通过进程间通信(IPC)来交换信息,这提供了较高的数据安全性。
(3)开销: 创建和销毁进程的开销较大,上下文切换(从一个进程到另一个进程)涉及到内存映射、寄存器状态、文件描述符等的保存和恢复,因此开销也较高。
(4)并发性: 操作系统调度进程进行并发执行,但同一时刻只有一个进程在执行(除非在多核CPU上)。
(5)独立性: 进程有自己的生命周期,不受其他进程影响,即使一个进程崩溃,也不会直接影响其他进程。
2、线程(Thread)
(1)轻量级: 线程是进程内的执行单元,共享进程的内存空间,创建和销毁线程的开销比进程小。
(2)并发性: 同一进程内的多个线程可以并发执行,尤其是在多核处理器上,可以充分利用硬件资源。
(3)上下文切换: 线程间切换的开销比进程切换小,因为它们共享内存,只需要保存和恢复少量寄存器状态。
(4)共享资源: 线程间可以直接访问共享内存,但也带来了数据竞争和同步问题,需要使用锁、信号量等机制来保护共享数据。
(5)风险: 一个线程的崩溃可能会影响整个进程。
3、协程(Coroutine)
(1)用户级调度: 协程是由用户代码控制的,而不是操作系统。它们在单个线程内切换,不需要操作系统级别的上下文切换。
(2)轻量级: 协程的创建和切换开销非常小,因为它们不涉及操作系统级别的资源分配。
(3)无共享状态: 协程通常设计为非共享状态,避免了锁和同步机制,减少了竞态条件的风险。
(4)合作式调度: 协程的执行依赖于彼此的协作,一个协程必须通过yield或await显式地让出控制权,才能切换到另一个协程。
(5)并发性: 在单线程中,协程可以实现逻辑上的并发,但不是真正的并行执行,因为它们仍受制于CPU的一个核。
总结来说,进程提供了资源隔离和安全性,但开销大且并发受限;线程在同一个进程内提供了更高的并发性,但需要同步机制来保证数据安全;协程则提供了一种轻量级的并发模型,适合于I/O密集型任务,减少了上下文切换的开销,但需要程序员手动管理执行流程。
协程示例
import asyncio
async def loop100():
for i in range(100):
print("loop100:" + str(i))
await asyncio.sleep(0.1)
async def loop10():
for i in range(10):
print("loop10:" + str(i))
await asyncio.sleep(0.2)
async def main():
await asyncio.gather(loop100(), loop10())
asyncio.run(main())
执行上述代码会发现,loop100和loop10是并发执行的。由于loop100的循环次数多于loop10,且每次循环的等待时间短于后者,因此在输出中,将看到loop100的打印信息穿插在loop10的打印信息之间,且整体上loop100的打印频率更高。
这种并发执行模式提高了程序处理I/O密集型任务的效率,而不会增加额外的线程或进程开销。
Python协程事件循环与Nodejs事件循环
Python的协程和Node.js的事件循环虽然都是用于处理异步编程,但它们的实现方式和使用场景有所不同。
Python协程与事件循环(asyncio)
1、协程(Coroutines)
(1)Python 3.5及以上版本引入了async和await关键字,用于定义和使用协程。
(2)协程是用户级别的轻量级线程,由程序员控制执行流程,通过await关键字挂起和恢复。
(3)asyncio库提供了事件循环(Event Loop)来调度协程的执行,它负责处理I/O事件和协程之间的切换。
2、事件循环(Event Loop):
(1)Python的事件循环基于asyncio库,它负责监听I/O事件,如网络套接字的读写准备就绪。
(2)当一个协程遇到await并等待某个异步操作时,事件循环会切换到其他协程,直到等待的操作完成。
(3)asyncio库还提供了create_task等方法来创建和管理协程任务。
Node.js事件循环与异步编程
1、事件驱动编程:
(1)Node.js 采用事件驱动模型,事件循环是其核心,处理异步I/O事件。
(2)异步操作通常通过回调函数、Promise 或 async/await 语法实现。
2、事件循环(Event Loop):
(1)Node.js的事件循环是基于libuv库实现的,libuv是一个跨平台的库,处理I/O复用、线程池等。
(2)事件循环分为多个阶段,如定时器、I/O完成、检查等,每个阶段处理特定类型的回调。
3、异步处理:
(1)Node.js的异步I/O操作不会阻塞事件循环,而是立即返回,等待事件完成后再执行回调。
(2)async/await语法在Node.js中也是基于Promise实现的,提供了更友好的异步编程模型。
对比分析
1、控制流
Python协程允许程序员控制执行流程,而Node.js的异步编程通常由事件驱动。Python的asyncio库提供了更多的控制级别,如Task对象可以被取消,而Node.js的异步操作一旦启动,通常难以中断。
2、并发模型
Python协程在单线程中运行,但可以实现并发执行,而Node.js的事件循环也在单线程中,也可以处理并发的I/O操作。Node.js 通过工作线程(Worker Threads)支持CPU密集型任务的并行处理,而Python协程主要处理I/O密集型任务。
3、错误处理
Python的asyncio库提供了try/except来处理协程中的错误,而Node.js的异步错误通常通过try/catch或Promise.catch来捕获。
4、复杂性
Python的协程相对简单,但需要理解async和await的工作原理。Node.js的异步编程更复杂,特别是回调函数的嵌套可能导致回调地狱,而Promise和async/await则缓解了这个问题。
作者:Clarify
链接:https://juejin.cn/post/7376073065333588005