python 协程

作为一只勤奋的小黄鸭,怎么可以不懂协程呢!!!!加油鸭

本篇大多转自https://blog.****.net/u014028063/article/details/81408395

协程,又称微线程,英文名Coroutine。

1、协程的基本概念

我们知道线程的调度(线程上下文切换)是由操 作系统决定的,当一个线程启动后,什么时候占用CPU、什么时候让出CPU,程序员都无法干涉。假设现在启动4个线程,CPU线程时间片为 5 毫秒,也就是说,每个线程每隔5ms就让出CPU,让其他线程抢占CPU。可想而知,等4个线程运行结束,要进行多少次切换?

如果我们能够自行调度自己写的程序,让一些代码块遇到IO操作时,切换去执行另外一些需要CPU操作的代码块,是不是节约了很多无畏的上下文切换呢?是的,协程就是针对这一情况而生的。我们把写好的一个应用程序分为很多个代码块,

python 协程

 

把应用程序的代码分为多个代码块,正常情况代码自上而下顺序执行。如果代码块A运行过程中,能够切换执行代码块B,又能够从代码块B再切换回去继续执行代码块A,这就实现了协程(通常是遇到IO操作时切换才有意义)。示意图如下:

 

python 协程

所以,关于协程可以总结以下两点:

(1)线程的调度是由操作系统负责,协程调度是程序自行负责。

(2)与线程相比,协程减少了无畏的操作系统切换。

实际上当遇到IO操作时做切换才更有意义,(因为IO操作不用占用CPU),如果没遇到IO操作,按照时间片切换,无意义。

举个例子,你在做一顿饭你要蒸饭和炒菜:最笨的方法是先蒸饭,饭蒸好了再去炒菜。这样一顿饭得花不少时间,就跟我们没采用并发编程一样。

多线程相当于,你5分钟在做蒸饭的工作,到了5分钟开始炒菜,又过了5分钟,你又去忙蒸饭。

协程相当于,你淘完米,放在电饭锅,按下煮饭键之后,你开始去炒菜。炒菜的时候油没热,你可以调佐料。这样,你炒两个菜出来,饭蒸好了。整个过程你没闲着,但是节约了不少时间。

2、协程的优缺点

优点:

  1. 无需线程上下文切换的开销
  2. 无需原子操作锁定及同步的开销。"原子操作(atomic operation)是不需要synchronized",所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者切割掉只执行部分。视作整体是原子性的核心。
  3. 方便切换控制流,简化编程模型。 高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。

缺点:

  1. 无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。当然 我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
  2. 进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序

 

3、yield(协程与生成器类似,都是定义体中包含 yield 关键字的函数)

3.1包含yield的函数,举例裴波那契函数

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

for re in fib(5):
    print(re)

执行结果:
1
1
2
3
5

当一个函数中包含yield语句时,python会自动将其识别为一个生成器。这时fib(5)并不会真正调用函数体,而是以函数体生成了一个生成器对象实例。

yield在这里可以保留fib函数的计算现场,暂停fib的计算并将b返回。而将fib放入for…in循环中时,每次循环都会调用next(fib(5)),唤醒生成器,执行到下一个yield语句处,直到抛出StopIteration异常。此异常会被for循环捕获,导致跳出循环。


3.2生成器的send方法

从上面的程序中可以看到,目前只有数据从fib(5)中通过yield流向外面的for循环;如果可以向fib(5)发送数据,那不是就可以在Python中实现协程了嘛。于是,Python中的生成器有了send函数,yield表达式也拥有了返回值

import time,random
def stupid_fib(n):
    index,a,b = 0,0,1
    while index < n:
        sleep_cnt = yield b
        print('let me think %s secs'%sleep_cnt)
        time.sleep(sleep_cnt)
        a, b = b, a + b
        index += 1
    print('-'*10 + 'test yield send' + '-'*10)
sfib = stupid_fib(5)
fib_res = next(sfib)
#next(sfib)相当于sfib.send(None),可以使得sfib运行至第一个yield处返回
while True:
    print(fib_res)
    try:
        fib_res = sfib.send(random.uniform(0, 0.5))
        #sfib.send(random.uniform(0, 0.5))则将一个随机的秒数发送给sfib,
        #作为当前中断的yield表达式的返回值
    except StopIteration:
        break

执行结果:
1
let me think 0.3081949420271053 secs
1
let me think 0.42677299320164236 secs
2
let me think 0.4280927967767854 secs
3
let me think 0.3217866019663162 secs
5
let me think 0.08867985436137671 secs
----------test yield send----------

其中next(sfib)相当于sfib.send(None),可以使得sfib运行至第一个yield处返回。后续的sfib.send(random.uniform(0, 0.5))则将一个随机的秒数发送给sfib,作为当前中断的yield表达式的返回值。这样,我们可以从“主”程序中控制协程计算斐波那契数列时的思考时间,协程可以返回给“主”程序计算结果。

3.3使用yield实现协程模拟生产者消费者模式

传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。

如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:

import time
def consumer(name):
    print("--->starting eating baozi...")
    while True:
        new_baozi = yield
        print("[%s] is eating baozi %s" % (name, new_baozi))
def producer():
    next(con)
    next(con2)
    n = 0
    while n < 5:
        n += 1
        print("\033[32;1m[producer]\033[0m is making baozi %s" % n)
        con.send(n)
        con2.send(n)
        time.sleep(1)

if __name__ == '__main__':
    con = consumer("c1")
    con2 = consumer("c2")
    p = producer()

执行结果:
--->starting eating baozi...
--->starting eating baozi...
[producer] is making baozi 1
[c1] is eating baozi 1
[c2] is eating baozi 1
[producer] is making baozi 2
[c1] is eating baozi 2
[c2] is eating baozi 2
[producer] is making baozi 3
[c1] is eating baozi 3
[c2] is eating baozi 3
[producer] is making baozi 4
[c1] is eating baozi 4
[c2] is eating baozi 4
[producer] is making baozi 5
[c1] is eating baozi 5
[c2] is eating baozi 5

注意到consumer函数是一个generator(生成器),把一个consumer传入produce后:

    首先调用next(con)启动生成器;

    然后,一旦生产了东西,通过con.send(n)切换到consumer执行;

    consumer通过yield拿到消息,处理,又通过yield把结果传回;

    produce拿到consumer处理的结果,继续生产下一条消息;

    produce决定不生产了,通过c.close()关闭consumer,整个过程结束。

整个流程无锁,由一个线程执行,produce和consumer协作完成任务,所以称为“协程”,而非线程的抢占式多任务。

最后套用Donald Knuth的一句话总结协程的特点:“子程序就是协程的一种特例。”

我们先给协程一个标准定义,即符合什么条件就能称之为协程:

  1. 必须在只有一个单线程里实现并发
  2. 修改共享数据不需加锁
  3. 用户程序里自己保存多个控制流的上下文栈
  4. 一个协程遇到IO操作自动切换到其它协程

基于上面这4点定义,我们刚才用yield实现的程并不能算是合格的线程,不满足最后一条。

 如何实现单线程下并发效果:遇到IO操作就切换,协程之所以能处理大并发,就是由于挤掉了IO操作,使得CPU一直运行。

关键在于切换出来后,什么时候再切换回去??需要程序自动监测IO操作,IO操作结束就切换回去

以上我们是通过yeild实现的协程的功能,yield能实现协程,不过实现过程不易于理解,greenlet是在这方面做了改进。接下来介绍python封装好的协程Greenlet。

4、使用GreenLet

Greenlet是python的一个C扩展,来源于Stackless python,旨在提供可自行调度的‘微线程’, 即协程。generator实现的协程在yield value时只能将value返回给调用者(caller)。 而在greenlet中,target.switch(value)可以切换到指定的协程(target), 然后yield value。greenlet用switch来表示协程的切换,从一个协程切换到另一个协程需要显式指定。是一种手动切换,后面会介绍能实现自动切换的Gevent

from greenlet import greenlet
def test1():
    print(12)
    gr2.switch()
    print(34)
    gr2.switch()
def test2():
    print(56)
    gr1.switch()
    print(78)
gr1 = greenlet(test1) #启动一个协程
gr2 = greenlet(test2)
gr1.switch() #手动切换

执行结果:
12
34
56
78

当创建一个greenlet时,首先初始化一个空的栈, switch到这个栈的时候,会运行在greenlet构造时传入的函数(首先在test1中打印 12), 如果在这个函数(test1)中switch到其他协程(到了test2 打印56),那么该协程会被挂起,等到切换回来(在test2中切换回来 打印34)。当这个协程对应函数执行完毕,那么这个协程就变成dead状态。

注意 下面代码打印test2的最后一行输出 78,因为在test1中又切换到gr2,否则若没有test1中最后这次切换,那么test2切换之后挂起,不从test1再切换回来,这个可能造成泄漏。