Python协程asyncio(一)

  Python使用multiprocessing进行多线程和多进程操作 这篇文章中介绍了使用多线程的方式对一些I/O操作(文件读写、网络请求,这些操作不用等待其结束,在此期间可以做其他事情)进行加速。而本篇文章介绍的协程可以理解成“微线程”,不开辟其他线程,只在一个线程中执行,并且执行函数时可以被中断的一种异步执行功能。我们可以把采用线程实现的阻塞式I/O操作转化为采用协程实现地异步I/O操作。与多线程比较,没有切换线程的开销和多线程锁机制,因此使用协程可以更加高效实现异步操作。

1、async与await关键字

  Python3.5之后我们可以采用async和await这两个关键字实现协程操作。它的基本原理与生成器(Python迭代器、生成器、装饰器的使用)类似,被asycn关键字修饰的函数,在函数被调用时不会立刻执行函数体内容,而是在等到需要时再一项一项的获取。协程可以从执行环境中获取输入值,并把这个函数执行后的结果放到这个执行环境中。协程与线程的区别在于,它不会把这个函数从头到尾的执行,而是遇到一个await表达式,就暂停一次,下次继续执行的时候,它会等待await所针对的那项操作(这个操作就是用async修饰的函数,或者是可等待对象)有了结果,然后再推进到下一个await表达式那里。需要注意的是await表达式只能在async修饰的函数中使用。

  Python可以让数量极多的async函数各自向前推进,看起来像很多条Python线程那样能够并发地运行。我们先看一个最简单的异步调用的例子:

import asyncio

async def async_task(name):
    print(f"任务{name} 开始执行")
    await asyncio.sleep(1)
    print(f"任务{name} 执行结束")
    return f"返回结果{name}"

result1 = async_task(1)
result2 = async_task(2)
result3 = async_task(3)
print("result的类型: ", type(result1))

  我们这里使用async定义了一个函数叫做async_task,这个函数传入一个参数name,函数体我们使用await asyncio.sleep(1) 模拟I/O堵塞1s的操作(注意这里不能使用time.sleep()函数来模拟,因为time.sleep()会将当前线程休眠并释放GIL,而对于协程来说我们只有一个线程,就是主线程,如果使用time.sleep()就是在堵塞主线程)。我们执行上述代码结果如下。

  我们会发现函数体并没有被执行(函数体内部的print没有打印),而且我们函数的结果也是一个“coroutine”类型,并不是我们return返回值。与此同时系统还抛出了一个运行警告。这是因为上述代码其实只是在定义协程任务(执行async修饰的函数会得到一个coroutine对象)。根据官方文档的定义,我们把被async修饰的函数称为协程函数,执行协程函数得到的对象称为协程对象(也就是上面看到的coroutine类实例对象)。

  当我们继续运行下面的代码,其实才是真正的开启了协程。

tasks = asyncio.wait([result1, result2, result3])
print("tasks类型: ", type(tasks))
asyncio.run(tasks)

  这里的asyncio.wait()传入了一个coroutine对象的可迭代对象,asyncio.wait()会将这些任务进行打包整个生成一个任务并返回corountine对象(也就是tasks的类型)。最后一行asyncio.run()需要传入一个coroutine对象(可以直接传入result1,但不能直接传入[result1, result2, result3]因为这是一个list,所以我们需要先采用asyncio.wait()将这些任务打包),此时就会开启协程,运行函数体。我们看下运行结果:

   我们可以看到此时才会真正的执行我们定义的函数体的内容(并且在开始执行和执行结束之间是有1s的停顿的,大家可以使用time库自行测试程序运行时间)。我们采用async和asyncio.run()完成了一个最简单的协程任务的使用。这里需要注意的是,使用asyncio.run是一个阻塞操作,会等到所有的协程任务结束之后才会继续执行后面的代码。

2、Task和事件循环

  在上面个例中我们已经可以简单的使用协程来完成异步操作了,但是我们会发现我们无法得到函数的返回结果。为了能得到任务函数的返回结果,我们还需要了解到协程更底层的一些概念和操作。首先我们要介绍两个概念:事件循环(Loop)、Task类。

  • 事件循环(Loop):管理所有的任务,在整个程序运行过程中不断循环执行并追踪这些任务发生的顺序,并将它们放在执行队列中,空闲时调用相应的事件处理者来处理这些任务。可以看作将任务放在Loop中,那么会让CPU去执行这些任务,而Loop则起到一个管家和追踪者的作用。实际上是ProactorEventLoop类。
  • Task类:这个类是asyncio.Task,它是asyncio.Future的子类。Future对象一般表示尚未完成的计算/结果,会在将来得到结果,这个对象一般不会直接创建,而是作为Task的基类。Task其实就是真正的运行对象,它可以看作是对协程函数的进一步封装,而Loop直接管理的也是Task对象。我们在上一节提到的“coroutine”类会在内部自动封装成Task类。其实我们前面提到的可等待对象(可以放在await表达式后的)其实就是Task对象、Future对象、coroutine对象(由async修饰的函数执行后得到的)

  我们下面来介绍如何获得Loop对象和Task对象。

2.1、事件循环对象

  我们可以通过以下方式获得事件循环对象:

  • asyncio.get_running_loop():获取正在运行的事件循环对象(如果当前线程中没有正在运行的事件循环则会报错)
  • asyncio.get_event_loop():获得一个事件循环对象,创建一个新的事件循环loop
  • asyncio.set_event_loop(loop):为当前线程设置一个事件循环对象(该函数会返回一个事件循环对象)
  • asyncio.new_event_loop():创建一个新的事件循环

   我们得到一个Loop对象后,就可以事项Loop对象的方法来启动协程了(上一节启动协程的方式是使用asyncio.run()这是比较高层次的API,查看其源码可以知道其实内部也是获取了Loop对象,再使用Loop.run_until_complete()启动协程的),因此针对上一节的代码,我们还可以使用以下代码来启动异步协程任务。

loop = asyncio.get_event_loop()
loop.run_until_complete(tasks)

  loop.run_until_complete()这个函数从函数名就可以知道,含义是:启动事件循环,直到里面的Future全部完成(这里看成协程任务全部完成也是可以的)。这个函数是有返回值的,会根据你传入的参数(传入的参数必须是Task对象、Future对象、coroutine对象)而得到不同的返回值。如果你是传入的Task对象,那么你的返回值就是这个Task对象执行后的返回值,例如我们直接执行一个协程函数。

import asyncio

async def async_task(name):
    print(f"任务{name} 开始执行")
    await asyncio.sleep(1)
    print(f"任务{name} 执行结束")
    return f"返回结果{name}"

loop = asyncio.get_event_loop()
result = loop.run_until_complete(async_task(1))
print(result)

  最终的执行结果如下:

   可以看到我们成功得到了协程函数的返回值(这也就解决了本节开头提出的问题:如何获取协程函数的返回值)。后面我们还会介绍如果loop.run_until_complete()传入的参数是其他对象那么所得到的返回值类型是不同的。

2.2、Task对象

  我们常通过以下方式来得到一个Task对象:

  • loop.create_task(corootine):从将coroutine对象(被async修饰函数执行后就是一个coroutine)封装得到一个Task对象
  • asyncio.create_task(coroutine):从将任意的一个可等待对象(coroutine对象、Future对象、Task对象)封装成一个Task对象然后返回,并将该对象放入事件循环中(推荐使用)

   可能有疑问为什么需要使用这些方法去得到Task对象呢,反正asyncio内部在执行任务时不就会将coroutine封装成Task执行么?其实是因为Task是最基本的可操作的协程对象(其实Future才是最基本的,不过我们一般都涉及不到这么底层,所以后文提到Future其实是指的其常用子类比如Task类)我们后续的一些操作比如给任务添加回调函数(callback),这都需要基于Task对象。并且我们还可以查看Task对象的当前状态(是否被执行了),以及执行后查看Task对象的返回值。下面我们通过手动将coroutine封装成Task对象,并使用Loop执行任务,看看会发生什么。

import asyncio

async def async_task(name):
    print(f"任务{name} 开始执行")
    await asyncio.sleep(1)
    print(f"任务{name} 执行结束")
    return f"返回结果{name}"

loop = asyncio.get_event_loop()
task = loop.create_task(async_task(1))
print("类型:", type(task))
print('任务运行前状态: ', task)
loop.run_until_complete(task)  # 运行task对象
print('\n任务运行后状态: ', task)
print("任务返回值: ", task.result())

  我们在代码第十行通过loop.create_task()将一个执行后的协程函数(得到coroutine对象)转化为了一个Task类对象,并且在执行任务前和任务执行后都分别打印了task。需要注意的是我们在代码第13行并没有接收loop.run_until_complete的结果,取而代之的是我们在代码的最后一行直接调用task.result()返回了这个任务的返回值(也就是函数return的那个字符串)。运行后结果如下:

   我们可以看到运行前的task和运行后的task有着明显的不同,运行后的task具有result这个内容并且还有finished标识,而运行前则是pending标识。并且我们最后也成功的使用task.result()得到了任务的返回值。

3、多任务并发

  在第一小节中我们其实已经提到,我们可以使用asyncio.wait()将多个coroutine对象包装成一个coroutine再执行。除了这种方法以外其实还可以使用asyncio.gather(*aws)的方式将多个任务“聚合”成一个任务执行。下面我们讲一讲这两种多任务并发方式的区别。

3.1、asyncio.wait(aws, *, timeout=None, return_when=ALL_COMPLETED)

  这个函数我们之前已经使用过了,传入一个包含可等待对象(Task对象、Future对象、coroutine对象(在后续将不会支持直接传入一个coroutine对象列表,需要将coroutine对象转变为Task对象))的列表,会返回一个coroutine对象(这里需要注意的是由于返回的对象并不是Task对象,因此无法直接设置回调函数,需要先将这个coroutine对象转化为Task对象后再加回调函数),这个对象就能放入loop.run_until_complete()或者asyncio.run()中从而实现多任务的并发。下面我说的是执行后的返回值,执行这个coroutine对象我们会得到一个元组返回值(ser(), set()),元组的每个元素都是一个集合,每个集合里装的都是Task对象,第一个set()里装的是顺利完成任务的Task对象,第二个set()里装的是还没有完成的Task对象。需要注意的是由于是集合,因此集合中的元素不一定与传入的aws列表中的任务顺序相对应。下面我们通过loop.run_until_complete()执行第一节中的例子,并获取返回值。

import asyncio
import time

async def async_task(name):
    print(f"任务{name} 开始执行")
    await asyncio.sleep(2)
    print(f"任务{name} 执行结束")
    return f"返回结果{name}"

start = time.time()
loop = asyncio.get_event_loop()
tasks = asyncio.wait([async_task(1), async_task(2), async_task(3)])

done, pending = loop.run_until_complete(tasks)  # 运行聚合任务对象
print("已完成任务: ", done)
print("未完成任务: ", pending)
print("程序运行时间:", time.time() - start)

  我们在14行执行loop.run_until_complete(),将聚合后的coroutine对象放入,返回值使用done和pending来接收这两个集合,之后打印这两个集合的结果,最后执行结果如下:

   可以看到"已完成任务"后面跟的集合里所有的Task都是finished状态的,并且都有着返回值(我们可以遍历这个集合的所有Task对象,并使用Task.result()即可得到这些任务返回值了)。

  asyncio.wait中有一个传入参数timeout,这个参数指的是:聚合后的coroutine对象执行时,程序只会等待timeout这么久,如果超出这个时间,那么没有完成的任务将会被放置在未完成集合中(也就是上面例中中的pending集合)。这里就不展示了,大家可以自己试一下将这个时间设置为1,那么上面例子的代码执行后所有任务都会在第二个集合中。

3.2、asyncio.gather(*aws, return_exceptions=False)

  这个方法与.wait不同的是asyncio.tasks._GatheringFuture对象(这个对象可以被放在loop.run_until_complete中执行,也可以跟在await 后被执行,但不能使用asyncio.run执行),并且需要注意到这里我们需要使用*aws的方式传入参数,换句话说asyncio.gather接收不定个数的可等待对象(Task对象、Future对象、coroutine对象)。我们将上面的例子改为使用asyncio.gather的方式:

import asyncio
import time

async def async_task(name):
    print(f"任务{name} 开始执行")
    await asyncio.sleep(2)
    print(f"任务{name} 执行结束")
    return f"返回结果{name}"

start = time.time()
loop = asyncio.get_event_loop()
tasks = asyncio.gather(*[async_task(1), async_task(2), async_task(3)])
print(type(tasks))
res = loop.run_until_complete(tasks)
print(res)
print("程序运行时间:", time.time() - start)

  注意第12行我们使用*[]的方式将可等待对象传入进去。最后程序的执行结果如下:

   我们注意到res的结果居然是返回的是一个列表,每一个元素都是对应任务的返回值,并且列表中返回值的顺序与传入asyncio.gather都是一致的,因此这个函数其实非常适合我们在一些逻辑不太复杂的并行运算中使用。

4、协程的认识误区

4.1、协程的再认识

  我们需要注意协程和多线程的差别,多线程是真的开启了另一个与主线程不同的处理流。操作系统在线程等待IO的时候,会阻塞当前线程,切换到其它线程,这样在当前线程等待IO的过程中,其它线程可以继续执行。示意图如下:

   但对于协程来说,本质上就只有一个线程,由事件循环来不停的调度执行池子中的任务,只不过我们可以人为的将当前协程任务的挂起和将其他任务加入到事件循环中。协程并没有增加线程数量,只是在主线程的基础之上通过分时复用的方式运行多个协程。示意图如下:

  从图中可以看出我们可以从手动控制从协程任务0切换到协程任务1,再等待协程任务1执行结束之后切换回协程任务0(或者是手动将协程任务1挂起,由事件循环决定去执行其他的协程任务)。为什么前面的代码中看起来就像是在并发一样,毕竟在上一节讲解asyncio.gather例子中,三个任务一起并发调用起来了。要想解释清楚这个问题,我们来看这下面这个例子。

import asyncio
import time

async def my_coroutine(name):
    print(f"协程任务{name}开始")
    await asyncio.sleep(3)
    print(f"协程任务{name}结束")

async def main():
    print("主函数started")
    for i in range(10):
        asyncio.create_task(my_coroutine(i))
    return {"main结果返回": "123"}

start = time.time()
a = asyncio.run(main())
print("main()运行结果:", a, "运行时间: ", time.time()-start)

  这段代码和前面的例子又不太一样,这里我们并没有使用任何的asyncio.gather或者asyncio.wait将任务进行封装,只是在12行时使用asyncio.create_task将协程对象封装成了Task对象。下面时代码的执行结果:

   很神奇,居然出现了并发,而且执行时间只有0.006s,更奇怪的是为什么协程任务中的第二句打印“任务已完成”并没有出现。换句话说所有的协程任务都没有被完整执行,而程序直接终止了,这是为什么呢?因为协程并没有创建线程,当我们主线程执行到了终点,就会结束整个程序,而并会不管协程任务是否完成。

  问题又来了,那为什么前面的所有例子协程任务都完整结束了呢?大家可以仔细看看前文的例子,要么使用了asyncio.wait,要么使用了loop.run_until_complete。前者返回一个协程对象,这个协程对象被放入asyncio.run()中执行,就会等到所有的协程任务结束(或者达到设定的timeout)。而后者run_until_complete从字面可以看出,放入的Task会被执行直到完成。换句话说上面两种情况下主线程都会被阻塞(即会等待协程任务的执行结束再执行后续代码,大家可以在上面所有例子的最后增加一句print语句,就会发现print语句一定等到协程执行完成才会被打印)。

  再来看当前的代码,在main函数中我们没有使用任何的会造成协程切换(使用await)或者线程阻塞的操作,我们只使用了asyncio.create_task将协程任务封装成了Task并放入到了事件循环中。所以当代码执行到asyncio.create_task时仅仅只是将协程任务放入到事件循环中等待被执行而已。这个操作并不会阻塞当前的线程,所以for循环会一直被执行,直到最后return。

  注意这里return之后意味着main()这个协程任务已经结束了!!!此时事件循环中只有for循环中放入的那10个my_coroutine(i),这10个协程任务会被事件循环择机调用。当main()这个协程任务return的一瞬间,事件循环开始执行my_coroutine(0),当执行my_coroutine(0)中时遇到了awit asyncio.sleep(3),按道理需要等待这个asyncio.sleep(3)完成后才能继续执行my_coroutine(0)的后续代码。但由于asyncio.sleep(3)并不是一个阻塞主线程的操作(这就是前面强调的不能使用time.sleep(3)来模拟),此时事件循环会将my_coroutine(0)这个任务挂起,转而去执行my_coroutine(1),直到又碰到awit asyncio.sleep(3),由此循环。

  由于事件循环的调度是非常快的,所以看起来就像这10个任务并发执行了(实际上时切换很快),所以在很快的时间里my_coroutine(9)都被执行到了awit asyncio.sleep(3),要注意到从从始至终都只有1个线程。接下来主线程该执行print了,所以这就是为什么print出来时间只有0.006。当print之后,主线程结束,相应得协程也被迫结束了,因此我们的协程任务中并没有打印第二句。

  我们再来看下面这个例子:

import asyncio
import time

async def my_coroutine(name):
    print(f"协程任务{name}开始")
    await asyncio.sleep(3)
    print(f"协程任务{name}结束")

async def main():
    print("主函数started")
    for i in range(10):
        await asyncio.create_task(my_coroutine(i))
    return {"main结果返回": "123"}

start = time.time()
a = asyncio.run(main())
print("main()运行结果:", a, "运行时间: ", time.time()-start)

  和上一段代码相比,我们只是在12行中添加了await ,也就是等待协程my_coroutine(0)结束之后再继续执行(继续for循环)。结果大家可以想到:

  当程序执行到await asyncio.create_task(my_coroutine(0))时,main()任务被挂起,由于此时事件循环中除了main以外就只有my_coroutine(0),所以执行my_coroutine(0)。当执行my_coroutine(0)到awit asyncio.sleep(3)时,my_coroutine(0)又被挂起,但此时的事件循环中没有其他的协程任务了(main任务在等待my_coroutine(0)结束,my_coroutine(0)又在等待)

  从上面的两个例子中可以看出,其实我们使用协程,就是在手动的控制线程的分时复用。我们可以做的也就是下面两件事。

情况一:让当前任务挂起,不再执行后续代码,而是等待其他任务的执行结束(await 其他协程对象)

情况二:将某个协程任务加入到事件循环中(asyncio.create_task(协程对象))

4.2、协程误区

  “协程只有在等待IO的过程中才能重复利用线程,而线程在等待IO的过程中会陷入阻塞状态。实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度,这往往是不能接受的。”该理解来参考网页。”因此在协程中不能调用导致线程阻塞的操作。也就是说,协程只有和异步IO结合起来,才能发挥最大的威力“。从这里我们可以知道为什么我们不能使用time.sleep()来模拟I/O了。并且更令人遗憾的是普通的http请求,读取文件,这次这些操作都是I/O阻塞的,因此都会阻塞线程从而阻塞协程。我们并不能将上面代码中asyncio.sleep()地方换成普通的I/O操作从而实现并发,而是要使用特别的包比如aiohttp实现异步的http请求发送(参看连接2)。

  那么如何处理在协程中调用阻塞IO的操作呢?一般有2种处理方式:

  • 在调用阻塞IO操作的时候,重新启动一个线程去执行这个操作,等执行完成后,协程再去读取结果。这其实和多线程没有太大区别。
  • 对系统的IO进行封装,改成异步调用的方式,这需要大量的工作,最好寄希望于编程语言原生支持。

   所以对于我们现阶段来说,我们如果想要使用协程来提升I/O的并发,我们还得配合支持协程的第三方库,比如使用aiohttp实现协程http请求,比如使用aiofiles实现协程的读取。我们不能想当然的就将原始的代码加上asyncio、await和async关键字就认为可以是实现协程了。

 

参考链接:

什么是协程? - 知乎 (zhihu.com)

爬虫速度太慢?来试试用异步协程提速吧! - 掘金 (juejin.cn)

 

posted @ 2023-05-11 20:38  Circle_Wang  阅读(335)  评论(0编辑  收藏  举报