Python异步编程入门

一、何为异步?

说起异步模型,不得不提老生常谈的同步模型,此二者是相对的概念。

同步模型即程序必须按照顺序依次执行,当程序在执行一个需要等待外部资源的操作时(网络数据收发、文件读写),会陷入阻塞状态,只有在外部资源到位后才会继续执行。与之相反,异步模型具有非阻塞的特点,程序在等待外部资源时,会继续执行其他代码。

在3.4版本中,Python引入了对异步编程的支持,在同一个线程下通过事件循环对多个协程进行调度以实现代码片的切换,可以消除阻塞对程序性能带来的负面影响,以及线程切换带来的额外开销

在一个线程中,用户可以创建异步任务(例如协程),并交给事件循环(event loop)进行统一调度与管理。协程(Coroutines)是比线程更小一级的代码执行单位。在同一时刻,事件循环中只有一个协程处于运行状态,当该协程等待外部资源时,并不会使该线程陷入阻塞,而是交出执行权,使事件循环继续执行其他协程。以上是Python异步模型的基本内容,具体操作待到后面第三章细讲。

二、为何异步?

学习如何在Python中进行异步编程之前,请诸位三思,为什么在你的代码里选择异步?

首先,性能优势。对于我们日常使用的Python解释器而言,GIL似乎是一个绕不过去的话题(GIL详见在下另一篇博客Python多线程详解)。由于受到GIL的限制,CPU在同一时刻实际上只有一个线程在执行,并发性能大大受限。当一个线程陷入阻塞状态时,会释放GIL锁并讲执行权交由其他线程继续执行。乍一看,好像和上面我们讲的协程执行原理十分类似。

其实流程上就是极为类似,协程也是在执行过程中遇到IO操作时,切换到其他协程继续执行。但是多线程场景下,阻塞以及线程切换带来的开销却要远高于协程调度开销,而且协程占用的内存资源也远小于线程。因此使用协程,可以在一个线程中以极小的代价完成代码片的交接。这种差距,放到具体应用场景上,会带来可观的性能提升。

再者,编程体验。开发者不再需要考虑锁资源竞争与释放、死锁问题、线程同步等一系列问题,因此异步编程相对来说是一种更为简单直观的编程模型。

三、如何异步?

此节以Python标准库中异步IO库asyncio的相关操作为讲解对象。还有其他库也同样对异步编程进行支持,例如Twisted、Tornado等,感兴趣的道友事后可以去了解。

3.1 协程(Coroutines)

协程为Python的异步编程模型提供了最基本的支持。请看下面这段示例程序:

import asyncio

async def func():
	print('Hello World, Asynchronous.')
	return 114514

async def main():
	res = await func()
	print(f'main func executed.{res}')
	
>>> func() # 协程函数的调用结果是一个协程对象
<coroutine object func at 0x000001D9C01C59C0>

>>> await func() # 通过await关键字可以执行协程对象,并获取真正的返回值。
Hello World, Asynchronous.
114514

>>> asyncio.run(main()) # 隐式创建新的事件循环,并执行协程对象。
Hello World, Asynchronous.
main func executed.114514

我们通过async关键字声明了两个协程函数。协程函数的调用结果是一个协程对象。通过await关键字可以执行协程对象,并获取真正的返回值。需要注意的是,如果在一个函数内需要异步调用另一个协程函数,则该函数本身必须也是一个协程函数。此处的main就是一个协程函数,因此它可以对func函数进行调用。

我们之前提到,用户可以将协程交由事件循环去管理调度。await的原理便是如此。如果当前线程下有处于运行状态的事件循环,就将协程对象交付给其进行调度。如果没有,则会创建并启用一个新的事件循环。

与之类似,asyncio.run()也可以通过事件循环来执行协程,区别在于asyncio.run()旨在用作函数的入口点,会强制创建一个新的事件循环,因此该方法被禁止在当前线程存在其他活跃事件循环时使用,否则会抛出RuntimeError

3.2 可等待对象(Awaitables)

可以被await关键字接收处理的对象被称为可等待对象,这类对象同样可以被事件循环接收执行。虽然我们可以通过实现__await__方法自定义可等待对象,但是一般情况下不推荐这么做。大部分情况下,我们使用Python提供的三类可等待对象,分别为CoroutineTaskFuture

Coroutine对象封装为Task对象可以获得更丰富的调度能力,下面是一个示例程序:

async def func():
	print('func executed.')

# 通过asyncio将func()协程对象封装为任务对象
task1 = asyncio.create_task(func(), name='task 01')

# 等价于通过事件循环
loop = asyncio.get_event_loop()
task2 = loop.create_task(func(), name='task 02')

Coroutines对象在创建后需要通过事件循环去执行不同,Task对象本身就是通过事件循环创建的,也就是说当你create_task的时候,其内在的那个协程已经处于运行状态。

Future是一种提供更底层API的可等待对象,其代表了异步操作的结果,也可以用来绑定一个尚未执行结束的协程。Task继承了Future的属性与方法,与Coroutine一同提供了更上层的异步API。一般来说不推荐直接创建Future进行异步调用,关于Future的内容放到之后的文章中再谈。

3.3 任务创建与取消

Task是Python异步编程中的重要工具。我们可以通过Task获悉协程当前的状态,并进行一定范围的调度。

3.3.1 创建

创建任务的方法正如3.2中的代码所示,可以通过asyncio库,也可以直接通过事件循环,这二者本质是一样的。需要注意:在create_task的时候,一定要保存对那个返回值Task的引用。 因为事件循环在接收到Task对象后,只对其维护一个弱引用,如果返回值未被妥善保管,该任务随时可能被垃圾回收掉,无论该任务对应的协程是否执行完毕。

3.3.2 取消

任务封装的协程被事件循环调度执行。事件循环同一时刻只执行一个协程,当一个协程交出执行权后,事件循环会进行调度并决定下一个要执行的协程。一个协程在执行结束之前,我们可以通过Task.cancel()方法取消其执行,如下列代码所示。

import asyncio

# 定义一个协程函数
async def func():
	count = 0
	while True:
		await asyncio.sleep(1)
		print(f'awake {count := count + 1} time(s).')

# 创建任务
task1 = asyncio.create_task(func(), name="task1")
task2 = asyncio.create_task(func(), name="task2")

task1.cancel() # 取消task1的执行

cancel()的原理是将目标任务的取消请求计数+1,在事件循环的下一次循环时,该协程的取消请求数目若大于0,则向协程函数传入一个CancelledError以终止其继续执行。

所以在正式向该协程抛入CancelledError之前,我们有机会使用uncancel()方法撤回对该任务的取消请求,该方法会将取消请求计数-1,在上面的例子里,我们可以:

# 交出执行权
task1.cancel()    # cancel_count += 1, curr = 1
task1.cancel()    # cancel_count += 1, curr = 2
task1.uncancel()  # cancel_count -= 1, curr = 1
task1.uncancel()  # cancel_count -= 1, curr = 0
# 下次事件循环,由于取消请求计数为0,不会取消task1的执行

当然,就算CancelledError真的传入了协程函数,我们也可以通过try... except...语句捕获该异常,从而避免协程函数中断。但是,该任务仍然会被标记成已取消状态,若想事件循环继续执行,我们仍需调用uncancel()撤回取消请求。

或者,我们可以用asyncio.shield()Task包装起来,这样也可以避免任务被取消。

task = asyncio.create_task(func())
await asyncio.shield(task)

# 由于如果直接await协程的话,协程是无法被取消的,因此上面的操作等价于
await func()

此外,对于正常执行的任务,我们可以通过Task.done()来判断该任务是否结束,通过Task.result()获取任务执行结果,如果结果尚不可用会抛出InvalidStateError,如果获取结果的任务已经被取消则会抛出CancelledError

3.4 休眠、超时、等待

3.4.1 休眠

asyncio.sleep()是我们经常使用的一个函数。它会使一个协程休眠我们指定的秒数,就类似于同步编程模型中的time.sleep()方法,区别在于asyncio.sleep()不会使线程陷入阻塞,而是使当前协程交出执行权。

这种特性很有用。上面讲过,事件循环同时只能执行一个协程。一个协程在正常执行过程中,除非遇到yieldawait或者IO操作,是不会交出执行权的。这就会导致其他的协程任务得不到执行。而通过asyncio.sleep()方法,哪怕秒数设置为0也可以使该协程当即交出执行权,等待事件循环的下一次调度。

3.4.2 超时

为了避免诸如异步网络请求、异步IO操作这样的异步任务消耗过长时间,以使协程无法正常执行,我们可以利用超时机制对协程的执行进行限时规划。

asyncio.timeout是一个异步的上下文管理器,可以设定超时时间。在该异步上下文中,协程执行一旦超时,则会抛出TimeError。这里的计时数据并非是时间间隔,而是当前事件循环开始执行起经过的时间。如果我们在启用该超时上下文时不清楚时间,可以暂且设置为None(无超时)。待进入上下文后,使用事件循环对象的time()方法获取已运行时间,再通过reschedule()方法重新规划超时。

async def main():
    try:
        # 启动异步超时上下文管理器
        async with asyncio.timeout(None) as cm:
            # 获取当前事件循环已运行时间,并计算超时时刻
            new_deadline = get_running_loop().time() + 10
            # 重新规划超时
            cm.reschedule(new_deadline)
			# 执行协程
            await long_running_task()
    except TimeoutError:
    	# 捕获超时异常,同时协程得以继续运行
        pass
	# 通过上下文管理器的expired方法判断是否发生过超时
    if cm.expired():
        print("发生超时.")

事件循环会在启动时初始化一个单调时钟,并在每次循环迭代中更新它。每次循环迭代时,事件循环会检查当前时间与上一次循环迭代的时间差,并将其累加到单调时钟上,以得到最新的时间。在上面的超时时间设定中,如果时间值小于loop.time(),则会在事件循环迭代时立刻触发超时。

当发生超时后,该上下文中所有未结束的任务都会被取消,所抛出的CancelledError会被转化为TimeoutError进行统一抛出。

3.4.3 等待

除去粗暴的超时机制,我们也可以通过asyncio.wait()等待一批任务,在超时后,会返回已完成与未完成的两个任务集合,方便我们分别处理。

import asyncio
async def func(delay: int):
	await asyncio.sleep(delay)
# 超时时间设置为5,对于执行时间1~10的10个协程来说,会有一半完成,另一半未完成,这两个集合都会返回
done, pending = await asyncio.wait([asyncio.create_task(func(i)) for i in range(1, 11)], timeout=5)
print(len(done), len(pending)) # 5 5

当然,除了设定等待超时规则外,你还可以通过return_when参数设置其他规则。主要可以传入以下三个常量:

参数值 描述
FIRST_COMPLETED 有一个完成就返回
FIRST_EXCEPTION 执行的任务中若抛出异常则立刻返回,否则等效于ALL_COMPLETED
ALL_COMPLETED 所有任务都执行完成或被取消后返回

在上面的例子里,套用以上三种规则得到的结果为:

done, pending = await asyncio.wait([asyncio.create_task(func(i)) for i in range(1, 11)], return_when=asyncio.FIRST_COMPLETED)
# FIRST_COMPLETED 1已完成 9未完成
# FIRST_EXCEPTION 10已完成 0未完成
# ALL_COMPLETED   10已完成 0未完成

3.5 线程指派

虽然目前Python中的同步方法都有了异步版本可供开发者使用。但如果你的协程函数中就是遇到一个同步方法非调用不可怎么办?一旦调用,该协程所在线程直接被阻塞,之前我们讲的事件循环、协程高效性等等都无从谈起。

所以为了避免该协程所在的重要线程被阻塞,我们可以采取折中的办法,通过asyncio.to_thread()方法,将同步方法包装成一个协程,并创建另一个线程来执行它。

import time
import asyncio
# 定义同步方法
def blocking_io():
    print(f"{time.strftime('%X')} 阻塞开始")
    # 使用time.sleep()来指代任意的同步方法,例如IO、网络请求、文件读写操作等
    time.sleep(1)
    print(f"{time.strftime('%X')} 阻塞结束")
    
async def main():
    print(f"协程从 {time.strftime('%X')} 开始执行")
    await asyncio.gather(
    	# 使用asyncio.to_thread封装一个同步函数,该方法返回一个协程
        asyncio.to_thread(blocking_io),
        asyncio.sleep(1))
        
    print(f"所有协程到 {time.strftime('%X')} 执行结束")
    
asyncio.run(main())
# 执行结果
#>>> 协程从 22:02:22 开始执行
#>>> 22:02:22 阻塞开始
#>>> 22:02:23 阻塞结束
#>>> 所有协程到 22:02:23 执行结束

在线程指派的过程中,传给该协程函数的所有参数都会被传递到另一个线程中。而且,本来线程之间的上下文变量是不共享的,但是通过线程指派创建的线程被主动传递了原线程的上下文变量。这确保了协程函数在另一个线程中可以被正确执行。

可以发现,协程所在线程并没有被阻塞,程序总耗时1s,如期执行完毕。需要注意的是,由于GIL的影响,to_thread()只能对IO密集型的同步方法起到性能提升效果,而对于CPU密集型的同步方法,CPU同时只能执行一个线程,所以即使将阻塞转移其他线程,也起不到明显效果。

除了临时创建一个线程,如果我们的编程环境本身就是多线程,可以通过asyncio.run_coroutine_threadsafe方法将协程指派到一个指定线程的事件循环中执行,进而实现协程在多个线程之间的灵活调度。

# loop1是来自另一个线程的正在运行的事件循环
future = asyncio.run_coroutine_threadsafe((coro:=asyncio.sleep(10)), loop)
# 返回值是一个Future对象,我们可以用与处理Task类似的方法处理它
try:
	# 获取结果,如果没执行完就等待一段时间
    result = future.result(timeout)
except TimeoutError:
	# 超时会触发超时异常,这个时候我们可以手动取消它
    print('协程执行超时,正在手动取消...')
    future.cancel()
except Exception as exc:
    print(f'协程函数抛出的其他异常: {exc!r}')
else:
	# 如果没问题的话,可以打印结果
    print(f'协程执行结果是{result!r}.')

以上就是本文的全部内容,在下水平有限,如有不当之处,烦请不吝赐教。

四、参考资料

[1] Python官方文档:协程与任务
[2] Python官方文档:事件循环

你可能感兴趣的:(Python,python,开发语言,个人开发)