目录
I/O多路复用技术机制(select, poll, epoll)
1. 并发、并行、同步、异步、阻塞、非阻塞
并发(concurrency):一段时间内,有几个程序在同一个cpu上运行,但是任意时刻只有一个程序在cpu上运行。
并行(parallel):任意时刻点上,多个程序同时运行在多个cpu上。(并行的最大 数量和CPU数量一致)
同步和异步为消息通信机制:
同步:代码调用IO操作时,必须等待IO操作完成才返回的调用方式。
异步:代码调用IO操作时,不必等待IO操作完成才返回的调用方式。
阻塞和非阻塞是函数调用的机制:
阻塞:调用函数的时候当前线程被挂起。
非阻塞:调用函数的时候当前线程不被挂起,而是立即返回。
2.IO多路复用
-
Linux下五中I/O模型
阻塞式I/O
非阻塞式I/O
I/O复用
信号驱动式I/O (不常见)
异步I/O(POSIX的系列函数aio_系列函数)
- 阻塞式I/O: 代码编写容易,但是浪费时间严重。
将数据从内核复制到用户空间:操作系统为了安全的保存内存,会保护一定的内存留给操作系统使用,recvfrom调用时会深入OS内核空间,然后再将数据从操作系统的地址拷贝到应用的缓存中。
- 非阻塞式I/O: 在状态请求过程会消耗CPU并且浪费时间,而阻塞式的只会浪费时间并不消耗CPU,因此并不是所有情况下非阻塞的性能都比阻塞的好。
- I/O复用:调用select后操作系统会返回已经准备好的数据。优点:可以监听多个文件句柄,一旦有一个句柄变化,则会立即返回,无需轮询,用途广泛,技术成熟,稳定。但是将数据从内核复制到用户空间的时间不可以节省。
- 信号驱动式I/O: 应用较少。
- 异步I/O: 目前性能不比I/O复用有大的优化,应用较少。
举例:使用非阻塞i/o实现http请求:
import socket
from urllib.parse import urlparse # 做url解析
# 使用非阻塞io完成http请求
def get_url(url):
# 通过socket请求html
url = urlparse(url)
host = url.netloc # 提取出host
path = url.path
if path == "":
path = "/"
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.setblocking(False) # connect函数会立即返回
try:
client.connect((host, 80))
except BlockingIOError:
pass
# http协议
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:
pass
# 将所有数据读取完成
data = b""
while True:
try:
d = client.recv(1024)
except BlockingIOError:
continue
if d:
data += d
else:
break # 数据读完 跳出循环
data = data.decode("utf8") # 根据不同网站的不同编码方式解码
html_data = data.split("\r\n\r\n")[1] # 删去http头
print(html_data)
client.close()
if __name__ == "__main__":
get_url("http://www.baidu.com")
-
I/O多路复用技术机制(select, poll, epoll)
I/O多路复用技术机制是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select, poll, epoll本身是同步I/O,因为他们都需要在读写事件后自己负责进行读写(从内核空间拷贝到用户空间),也就是说这个读写的过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会把数据从内核空间拷贝到用户空间。
- select:
select函数监视的文件描述符分为3类,分别是writefds、readfds和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写或有异常),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。返回后可以通过遍历fdset(用三个位图表示)来找到就绪的描述符。
select的缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
下面举例通过select+回调+非阻塞i/o+时间循环实现http请求:
select本身不支持register模式,而selector类封装了select支持select的一系列操作。
import socket
from urllib.parse import urlparse # 做url解析
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE # 根据平台自动选择poll(win) epoll(linux)
# select + 回调 + 事件循环
selector = DefaultSelector()
urls = ["http://www.baidu.com"]
stop = False
class Fetcher:
def connected(self, key): # 回调函数:获取事件后需要执行的操作
selector.unregister(key.fd) # fileno的返回值
# http协议 因为有时间监听则无需轮询
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] # 删去http头
print(html_data)
self.client.close()
urls.remove(self.spider_url)
if not urls:
global stop
stop = True
# 使用select完成http请求
def get_url(self, url):
# 通过socket请求html
self.spider_url = url
url = urlparse(url)
self.data = b""
self.host = url.netloc # 提取出host
self.path = url.path
if self.path == "":
self.path = "/"
self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client.setblocking(False) # connect函数会立即返回 使用非阻塞i/o
try:
self.client.connect((self.host, 80))
except BlockingIOError:
pass
# 注册 参数列表:fileobj(注册socket的文件描述符) events: 事件(read, write, 回调函数)
selector.register(self.client.fileno(), EVENT_WRITE, self.connected)
def loop():
# 事件循环 不停的请求socket状态并调用对应的回调函数
# 通过selector不停判断是否可读或可写
# socket状态变化后的回调是由程序员自己完成的
while not stop:
ready = selector.select()
for key, mask in ready:
call_back = key.data
call_back(key)
if __name__ == "__main__":
fetcher = Fetcher()
fetcher.get_url("http://www.baidu.com")
loop()
Select + 回调+ 事件循环的优点:并发性很高,该例子使用的是单线程,cpu操作的效率远远大于io操作,不需要线程来回切换。当请求的url很多时,这种架构的效率非常高。
- poll:
poll使用一个pollfd的指针实现,它包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时pollfd没有最大数量限制(但是数量大后性能会下降)。和select一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
- epoll:
是select和poll的增强版,更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个文件描述符,将用户关系的文件描述符的事件存放到内核的一个时间表中,这样在内核空间和用户空间的copy只需要一次。
epoll的性能不一定总比select好。在高并发的情况下,连接活跃度不是很高,epoll比select好(如网站);在并发性不高的情况下,同时连接很活跃,select比epoll好(如游戏等)。
3.协程
回调函数有很多痛点:如果函数执行不正常该如何?如果回调里面还要嵌套回调该怎么办?如果嵌套了多层,某个环节出错了会造成什么后果?如果有个数据需要被每个回调都处理怎么办?怎么使用当前函数中的局部变量?因此我们目前遇到了一些问题:回调模式编码复杂度高,同步编程的并发性不高,多线程变成需要线程间同步(lock会降低并发性能)。我们由如下设想的解决方案:
①使用同步的方式去编写异步的代码,在适当的时候暂停函数并在适当的时候启动函数;
②使用单线程去切换任务:不需要锁,并发性高,单线程内切换函数其性能远高于线程切换,并发性高。所以需要一个可以暂停的函数,并且可以在适当的时候恢复该函数的执行。
但是线程是由操作系统切换的,单线程切换意味着我们需要程序员自己去调度任务。
因此,我们引入协程来解决回调函数的一系列痛点。
协程 ,又称为微线程,它是实现多任务的另一种方式,只不过是比线程更小的执行单元。因为它自带CPU的上下文,这样只要在合适的时机,我们可以把一个协程切换到另一个协程。
通俗的理解: 在一个线程中的某个函数中,我们可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行,注意不是通过调用函数的方式做到的 ,并且切换的次数以及什么时候再切换到原来的函数都由开发者自己确定。
协程与线程的差异:
在实现多任务时, 线程切换__从系统层面__远不止保存和恢复CPU上下文这么简单。操作系统为了程序运行的高效性,每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作,所以线程的切换非常耗性能。但是__协程的切换只是单纯地操作CPU的上下文__,所以一秒钟切换个上百万次系统都抗的住。(来自python多任务—协程(一))
4.生成器进阶
生成器可以理解为有多个入口的函数或者是可以暂停的函数,并且可以向暂停的地方传入值。可以通过getgeneratorstate函数获取当前生成器的状态:(其中SUSPENDED状态即为暂停的状态)
import inspect
def gen_func():
yield 1
return "Lil_Hoe"
if __name__ == "__main__":
gen = gen_func()
print(inspect.getgeneratorstate(gen))
next(gen)
print(inspect.getgeneratorstate(gen))
try:
next(gen)
except StopIteration:
pass
print(inspect.getgeneratorstate(gen))
输出:
GEN_CREATED
GEN_SUSPENDED
GEN_CLOSED
- 生成器实现了迭代协议,启动生成器的方式有两种:next(), send()
def gen_fun():
yield 1
yield 2
yield 3
return "hello"
if __name__ == "__main__":
gen = gen_fun()
print(next(gen))
print(next(gen))
print(next(gen))
print(next(gen))
输出结果:(由于最后一个return不是生成器,迭代止步)
1
2
3
Traceback (most recent call last):
File "/Users/lilhoe/Desktop/Python/socket/socket_http.py", line 12, in <module>
print(next(gen))
StopIteration: hello
- 生成器不止可以产出值,也可以接收值。
当生成器写在赋值语句的右边时,可以产出值并调用方传递寄哪里的值接收到生成器内部。
def gen_fun():
html = yield "http://www.baidu.com"
print(html)
yield 1
if __name__ == "__main__":
gen = gen_fun()
url = next(gen)
html = "Lil_Hoe"
print(gen.send(html)) # send可以传递值进入生成器内部,同时还可以重启生成器执行到下一个yield语句
输出结果:
Lil_Hoe
1
上述代码如果将第一个next语句删去会报错,因为第一次启动生成器时不可以在send中传递一个非None的参数,因此在send调用非None值之前必须启动生成器一次(gen.send(None)或next(gen))
- gen.close()
该函数停止generator运行,但是如果手动捕获GeneratorExit异常,并且pass的话会报错,因为close()进行关闭的功能而pass是忽略向下执行,冲突报出RuntimeError异常。
- gen.throw()
抛出异常,但是是抛出的上一条yield语句的异常。可以通过except Exception(最基础的异常类)来pass掉异常。
- yield from iterable
首先通过一个chain函数了解yield from。
from itertools import chain # 可以将迭代的类型连接起来做一个for循环
list = [1, 2, 3]
dict = {4:"sd", 5:"ewe"}
for value in chain(list, dict, range(6,10)):
print(value)
输出:
1
2
3
4
5
6
7
8
9
我们用yield语句实现自定义chain函数来实现同样的效果:
list = [1, 2, 3]
dict = {4:"sd", 5:"ewe"}
def my_chain(*args, **kwargs):
for my_iterable in args:
for value in my_iterable:
yield value
for value in my_chain(list, dict, range(6,10)):
print(value)
用yield from语句实现同样的效果:
list = [1, 2, 3]
dict = {4:"sd", 5:"ewe"}
def my_chain(*args, **kwargs):
for my_iterable in args:
yield from my_iterable
for value in my_chain(list, dict, range(6,10)):
print(value)
由此可见,yield是直接将对象输出,而yield from是将迭代对象逐一遍历后再输出。
由下面一个例子可以明显看出这两者的区别:
def g1(iterable):
yield iterable
def g2(iterable):
yield from iterable
for value in g1(range(10)):
print(value)
for value in g2(range(10)):
print(value)
输出:
range(0, 10)
0
1
2
3
4
5
6
7
8
9
生成器的迭代使用:
def g1(gen):
yield from gen
def main():
g = g1()
g.send(None)
在这里,main是调用方,g1是委托生成器,gen是子生成器。yield from会在调用方和子生成器之间建立一个双向通道(即gen里的值直接可以和main之间传递,不需要再返回给g1)。
下面通过一个计算总销量的例子熟悉yield from的用法:(使用yield from可以避免StopIteration异常的发生)
final_result = {}
def sales_sum(pro_name): # 子生成器
total = 0
nums = []
while True:
x = yield
if not x:
break
print(pro_name + "销量:", x)
total += x
nums.append(x)
return total, nums # 直接返回到final_result里
def middle(key): # 委托生成器
while True:
final_result[key] = yield from sales_sum(key)
print(key + "销售统计完成!\n")
if __name__ == "__main__":
data_sets = {
"保时捷": [1000, 4566, 3425, 3446],
"兰博基尼": [2435, 5245, 8671, 4867],
"路虎": [5442, 1345, 1234, 5653]
}
for key, data_set in data_sets.items():
print("start key:", key)
m = middle(key)
m.send(None) # 预激middle协程
for value in data_set:
m.send(value) # 给协程传递每一组值
m.send(None) # 使子生成器结束
print("final result:", final_result)
输出:
start key: 保时捷
保时捷销量: 1000
保时捷销量: 4566
保时捷销量: 3425
保时捷销量: 3446
保时捷销售统计完成!
start key: 兰博基尼
兰博基尼销量: 2435
兰博基尼销量: 5245
兰博基尼销量: 8671
兰博基尼销量: 4867
兰博基尼销售统计完成!
start key: 路虎
路虎销量: 5442
路虎销量: 1345
路虎销量: 1234
路虎销量: 5653
路虎销售统计完成!
final result: {'保时捷': (12437, [1000, 4566, 3425, 3446]), '兰博基尼': (21218, [2435, 5245, 8671, 4867]), '路虎': (13674, [5442, 1345, 1234, 5653])}
总结:yield from做的工作
①子生成器生产的值都是直接传递给调用方;调用方通过send()发送的值都是直接传递给子生成器的。如果发送的是None,会调用子生成器的__next__()方法;否则调用send()。
②子生成器退出时,最后的return EXPR,会触发一个StopIteration(EXPR)异常。
③yield from表达式的值,是子生成器终止时,传递给StopIteration异常的第一个参数。
④如果调用时出现StopIteration异常,委托生成器会恢复运行,同时其他的异常会向上”冒泡“。
⑤传入委托生成器的异常里,除了GeneratorExit之外,其他的所有异常全都传递给子生成器的.throw()方法;如果调用throw时产生StopIteration异常,就恢复委托生成器的运行,其他异常全部向上”冒泡“。
⑥如果在委托生成器上调用.close()或传入GeneratorExit异常,会调用子生成器的 .close()方法,没有的话就不调用。如果在close()的时候抛出了异常,那么就向上”冒泡“,否则的话,委托生成器会抛出GeneratorExit异常。
5. async和await
async和await语句用于定义python原生的协程,和生成器产生的协程类似。
下面例子调用async和await模拟获取http:
async def downloader(url):
return "Lil_Hoe"
async def download_url(url): # async函数中不可以定义生成器
html = await downloader(url) # await类似于yield from且只能出现在async函数中
return html
if __name__ == "__main__":
coro = download_url("http://www.baidu.com")
# next(coro)
coro.send(None)
运行结果:(异常直接上抛,输出结果正确)
Traceback (most recent call last):
File "/Users/lilhoe/Desktop/Python/socket/socket_http.py", line 11, in <module>
coro.send(None)
StopIteration: Lil_Hoe
如果调用的是next方法则会报错:
Traceback (most recent call last):
File "/Users/lilhoe/Desktop/Python/socket/socket_http.py", line 10, in <module>
next(coro)
TypeError: 'coroutine' object is not an iterator
sys:1: RuntimeWarning: coroutine 'download_url' was never awaited
await后面只能接受awaitble对象,如果要使用yield语句,需要使用types装饰器进行装饰:
import types
@types.coroutine
def downloader(url):
yield "Lil_Hoe"
yield既能代表生成器又能代表协程,容易造成混乱。所以实现这种类型的协程时,最好使用async和await语句。
6. 生成器实现协程
协程时单线程模式,协程调度 = 时间循环+协程模式。
协程模式可以直接使用一个函数里前面的值,而回调函数则需要创建一个类来修改实例的属性;当产生异常可以直接向子生成器中抛出异常,即编程顺序和原先的同步编程方式一致。
程序中耗io的操作都可以使用yield语句来实现异步节省时间,在协程中不能编写耗时(阻塞)的操作(如sleep语句)。