zoukankan      html  css  js  c++  java
  • python 协程、I/O模型

    一、引子 (超哥协程

      并发本质:保存状态+切换

      cpu正在运行一个任务,转而执行另一个任务的情概况:1、是该任务发生了阻塞;2、该任务计算的时间过长或有一个优先级更高的程序替代了它。

      协程本质上就是一个线程,使用代码来控制任务的切换。以前线程任务的切换是由操作系统控制的,遇到I/O自动切换,现在我们用协程的目的就是较少操作系统切换的开销(开关线程,创建寄存器、堆栈等,在他们之间进行切换等),在我们自己的程序里面来控制任务的切换。

      

      ps:在介绍进程理论时,提及进程的三种执行状态,而线程才是执行单位,所以也可以将上图理解为线程的三种状态   

      一:其中第二种情况并不能提升效率,只是为了让cpu能够雨露均沾,实现看起来所有任务都被“同时”执行的效果,如果多个任务都是纯计算的,这种切换反而会降低效率。

    二、yield 模拟单线程之间的任务切换过程

    #1 yiled可以保存状态,yield的状态保存与操作系统的保存线程状态很像,但是yield是代码级别控制的,更轻量级
    #2 send可以把一个函数的结果传给另外一个函数,以此实现单线程内程序之间的切换  
    import time
    
    def jishi1hao():
        for  i in range(6):
            print("快快来~~~~")
            yield #yield 可以记录任务的执行状态
            time.sleep(1)
            print("%s号客户2s搞定"%i)
    
    def jishi2hao():
        g=jishi1hao() #获取到生成器
        g.__next__() #执行第一段函数,到第一个yield结束
        for  i in range(5):
            time.sleep(1)
            print("%s号技师正在忙!!!"%i)
            g.__next__() #继续到下一个yield
    
    jishi2hao()
    #使用yield可以实现程序的切换执行,但是不能提高效率,只是简单的程序分段穿插运行
    通过yield实现任务切换+保存现场

      注意:yield可以实现任务的交替执行,但是不能提高程序的运行效率,反而降低了运行效率(切换过程需要时间)。

    #基于yield并发执行,多任务之间来回切换,这就是个简单的协程的体现,但是他能够节省I/O时间吗?不能
    import time
    def consumer():
        '''任务1:接收数据,处理数据'''
        while True:
            x=yield
            time.sleep(1) #发现什么?只是进行了切换,但是并没有节省I/O时间
            print('处理了数据:',x)
    def producer():
        '''任务2:生产数据'''
        g=consumer()
        next(g)  #找到了consumer函数的yield位置
        for i in range(3):
            g.send(i)  #给yield传值,然后再循环给下一个yield传值,并且多了切换的程序,比直接串行执行还多了一些步骤,导致执行效率反而更低了。
            print('发送了数据:',i)
    start=time.time()
    #基于yield保存状态,实现两个任务直接来回切换,即并发的效果
    #PS:如果每个任务中都加上打印,那么明显地看到两个任务的打印是你一次我一次,即并发执行的.
    producer() #我在当前线程中只执行了这个函数,但是通过这个函数里面的send切换了另外一个任务
    stop=time.time()
    
    # 串行执行的方式
    s_t=time.time()
    res=producer()
    consumer()
    e_t=time.time()
    print("yield的时间>>>",stop-start)
    print("串行的时间>>>",e_t-s_t)
    
    #结果显示
    #yield的时间>>> 3.0015313625335693
    #串行的时间>>> 3.0011236667633057
    单纯的切换反而会降低运行速度

      对于单线程下,我们不可避免程序中出现io操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到io阻塞时就切换到另外一个任务去计算,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的io操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,io比较少,从而更多的将cpu的执行权限分配给我们的线程。

      协程的本质就是在单线程下,由用户自己控制一个任务遇到io阻塞了就切换另外一个任务去执行,以此来提升效率。为了实现它,我们需要找寻一种可以同时满足以下条件的解决方案:

    #1、可以检测io操作,在遇到io操作的情况下才发生切换
    #2. 可以控制多个任务之间的切换,切换之前将任务的状态保存下来,以便重新运行时,可以基于暂停的位置继续执行。

    三、协程介绍

      协程:是单线程下的并发,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。

      需要强调的是:

    #1. python的线程属于内核级别的,即由操作系统控制调度(如单线程遇到io或执行时间过长就会被迫交出cpu执行权限,切换其他线程运行)
    #2. 单线程内开启协程,一旦遇到io,就会从应用程序级别(而非操作系统)控制切换,以此来提升效率(!!!非io操作的切换与效率无关)

      协程优点:

    #1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级
    #2. 单线程内就可以实现并发的效果,最大限度地利用cpu

      协程缺点

    #1. 协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程
    #2. 协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程

      

    总结协程特点:

    1. 必须在只有一个单线程里实现并发
    2. 修改共享数据不需加锁
    3. 用户程序里自己保存多个控制流的上下文栈
    4. 附加:一个协程遇到IO操作自动切换到其它协程(如何实现检测IO,yield、greenlet都无法实现,就用到了gevent模块(select机制)

    四、Greenlet

      

      如果我们在单个线程内有20个任务,要想实现在多个任务之间切换,使用yield生成器的方式过于麻烦(需要先得到初始化一次的生成器,然后再调用send。。。非常麻烦),而使用greenlet模块可以非常简单地实现这20个任务直接的切换

      #真正的协程模块就是使用greenlet完成的切换
    from greenlet import greenlet
    
    def eat(name):
        print('%s eat 1' %name)  #2
        g2.switch('taibai')   #3
        print('%s eat 2' %name) #6
        g2.switch() #7
    def play(name):
        print('%s play 1' %name) #4
        g1.switch()      #5
        print('%s play 2' %name) #8
    
    g1=greenlet(eat)
    g2=greenlet(play)
    
    g1.switch('taibai')#可以在第一次switch时传入参数,以后都不需要  1
    greenlet实现协程(不能提高效率)

      单纯的切换(在没有io的情况下或者没有重复开辟内存空间的操作),反而会降低程序的执行速度。

      greenlet只是提供了一种比generator更加便捷的切换方式,当切到一个任务执行时如果遇到io,那就原地阻塞,仍然是没有解决遇到IO自动切换来提升效率的问题。

     

    五、Gevent介绍

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

      

    #用法
    g1=gevent.spawn(func,1,2,3,x=4,y=5)创建一个协程对象g1,spawn括号内第一个参数是函数名,如eat,后面可以有多个参数,可以是位置实参或关键字实参,都是传给函数eat的,spawn是异步提交任务
    
    g2=gevent.spawn(func2)
    
    g1.join() #等待g1结束,上面只是创建协程对象,这个join才是去执行
    
    g2.join() #等待g2结束  有人测试的时候会发现,不写第二个join也能执行g2,是的,协程帮你切换执行了,但是你会发现,如果g2里面的任务执行的时间长,但是不写join的话,就不会执行完等到g2剩下的任务了
    
    
    gevent.joinall([g1,g2]) #等待列表中的所有任务执行完毕
    
    g1.value#拿到func1的返回值
    import gevent
    import time
    
    def func1(i):
        print("func1开始")
        gevent.sleep(2)
        print("1111>>>",i)
    
    def func2(i):
        print("func2开始")
        gevent.sleep(2)
        print("2222>>>", i)
    
    
    if __name__ == '__main__':
        s_t=time.time()
        g1=gevent.spawn(func1,"a")
        g2=gevent.spawn(func2,"b")
        g1.join()
        g2.join()
        e_t=time.time()
        print("gevent耗时>>>",e_t-s_t)
        print("主任务结束")
    gevent方法使用和时间测试

     

      from gevent import monkey;monkey.patch_all( )必须放在文件开头,表示拾取文件中的所有的I/O操作。

    from gevent import monkey;monkey.patch_all() #必须写在最上面,这句话后面的所有阻塞全部能够识别了
    
    import gevent  #直接导入即可
    import time
    def eat():
        #print()  
        print('eat food 1')
        time.sleep(2)  #加上mokey就能够识别到time模块的sleep了
        print('eat food 2')
    
    def play():
        print('play 1')
        time.sleep(1)  #来回切换,直到一个I/O的时间结束,这里都是我们个gevent做得,不再是控制不了的操作系统了。
        print('play 2')
    
    g1=gevent.spawn(eat)
    g2=gevent.spawn(play_phone)
    gevent.joinall([g1,g2])
    print('')
    monkey使用示例

      gevent中的同步与异步效率对比

    from gevent import spawn,joinall,monkey;monkey.patch_all()
    
    import time
    def task(pid):
        """
        Some non-deterministic task
        """
        time.sleep(0.5)
        print('Task %s done' % pid)
    
    
    def synchronous():#同步提交任务,串行,一次出来一个
        for i in range(10):
            task(i)
    
    def asynchronous():#异步提交任务
        g_l=[spawn(task,i) for i in range(10)]
        joinall(g_l)
    
    if __name__ == '__main__':
        print('Synchronous:')
        synchronous()
    
        print('Asynchronous:')
        asynchronous()
    gevent 同步和异步

    六、协程的应用

      爬虫

    from gevent import monkey;monkey.patch_all()
    import gevent
    import requests
    import time
    
    def get_page(url):
        print('GET: %s' %url)
        response=requests.get(url)
        if response.status_code == 200:
            print('%d bytes received from %s' %(len(response.text),url))
    if __name__ == '__main__':

    start_time=time.time()
    gevent.joinall([
    gevent.spawn(get_page,'https://www.zhihu.com/'),
    gevent.spawn(get_page,'https://www.yahoo.com/'),
    gevent.spawn(get_page,'https://github.com/'),
    ])
    stop_time=time.time()
    print('run time is %s' %(stop_time-start_time))
     

    七、I/O模型简介(超哥IO模型

      Stevens在文章中一共比较了五种IO Model:
      * blocking IO          阻塞IO
      * nonblocking IO    非阻塞IO
      * IO multiplexing    IO多路复用
      * signal driven IO   信号驱动IO(不常见,不讲)
      * asynchronous IO 异步IO

       再说一下IO发生时涉及的对象和步骤。对于一个network IO (这里我们以read、recv举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read/recv读数据的操作发生时,该操作会经历两个阶段:

    #1)等待数据准备 (Waiting for the data to be ready)
    #2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
    #1、输入操作:read、readv、recv、recvfrom、recvmsg共5个函数,如果会阻塞状态,则会经历wait data和copy data两个阶段,如果设置为非阻塞则在wait 不到data时抛出异常
    
    #2、输出操作:write、writev、send、sendto、sendmsg共5个函数,在发送缓冲区满了会阻塞在原地,如果设置为非阻塞,则会抛出异常
    
    #3、接收外来链接:accept,与输入操作类似
    
    #4、发起外出链接:connect,与输出操作类似
    网络常见的阻塞函数

      (1)、阻塞I/O(blocking IO)

      上图分析:两个阻塞阶段

      

      当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。

      而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

      

      (2)非阻塞 IO (设置socket 变成non-blocking)

       从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),然后返回。

      也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

      所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有。

    # 服务端
    import socket
    import time
    
    server=socket.socket()
    server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
    server.bind(('127.0.0.1',8083))
    server.listen(5)
    
    server.setblocking(False) #设置不阻塞
    r_list=[]  #用来存储所有来请求server端的conn连接
    w_list={}  #用来存储所有已经有了请求数据的conn的请求数据
    
    while 1:
        try:
            conn,addr=server.accept() #不阻塞,会报错
            r_list.append(conn)  #为了将连接保存起来,不然下次循环的时候,上一次的连接就没有了
        except BlockingIOError:
            # 强调强调强调:!!!非阻塞IO的精髓在于完全没有阻塞!!!
            # time.sleep(0.5) # 打开该行注释纯属为了方便查看效果
            print('在做其他的事情')
            print('rlist: ',len(r_list))
            print('wlist: ',len(w_list))
    
    
            # 遍历读列表,依次取出套接字读取内容
            del_rlist=[] #用来存储删除的conn连接
            for conn in r_list:
                try:
                    data=conn.recv(1024) #不阻塞,会报错
                    if not data: #当一个客户端暴力关闭的时候,会一直接收b'',别忘了判断一下数据
                        conn.close()
                        del_rlist.append(conn)
                        continue
                    w_list[conn]=data.upper()
                except BlockingIOError: # 没有收成功,则继续检索下一个套接字的接收
                    continue
                except ConnectionResetError: # 当前套接字出异常,则关闭,然后加入删除列表,等待被清除
                    conn.close()
                    del_rlist.append(conn)
    
    
            # 遍历写列表,依次取出套接字发送内容
            del_wlist=[]
            for conn,data in w_list.items():
                try:
                    conn.send(data)
                    del_wlist.append(conn)
                except BlockingIOError:
                    continue
    
    
            # 清理无用的套接字,无需再监听它们的IO操作
            for conn in del_rlist:
                r_list.remove(conn)
            #del_rlist.clear() #清空列表中保存的已经删除的内容
            for conn in del_wlist:
                w_list.pop(conn)
            #del_wlist.clear()
    
    #客户端
    import socket
    import os
    import time
    import threading
    client=socket.socket()
    client.connect(('127.0.0.1',8083))
    
    while 1:
        res=('%s hello' %os.getpid()).encode('utf-8')
        client.send(res)
        data=client.recv(1024)
    
        print(data.decode('utf-8'))
    
    
    ##多线程的客户端请求版本
    # def func():
    #     sk = socket.socket()
    #     sk.connect(('127.0.0.1',9000))
    #     sk.send(b'hello')
    #     time.sleep(1)
    #     print(sk.recv(1024))
    #     sk.close()
    #
    # for i in range(20):
    #     threading.Thread(target=func).start()
    非阻塞IO示例

      虽然我们上面的代码通过设置非阻塞,规避了IO操作,但是非阻塞IO模型绝不被推荐。

      我们不能否定其优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在“”同时“”执行)。

      (3)多路复用IO(IO multiplexing) (重点)

        多路复用:采用了代理(select)模式,把所有的需要监控的对象传递给selct监控,select监控到了被监控对象有动作,就返回,执行相应的任务。

      IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

      当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

       python中的select模块: 

    import select
    
    fd_r_list, fd_w_list, fd_e_list = select.select(rlist, wlist, xlist, [timeout])
    
    参数: 可接受四个参数(前三个必须)
        rlist: wait until ready for reading  #等待读的对象,你需要监听的需要获取数据的对象列表
        wlist: wait until ready for writing  #等待写的对象,你需要写一些内容的时候,input等等,也就是说我会循环他看看是否有需要发送的消息,如果有我取出这个对象的消息并发送出去,一般用不到,这里我们也给一个[]。
        xlist: wait for an “exceptional condition”  #等待异常的对象,一些额外的情况,一般用不到,但是必须传,那么我们就给他一个[]。
        timeout: 超时时间
        当超时时间 = n(正整数)时,那么如果监听的句柄均无任何变化,则select会阻塞n秒,之后返回三个空列表,如果监听的句柄有变化,则直接执行。
    返回值:三个列表与上面的三个参数列表是对应的
      select方法用来监视文件描述符(当文件描述符条件不满足时,select会阻塞),当某个文件描述符状态改变后,会返回三个列表
        1、当参数1 序列中的fd满足“可读”条件时,则获取发生变化的fd并添加到fd_r_list中
        2、当参数2 序列中含有fd时,则将该序列中所有的fd添加到 fd_w_list中
        3、当参数3 序列中的fd发生错误时,则将该发生错误的fd添加到 fd_e_list中
        4、当超时时间为空,则select会一直阻塞,直到监听的句柄发生变化

      结论: select的优势在于可以处理多个连接,不适用于单个连接  

    #服务端
    from socket import *
    import select
    server = socket(AF_INET, SOCK_STREAM)
    server.bind(('127.0.0.1',8093))
    server.listen(5)
    # 设置为非阻塞
    server.setblocking(False)
    
    # 初始化将服务端socket对象加入监听列表,后面还要动态添加一些conn连接对象,当accept的时候sk就有感应,当recv的时候conn就有动静
    rlist=[server,]
    rdata = {}  #存放客户端发送过来的消息
    
    wlist=[]  #等待写对象
    wdata={}  #存放要返回给客户端的消息
    
    print('预备!监听!!!')
    count = 0 #写着计数用的,为了看实验效果用的,没用
    while True:
        # 开始 select 监听,对rlist中的服务端server进行监听,select函数阻塞进程,直到rlist中的套接字被触发(在此例中,套接字接收到客户端发来的握手信号,从而变得可读,满足select函数的“可读”条件),被触发的(有动静的)套接字(服务器套接字)返回给了rl这个返回值里面;
        rl,wl,xl=select.select(rlist,wlist,[],0.5)
        print('%s 次数>>'%(count),wl)
        count = count + 1
        # 对rl进行循环判断是否有客户端连接进来,当有客户端连接进来时select将触发
        for sock in rl:
            # 判断当前触发的是不是socket对象, 当触发的对象是socket对象时,说明有新客户端accept连接进来了
            if sock == server:
                # 接收客户端的连接, 获取客户端对象和客户端地址信息
                conn,addr=sock.accept()
                #把新的客户端连接加入到监听列表中,当客户端的连接有接收消息的时候,select将被触发,会知道这个连接有动静,有消息,那么返回给rl这个返回值列表里面。
                rlist.append(conn)
            else:
                # 由于客户端连接进来时socket接收客户端连接请求,将客户端连接加入到了监听列表中(rlist),客户端发送消息的时候这个连接将触发
                # 所以判断是否是客户端连接对象触发
                try:
                    data=sock.recv(1024)
                    #没有数据的时候,我们将这个连接关闭掉,并从监听列表中移除
                    if not data:
                        sock.close()
                        rlist.remove(sock)
                        continue
                    print("received {0} from client {1}".format(data.decode(), sock))
                    #将接受到的客户端的消息保存下来
                    rdata[sock] = data.decode()
    
                    #将客户端连接对象和这个对象接收到的消息加工成返回消息,并添加到wdata这个字典里面
                    wdata[sock]=data.upper()
                    #需要给这个客户端回复消息的时候,我们将这个连接添加到wlist写监听列表中
                    wlist.append(sock)
                #如果这个连接出错了,客户端暴力断开了(注意,我还没有接收他的消息,或者接收他的消息的过程中出错了)
                except Exception:
                    #关闭这个连接
                    sock.close()
                    #在监听列表中将他移除,因为不管什么原因,它毕竟是断开了,没必要再监听它了
                    rlist.remove(sock)
        # 如果现在没有客户端请求连接,也没有客户端发送消息时,开始对发送消息列表进行处理,是否需要发送消息
        for sock in wl:
            sock.send(wdata[sock])
            wlist.remove(sock)
            wdata.pop(sock)
    
        # #将一次select监听列表中有接收数据的conn对象所接收到的消息打印一下
        # for k,v in rdata.items():
        #     print(k,'发来的消息是:',v)
        # #清空接收到的消息
        # rdata.clear()
    
    ---------------------------------------
    #客户端
    from socket import *
    
    client=socket(AF_INET,SOCK_STREAM)
    client.connect(('127.0.0.1',8093))
    
    
    while True:
        msg=input('>>: ').strip()
        if not msg:continue
        client.send(msg.encode('utf-8'))
        data=client.recv(1024)
        print(data.decode('utf-8'))
    
    client.close()
    select 网络IO模型代码

      

    select做得事情和第二阶段的阻塞没有关系,就是从内核态将数据拷贝到用户态的阻塞,始终帮你做得监听的工作,帮你节省了一些第一阶段阻塞的时间。

       IO多路复用的机制:

        select机制: Windows、Linux

        poll机制    : Linux    #和lselect监听机制一样,但是对监听列表里面的数量没有限制,select默认限制是1024个,但是他们两个都是操作系统轮询每一个被监听的文件描述符(如果数量很大,其实效率不太好),看是否有可读操作。

        epoll机制  : Linux    #它的监听机制和上面两个不同,他给每一个监听的对象绑定了一个回调函数,你这个对象有消息,那么触发回调函数给用户,用户就进行系统调用来拷贝数据,并不是轮询监听所有的被监听对象,这样的效率高很多。

  • 相关阅读:
    Luogu P1067 多项式模拟
    关于事件流,事件冒泡和事件捕获
    JavaScript高程读书笔记
    前端面试题2017
    Bootstrap教程
    jquery实现JSON数据获取
    AJAX基本格式步骤
    【转】XMLHTTP中setRequestHeader参数问题
    warp()和wrapAll()区别
    append()和appendTo(),prepend()和prependTo()区别
  • 原文地址:https://www.cnblogs.com/angle6-liu/p/10064616.html
Copyright © 2011-2022 走看看