11.协程与异步IO

1. 并发、并行、同步、异步、阻塞、非阻塞

  • 并发和并行:

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

    并行: 任意时刻点上, 有多个程序同时运行在多个cpu上

实际举例说明:

问题 - 喝茶
    情况: 开水没有,水壶要洗,茶壶茶杯要洗;火生类, 茶叶也有了。怎么办?
所需时间:
    洗水壶: 3
    灌凉水: 1
    洗茶壶: 3
    洗茶杯: 3
    拿茶叶: 1
    泡茶: 1
    烧开水: 30

并发版本:
老赵(cpu1):
办法1 洗水壶,灌凉水, 放在火上;等待水烧开的时间里, 洗水壶,洗茶杯,拿茶叶;等水开了,泡茶喝。
        总用时: 3+1+30+1 = 35
办法2:  先做好一些准备工作, 洗水壶,洗茶壶茶杯,拿茶叶;也切就绪,灌水烧水;坐等水开了泡茶喝。
        总用时: 3+3+3+1+30+1 = 41
办法3: 洗净水壶,灌上凉水,放在火上,坐等水开;水开之后, 急急忙忙找茶叶,洗茶壶茶杯,泡茶喝。
        总用时: 3+1+30+3+3+1 = 41


并行版本:
老赵(cpu1):洗好水壶,灌上凉水,放在火上
老李(cpu2):洗茶壶
老李(cpu2):洗茶壶
老谢(cpu3):洗茶杯
        总用时: 31
  • 同步和异步:

    同步: 代码调用IO操作时,必须等待IO操作完成才返回的调用方式

    异步: 代码调用IO操作时,不必等IO操作完成就返回的调用方式

  • 阻塞和非阻塞:

    阻塞: 调用函数时,当前线程被挂起 (比如前面socket编程中socket.connect就是阻塞的)

    非阻塞: 调用函数时,当前线程不会被挂起,而是立即返回

例子:

小乐爱喝茶,废话不说,煮开水。
出场人物:小乐,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
   1 小乐把水壶放到火上,立等水开。(同步阻塞)
     ——   小乐觉得自己有点傻
   2 小乐把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)
     ——  小乐还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出呜呜~~~~的噪音。
   3 小乐把响水壶放到火上,立等水开。(异步阻塞)
     ——  小乐觉得这样傻等意义不大
   4 小乐把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)
     ——  小乐觉得自己聪明了。


所谓同步异步,只是对于水壶而言。
 普通水壶,同步;响水壶,异步。
 虽然都能干活,但响水壶可以在自己完工之后,提示小乐水开了。这是普通水壶所不能及的。
 同步只能让调用者去轮询自己(情况2中),造成小乐效率的低下。

所谓阻塞非阻塞,仅仅对于小乐而言。
—— 立等的小乐,阻塞;看电视的小乐,非阻塞。

情况1和情况3中小乐就是阻塞的,媳妇喊他都不知道。

虽然3中响水壶是异步的,可对于立等的小乐没有太大的意义。

所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。

2. I/O多路复用介绍(select, poll, epoll)

  • Unix下五种I/O模型:

1)阻塞I/O(blocking I/O2)非阻塞I/O (nonblocking I/O3)I/O复用(select 和poll) (I/O multiplexing)
4)信号驱动I/O (signal driven I/O (SIGIO))
5)异步I/O (asynchronous I/O (the POSIX aio_functions))

前四种都是同步,只有最后一种才是真正的异步IO。

阻塞I/O:

简介:进程会一直阻塞,直到数据拷贝完成

描述:应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。
如果数据没有准备好,一直等待….数据准备好了,从内核拷贝到用户空间,IO函数返回成功指示。
11.协程与异步IO_第1张图片

非阻塞IO:

简介:非阻塞IO通过进程反复调用IO函数(多次系统调用,并马上返回);在数据拷贝的过程中,进程是阻塞的;

描述:我们把一个SOCKET接口设置为非阻塞就是告诉内核,当所请求的I/O操作无法完成时,
不要将进程睡眠,而是返回一个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备好,
如果没有准备好,继续测试,直到数据准备好为止。在这个不断测试的过程中,会大量的占用CPU的时间。

11.协程与异步IO_第2张图片

IO复用:

简介:主要是select和epoll;对一个IO端口,两次调用,两次返回,
比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听;

描述:I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,
但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,
多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

11.协程与异步IO_第3张图片

信号驱动IO:

简介:两次调用,两次返回;

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

11.协程与异步IO_第4张图片

异步IO模型

简介:数据拷贝的时候进程无需阻塞。

描述:当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,
通过状态、通知和回调来通知调用者的输入输出操作同步IO引起进程阻塞,直至IO操作完成。
异步IO不会引起进程阻塞。IO复用是先通过select调用阻塞。

11.协程与异步IO_第5张图片

5种I/O模型的比较:

11.协程与异步IO_第6张图片

  • IO复用详解:

I/O复用的典型应用场景:

1.处理多个描述字时,比如同时处理套接字和磁盘IO、终端IO
2.一个客户同时处理多个套接字
3.服务器既要处理监听套接字,又要处理已连接套接字
4.既要处理TCP、也要处理UDP
5.一个服务器要处理多个服务和协议
 
I/O多路复用不局限于网络编程,也可以用于其他程序。

select、poll、epoll简介

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_SETSIZESocket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epollkqueue做的。
    
    3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

poll:

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,
然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,
如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,
被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

2poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd

epoll:

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,
并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd
一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fdepoll_wait便可以收到通知

epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于10241G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于selectpoll

3 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

在高并发情况下, 连接活跃度不是很高, epoll比select好
在并发不高情况下, 连接活跃度高, select比epoll好

3. select+回调+时间循环获取html

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

import socket
from urllib.parse import urlparse


def get_url(url):
    # 通过socket请求html
    url = urlparse(url)
    host = url.netloc
    path = url.path
    if path == "":
        path = '/'

    # 建立socket连接
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.setblocking(False)   # 非阻塞IO
    try:
        client.connect((host, 80))
    except BlockingIOError as e:
        pass

    while True:
        try:
            client.send(
                "GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format(path, host).encode('utf8'))
            break
        except OSError as e:
            pass

    data = b""
    while True:
        try:
            d = client.recv(1024)
        except BlockingIOError as e:
            continue
        if d:
            data += d
        else:
            break

    data = data.decode('utf8')
    html_data = data.split('\r\n\r\n')[1]  # 把请求头信息去掉, 只要网页内容
    print(html_data)
    client.close()


if __name__ == '__main__':
    get_url('http://www.baidu.com')

结论:

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

接下来尝试使用select/epoll方法:

# 通过非阻塞IO实现http请求
# select/epoll + 回调 + 事件循环方式编码
# 好处:  并发性高
# 在抓取多个网站时,使用单线程代替了多线程。省去了多线程间切换和计算资源的开销。


import socket
from urllib.parse import urlparse
from selectors import DefaultSelector, EVENT_WRITE, EVENT_READ   
# DefaultSelector会自动选择select还是epollwindowsselect,linuxepoll。但最好还是linux下运行

selector = DefaultSelector()
urls = ['http://www.baidu.com']
stop = False


class Fetcher:
    def connected(self, key):
        selector.unregister(key.fd)
        self.client.send(
            "GET {} HTTP/1.1\r\nHost:{}\r\nConnection:close\r\n\r\n".format(self.path, self.host).encode('utf8'))
        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)
            data = self.data.decode('utf8')
            html_data = data.split('\r\n\r\n')[1]  # 把请求头信息去掉, 只要网页内容
            print(html_data)
            self.client.close()
            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""
        if self.path == "":
            self.path = '/'

        # 建立socket连接
        self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.client.setblocking(False)

        try:
            self.client.connect((self.host, 80))
        except Exception as e:
            pass

        # 注册到select中去
        selector.register(self.client.fileno(), EVENT_WRITE, self.connected)


def loop():
    # 事件循环,不停请求socket状态并调用对应的回调函数
    # 1. secletor本身不支持register模式
    # 2. socket状态变化后的回调是由程序员完成的
    while not stop:
        ready = selector.select()
        for key, mask in ready:
            call_back = key.data
            call_back(key)

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


if __name__ == '__main__':
    fetcher = Fetcher()
    fetcher.get_url('http://www.baidu.com')
    loop()  # 事件循环


4. 回调之痛

回调实现了单线程的并发,但却伴随一系列问题:

根据select/epoll的回调编码方式, 我们可以看出:

1.常规的编码方式是从上而下的,理解比较容易。但是回调的编码方式,把代码割得四分五裂,难以理解。

2.回调的编码方式在代码出错时,查错会变得非常麻烦。

3.回调的共享变量非常麻烦,只有都处于同一个类中,或者变量全部使用全局变量。

总结:

1.可读性差
2.共享状态管理困难
3.异常处理困难

针对回调之痛,我们将引入协程来解决。


5. 什么是协程

1. 先来总结一下回调、同步、多线程的编码方式的问题:

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

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

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

3. 明白的了需求,我门来看一下协程的实际使用场景:

我们在编写爬虫时,需要先抓取网页源码和解析源码。但在抓取源码时,往往有等待网站服务器响应时间, 我们在等待的时间里想要先让函数处于暂停状态,去执行另一个爬取的函数,伪代码:

def get_url(url):
    html1 = get_html(url)   # 当爬取源码等待响应时,暂停当前函数,去爬取html2
    urls = parse_url(html)
    
def get_url(url):
    html2 = get_html(url)
    urls = parse_url(html2)

从伪代码中我们看出协程的概念: 可以暂停的函数(可以向暂停的地方传入值) 
在python中,我们用生成器实现协程。

5. 生成器进阶

  • send、close和throw方法

send方法:

# 1 生成器不只可以产生值,还可以接收值。对应的  启动生成器有两种方式 nextsend


def gen_func():
    cannon1 = yield 1     # 按顺序接受send的传入值
    print(cannon1)
    yield 2
    yield 3
    


if __name__ == '__main__':
   
    gen = gen_func()
    print(next(gen))  
    # 在调用send发送非None值之前, 我们必须启动一次生成器, 方式有两种 1. gen.send(None) 2. next(gen)
    print(gen.send('cannon')) 
    # send 方法可以将值传入生成器内部,同时还会重启暂停的函数,相当于执行一次next
    print(next(gen))
    print(gen)

运行结果:

1
cannon   
2
3

close方法

def gen_func():
    yield 'http://projectsedu.com'
    yield 2
    yield 3
    return 'cannon'


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

运行结果会报错, 因为close后 又执行了next

Traceback (most recent call last):
  File "tempCodeRunnerFile.py", line 12, in <module>
    next(gen)    # gen_func中还有yield  再调用next就会报异常
StopIteration

throw方法- 往生成器扔异常

def gen_func():
    yield 'http://projectsedu.com'
    yield 2
    yield 3
    return 'cannon'


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

运行结果, 紧随yield 2 后面报异常

http://projectsedu.com
2
Traceback (most recent call last):
  File "/3.gen_throw.py", line 12, in <module>
    gen.throw(Exception, 'download error')
  File "3.gen_throw.py", line 3, in gen_func
    yield 2
Exception: download error

  • yield from 的使用

yield from 是python3.3 新加的语法, 在讲解该语法前,我们先演示一段代码:

from itertools import chain

my_list = [1, 2, 3]
my_dict = {
    'cannon1': 'http://projectsedu.com',
    'cannon2': 'http://www.baidu.com',
}

for value in chain(my_list, my_dict, range(5, 10)):
    print(value)

我们通过chain 可以让不同的迭代类型一起被遍历, 得到结果如下:

1
2
3
cannon1     # 字典类型被遍历 只遍历key  不是value
cannon2
5
6
7
8
9

那么chain是如何实现的呢? 下面我们自己来实现一个my_chain:

def my_chain(*args, **kwargs):
    for my_iterable in args:
        for value in my_iterable:
            yield value


for value in my_chain(my_list, my_dict, range(5, 10)):
    print(value)

结果一致,不展示了。 yield from 语法 可让my_chain函数简化代码:

def my_chain(*args, **kwargs):
    for my_iterable in args:
        yield from my_iterable   # yield from 用法展示
        # for value in my_iterable:
        #     yield value


for value in my_chain(my_list, my_dict, range(5, 10)):
    print(value)

可以看到 yield from my_iterable就是 for value in my_iterable: yield value的简化。当然yield from的应用远没那么简单, 下面我们依然通过代码对它进一步讲解

final_result = {}

# 称为子协程
def sales_sum(pro_name):
    total = 0
    nums = []
    while True:
        x = yield
        print(pro_name+'销量:', x)
        if not x:    # send(None)跳出
            break
        total += x
        nums.append(x)
    return total, nums

# 称为调用方
def middle(key):
    while True:
        final_result[key] = yield from sales_sum(key)
        print(key+'销量统计完成!')


def main():
    data_sets = {
        '面膜': [1200, 1500, 1300],
        '手机': [28, 55, 98, 108],
        '大衣': [280, 560, 778, 70]
    }
    for key, data_set in data_sets.items():
        print('start key:', key)
        m = middle(key)
        m.send(None)  # 预激活middle协程  yield from sales_sum(key)开始接受数据
        for value in data_set:
            m.send(value)  # 给协程传递每一组值
        m.send(None)  # sales_sum    跳出while True
        
    print('final_result:', final_result)


if __name__ == '__main__':
    main()

这是一个统计销量的代码, 我们得到结果:

start key: 面膜
面膜销量: 1200
面膜销量: 1500
面膜销量: 1300
面膜销量: None
面膜销量统计完成!
start key: 手机
手机销量: 28
手机销量: 55
手机销量: 98
手机销量: 108
手机销量: None
手机销量统计完成!
start key: 大衣
大衣销量: 280
大衣销量: 560
大衣销量: 778
大衣销量: 70
大衣销量: None
大衣销量统计完成!
final_result: {'面膜': (4000, [1200, 1500, 1300]), '手机': (289, [28, 55, 98, 108]), '大衣': (1688, [280, 560, 778, 70])}

你可能会有疑惑, 为什么要有中间middle并放入yield from来实现这样的功能,之间在main函数中调用sales_sum函数不行吗?
其实yield from帮我们自动解决了很多的报错, 如果直接调用sales_sum没有middle,我们需要增加很多代码来处理报错。举例:

def sales_sum(pro_name):
    total = 0
    nums = []
    while True:
        x = yield
        print(pro_name+'销量:', x)
        if not x:    # send(None)跳出
            break
        total += x
        nums.append(x)
    return total, nums

if __name__ == '__main__':
    my_gen = sales_sum('手机')
    my_gen.send(None)   # 预激活生成器
    my_gen.send(1200)
    my_gen.send(1300)
    my_gen.send(1222)
    my_gen.send(999)
    my_gen.send(None)

得到的结果(如下)会出现报StopIteration错,而yield from内部会帮我们自动try except处理:

手机销量: 1200
手机销量: 1300
手机销量: 1222
手机销量: 999
手机销量: None
Traceback (most recent call last):
  File 4.yieldfrom_example.py", line 47, in 
    my_gen.send(None)
StopIteration: (4721, [1200, 1300, 1222, 999])

6. 原生协程 async与await

生成器实现协程会带来一个问题: 你的代码中yield是用于生成器还是用于协程?
为了让编写的代码可读性更好,python3.5之后引入了async和await实现协程。
async和await的用法,我们通过上一个yield from的代码例子的修改来说明:

import types
final_result = {}

# 不同于yield from  await调用的子生成器函数 需要加装饰器@types.coroutine
@types.coroutine
def sales_sum(pro_name):     
    total = 0
    nums = []
    while True:
        x = yield
        print(pro_name+'销量:', x)
        if not x:    # send(None)跳出
            break
        total += x
        nums.append(x)
    return total, nums


# async中不能 yield
# await只能出现async的函数中, await可以看作是yield from的替代
async def middle(key):
    while True:
        final_result[key] = await sales_sum(key)
        print(key+'销量统计完成!')


def main():
    data_sets = {
        '面膜': [1200, 1500, 1300],
        '手机': [28, 55, 98, 108],
        '大衣': [280, 560, 778, 70]
    }
    for key, data_set in data_sets.items():
        print('start key:', key)
        m = middle(key)
        m.send(None)  # 预激活middle协程  yield from sales_sum(key)开始接受数据
        for value in data_set:
            m.send(value)  # 给协程传递每一组值
        m.send(None)  # sales_sum    跳出while True

    print('final_result:', final_result)


if __name__ == '__main__':
    main()

结果和之前使用yield from实现一样。

start key: 面膜
面膜销量: 1200
面膜销量: 1500
面膜销量: 1300
面膜销量: None
面膜销量统计完成!
start key: 手机
手机销量: 28
手机销量: 55
手机销量: 98
手机销量: 108
手机销量: None
手机销量统计完成!
start key: 大衣
大衣销量: 280
大衣销量: 560
大衣销量: 778
大衣销量: 70
大衣销量: None
大衣销量统计完成!
final_result: {'面膜': (4000, [1200, 1500, 1300]), '手机': (289, [28, 55, 98, 108]), '大衣': (1688, [280, 560, 778, 70])}

你可能感兴趣的:(python高级编程)