Python 协程与异步IO


title: 协程与异步IO
copyright: true
top: 0
date: 2019-03-29 17:38:02
tags: 协程与异步IO
categories: Python高阶笔记
permalink:
password:
keywords:
description: Python中协程的概念与原理,UNIX下五大IO模型,包括生成器,生成器实现协程方法。

城市映在她的眼瞳里,仿佛昏黄色的星海。

在此之后可以尝试阅读Python 异步协程概念,协程是一个很大的框架,需要从基础原理慢慢学习。

常见概念

CPU执行程序

一个CPU在一个时刻只能运行一个程序,但是因为CPU的执行效率高速度快,每次程序切换快人类肉眼是无法察觉的,所以给人感觉是在同时运行,比如你看网页同时听歌,单核CPU并不是不可以多任务处理,只是单核CPU的多任务能力相对多核CPU较弱而已,看网页和听歌都是对CPU资源占用非常少的应用。

并发

并发:一个时间段内,有多个程序在同一个CPU上运行,但是在任意时刻只有一个程序在CPU上运行。

并发是指一段时间内CPU上执行的程序数。(可以实现高并发)

并行

并行:一个时刻有多个程序在多个CPU上同时运行,并行的概念是对多核CPU来言的,每个CPU独立运行自己的程序,互不干扰。

并行是指多个CPU在同一个时刻运行多个程序。(无法实现高并行)

同步

同步:同步就是协同步调,按预定的先后次序进行运行。可理解为进程或线程A和B一块配合,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行,B依言执行,再将结果给A,A再继续操作。好比一个函数调用在没结束前原来的函数啥都不能做,粗略理解为:在等待一件事情的处理结果时, 对方是否提供通知服务, 如果对方不提供通知服务, 则为同步。

同步是指在调用IO操作或者等待的时候,必须等待IO操作完成或者等待完成才返回的调用方式。

异步

异步:好比一个函数调用后,原来的函数继续干自己的事情,等那个函数干完后,借助某种手段通知原来的函数执行结果。也是一种目的,一般是通过多线程技术去实现。粗略理解为:在等待一件事情的处理结果时, 对方是否提供通知服务, 如果对方提供通知服务, 则为异步。

异步是指代码在进行IO操作或者等待的时候,不必等待IO操作完成或者等待完成就直接返回的调用方式。

同步异步是一种消息通信机制,类似于发一个消息给另一个线程或者另一个协程让他去执行某个操作,提交数据后会得到一个future,后期通过这个future拿到结果

阻塞

阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。粗略理解为:在等待一件事情的处理结果时, 你是否还去干点其他的事情, 如果不去, 则为阻塞。

非阻塞

非阻塞:在不能立刻得到结果之前,该调用不会阻塞当前线程。粗略理解为:在等待一件事情的处理结果时, 你是否还去干点其他的事情, 如果去了, 则为非阻塞。

阻塞非阻塞是一种函数调用机制,重点关注程序代码在等待调用结果(消息,返回值)时的状态

举例说明

同步的定义看起来跟阻塞很像,但是同步跟阻塞是两个概念, 同步调用的时候,线程不一定阻塞,调用虽然没返回,但它还是在运行状态中的,CPU很可能还在执行这段代码,而阻塞的话,它就肯定不在CPU中跑这个代码了。这就是同步和阻塞的区别。同步是肯定可以在,阻塞是肯定不在。

异步和非阻塞的定义比较像,两者的区别是异步是说调用的时候结果不会马上返回,线程可能被阻塞起来,也可能不阻塞,两者没关系。非阻塞是说调用的时候,线程肯定不会进入阻塞状态。

上面两组概念,就有4种组合。

同步阻塞调用:得不到结果不返回,线程进入阻塞态等待。

同步非阻塞调用:得不到结果不返回,线程不阻塞一直在CPU运行。

异步阻塞调用:去到别的线程,让别的线程阻塞起来等待结果,自己不阻塞。

异步非阻塞调用:去到别的线程,别的线程一直在运行,直到得出结果。

任务:浪子要完成烧水泡茶和拖地。

并发:在半个小时内,浪子完成了烧水和拖地和泡茶三个任务。

并行:浪子把小桃红叫过来,浪子拖地,小桃红烧水泡茶。

同步:浪子在烧水的时候,时时刻刻盯着烧水壶,水烧开了就去泡茶。(所谓同步异步,只是对于水壶而言。)

异步:浪子买了个智能烧水壶,水烧开了会滴滴叫,浪子在烧水的时候,可以选择去拖地或者干小桃红,然后水开了滴滴叫,浪子就去泡茶。(所谓同步异步,只是对于水壶而言。)

阻塞:浪子先烧水,等水烧开了,然后泡茶,泡完茶了就去拖地。(所谓阻塞非阻塞,只是对于浪子而言。)

非阻塞:浪子先烧水,然后去拖地,水烧开了就去泡茶。(所谓阻塞非阻塞,只是对于浪子而言。)

同步阻塞:浪子在烧水的时候,时时刻刻盯着烧水壶,水烧开了就去泡茶,泡完茶就去拖地。

同步非阻塞:浪子先烧水,然后拖地,时不时去看看水烧开了没,烧开了就泡茶。

异步阻塞:浪子买了个智能烧水壶,浪子开始烧水,然后浪子坐在那里等水壶滴滴叫,什么都不干。

异步非阻塞:浪子买了个智能烧水壶,浪子开始烧水,然后去拖地,水开了滴滴叫,浪子就去泡茶。

在网上有一个很好的解释,这里直接贴过来原地址

同步异步和阻塞非阻塞要分开来看。

同步异步关心的是“消息通知机制”。

比如你打电话去书店问你这里有没有某某书。

同步的做法是,老板会说让你等一下,我找找。这时整个通信过程会在一次通话中完成。

异步的做法是,老板说我找一下,迟点在回复你。此时通信过程分成两次通话完成。

而阻塞非阻塞关心的是“程序在等待调用结果(消息,返回值)时的状态”。

同样是上面的例子。

阻塞的做法是你打电话问了之后,就一直拿着电话在等老板的回复,等待期间其他什么都不干。

而非阻塞则是你先放下电话,等老板来回复才回来继续这个通话。

那么剩下的就很好理解。

同步阻塞就是你一直在那里等,老板也不挂掉电话而是直到他找到或者没找到再回复你。

异步阻塞就是,尽管老板已经说了找到了再另行通知你,你仍就是停在那里,什么都不干的等电话

同步非阻塞就是,老板没挂断电话,但是你仍旧去干其他事情了,不在电话前等着他。

异步非阻塞就是大家都不等在电话前。

在查资料的时候,发现了一个很有趣的假设方法,贴上来原地址

餐厅来了10个顾客,为了提供最佳消费体验,不让顾客等待,为每个顾客分配一个服务员。服务员给顾客安排好座位,把菜单交给顾客,然后在一边等待顾客点餐。点完餐后把订单交给厨房,然后在厨房等待大厨烹饪。菜做好后,将菜送到桌上,然后在桌旁静静的看着顾客吃。。。

这就是同步,为了保证“实时”的服务,需要有一个专门的人员时刻等待。

有人说了,这尼玛不是有病么!哪个餐厅这么干?可是传统的web服务器就是这样的呢,比如apache。

 

现实中的餐厅为了节省成本,当然不会这么做,也许两个服务员就足够了。给新进来的顾客安排好座位,菜单交给他,然后就可以去忙活其他的顾客了。等顾客点好菜喊一声,赶紧冲过来下单。。。

这就是前文所说的异步方式,服务员不需要等待顾客完成全部任务,就“返回”了。

那有这种方式的web服务器么?当然有了,也是近年来的新趋势,如nginx、tornado等。

UNIX五大IO模型

阻塞式IO

阻塞式I/O模型:默认情况下,所有套接字都是阻塞的。

先理解这么个流程,一个输入操作通常包括两个不同阶段:

  1. 等待数据准备好;
  2. 从内核向进程复制数据。对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所有等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用程序缓冲区。 好,下面我们以阻塞套接字的recvfrom的的调用图来说明阻塞

从内核复制到用户空间:如果内存有8G,操作系统会吃1个G,其他的内存给别的程序吃,操作系统为了安全管理内存和权限问题,recvfrom函数其实就是操作系统提供的函数,recvfrom先从程序中请求到操作系统,然后操作系统会去获取到数据(比如连接网络获取数据,计算数学获取数据),操作系统中获取到数据后,放在自己的空间(也叫内核空间),程序想要得到数据,还需要从内核空间复制到外部的内存用户空间。

标红的这部分过程就是阻塞,直到阻塞结束recvfrom才能返回。

优点:起来非常简单,按照顺序一步一步的执行,需要等待的地方就等待。

缺点:中间等待的时间旧一直等待,浪费时间。

非阻塞式IO

非阻塞式I/O: 以下这句话很重要:进程把一个套接字设置成非阻塞是在通知内核,当所请求的I/O操作非得把本进程投入睡眠才能完成时,不要把进程投入睡眠,而是返回一个错误。看看非阻塞的套接字的recvfrom操作如何进行

可以看出recvfrom总是立即返回。

优点:使用起来也很简单,按照顺序一步一步的执行,需要要等待的地方的时候,可以做一些其他的事情(比如做计算或者发起其他连接请求),然后等待的地方完成等待返回结果后,执行后面的代码。

缺点:遇到需要要等待的地方时候,如果不做其他的事,内核中还是在不断的询问探测那个需要等待的地方时候是否完成,比较消耗cpu资源。

IO多路复用(目前主流)

I/O多路复用:虽然I/O多路复用的函数也是阻塞的,但是其与以上两种还是有不同的,I/O多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。如图

select,epool,pool都是IO多路复用的机制,通过一个进程可以监控多个描述符,一旦某个描述符就绪(即某个连接完成连接或者某个请求获取到数据),就通知程序进行相应的操作(即程序使用recvfrom函数从内核空间获取数据复制到用户空间)。

但是select,epool,pool本质上都是同步IO,他们都需要在读写事件就绪(即监控连接是否完成,监控某个请求是否获取到数据)后自己负责进行读写(即遍历检查没有就绪的描述符),这个读写过程是堵塞的(并且描述符数量大会严重消耗资源),异步IO则无序自己负责进行读写,异步IO会负责把数据直接从内核空间拷贝到用户空间。

优点:select,epool,pool是阻塞的,但是它可以同时监听多个文件句柄或者socket连接状态,如果其中某个socket连接有了数据,就会返回这个状态。比如同时发起了100个非阻塞式的socket连接请求,select监听这100个请求,如果其中某个连接成功,就程序通过recvfrom获取到连接,然后将数据从内核复制到用户空间。

缺点:没办法节省将数据从内核复制到用户空间的时间,select在单个进程中能监控的文件描述符的数量存在最大限制,在linux中一般为1024,select()所维护的存储大量的文件描述符的数据结构,随着文件miaoshu描述符的数量增大,其复制的开销也就不断的增大。同时,由于网络响应时间的延迟使得大量tcp连接处于非活跃状态,但调用select()会对所有的socket进行一次线性扫描,所以在一定程度上造成了浪费。

可以通过修改宏定义甚至重新编译内核修改。pool没有监控数量限制,但是和select一样,pool返回后,还需要遍历文件描述符中没有就绪的描述符,事实上随着监控的描述符数量增加,效率也会下降。

epool只有linux支持,是select和pool增强版,他没有描述符限制,使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中(这个事件表的数据结构是红黑树结构),简单的说, 使用epool的话,如果有100socket交给内核去检测,只不过当100里面有一个链接活跃了,这个内核会告诉用户哪一个链接有数据,所以用户直接取到这个链接去读数据就行了,这就是epoll最主要的优点。如果我们有6万个链接里面有两个活跃,只要循环去这两个就可以了。epoll也可以同时支持水平触发和边缘触发(水平触发就是说100个连接中有两个有数据,活跃了,内核返回给用户的程序,用户去取数据,但是这个时候用户有其他的事情,没有来得及去处理,没有去取数据,那数据还在内核态,除非用户主动的去取数据,这个数据才会从内核态拷贝到用户态。下一次可以继续监测再通知这个有数据的连接里;边缘触发就是说如果数据来了,告诉用户数据准备好了可以来取了,如果用户没有去取数据,那就数据就一直存储在内核中,但是下次不会再次通知,所以数据对于用户来说就没有了)

epoll没有最大socket数的限制,依然是io多路复用,不是异步io

select pool epool 三者区别

epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。

select:

select函数进行IO复用服务器模型的原理是:
    当一个客户端连接上服务器时,服务器就将其连接的fd加入fd_set集合,
    等到这个连接准备好读或写的时候,就通知程序进行IO操作,与客户端进行数据通信。
    大部分 Unix/Linux 都支select函数,该函数用于探测多个文件句柄的状态变化。
 
这样所带来的缺点是:
    
    1、 单个进程可监视的fd数量被限制,即能监听端口的大小有限。
    
    一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.
    
    2、 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:
    
    当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
    
    3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

poll:

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,
然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,
如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,
被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
 
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
 
1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
 
2、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll:

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,
并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,
一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知
 
epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);

2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
 
3、 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

epoll 自身是基于 IO 多路复用模式的同步IO模型,可实现是基于事件驱动的异步非阻塞的进程模型。不要把进程模型的异步非阻塞看成异步IO。调用epoll是会进入等待的(取决设置timeout的值),而且在数据从内核空间复制到用户空间也是阻塞的,严格意义上说,epoll实现的多路复用IO是阻塞同步IO。相对于普通阻塞同步IO,就是能够不开启多线程来处理多个IO请求。

使用场景

在高并发中,连接活跃度不是很高的话,epool优于select(网页,web服务中)

在并发性不高,但是连接活跃度很活跃的话,select优于epool(游戏中)

信号驱动式IO

信号驱动式I/O:用的很少,就不做讲解了。

首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

异步IO(POSIX的aio_系列函数)

异步I/O:这类函数的工作机制是告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到用户空间)完成后通知我们。如图:

注意红线标记处说明在调用时就可以立马返回,等函数操作完成会通知我们。

用户进程发起aio_read操作之后,立刻就可以开始去做其它的事。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,
通过状态、通知和回调来通知调用者的输入输出操作同步IO引起进程阻塞,直至IO操作完成。
异步IO不会引起进程阻塞,IO复用是通过select,pool或者epool调用阻塞的。

当前,异步IO相对于IO多路复用提升的效率没有太多,当前的主流趋势还是IO多路复用。

五大模型的区别:

对unix来讲:阻塞式I/O(默认),非阻塞式I/O(nonblock),I/O复用(select/poll/epoll)都属于同步I/O,因为它们在数据由内核空间复制回进程缓冲区时都是阻塞的(不能干别的事)。只有异步I/O模型(AIO)是符合异步I/O操作的含义的,即在1数据准备完成、2由内核空间拷贝回缓冲区后 通知进程,在等待通知的这段时间里可以干别的事。

使用非阻塞IO和IO多路复用实现网络请求模型

先用非阻塞IO的方式实现 模拟http请求:

# -*- coding:utf-8 -*-
import socket
from urllib.parse import  urlparse
# 对url做解析

def get_url(url):
    url = urlparse(url)
    # 对传入的网址进行解析,比如传入http://www.langzi.fun/admin.php
    host,path = url.netloc,url.path
    # 获取传入网址的主域名和后面的url路径
    if path == '':
        path = '/'
    c = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
    
    c.setblocking(False)
    # 这里设置成非阻塞模式

    try:
        c.connect((host, 80))
    except BlockingIOError as e:
        pass
    # 这里不停的循环请求,一直到TCP完成三次握手


    headers = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'
    # 绑定ip与端口
    while True:
        try:
            c.send('GET {}\r\nHost:{}\r\nUser-Agent:{}\r\n'.format(path, host, headers).encode('utf-8'))
            break
        except OSError as e:
            pass
    # 发送请求,请求方式为GET 内容是url的路径,然后分隔换行
    # 发送请求,请求的HOST主机就是主域名
    # 在这里可以把请求头,cookie加进来


    data = b''
    # 获取数据
    while 1:
        try:
            d = c.recv(1024)
        except BlockingIOError as e:
            continue
        if d:
            data +=d
        else:
            break
    print(data.decode('utf-8'))


get_url('http://www.langzi.fun/admin.php')

总结:

在模拟http请求的情形下:
在client.setblocking(False)设置为非阻塞IO之后, client.connect和 client.send依然需要不停的尝试,
直到成功。实际效果并不比阻塞式IO好。

然后用IO多路复用实现:

# -*- coding:utf-8 -*-
import socket
from urllib.parse import urlparse
from selectors import DefaultSelector,EVENT_READ,EVENT_WRITE
# 使用IO多路复用下的select方法实现,Python包中selectors是对select做了一个优化封装
# DefaultSelector会自动选择select还是epoll,windows是select,linux是epoll
# EVENT_READ,EVENT_WRITE 分别是读事件和写事件,即对文件描述符的操作


selector = DefaultSelector()
# 实例化一个全局的select方法

urls = ['http://www.langzi.fun/admin.php','https://www.lds.org']
# 需要访问的url
stop = False
# 设置全局变量stop,功能是如果一个socket连接完成后就继续下一个


class Fetcher:
    '''
    使用类方法更加易于操作,

    顺序逻辑如下
    1. 调用get_url函数 --(当某个socket连接可用发送数据,即可以写入数据)--> 2. 调用connected()函数发送数据--(当数据发送出去,数据变成可读)--> 3. 调用readable()函数,打印数据


    概念介绍:

    回调:当逻辑变成可写设置好状态(EVENT_WRITE可以写入/EVENT_READ可以读取)的时候,需要执行什么函数
    例子:(self.client.fileno(),EVENT_READ,self.readable)
        当 self.client.fileno() 的描述符  变成 EVENT_READ (可以读) 的时候 ,就执行readable()函数


    '''

    def connected(self,key):
        # connected 是回调函数
        # 回调函数:当逻辑变成可写EVENT_WRITE的时候,需要执行什么函数
        selector.unregister(key.fd)
        self.client.send('GET {}\r\nHost:{}\r\nUser-Agent:{}\r\n'.format(self.path, self.host, self.headers).encode('utf-8'))
        selector.register(self.client.fileno(),EVENT_READ,self.readable)


    def readable(self,key):
        d = self.client.recv(1024)
        if d:
            self.data += d
        else:
            selector.unregister(key.fd)
            self.client.close()
            print(self.data.decode())
            urls.remove(self.spider_url)
            if not urls:
                global stop
                stop = True



    def get_url(self,url):
        self.spider_url = url
        url = urlparse(url)
        self.host = url.netloc
        self.path = url.path
        self.data = b''
        self.headers = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'

        if self.path == '':
            self.path = '/'
        # 完成对传入的URL进行分割

        self.client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
        self.client.setblocking(False)
        # 实现一个客户请求端,设置成非阻塞模式

        try:
            self.client.connect((self.host,80))
        except BlockingIOError as e:
            pass
        # 实现对目前发起请求


        selector.register(self.client.fileno(),EVENT_WRITE,self.connected)
        '''
        注册文件描述符(传入事件的描述符,设置是等待【读事件】还是【写事件】,设置回调函数)
        
        self.client.fileno() 是发起连接的描述符
        EVENT_WRITE          是写入事件(因为要对链接发起请求)
        self.connected       是回调函数
        
        即当self.client的事件描述符变成可以写入的模式的时候,就调用self.connected函数
        功能是监听self.client 是否可以写入(是否完成连接,能否发起数据)
        
        '''

def loop():
    # 核心函数
    # 循环对selector监控,查看哪个clinet可读可写
    # 事件循环,不停请求socket状态并调用对应的回调函数
    # 1. seclet本身不支持register模式,但是selector对select优化封装
    # 2. socket状态变化后的回调是由程序员完成的
    # 3. 回调如果要系统运行那就只能通过aio(异步io模型)完成

    while not stop:
        ready = selector.select()
        for key, mask in ready:
            call_back = key.data
            call_back(key)

    # 回调+事件循环+select(poll/epoll)



if __name__ == '__main__':
    for x in urls:
        fetcher = Fetcher()
        fetcher.get_url(x)
    loop()  # 事件循环

IO多路复用本质上是使用select对事件描述符监控,通过循环监控和函数回调完成的,他是一个单线程,并发性高,没有多线程之间上下文管理的开销并且占用高内存。

总结:

1.常规的编码方式是从上而下的,理解比较容易。但是回调的编码方式,把代码割得四分五裂,难以理解。
2.回调的编码方式在代码出错时,查错会变得非常麻烦。 
3.回调的共享变量非常麻烦,只有都处于同一个类中,或者变量全部使用全局变量。

引入生成器解决问题

回调、同步、多线程的编码方式的问题:

  1. 回调模式编码复杂度高
  2. 同步编程的并发性不高
  3. 多线程编程需要线程同步, 使用lock锁的机制,影响性能。

综合三种编码的优点,又摒弃各自缺点:

  1. 采用同步的方式去编写异步的代码
  2. 采用单线程去切换任务:

要求限制:

  1. 线程是由操作系统切换的,单线程切换意味着我们需要程序员自己去调度任务。
  2. 不在需要锁,并发性高。如果单线程内切换函数,性能会远高于多线程间切换,并发性高。

解决单线程中回调模式复杂的问题,需要存在 一种方法:第一个函数需要请求网络的时候处于暂停状态,去执行第二个的不需要请求网络函数,第二个函数执行完毕后继续执行第一个函数,并且可以向第一个暂停的函数传入值,这个方法就是生成器,这种函数调用 实现的过程也叫协程。

生成器的send方法

# -*- coding:utf-8 -*-
import requests
import re

def get_title(url):
    try:
        r = requests.get(url)
        title = re.search('<title>(.*?)</title>',r.content.decode(),re.I)
        return title.group(1)
    except:
        return '获取标题失败'

def get_urls():
    url1 = yield 'https://www.baidu.com'
    # 这样可以产出yield的值,并且能获取到传递进来的值,传递进来的值赋值给url1
    print(url1)

    yield 'http://www.langzi.fun'



gen = get_urls()
# 在调用send发送非None值之前,必须先启动生成器,启动方式有如下两种:
'''
1. url = gen.send(None)
2. url = next(gen)
这两种都能启动生成器函数,并且获取到yield的值
'''
url_1 = next(gen)
print(url_1)
print('-----------')
urls = (gen.send(get_title(url_1)))# send可以传递值到生成器内部,同时还能重启生成器到下一个yield的位置
print(urls)
# 传递值到生成器内部,这个时候生成器重启到下一个yield位置

返回结果:

https://www.baidu.com
-----------
百度一下,你就知道
http://www.langzi.fun

生成器的close方法

def gen_func():
    yield 1
    yield 2
    yield 3
 
 
if __name__ == '__main__':
    gen = gen_func()
    print(next(gen))
    gen.close()   
    next(gen)    # gen_func中还有yield  再调用next就会报异常

返回结果:

1
Traceback (most recent call last):
  File "C:/CODE/Python知识体系/Python进阶知识/异步协程/生成器方法01.py", line 47, in <module>
    next(gen)  # gen_func中还有yield  再调用next就会报异常
StopIteration

生成器的throw方法

throw方法:即向生成器中抛进去一个异常

def gen_func():
    yield 1
    yield 2
    yield 3


if __name__ == '__main__':
    gen = gen_func()
    print(next(gen))
    gen.throw(Exception, '异常发生')  # 抛自定义异常
    print(next(gen))

返回结果:

1
Traceback (most recent call last):
  File "C:/CODE/Python知识体系/Python进阶知识/异步协程/生成器方法01.py", line 47, in <module>
    gen.throw(Exception, '异常发生')  # 抛自定义异常
  File "C:/CODE/Python知识体系/Python进阶知识/异步协程/生成器方法01.py", line 38, in gen_func
    yield 1
Exception: 异常发生

生成器的yield from方法

yield from的主要功能是打开双向通道,把最外层的调用方法与最内层的子生成器连接起来。

简单应用:拼接可迭代对象

举例子说明:

# -*- coding:utf-8 -*-
def gene():
    for c in 'AB':
        yield c  #遇到yeild程序返回循环,下次从yeild后面开始。
    for i in range(3):
        yield i
if __name__=="__main__":
    print(list(gene()))#list内部会预激生成器

返回结果:

['A', 'B', 0, 1, 2]

使用yirld from简化

def gene():
    yield from 'ab'
    yield from range(3)
if __name__ == "__main__":
    print(list(gene()))

返回结果:

['A', 'B', 0, 1, 2]

不仅仅是字符串和列表,使用下面数据都能连接起来

# 字符串
astr='ABC'
# 列表
alist=[1,2,3]
# 字典
adict={"name":"wangbm","age":18}
# 生成器
agen=(i for i in range(4,8))

def gen(*args, **kw):
    for item in args:
        yield from item

new_list=gen(astr, alist, adict, agen)
print(list(new_list))

返回结果

['A', 'B', 'C', 1, 2, 3, 'name', 'age', 4, 5, 6, 7]

总结:可以看出,yield from后面加上可迭代对象,他可以把可迭代对象里的每个元素一个一个的yield出来,对比yield来说代码更加简洁,结构更加清晰。

复杂应用:生成器的嵌套

当 yield from 后面加上一个生成器后,就实现了生成的嵌套。

讲解它之前,首先要知道这个几个概念

委派生成器:包含yield from 表达式的生成器函数

子生成器:从yield from 部分获取的生成器,含yield的。

调用方:调用委派生成器的客户端(调用方)代码,也就是运行入口。

一个简单的例子:

def g1(gen):
	yield from gen

def main():
	g = g1()
	g.send(None)

这里:

main:调用方
g1:委派生成器
gen:子生成器

yield from会在调用方和子生成器之间建立一个双向通道,

例子 1
def fetch():
    res = []
    while 1:
        url = yield
        if not url:
            break
        r = requests.head(url)
        print(r.status_code)
        res.append(str(r.status_code)+':'+r.url)
    return res


def gen(lis):
    while 1:
        li = yield from fetch()
        lis.append(li)

def main():
    # main是调用方
    # gen是委派生成器
    # fetch是子生成器
    '''
    yield from 会在调用方和子生成器中生成一个双向通道
    也就是说会在main 和 fetch中实现相互发送数据
    在本次代码中:
        使用list列表保存最后得到的数据
        使用next(m)开启生成器
        使用send u 传递值,即从调用方传递  网址  到子生成器
        他们中间保存的数据通过 gen 函数实现,
        即main中的保存结果的列表传递到gen函数,main中的网址通过gen函数传递到fetch函数
        然后fetch函数返回的结果会返回到gen函数
        因为列表是可变对象,所以所有的数据保存到这个列表当中

        但是有一个问题,即怎么断开while循环?
            通过fetch函数中的:
                if not url:
                    break
            和 main函数中的:
                m.send(None)
            实现

        即遍历传入网址,最后传入一个None值,最后传入值就是url = None
        这个时候就会跳出循环
    '''
    lis = []
    urls = ["http://www.baidu.com", "http://www.cnblogs.com",'http://www.langzi.fun']
    for u in urls:
        m = gen(lis)
        next(m)
        m.send(u)
        m.send(None)
    return lis

if __name__ == '__main__':
    res = main()
    print(res)
例子 2

举例子说明(使用yeild from写一个异步爬虫来源):

import requests
from collections import namedtuple
# 引入一个具名元组,可以后面实现一个简单的类。

Response = namedtuple("rs", 'url status')
# 对请求参数做一个格式化处理,后面通过获取属性即可。


# 子生产器
def fecth():
# 一个协程,通过requests模块可以发起网络请求。
    res=[]
    while 1:
        url = yield
        # main函数的发送的值绑定到这里的url上
        if url is None:
        # url为None即没有url的时候结束循环的。
            break
        req = requests.get(url)
        res.append(Response(url=url, status=req.status_code))
    return res

#委派生成器
def url_list(l, key):
    while 1:
    # 这个循环每次都会新建一个fetch 实例,每个实例都是作为协程使用的生成器对象。
        l[key] = yield from fecth()
    # url_list发送的每个值都会经由yield from 处理,然后传给fetch 实例。
    # url_list会在yield from表达式处暂停,等待fetch实例处理客户端发来的值。
    # fetch实例运行完毕后,返回的值绑定到l[key]  上。
    # while 循环会不断创建fetch实例,处理更多的值。


#调用方
def main():
    l = {}
    u = ["http://www.baidu.com", "http://www.cnblogs.com"]
    for index, url in enumerate(u):
        if index == 0:
            ul = url_list(l, index)
            next(ul)
            # 激活url_list生成器
        ul.send(url)
        # 把各个url以及其序列号index,
        # 传给url_list传入的值最终到达fetch函数中,
        # url_list并不知道传入的是什么,
        # 同时url_list实例在yield from处暂停。
        # 直到fetch的一个实例处理完才进行赋值。
    ul.send(None)
            # 关键的一步,# 
    # 把None传入url_list,传入的值最终到达fetch函数中,导致当前实例终止。
    # 然后继续创建下一个实例。
    # 如果没有ul.send(None),那么fetch子生成器永远不会终止,
    # 因为ul.send()发送的值实际是在fetch实例中进行,
    # 委派生成器也永远不会在此激活,也就不会为l[key]赋值
    return l


if __name__ == '__main__':
    res = main()
    print(res)

tips:注意requests是堵塞非异步的,所以上面两个例子并不是真正意义上的异步协程。

相关资料

参考链接

参考链接

参考链接

参考链接

参考链接

参考链接

参考链接

参考链接

参考链接

欢迎关注公众号:【安全研发】获取更多相关工具,课程,资料分享哦~在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浪子燕青啦啦啦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值