第十二课 python进阶协程和异步IO
tags:
- Docker
- 慕课网
categories:
- 网络变成概念
- I/O模型
- 多路复用
- 回调加事件循环
- 协程
第一节 网络编程常用概念
1.1 并发和并行
- 并发是指一个时间段内有几个程序在同一个cpu运行,但是任意时刻只有一个程序在cpu上运行。简言之,是指系统具有处理多个任务的能力。
- 并行是指任意时刻点上,有多个程序同时运行在多个cpu上,简言之,是指系统具有同时处理多个任务的能力。
1.2 同步和异步
- 同步是指代码调用IO操作时,必须等待IO操作完成才返回的调用方式。
- 异步是指代码调用IO操作时,不必等IO操作完成就返回的调用方式。
- 只有IO操作我们才考虑,同步异步阻塞和非阻塞的概念的。
1.3 阻塞和非阻塞
- 阻塞是指调用函数时候当前线程被挂起。
- 非阻塞是指调用函数时候当前线程不会被挂起,而是立即返回。
第二节 C10k和IO多路复用
- 推荐博客:https://www.cnblogs.com/alex3714/articles/5876749.html
2.1 C10K问题
- C10k是一个在1999年被提出来的技术挑战
- 如何在一颗1GHz CPU , 2G内存, 1gbps网络环境下,让单台服务器同时为1万个客户端提供FTP服务
2.2 Unix下五种I/O模型
- 阻塞式IO
- 非阻塞式IO
- IO多路复用
- 驱动式IO(用的比较少)
- 异步IO(POSIX的aio_系列函数)
- 比如我们拿网络IO中的函数recvfrom函数,从我们端口中读取数据。例:获取一个网页的返回。如第十章中的案例。下面函数connect和recv都是阻塞的。
- 阻塞式IO中CPU是空闲的,浪费了大量CPU。等待浪费时间
- client.setblocking(False) 可以设置非阻塞,在调用connect函数时,立马返回。这就是非阻塞IO,但是这里有个问题。
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.setblocking(False)
# 建立连接三次握手 这个函数是阻塞的
client.connect((host, 80))
client.recv(1024)
- 虽然立马返回, 但是它并不保证网络的三次连接完成。如果没有完成,我们调用send函数就会报异常。所以我们在发送之前需要不停的while循环判断状态,连接是否建立完成。这里while循环是耗费cpu的。
- 如果在connect之后做一些其他操作比如:建立新的连接,计算。它相对于阻塞IO优势还是很明显的。
- 时间主要耗费在:状态的查询和内核数据复制到用户空间。
- 用户空间与内核空间
- 操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
- 为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
- 有没有一种机制:数据进入操作系统的缓存,操作系统发送一个消息给应用程序说数据准备好呢?这就是我们的IO多路复用。
- select可以同时监听多个连接。只要有一个准备就绪就可以进行发送。(select依然是一个阻塞的函数)
- 它节省了状态查询的时间,内核数据复制到用户空间时间依然没有改进。
- 信号驱动式IO(用的比较少),建立一个信号处理程序,操作系统会主动发送一个信号给我们的信号处理程序。(依据信号)
- 异步IO(真的异步IO,AIO),很多高并发的框架都并没有使用异步IO,而是使用的IO多路复用
- IO多路复用很成熟,用的很多
- 异步IO相对于IO多路复用 性能没有很明显提升
- 编码难度比IO多路复用多很多
- 异步IO
- 用户进程发起read操作之后,立刻就可以开始去做其它的事。
- 从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。
- kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
2.3 select、poll、 epoll
- select , poll , epoll都是IO多路复用的机制。
- I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般读就绪或者写就绪) , 能够通知程序进行相应的读写操作。
- 但select , poll , epoll本质上都是同步I/O ,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
- select介绍
- select函数监视的文件描述符分3类,分别是writefds、readfds、 和exceptfds。
- 调用后select函数会阻塞,直到有描述副就绪(有数据可读、可写、或者有except ) , 或者超时( timeout指定等待时间,如果立即返回设为null即可) , 函数返回。
- 当select函数返回后 ,可以通过遍历fdset,来找到就绪的描述符。
- select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024 ,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
- poll介绍
- 不同与select使用三个位图来表示三个fdset的方式, poll使用一个pollfd的指针实现。
- pollfd结构包含了要监视的even和发生的event ,不再使用select“参数-值”传递的方式。同时, polfd并没有最大数量限制(但是数量过大后性能也是会下降)。和select函数一样, poll返回后,需要轮询pollfd来获取就绪的描述符。
- 从上面看, select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降
- epoll介绍
- 只在linux下可用,windows下不支持的。
- epoll是在2.6内核中提出的,是之前的selec和poll的增强版本。相对于select和poll来说, epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
- epoll查询采用红黑数,查询效率是很高的。
- epoll并不代表一定比kselect好。
- 在并发高的情况下,连接活跃度不是很高,epoll 比select好 如:网站、web开发中
- 并发性不高,同时连接很活跃(游戏连接中) select比epoll好 如: 游戏
第三节 I/O模型的实践
3.1 非阻塞IO完成http请求
- client.setblocking(False), 非阻塞建立连接,不断轮询直到连接建立完成为止。
import socket
from urllib.parse import urlparse
# 使用非阻塞io完成http请求
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)
try:
client.connect((host, 80)) # 阻塞不会消耗cpu
except BlockingIOError as e:
pass
# 不停的询问连接是否建立好, 需要while循环不停的去检查状态
# 做计算任务或者再次发起其他的连接请求
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")
3.2 IO多路复用完成http请求
- import select。其实我们使用select的时候并不多。
- from selectors import DefaultSelector 而是使用它的包装库selectors
- DefaultSelector自动在不同平台上选用epoll或者poll、select(windows下没有epoll)
- 事件循环,不停的请求socket的状态并调用对应的回调函数
- 事件循环在所有IO多路复用都有。如:twist,tornado,协程,gevent
- 都是 回调+事件循环+select(poll\epoll) 这种模式
- 这种模式并发性高。(驱动程序运行主要是我们的事件循环)
- 不会阻塞在建立连接或者等待网络请求的过程中,一旦建立好立马执行回调方法
- 回调方法中没有费IO的同步操作的。
- 下面代码单线程,除非所有连接都没连接好,只要一个连接好就会检测到,触发事件(省了多线程开销)
import socket
from urllib.parse import urlparse
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
selector = DefaultSelector()
# 使用select完成http请求
urls = []
stop = False
class Fetcher:
def connected(self, key):
# fd是self.client.fileno()返回值
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)
# 不能用while因为获取一次后并不能获取完全,第二次循环过来就会报错。因为内核数据没准备好
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连接 设置非阻塞IO
self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client.setblocking(False)
try:
self.client.connect((self.host, 80)) # 阻塞不会消耗cpu
except BlockingIOError as e:
pass
# 注册 看参数fileobj就是我们的socket data就是我们的回调函数
# fileno() socket的文件描述符, EVENT_WRITE写事件, 变成可写事调用self.connected
selector.register(self.client.fileno(), EVENT_WRITE, self.connected)
# 如果把send写到这就是阻塞啦, 使用回调模式
def loop():
# 事件循环,不停的请求socket的状态并调用对应的回调函数
# 1. select本身是不支持register模式
# 2. socket状态变化以后的回调是由程序员完成的
# 通过stop判读防止windows下默认为select少参数的报错,因为url没有就不调用
while not stop:
# windows下默认为select linux下是epoll
ready = selector.select()
for key, mask in ready:
call_back = key.data
call_back(key)
# 回调+事件循环+select(poll\epoll)
if __name__ == "__main__":
fetcher = Fetcher()
import time
start_time = time.time()
for url in range(20):
url = "http://shop.projectsedu.com/goods/{}/".format(url)
urls.append(url)
fetcher = Fetcher()
fetcher.get_url(url)
loop()
print(time.time()-start_time)
3.3 回调的弊端
- 如果回调函数执行不正常该如何? 异常处理麻烦
- 如果回调里面还要嵌套回调怎么办?要嵌套很多层怎么办? 代码难维护
- 如果嵌套了多层,其中某个环节出错了会造成什么后果?
- 如果有个数据需要被每个回调都处理怎么办?
- 怎么使用当前函数中的局部变量? 变量共享维护难受
- 回调的弊端总结:
1.可读性差
2.共享状态管理困难
3.异常处理困难 - 如果我们既想要回调的高性能并发,又想要自上而下编程的体验。那么协程可以解决这个问题。
第四节 协程
4.1 协程是什么
- C10M问题: 如何利用8核心CPU , 64G内存,在10gbps的网络上保持1000万并发连接
- 协程解决的问题
- 回调模式编码复杂度高
- 同步编程的并发性不高
- 多线程编程需要线程间同步,lock
- 协程实现了
- 采用同步的方式去编写异步的代码
- 使用单线程去切换任务
- 协程,又称微线程,是一个可以暂停的函数,可以向暂停地方传入值。协程调度切换的时候,将寄存器上下文和栈都保存到其他地方。
4.2 生成器next、send、close、throw方法
- next方法next(func)。启动生成器,返回yield后面的值。如果next次数超过yield,报错。
def gen_func():
html = yield "http://projectsedu.com"
print(html)
yield 2
yield 3
return "bobby"
if __name__ == "__main__":
gen = gen_func()
print(next(gen))
print(next(gen))
print(next(gen))
print(next(gen)) # 报错
- send方法可以传递值进入生成器内部gen.send(“booby”)重启生成器执行到下一个yield位置。
- 在调用send发送非none值之前,我们必须启动一次生成器(让它卡在yield处)
- 启动方式有两种1. gen.send(None), 2. next(gen)
def gen_func():
# 1. 可以产出值
# 2. 可以接收值(调用方传递进来的值)
html = yield "http://projectsedu.com"
print(html)
yield 2
yield 3
return "bobby"
# 生成器不只可以产出值,还可以接收值
if __name__ == "__main__":
gen = gen_func()
# 在调用send发送非none值之前,我们必须启动一次生成器(让它卡在yield处)
# 启动方式有两种1. gen.send(None), 2. next(gen)
# next(gen)效果一样, 到第一个yield处停止。返回yield后的值,yield前html并没有赋值
url = gen.send(None)
print(url)
html = "bobby"
# send方法可以传递值进入生成器内部,同时还可以重启生成器执行到下一个yield位置
# 发送html, 返回第二个yield的值
print(gen.send(html))
print(gen.send(html))
- close方法。关闭生成器,如果我们不进行捕获异常它是不会报错的。
def gen_func():
try:
yield "http://projectsedu.com"
# 注意: 这里什么都没有做pass, 它会抛出异常RuntimeError到close方法上,
# 因为pass后继续执行到yield 2但是已经close啦, 注释后面的yield就没有问题或者加上处理
# 如果我们处理raise StopIteration。就不会报异常啦
except GeneratorExit:
raise StopIteration
# GeneratorExit是继承自BaseException, Exception.所以下面方法捕获不到异常的
# except Exception:
# pass
yield 2
yield 3
return "bobby"
if __name__ == "__main__":
gen = gen_func()
print(next(gen))
gen.close()
print("bobby")
# next(gen) # 抛出StopIteration异常
- throw方法。我们抛出异常到生成器,如果没有处理,会报错的(和上面close相反)
- 抛给生成器一个异常,然后如果生成器能处理掉异常的话,throw方法接着迭代一次取得返回值
def gen_func():
try:
yield "http://projectsedu.com"
except Exception as e:
print("异常处理")
pass
print("位置1")
yield 2
print("位置2")
yield 3
return "bobby"
if __name__ == "__main__":
gen = gen_func()
print(next(gen))
# 虽然上面把"http://projectsedu.com"yield出来,
# 下面抛出的异常依然是yield "http://projectsedu.com"的异常
a = gen.throw(Exception, "download error")
print(a) # 这里是2
print(next(gen)) # 这里是3
gen.throw(Exception, "download error")
4.3 生成器yield from初体验(比较难理解)
- python3.3新加了yield from语法
- 注意: yield from会在调用方与子生成器之间建立一个双向通道
# python3.3新加了yield from语法
from itertools import chain
my_list = [1, 2, 3]
my_dict = {
"bobby1": "http://projectsedu.com",
"bobby2": "http://www.imooc.com",
}
# chain的用处是把所有可迭代对象循环获得值
# for value in chain(my_list, my_dict, range(5, 10)):
# print(value)
# 做个yield from的测试
# def g1(iterable):
# yield iterable
#
# def g2(iterable):
# yield from iterable
#
# # range(0, 10)
# for value in g1(range(10)):
# print(value)
# # 0, 1, ... 9
# for value in g2(range(10)):
# print(value)
# 自己实现一个chain的功能
def my_chain(*args, **kwargs):
for my_iterable in args:
# yield from 和下面同样的效果,但是它的功能远不止于此。看下面的例子
yield from my_iterable
# for value in my_iterable:
# yield value
for value in my_chain(my_list, my_dict, range(5, 10)):
print(value)
# main 调用方 g1(委托生成器) gen 子生成器
# yield from会在调用方与子生成器之间建立一个双向通道, 有了yield from它就直接发送给我们的子生成器gen
def g1(gen):
yield from gen
def main():
g = g1()
g.send(None)
4.4 生成器yield from应用实例
final_result = {}
def sales_sum(pro_name):
total = 0
nums = []
while True:
x = yield
print(pro_name+"销量: ", x)
if not x:
break
total += x
nums.append(x)
return total, nums
def middle(key):
while True:
# 最后返回值 yield from 帮我们捕获了StopIteration并取值
# yield from还帮我们做了很多事
final_result[key] = yield from sales_sum(key)
print(key+"销量统计完成!!.")
def main():
data_sets = {
"bobby牌面膜": [1200, 1500, 3000],
"bobby牌手机": [28, 55, 98, 108],
"bobby牌大衣": [280, 560, 778, 70],
}
for key, data_set in data_sets.items():
print("start key:", key)
m = middle(key)
# 预激middle协程
m.send(None)
for value in data_set:
# 给协程传递每一组的值 通过yield from相当于调用子生成器sales_sum
m.send(value)
m.send(None)
print("final_result:", final_result)
if __name__ == '__main__':
main()
# 如果直接调用需要自己加上异常捕获
# if __name__ == "__main__":
# my_gen = sales_sum("bobby牌手机")
# my_gen.send(None)
# my_gen.send(1200)
# my_gen.send(1500)
# my_gen.send(3000)
# try:
# my_gen.send(None)
# except StopIteration as e:
# result = e.value
# print(result)
4.5 生成器yield from源码分析
- yield from源码总结一下关键点:
- 子生成器生产的值,都是直接传给调用方的;调用方通过.send()发送的值都是直接传递给子生成器的;如果发送的是 None,会调用子生成器的__next__()方法,如果不是 None,会调用子生成器的.send()方法;
- 子生成器退出的时候,最后的return EXPR,会触发一个StopIteration(EXPR)异常;
- yield from表达式的值,是子生成器终止时,传递给StopIteration异常的第一个参数;
- 如果调用的时候出现StopIteration异常,委托生成器会恢复运行,同时其他的异常会向上 “冒泡”;
- 传入委托生成器的异常里,除了GeneratorExit之外,其他的所有异常全部传递给子生成器的.throw()方法;如果调用.throw()的时候出现了StopIteration异常,那么就恢复委托生成器的运行,其他的异常全部向上 “冒泡”;
- 如果在委托生成器上调用.close()或传入GeneratorExit异常,会调用子生成器的.close()方法,没有的话就不调用。如果在调用.close()的时候抛出了异常,那么就向上 “冒泡”,否则的话委托生成器会抛出GeneratorExit异常。
#pep380
#1. RESULT = yield from EXPR可以简化成下面这样
#一些说明
"""
_i:子生成器,同时也是一个迭代器
_y:子生成器生产的值
_r:yield from 表达式最终的值
_s:调用方通过send()发送的值
_e:异常对象
"""
# EXPR是一个可迭代对象,_i其实是子生成器;
_i = iter(EXPR)
try:
# 预激子生成器,把产出的第一个值存在_y中;
_y = next(_i)
except StopIteration as _e:
# 如果抛出了`StopIteration`异常,那么就将异常对象的`value`属性保存到_r,这是最简单的情况的返回值;
_r = _e.value
else:
# 尝试执行这个循环,委托生成器会阻塞;
while 1:
# 生产子生成器的值,等待调用方`send()`值,发送过来的值将保存在_s中;
_s = yield _y
try:
# 转发_s,并且尝试向下执行;
_y = _i.send(_s)
except StopIteration as _e:
# 如果子生成器抛出异常,那么就获取异常对象的`value`属性存到_r,退出循环,恢复委托生成器的运行;
_r = _e.value
break
# _r就是整个yield from表达式返回的值。
RESULT = _r
"""
yield from 处理下面的情况代码
1. 子生成器可能只是一个迭代器,并不是一个作为协程的生成器,所以它不支持.throw()和.close()方法;
2. 如果子生成器支持.throw()和.close()方法,但是在子生成器内部,这两个方法都会抛出异常;
3. 调用方让子生成器自己抛出异常
4. 当调用方使用next()或者.send(None)时,都要在子生成器上调用next()函数,当调用方使用.send()发送非 None 值时,才调用子生成器的.send()方法;
"""
_i = iter(EXPR)
try:
_y = next(_i)
except StopIteration as _e:
_r = _e.value
else:
while 1:
try:
_s = yield _y
except GeneratorExit as _e:
try:
_m = _i.close
except AttributeError:
pass
else:
_m()
raise _e
except BaseException as _e:
_x = sys.exc_info()
try:
_m = _i.throw
except AttributeError:
raise _e
else:
try:
_y = _m(*_x)
except StopIteration as _e:
_r = _e.value
break
else:
try:
if _s is None:
_y = next(_i)
else:
_y = _i.send(_s)
except StopIteration as _e:
_r = _e.value
break
RESULT = _r
4.6 生成器实现协程
- 查看生成器状态。
#生成器是可以暂停的函数
import inspect
def gen_func():
value = yield from
# 第一返回值给调用方, 第二调用方通过send方式返回值给gen
return "bobby"
if __name__ == "__main__":
gen = gen_func()
print(inspect.getgeneratorstate(gen)) # 生成器状态GEN_CREATED
next(gen)
print(inspect.getgeneratorstate(gen)) # 生成器状态GEN_SUSPENDED
try:
next(gen)
except StopIteration:
pass
print(inspect.getgeneratorstate(gen)) # 生成器状态GEN_CLOSED
- 实现协程的伪代码。
# 1. 用同步的方式编写异步的代码, 在适当的时候暂停函数并在适当的时候启动函数
# 下面是伪代码
import socket
def get_socket_data():
yield "bobby"
def downloader(url):
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.setblocking(False)
try:
client.connect((host, 80)) # 阻塞不会消耗cpu
except BlockingIOError as e:
pass
selector.register(self.client.fileno(), EVENT_WRITE, self.connected)
# 从socket中读数据
source = yield from get_socket_data()
data = source.decode("utf8")
html_data = data.split("\r\n\r\n")[1]
print(html_data)
def download_html(html):
html = yield from downloader()
if __name__ == "__main__":
# 协程的调度依然是 事件循环+协程模式 ,协程是单线程模式
pass
4.7 python中原生协程async和await
- python3.5之后就支持了原生的协程。为了将语义变得更加明确,就引入了async和await关键词用于定义原生的协程
- 防止混乱,即使生成器又是协程。async中不能定义yield 会报异常的, await只能出现在async中
import types
# 实现了__await__魔法方法就可以await
# from collections import Awaitable
# 装饰之后实现了__await__
# @types.coroutine
# def downloader(url):
# yield "bobby"
async def downloader(url):
return "bobby"
async def download_url(url):
# do somethings
# async中不能定义yield 会报异常的, await只能出现在async中
html = await downloader(url)
return html
if __name__ == "__main__":
coro = download_url("http://www.imooc.com")
# next(None) 原生协程 不能用这个会报错
# 因为直接返回"bobby", 不能向上继续抛 回报StopIteration
coro.send(None)