Python协程的引入与原理分析 - IO多路复用

1、概念

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

协程的概念应该是从进程和线程演变而来的,他们都是独立的执行一段代码,但是不同是线程比进程要轻量级,协程比线程还要轻量级。多线程在同一个进程中执行,而协程通常也是在一个线程当中执行。

我们都知道Python由于GIL原因,其线程效率并不高,并且在*nix系统中,创建线程的开销并不比进程小,因此在并发操作时,多线程的效率还是受到了很大制约的。所以后来人们发现通过yield来中断代码片段的执行,同时交出了cpu的使用权,于是协程的概念产生了。在Python3.4正式引入了协程的概念

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

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

协程的优点:

(1)无需线程上下文切换的开销,协程避免了无意义的调度,由此可以提高性能(但也因此,程序员必须自己承担调度的责任,
同时,协程也失去了标准线程使用多CPU的能力)
(2)无需原子操作锁定及同步的开销
(3)方便切换控制流,简化编程模型
(4)高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。

协程的缺点:

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

2、实现线程

1、yield实现协程效果

def consumer(name):
    print('开始吃包子...')
    while True:
        print('\033[31;1m[consumer]%s需要包子\033[0m'%name)
        bone = yield   #接收send发送的数据
        print('\033[31;1m[%s]吃了%s个包子\033[0m'%(name,bone))
def producer(obj1):
    obj1.send(None)   #必须先发送None
    for i in range(3):
        print('\033[32;1m[producer]\033[0m正在做%s个包子'%i)
        obj1.send(i)


if __name__ == '__main__':
    con1 = consumer('消费者A')  #创建消费者对象
    producer(con1)

-----------------------------------------------------------
#output:
开始吃包子...
[consumer]消费者A需要包子
[producer]正在做0个包子
[消费者A]吃了0个包子
[consumer]消费者A需要包子
[producer]正在做1个包子
[消费者A]吃了1个包子
[consumer]消费者A需要包子
[producer]正在做2个包子
[消费者A]吃了2个包子
[consumer]消费者A需要包子

2、greenlet模块 实现程序间切换执行

import greenlet

def A():
    print('a.....')
    g2.switch()  #切换至B
    print('a....2')
    g2.switch()
def B():
    print('b.....')
    g1.switch()  #切换至A
    print('b....2')

g1 = greenlet.greenlet(A) #启动一个线程
g2 = greenlet.greenlet(B)
g1.switch()

3、gevent实现协程

Gevent 是一个第三方库,可以轻松通过gevent实现协程程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。

gevent会主动识别程序内部的IO操作,当子程序遇到IO后,切换到别的子程序。如果所有的子程序都进入IO,则阻塞。

import gevent

def foo():
    print('running in foo')
    gevent.sleep(2)
    print('com back from bar in to foo')
def bar():
    print('running in bar')
    gevent.sleep(2)
    print('com back from foo in to bar')

gevent.joinall([      #创建线程并行执行程序,碰到IO就切换
    gevent.spawn(foo),
    gevent.spawn(bar),
])

线程函数同步与异步比较:

import gevent
def task(pid):
    gevent.sleep(1)
    print('task %s done'%pid)

def synchronous():  #同步一个线程执行函数
    for i in range(1,10):
        task(i)
def asynchronous(): #异步一个线程执行函数
    threads = [gevent.spawn(task,i) for i in range(10)]
    gevent.joinall(threads)

print('synchronous:')
synchronous()   #同步执行时要等待执行完后再执行
print('asynchronous:')
asynchronous()  #异步时遇到等待则会切换执行

爬虫异步IO阻塞切换:

from urllib import request
import gevent,time
from gevent import monkey

monkey.patch_all()   #将程序中所有IO操作做上标记使程序非阻塞状态
def url_request(url):
    print('get:%s'%url)
    resp = request.urlopen(url)
    data = resp.read()
    print('%s bytes received from %s'%(len(data),url))

async_time_start = time.time() #开始时间
gevent.joinall([
    gevent.spawn(url_request,'https://www.python.org/'),
    gevent.spawn(url_request,'https://www.nginx.org/'),
    gevent.spawn(url_request,'https://www.ibm.com'),
])
print('haoshi:',time.time()-async_time_start) #总用时

 3、事件驱动

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理,另外两种常用的编程范式是(单线程)同步以及多线程编程。

服务器处理模型的程序时,有以下几种模型:

  1. 每收到一个请求,创建一个新的进程,来处理该请求;
  2. 每收到一个请求,创建一个新的线程,来处理该请求;
  3. 每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
  • 第1种方法,由于创建新的进程的开销比较大,所以,会导致服务器性能比较差,但实现比较简单。
  • 第2种方式,由于要涉及到线程的同步,有可能会面临死锁等问题。
  • 第3种方式,在写应用程序代码时,逻辑比前面两种都复杂。

综合考虑各方面因素,一般普遍认为第(3)种方式是大多数网络服务器采用的方式

让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。

Python协程的引入与原理分析 - IO多路复用_第1张图片

在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。

在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。

在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。

当程序中有许多任务,且任务之间高度独立(它们不需要互相通信,或等待彼此)而且在等待事件到来时,某些任务会阻塞时事件驱动模型时个很好的选择;当应用程序需要在任务间共享可变的数据时,事件驱动模式可以更好的在单线程下处理。

网络应用程序通常都是上述特点,这使得它们能够很好的契合事件驱动编程模型。

此处要提出一个问题,就是,上面的事件驱动模型中,只要一遇到IO就注册一个事件,然后主程序就可以继续干其它的事情了,只到io处理完毕后,继续恢复之前中断的任务,这本质上是怎么实现的呢?这就涉及到select\poll\epoll异步IO


4、IO多路复用

sllect, poll, epoll都是IO多路复用的机制。IO多路复用就是通过这样一种机制:一个进程可以监听多个描述符,一旦某个描述符就绪(一般是读就绪和写就绪),能够通知程序进行相应的操作。但select,poll,epoll本质上都是同步IO,因为他们都需要在读写事件就绪后自己负责进行读写(即将数据从内核空间拷贝到应用缓存)。也就是说这个读写过程是阻塞的。而异步IO则无需自己负责读写,异步IO的实现会负责把数据从内核拷贝到用户空间。

select   

select函数监听的文件描述符分三类:writefds、readfds、和exceptfds。调用后select函数会阻塞,直到描述符就绪
(有数据可读、写、或者有except)或者超时(timeout指定等待时间,如果立即返回则设置为null),函数返回。当select函数返回
后,可以通过遍历fdset,来找到就绪的描述符。

优点:良好的跨平台性(几乎所有的平台都支持)
缺点:单个进程能够监听的文件描述符数量存在最大限制,在linux上一般为1024,可以通过修改宏定义甚至重新编译内核来提升,
但是这样也会造成效率降低。

poll  

不同于select使用三个位图来表示fdset的方式,poll使用的是pollfd的指针实现

   pollfd结构包含了要监听的event和发生的event,不再使用select“参数-值”传递的方式。同时pollfd并没有最大数量限制(但
是数量过大之后性能也是会下降)。和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

    从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在
同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会下降。

epoll

epoll是在linux2.6内核中国提出的,(windows不支持),是之前的select和poll增强版。相对于select和poll来说,epoll更加
灵活,没有描述符的限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的时间存放到内核的一个时间表中。这
样在用户控件和内核控件的coppy只需要一次。

如何选择?

①在并发高同时连接活跃度不是很高的请看下,epoll比select好(网站或web系统中,用户请求一个页面后随时可能会关闭)

②并发性不高,同时连接很活跃,select比epoll好。(比如说游戏中数据一但连接了就会一直活跃,不会中断)

 省略章节:由于在用到select的时候需要嵌套多层回调函数,然后印发一系列的问题,如可读性差,共享状态管理困难,出现异常排查复杂,于是引入协程,既操作简单,速度又快。


对于上面的问题,我们希望去解决这样几个问题:

  1. 采用同步的方式去编写异步的代码,使代码的可读性高,更简便。
  2. 使用单线程去切换任务(就像单线程间函数之间的切换那样,速度超快)

   (1):线程是由操作系统切换的,单线程的切换意味着我们需要程序员自己去调度任务。

   (2):不需要锁,并发性高,如果单线程内切换函数,性能远高于线程切换,并发性更高。

例如我们在做爬虫的时候:

def get_url(url):
    html = get_html(url) # 此处网络下载IO操作比较耗时,希望切换到另一个函数去执行
    infos = parse_html(html)

def get_html(url):  # 下载url中的html
    pass

def parse_html(html): # 解析网页
    pass

意味着我们需要一个可以暂停的函数,对于此函数可以向暂停的地方穿入值。(回忆我们的生成器函数就可以满足这两个条件)所以就引入了协程。


生成器进阶

  • 生成器不仅可以产出值,还可以接收值,用send()方法。注意:在调用send()发送非None值之前必须先启动生成器,可以用①next()②send(None)两种方式激活
  • close()方法。
  •  调用throw()方法。用于抛出一个异常。该异常可以捕捉忽略。

特别注意:调用close.()之后, 生成器在往下运行的时候就会产生出一个GeneratorExit,单数如果用try捕获异常的话,就算捕获了遇到后面还有yield的话,还是不能往下运行了,因为一旦调用close方法生成器就终止运行了(如果还有next,就会会产生一个异常)所以我们不要去try捕捉该异常。(此注意可以先忽略)

yield from 功能总结:

1、子生成器生产的值,都是直接给调用方;调用发通过.send()发送的值都是直接传递给子生成器,如果传递None,会调用子
生成器的next()方法,如果不是None,会调用子生成器的sen()方法。
2、子生成器退出的时候,最后的return EXPR,会触发一个StopIteration(EXPR)异常
3、yield from 表达式的值,是子生成器终止时,传递给StopIteration异常的第一个参数。
4、如果调用的时候出现了StopIteration异常,委托方生成器恢复运行,同时其他的异常向上冒泡。
5、传入委托生成器的异常里,除了GeneratorExit之后,其他所有异常全部传递给子生成器的.throw()方法;如果调用.throw()的
时候出现StopIteration异常,那么就恢复委托生成器的运行,其他的异常全部向上冒泡
6、如果在委托生成器上调用.close()或传入GeneratorExit异常,会调用子生成器的.close()方法,没有就不调用,如果在调用
.close()时候抛出了异常,那么就向上冒泡,否则的话委托生成器跑出GeneratorExit 异常。 

相关概念

  • 并发:指一个时间段内,有几个程序在同一个cpu上运行,但是任意时刻只有一个程序在cpu上运行。比如说在一秒内cpu切换了100个进程,就可以认为cpu的并发是100。
  • 并行:值任意时刻点上,有多个程序同时运行在cpu上,可以理解为多个cpu,每个cpu独立运行自己程序,互不干扰。并行数量和cpu数量是一致的。

我们平时常说的高并发而不是高并行,是因为cpu的数量是有限的,不可以增加。

形象的理解:cpu对应一个人,程序对应喝茶,人要喝茶需要四个步骤(可以对应程序需要开启四个线程):1烧水,2备茶叶,3洗茶杯,4泡茶。

并发方式:烧水的同时做好2备茶叶,3洗茶杯,等水烧好之后执行4泡茶。因为1、23事件同时进行,会比顺序执行1234要省时间。

并行方式:叫来四个人(开启四个进程),分别执行任务1234,整个程序执行时间取决于耗时最多的步骤。

  • 同步 (注意同步和异步只是针对于I/O操作来讲的)值调用IO操作时,必须等待IO操作完成后才开始新的的调用方式。
  • 异步 指调用IO操作时,不必等待IO操作完成就开始新的的调用方式。
  • 阻塞  指调用函数的时候,当前线程被挂起。
  • 非阻塞  指调用函数的时候,当前线程不会被挂起,而是立即返回。

协程与进程、线程的关系

                           Python协程的引入与原理分析 - IO多路复用_第2张图片

你可能感兴趣的:(python学习)