生成器与协程

Generator and Coroutine

Posted by Bryan on November 4, 2019

背景介绍

在 Python 中由于 GIL 锁的存在,多线程的并发效率不高。为了比较高效地实现并发,在 Python 中一般的方案是采用多进程 + 协程的方案。

协程也被称为纤线程,是一种程序级别的并发控制,多个协程会执行在同一线程中。协程的思想是由程序自身指定中断点,在 IO 操作时,程序可以自行中断,主动放弃 CPU,此时调度另外的协程继续运行。当 IO 就绪后,再调度此程序从中断点继续向下执行。

Python 中的协程是基于生成器实现的,在 Python 3 的版本演化中,生成器与协程的概念反复纠缠。导致这两个概念比较混杂,在这边梳理一下,也希望帮助后面的人少走弯路。

生成器 Generator

在 Python 最早的设计中,生成器是内部包含 yield 语句的函数。使用生成器可以比较优雅地实现一个迭代器。关于优雅迭代器的部分可以参考之前的 一篇博客 。为了更好地梳理整个流程,我们还是简要地回顾一下:

yield

yield 是 Python 2 中就开始提供的一个语句,可以程序中设置中断点,并在中断点返回特定的值,之后可以通过 next() 方法从中断点恢复程序的执行,运行到下一个中断点或程序执行结束。通过返回调用 next() 方法,每次获取一个值,即可实现类似迭代器的功能。

而内部包含 yield 的函数就被称为生成器,生成器的主要价值是实现优雅的迭代器。一个简单的代码如下所示:

def generator_func():
    yield 1
    yield 2

gf = generator_func()
# 1

print(next(gf))     
# 2

print(next(gf))     

send()

理论上,生成器如果被用于实现迭代器的功能,借助 yield 就已经足够了,但是为了更好地支持协程的功能,在 pep-0324 中提到需要增强生成器,支持 send() 方法。通过 send() 方法我们不仅可以从生成器中获取值,还能给生成器传递值。此时我们不用关心为什么协程需要此方法,只是了解到我们现在得到一个更强大的生成器。

def generator_func():
    val1 = yield 1
    # 11
    
    print(f'val1: {val1}')           
    
    yield val1 + 2

gf = generator_func()
first_val = next(gf)
# 1

print(f'first val: {first_val}')      

second_val = gf.send(first_val + 10)
# 13

print(f'second val: {second_val}')    

通过上面的代码可以看到,我们不仅可以通过 next() 从生成器中不断获取新的值,还能通过 send() 给生成器传递值,生成器可以根据实际得到值生成新的值。从而支持更灵活和复杂的场景。

yield from

为了更好地支持协程,生成器被增强,在 pep-380 中支持了子生成器(Subgenerator)。这样可以引用其他生成器的,从而写出更加灵活的生成器。类似代码如下所示:

def generator_func():
    yield 1
    yield 2

def new_generator_func():
    yield from generator_func()

ngf = new_generator_func()
# 1

next(ngf)          
# 2

next(ngf)          

通过上面的代码,new_generator_func()generator_func() 函数一样,都是生成器。实现的效果是一样的。在实际中我们可以根据利用不同的生成器组合出更加复杂的生成器。

生成器总结

在上面的生成器的使用与增强中,可以理清生成器的概念。生成器本质是一个生成数据的机制。借助 yield 提供的中断恢复机制,可以不需要一次性生成所需的全部数据,可以不断执行,不断生产新的数据。

在生成器的增强中,虽然对基础的生成器而言不是必须的。但是也并不是加上了 send() 机制或 yield from 之后就变成了协程了,而是一种增强版本的生成器。网上不少博客在描述之中对在这个概念上的介绍中完全是胡说八道。

协程 Coroutine

协程是一种在程序级别的并发控制,多个协程会在同一线程中执行。由于同一时间一个线程中只有一个协程在执行,因此可以避免加锁的问题。基于最基础的生成器就可以实现协程。当然基于增强型的生成器可以实现更加有实用机制的协程。

下面以一个简单的生产者和消费者协程为例进行介绍:

def consumer():
    while True:
        d = yield 'data from consumer'
        print('[Consumer] get data from producer: %d' % d)

def producer(consumer_obj):
    next(consumer_obj)
    for count in range(5):
        print('[Producer] producing %d' % count)
        consumer_obj.send(count)
    consumer_obj.close()

c = consumer()
producer(c)

在上面的代码中,利用生成器实现的协程。可以在无锁的情况下实现从 producer()consumer() 传递数据。单独看 consumer() 还是生成器类型,但是整体是借助生成器实现任务的协作控制。

@asyncio.coroutine与yield from

在前面的例子表现,多个协程的协作还比较麻烦,看起来也还和原始的生成器类似,只能通过 send() 进行数据传递与任务恢复。在 pep-3156 开始引入事件循环,并可以通过 @asyncio.coroutine 显示地声明为协程,可以将多个协程在事件循环中运行。

一个简单的例子如下所示:

@asyncio.coroutine
def task1():
    while True:
        print('[Task1] Run task1 at %s' % datetime.now())
        yield from asyncio.sleep(2)

@asyncio.coroutine
def task2():
    while True:
        print('[Task2] Run task2 at %s' % datetime.now())
        yield from asyncio.sleep(2)

loop = asyncio.get_event_loop()
tasks = [task1(), task2()]
loop.run_until_complete(asyncio.gather(*tasks))
loop.close()

在上面的任务中 asyncio.sleep(2) 模拟异步 IO 操作,可以看到在同一线程下,协程 task1()task2() 在事件循环中执行,当其中一个协程阻塞在模拟的 IO 操作时,就会放弃 CPU,另一个协程就会被切换执行。利用事件循环可以更充分地利用 CPU 资源,同时可以避免线程切换带来的开销。

async 和 await

在上面的代码中,虽然实现了协程的并行执行,但是如果去查看 task1() 的类型,会发现依旧是生成器类型(class generator),看起来还是类似生成器的应用。而且使用 @asyncio.coroutine 以及 yield from 看起来也并不优雅。

pep-0492 中 Python 提出了全新的 async 和 await 语法,对 @asyncio.coroutine 以及 yield from 进行了替换,按照新的语法改写上面的代码如下所示:

async def task1():
    while True:
        print('[Task1] Run task1 at %s' % datetime.now())
        await asyncio.sleep(2)

async def task2():
    while True:
        print('[Task2] Run task2 at %s' % datetime.now())
        await asyncio.sleep(2)

loop = asyncio.get_event_loop()
tasks = [task1(), task2()]
loop.run_until_complete(asyncio.gather(*tasks))
loop.close()

在使用了全新的语法之后,task1() 的类型才变成了协程类型(class coroutine ) ,而从语法上,也与生成器的 yield 语法彻底区分开来。从各个方面将协程与生成器进行了区分。

总结

这篇文章是在梳理 Python 3 的异步操作时看到了各个技术博主随意使用生成器与协程,看着特别奇怪,新手真的会被各种奇怪的说法绕晕了。初看这两者,在 Python 中由于都是基于 yield 提供的中断与恢复机制,所以看起来确实很相似。但是从使用角度讲,这两者就有很大区别了。生成器本质上一个数据生产器,而协程是一个程序级别的并发控制机制。

当然如果直接从 async 和 await 用起来,那么就不会将协程与生成器弄混了。还是早日拥抱新特性吧。