Python协程与异步IO

协程的概念

什么是协程?

协程,又称微线程、纤程。英文名为Coroutine,是一种用户态的轻量级线程。

子程序,或者称为函数,在所有语言中都是层级调用的。比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。

线程是系统级别的,通过操作系统来调度,而协程则是程序级别的由程序根据需要自己调度。在一个线程中会有很多函数,我们把这些函数称为子程序,在子程序执行过程中可以中断去执行别的子程序,而别的子程序也可以中断回来继续执行之前的子程序,这个过程就称为协程。也就是说在同一线程内一段代码在执行过程中会中断然后跳转执行别的代码,接着在之前中断的地方继续开始执行。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态。换种说法就是,进入上一次离开时所处逻辑流的位置。

协程的优缺点

优点
(1)无需花费线程上下文切换的开销,协程避免了无意义的调度,从而提高性能。
(2)无需原子操作锁定及同步的开销
(3)方便切换控制流,简化编程模型
(4)高并发+高扩展性+低成本,一个CPU可以支持上万个协程,所以很适合用于高并发处理。

缺点
(1)无法利用多核资源。协程的本质是个单线程,它不能同时将单个CPU的多个核用上,协程需要和进程配合才能运行在多CPU上,当然我们日常所编写的绝大部分应用都没这个必要,除非是CPU密集型应用。
(2)进行阻塞操作(如IO时)会阻塞掉整个程序。

python协程的简单实现

Python可以通过生成器(generator)来实现对协程的支持。在generator中,不但可以通过for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。

yield的使用
def func():
    print('starting...')
    while True:
        res = yield 6
        print('res:', res)


if __name__ == '__main__':
    g = func()  # generator
    print(next(g))
    print('------')
    print(next(g))

执行结果如下

starting...
6
------
res: None
6

执行的具体过程如下:

  1. 程序开始执行以后,因为func函数中含有yield关键字,所以func函数并不会真正执行,而是得到一个生成器g
  2. 直到调用next方法,func函数才正式开始执行,先打印"starting...",然后进入while循环中
  3. 程序遇到yield关键字,程序暂时挂起并返回一个6(注:这里并没有执行赋值给res的操作),此时n第一个next(g)语句执行完成,打印返回的6,并执行往下的语句
  4. 程序执行print("------")
  5. 直到程序遇到第二next(g)语句,这个时候从上次执行next语句停止的地方开始执行,即要执行res的赋值操作。需要注意的是,这个时候赋值操作的右边是没有值的,所以这个时候res赋值为None,接着打印出"res:None"
  6. 程序会继续在while里执行,当再次遇到yield关键字的时候返回6,然后程序挂起,next(g)语句执行完成,并打印出返回的6

带yield的函数是一个生成器,这个生成器有一个函数就是next函数,next就相当于“下一步”生成哪个数,这一次的next开始的地方是接着上一次的next停止的地方执行的,所以调用next的时候,生成器并不会从func函数的开始执行,而是接着上一步停止的地方开始,然后遇到yield后,返回出要生成的值,此步就结束。

yield实现简单的协程

在上面例子,我们已经了解了yield语句的原理和使用,现在我们来尝试实现一个简单协程

import time


def a():
    while True:
        print('---A---')
        yield
        time.sleep(0.5)


def b(c):
    while True:
        print('---B---')
        c.__next__()
        time.sleep(0.5)


if __name__ == '__main__':
    a = a()
    b(a)

执行结果如下

---B---
---A---
---B---
---A---
---B---
---A---
---B---
...
send发送数据

send是发送一个参数给res,第一个例子中,在yield的时候,并没有将6赋值给res,下次执行的时候,继续执行赋值操作就会赋值为None。而使用send方法可以在接着上一次执行的时候,先把send()中的值赋值给res,然后执行next操作,直到遇到下一个yield使程序挂起,返回结果后结束。

def func():
    print('starting...')
    while True:
        res = yield 6
        print('res:', res)


if __name__ == '__main__':
    g = func()
    print(next(g))
    print('------')
    print(g.send(10))  # 传入10后执行next

执行结果如下

starting...
6
------
res: 10
6
协程实现生产者消费者
import time


def producer(c):
    """生产者"""
    c.send(None)
    for i in range(1, 6):
        print('生产者生产%d产品' % i)
        c.send(str(i))
        time.sleep(1)


def customer():
    """消费者"""
    res = ''
    while True:
        data = yield res
        if not data:
            return
        print('消费者消费%s产品' % data)


if __name__ == '__main__':
    c = customer()
    producer(c)

执行结果如下

生产者生产1产品
消费者消费1产品
生产者生产2产品
消费者消费2产品
生产者生产3产品
消费者消费3产品
生产者生产4产品
消费者消费4产品
生产者生产5产品
消费者消费5产品

异步IO协程

使用异步IO,无非是提高我们写的软件的并发。这个软件系统,可以是网络爬虫,也可以是Web服务等。

并发的方式有很多,如多线程、多进程、异步IO等。多线程和多进程更多应用于CPU密集型的场景,比如科学计算的时间都耗费在CPU上,利用多核CPU来分担计算任务。多线程和多进程之间的场景切换和通讯代价很高,不适合IO密集型的场景。而异步IO就是非常适合IO密集型的场景,比如网络爬虫和Web服务。

IO就是读写磁盘、读写网络的操作,这种读写速度比读写内存、CPU缓存慢得多,前者的耗时是后者的成千上万倍甚至更多。这就导致,IO密集型的场景99%以上的时间都花费在IO等待的时间上。异步IO就是把CPU从漫长的等待中解放出来的方法。

asyncio简介

基于生成器(使用yield from语句创建的python生成器)的协程将会在python3.10中被移除,取而代之的是asyncio中的async/await语法。

asyncio是Python3.4版本引入的标准库,直接内置了对异步IO的支持。asyncio的编程模型就是一个消息循环。我们从asyncio模块中直接获取一个EventLoop的引用,然后把需要执行的协程扔到EventLoop中执行,就实现了异步IO。
(1)event_loop事件循环:程序开启一个无限的循环,程序会把一些函数注册到事件上。当满足事件发生的时候,调用相应的协程函数。
(2)coroutine协程:协程对象,指一个使用async关键字定义的函数,它的调用不会立即执行函数,而是会返回一个协程对象。协程对象需要注册到事件循环,由事件循环调用。
(3)task任务:一个协程对象就是一个原生可以挂起的函数,任务则是对协程进一步封装,其中包含任务的各种状态。
(4)future:代表将来执行或没有执行的任务的结果,它和task上没有本质的区别。
(5)async/await关键字:python3.5用于定义协程的关键字,async定义一个协程,await用于挂起阻塞的异步调用接口。

使用asyncio定义一个协程

协程通过async/await语法进行声明,是编写asyncio应用的推荐方式。下面来简单编写一个协程(注:需要Python3.7+)

import asyncio


async def main():
    print('hello')
    await asyncio.sleep(1)
    print('world')


if __name__ == '__main__':
    asyncio.run(main())

执行结果如下

hello
world

上面例子中,我们用async def定义了一个协程main()。如果直接调用main()协程,并不会将其加入到执行日程,要真正运行一个协程,需要使用asyncio.run()方法。

可等待对象

如果一个对象可以在await语句中使用,那么它就是可等待对象,可等待对象主要有三种,分别是协程任务Future

import asyncio
import time


async def do_something(job, cost_time):
    print(f"--- started {job} at {time.strftime('%X')} ---")
    await asyncio.sleep(cost_time)
    print(f"--- finished {job} at {time.strftime('%X')} ---")


async def main():
    start_time = time.time()
    print(f"started at {time.strftime('%X')}")

    await do_something("eating food", 3)
    await do_something("watching TV", 5)

    end_time = time.time()
    print(f"finished at {time.strftime('%X')}, it cost {int(end_time-start_time)}s.")


if __name__ == '__main__':
    asyncio.run(main())

执行结果如下

started at 18:31:20
--- started eating food at 18:31:20 ---
--- finished eating food at 18:31:23 ---
--- started watching TV at 18:31:23 ---
--- finished watching TV at 18:31:28 ---
finished at 18:31:28, it cost 8s.

上面例子中的do_something()协程就是一个可等待对象,使用await挂起do_something()协程(此时主协程main()处于一个阻塞状态),直到完成do_something()中的任务才会接着运行,总耗时为8s。

asyncio任务

任务被用来设置日程以便并发执行协程

当一个协程通过asyncio.create_task()等函数被打包为一个任务,该协程将自动排入日程准备立即运行,我们可以通过该函数来并发运行作为asyncio任务的多个协程。

我们修改以上实例,并发运行两个协程

import asyncio
import time


async def do_something(job, cost_time):
    print(f"--- started {job} at {time.strftime('%X')} ---")
    await asyncio.sleep(cost_time)
    print(f"--- finished {job} at {time.strftime('%X')} ---")


async def main():
    start_time = time.time()
    task1 = asyncio.create_task(do_something("eating food", 3))
    task2 = asyncio.create_task(do_something("watching TV", 5))
    print(f"started at {time.strftime('%X')}")

    await task1
    await task2

    end_time = time.time()
    print(f"finished at {time.strftime('%X')}, it cost {int(end_time-start_time)}s.")


if __name__ == '__main__':
    asyncio.run(main())

执行结果如下

started at 18:42:41
--- started eating food at 18:42:41 ---
--- started watching TV at 18:42:41 ---
--- finished eating food at 18:42:44 ---
--- finished watching TV at 18:42:46 ---
finished at 18:42:46, it cost 5s.

可以看出,输出结果的运行时间比之前快了3秒.

Future对象

Future是一种特殊的低层级可等待对象,表示一个异步操作的最终结果

当一个Future对象被等待,这意味着协程将保持等待直到该Future对象在其他地方操作完毕,在asyncio中需要Future对象以便允许通过async/await使用基于回调的代码。

import asyncio


async def main():
    # 获取当前事件循环
    loop = asyncio.get_running_loop()

    # 创建一个任务(Future对象),这个任务什么都不干
    fut = loop.create_future()

    # 等待任务最终结果(Future对象),没有结果则会一直等下去
    await fut


asyncio.run(main())

由于Future对象得不到结果,所以程序会一直等待下去。

下面我们在一个task中通过Future对象的set_result()方法来给它一个结果

import asyncio


async def set_after(fut):
    await asyncio.sleep(2)
    fut.set_result('666')


async def main():
    # 获取当前事件循环
    loop = asyncio.get_running_loop()

    # 创建一个任务(Future对象),这个任务什么都不干
    fut = loop.create_future()
    await loop.create_task(set_after(fut))

    data = await fut
    print(data)


asyncio.run(main())

创建一个任务(Task对象),绑定了set_after协程函数,协程内部在2s之后,会给fut赋值,即手动设置future任务的最终结果,那么fut就可以结束了

Task继承Future,Task对象内部await结果的处理基于Future对象来的。

import asyncio
import time


async def add(a, b, cost_time):
    await asyncio.sleep(cost_time)
    return a + b


async def main():
    start_time = time.time()
    res = await asyncio.gather(
        add(1, 2, 2),
        add(5, 6, 4)
    )
    print(res)
    end_time = time.time()
    print(f'it cost {int(end_time - start_time)}s.')


if __name__ == '__main__':
    asyncio.run(main())

执行结果如下

[3, 11]
it cost 4s.

上面例子中使用asyncio.gather()方法挂起两个协程,每个协程add()都会返回一个值(a+b的和),最终res会接收所有协程返回的值并存放到一个列表中,程序的执行是并发的,这个列表就是最终结果。

posted @ 2020-10-23 19:59  蓝莓薄荷  阅读(376)  评论(0编辑  收藏  举报