理解 python 中的协程

3,239 阅读10分钟
原文链接: www.jianshu.com

什么是Coroutine?

Coroutine,又称作协程。从字面上来理解,即协同运行的例程,它是比是线程(thread)更细量级的用户态线程,特点是允许用户的主动调用和主动退出,挂起当前的例程然后返回值或去执行其他任务,接着返回原来停下的点继续执行。等下,这是否有点奇怪?我们都知道一般函数都是线性执行的,不可能说执行到一半返回,等会儿又跑到原来的地方继续执行。但一些熟悉python(or其他动态语言)的童鞋都知道这可以做到,答案是用yield语句。其实这里我们要感谢操作系统(OS)为我们做的工作,因为它具有getcontext和swapcontext这些特性,通过系统调用,我们可以把上下文和状态保存起来,切换到其他的上下文,这些特性为coroutine的实现提供了底层的基础。操作系统的Interrupts和Traps机制则为这种实现提供了可能性,因此它看起来可能是下面这样的:


系统调用

栗子?

理解生成器(generator)

学过生成器和迭代器的同学应该都知道python有yield这个关键字,yield能把一个函数变成一个generator,与return不同,yield在函数中返回值时会保存函数的状态,使下一次调用函数时会从上一次的状态继续执行,即从yield的下一条语句开始执行,这样做有许多好处,比如我们想要生成一个数列,若该数列的存储空间太大,而我们仅仅需要访问前面几个元素,那么yield就派上用场了,它实现了这种一边循环一边计算的机制,节省了存储空间,提高了运行效率。

这里以斐波那契数列为例:

def fib(max):
    n, a, b = 0, 0, 1
    while n  max:
        print b
        a, b = b, a + b
        n = n + 1

如果使用上述的算法,那么我每一次调用函数时,都要耗费大量时间循环做重复的事情。而如果使用yield的话,它则会生成一个generator,当我需要时,调用它的next方法获得下一个值,改动的方法很简单,直接把print改为yield就OK。

def fib(max):
    n, a, b = 0, 0, 1
    while n  max:
        yield b
        a, b = b, a + b
        n = n + 1

生产者-消费者的协程

OK,现在理解yield语句的作用了吗?

下面这个例子是典型的生产者-消费者问题,我们用协程的方式来实现它。首先从主程序中开始看,第一句c = consumer(),因为consumer函数中存在yield语句,python会把它当成一个generator(生成器,注意:生成器和协程的概念区别很大,等会再说,千万别混淆了两者),因此在运行这条语句后,python并不会像执行函数一样,而是返回了一个generator object。

再看第二条语句c.send(None),这条语句的作用是将consumer(即变量c,它是一个generator)中的语句推进到第一个yield语句出现的位置,那么在例子中,consumer中的status = Truewhile True:都已经被执行了,程序停留在n = yield status的位置(注意:此时这条语句还没有被执行),上面说的send(None)语句十分重要,如果漏写这一句,那么程序直接报错,这个send()方法看上去似乎挺神奇,等下再讲他的作用。

下面第三句p = producer(c),这里则像上面一样定义了producer的生成器,注意的是这里我们传入了消费者的生成器,来让producer跟consumer通信。

第四句for status in p:,这条语句会循环地运行producer和获取它yield回来的状态。

好了,进入正题,现在我们要让生产者发送1,2,3,4,5给消费者,消费者接受数字,返回状态给生产者,而我们的消费者只需要3,4,5就行了,当数字等于3时,会返回一个错误的状态。最终我们需要由主程序来监控生产者-消费者的过程状态,调度结束程序。

现在程序流进入了producer里面,我们直接看yield consumer.send(n),生产者调用了消费者的send()方法,把n发送给consumer(即c),在consumer中的n = yield status,n拿到的是消费者发送的数字,同时,consumer用yield的方式把状态(status)返回给消费者,注意:这时producer(即消费者)的consumer.send()调用返回的就是consumer中yield的status!消费者马上将status返回给调度它的主程序,主程序获取状态,判断是否错误,若错误,则终止循环,结束程序。上面看起来有点绕,其实这里面generator.send(n)的作用是:把n发送generator(生成器)中yield的赋值语句中,同时返回generator中yield的变量(结果)。

于是程序便一直运作,直至consumer中获取的n的值变为3!此时consumer把status变为False,最后返回到主程序,主程序中断循环,程序结束。(观察输出结果,是否如你所想?)

这种协程的方式保证了程序以有序地方式协作运行例程,而且它还能保存函数中的上下文状态(看看status),使每次重新进入时都能获取以前上下文的变量值。

#-*- coding:utf-8
def consumer():
    status = True
    while True:
        n = yield status
        print("我拿到了{}!".format(n))
        if n == 3:
            status = False

def producer(consumer):
    n = 5
    while n > 0:
        # yield给主程序返回消费者的状态
        yield consumer.send(n)
        n -= 1
 
if __name__ == '__main__':
    c = consumer()
    c.send(None)
    p = producer(c)
    for status in p:
        if status == False:
            print("我只要3,4,5就行啦")
            break
    print("程序结束")

输出结果:

我拿到了5!
我拿到了4!
我拿到了3!
我只要3,4,5就行啦
程序结束

Coroutine与Generator

有些人会把生成器(generator)和协程(coroutine)的概念混淆,我以前也会这样,不过其实发现,两者的区别还是很大的。

直接上最重要的区别:

  • generator总是生成值,一般是迭代的序列
  • coroutine关注的是消耗值,是数据(data)的消费者
  • coroutine不会与迭代操作关联,而generator会
  • coroutine强调协同控制程序流,generator强调保存状态和产生数据

相似的是,它们都是不用return来实现重复调用的函数/对象,都用到了yield(中断/恢复)的方式来实现。

Coroutine的应用

管道

下面展示如何用coroutine实现管道。


首先了解管道的要素:

  • 要有起始的源(source)

  • 要有终点(end-point)

首先,我们在source建立一个模拟Unix的tail -f命令的coroutine。

import time
def follow(thefile, target):
    thefile.seek(0,2)      # 到达文件的底部
    while True:
         line = thefile.readline()
         if not line:
             time.sleep(0.1)
             continue
         target.send(line)

它接受一个文件以及一个coroutine对象作为参数,用send()把文件每一行传递到管道一侧的coroutine中。

现在,我们再为管道接上终点(end-point),也就是下面的printer(),它将负责打印行结果。
这里定义了coroutine作为装饰器,帮我们做了send(None)的操作,免去了我们的手动调用。

def coroutine(func):
    def wrapper(*args, **kws):
        cr = func(*args, **kws)
        cr.send(None)
        return cr 
    return wrapper

@coroutine
def printer():
    while True:
         line = (yield)
         print line

注意了,目前为止,我们的管道是这样的:follow -> printer。

下面我们尝试使用它,跟踪读取一个日志文件:

if __name__ == '__main__':
    f = open("access-log")
    follow(f, printer())

而现在,我们在管道中间定义一个过滤器,同样的,模拟的是UNIX中grep的功能。

def grep(pattern):
    print("Looking for %s" % pattern)
    while True:
        line = (yield)
        if pattern in line:
            print line,

到了这里,我们的管道是这样的:follow -> grep -> printer。

使用它:

if __name__ == '__main__':
    f = open("access-log")
        follow(f,
               grep('python',
               printer()))

利用coroutine我们可以很便捷地根据需要来拼接管道,定制不同的功能,十分灵活。


branch

我们甚至可以用多管道的方式,分支查找数据。

• A more disturbing variation...
f = open("access-log")
p = printer()
follow(f,
       broadcast([grep('python',p),
                  grep('ply',p),
)
grep('swig',p)])

事件处理

用coroutine使事件处理的方式更加简单。

试想一下Python中的XML Parser,里面就有Event Handling的概念。转换成coroutine way,我们可以借助send()方法,接受和处理Handler中发来的数据。


事件分发

下面演示了一个爬取公交车信息的例子,它使用了xml.sax
来解析读取的xml信息,其中用到了事件处理和数据过滤。

@coroutine 
def text_collector(target):
    while True:
        while True:
            event, value = (yield)
            if event == 'start' or event == 'end':
                target.send((event,value))
            else:
                chunks = [value]
                while True:
                    event, value = (yield)
                    if event != 'text': break
                    chunks.append(value)
                target.send(('text',"".join(chunks)))
                target.send((event,value))

@coroutine
def buses_to_dicts(target):
    while True:
        event, value = (yield)
        # Look for the start of a  element
        if event == 'start' and value[0] == 'bus':
            busdict = { }
            # Capture text of inner elements in a dict
            while True:
                event, value = (yield)
                if event == 'text':  
                    textvalue = value
                elif event == 'end':
                    if value != 'bus':
                        busdict[value] = textvalue
                    else:
                        target.send(busdict)
                        break

@coroutine
def filter_on_field(fieldname,value,target):
    while True:
        d = (yield)
        if d.get(fieldname) == value:
            target.send(d)

@coroutine
def bus_locations():
    while True:
        bus = (yield)
        print "%(route)s,%(id)s,\"%(direction)s\","\
              "%(latitude)s,%(longitude)s" % bus 

if __name__ == '__main__':
    import xml.sax
    from cosax import EventHandler

    xml.sax.parse("allroutes.xml",
              EventHandler(
                   text_collector(
                   buses_to_dicts(
                   filter_on_field("route","22",
                   filter_on_field("direction","North Bound",
                   bus_locations()))))
              ))

异步IO

Coroutine的机制使得我们可以用同步的方式写出异步运行的代码。

总所周知,Python因为有GIL(全局解释锁)这玩意,不可能有真正的多线程的存在,因此很多情况下都会用multiprocessing实现并发,而且在Python中应用多线程还要注意关键地方的同步,不太方便,用协程代替多线程和多进程是一个很好的选择,因为它吸引人的特性:主动调用/退出,状态保存,避免cpu上下文切换等…一些时候,性能甚至能超越多线程的实现方式。

更简单地实现协程

asyncio

asyncio是python 3.4中新增的模块,它提供了一种机制,使得你可以用协程(coroutines)、IO复用(multiplexing I/O)在单线程环境中编写并发模型。

asyncio模块主要包括了:

  • 具有特定系统实现的事件循环(event loop);
  • 数据通讯和协议抽象(类似Twisted中的部分);
  • TCP,UDP,SSL,子进程管道,延迟调用和其他;
  • Future类;
  • yield from的支持;
  • 同步的支持;
  • 提供向线程池转移作业的接口;

下面来看下asyncio的一个例子:

import asyncio

async def compute(x, y):
    print("Compute %s + %s ..." % (x, y))
    await asyncio.sleep(1.0)
    return x + y

async def print_sum(x, y):
    result = await compute(x, y)
    print("%s + %s = %s" % (x, y, result))

loop = asyncio.get_event_loop()
loop.run_until_complete(print_sum(1, 2))
loop.close()

../_images/tulip_coro.png

当事件循环开始运行时,它会在Task中寻找coroutine来执行调度,因为事件循环注册了print_sum(),因此print_sum()被调用,执行result = await compute(x, y)这条语句(等同于result = yield from compute(x, y)),因为compute()自身就是一个coroutine,因此print_sum()这个协程就会暂时被挂起,compute()被加入到事件循环中,程序流执行compute()中的print语句,打印”Compute %s + %s …”,然后执行了await asyncio.sleep(1.0),因为asyncio.sleep()也是一个coroutine,接着compute()就会被挂起,等待计时器读秒,在这1秒的过程中,事件循环会在队列中查询可以被调度的coroutine,而因为此前print_sum()compute()都被挂起了,因此事件循环会停下来等待协程的调度,当计时器读秒结束后,程序流便会返回到compute()中执行return语句,结果会返回到print_sum()中的result中,最后打印result,事件队列中没有可以调度的任务了,此时loop.close()把事件队列关闭,程序结束。

Gevent

如果觉得asyncio还不够简便,那么gevent将使你的最佳选择。gevent是一个基于libv的封装了greenlet的网络库,主要用于协程以及并发的处理。

gevent的基本原理:

当一个greenlet遇到IO操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。

更多可了解gevent