由于在Django项目中使用了基于异步的websocket框架,故而打算对异步的工作原理进行一波深入的了解。
阻塞
非阻塞
同步
异步
在进程通信层面
, 阻塞/非阻塞, 同步/异步基本是同义词, 但是需要注意区分讨论的对象是发送方还是接收方。
这是由于进程间的通信是通过 send()
和 receive()
两种基本操作完成的。消息的传递有可能是阻塞的或非阻塞的 —— 也被称为同步或异步的
异步编程
以进程、线程、协程、函数/方法作为执行任务程序的基本单位,结合回调、事件循环、信号量等机制,以提高程序整体执行效率和并发能力的编程方式。
异步编程的难点
为什么需要异步编程
操作 | 真实延迟 | CPU体验 |
---|---|---|
执指 | 0.38ns | 1s |
读l1缓存 | 0.5ns | 1.3s |
分支纠错 | 5ns | 13s |
读l2缓存 | 7ns | 18.2s |
加解互斥锁 | 25ns | 1min 5s |
内存寻址 | 100ns | 4min 20s |
上下文切换/系统调用 | 1.5us | 1h |
1Gpbs网络传输2kb数据 | 20us | 14.4h |
从RAM读取1M数据块 | 250us | 7.5day |
Ping单一IDC主机 | 500us | 15day |
从SSD读1M数据 | 1ms | 1month |
从硬盘读1M数据 | 20ms | 20month |
Ping不同城市主机 | 150ms | 15year |
虚拟机重启 | 4s | 300year |
服务器重启 | 5min | 25000year |
如上表所示,在千兆网上传输2KB的数据,CPU感觉过了大约14个小时。在10M的共网上,效率又会降低100倍,这段时间CPU干不了任何事情。
因此,通过异步编程实现效率的提升是十分值得的一件事情。
阻塞,非阻塞描述的是进程的一个操作是否会是的进程转变为“等待状态”,除了我们主动调用 wait()
或 sleep()
等挂起自己的操作, 另一种就是它调用 System Call
, 而 System Call
因为涉及到了 I/O 操作,不能立即执行,所以内核会将进程挂起,调度其他进程的运行。
其中,网络I/O是最大的的I/O瓶颈
了解异步编程,我们可以用简单的例子去一步步实现,从而观察到它的工作原理。
说好的是异步,为什么要写同步的呢,万事开头难,由浅入深才能有更好的理解。
import os
import socket
import time
import random
from tech_share.time_deco import TimeLogger
'''
Python中 time.sleep 是阻塞的,都知道使用它要谨慎,但在多线程编程中,time.sleep 并不会阻塞其他线程。
可以通过time sleep 模拟阻塞操作
'''
# 一个简单的同步socket应用
def blocking_way(number):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 向baidu主机的443端口发起网络连接请求 --> blocking
sock.connect(('www.baidu.com', 443))
time.sleep(random.random() * 3) # 模拟耗时操作
request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
# sock.send()函数并不会阻塞太久,它只负责将请求数据拷贝到TCP/IP协议栈的系统缓冲区中就返回,并不等待服务端返回的应答确认。
sock.send(request)
response = b''
# socket上读取4K字节数据 --> blocking
chunk = sock.recv(4096)
while chunk:
response += chunk
# blocking
chunk = sock.recv(4096)
print('task {} end ({}) time: {}'.format(number, os.getpid(), time.time()))
return response
# 同步方式(大约耗时13~17s)
@TimeLogger()
def sync_way():
res = []
for i in range(10):
res.append(blocking_way(i))
return len(res)
其中使用Python的time.sleep模拟了阻塞状态,让我们来运行一下看看
start time: 1565678320.9846358
Parent process 35404.
task 0 end (35404) time: 1565678322.719079
task 1 end (35404) time: 1565678323.2454321
task 2 end (35404) time: 1565678323.853733
task 3 end (35404) time: 1565678325.221901
task 4 end (35404) time: 1565678327.269089
task 5 end (35404) time: 1565678328.531013
task 6 end (35404) time: 1565678329.869162
task 7 end (35404) time: 1565678330.091727
task 8 end (35404) time: 1565678330.9530041
task 9 end (35404) time: 1565678333.785743
use time: 12.80132007598877
可以看到,大约执行了13秒左右,多次执行的时间区间大约在(13~17秒)
其中sock.connect(('www.baidu.com', 443))
的作用是向www.baicu.com
主机的443
端口发起网络连接请求。
sock.recv(4096)
的作用是从socket上读取4K字节数据。
创建网络的过程有时候不可能是一帆风顺的,网络状况不佳,服务器性能不够均可能导致网络创建缓慢。
此外,服务端什么时候返回了响应数据并被客户端接收到可供程序读取,也是不可预测的。
所以sock.connect()
和sock.recv()
这两个调用在默认情况下是阻塞的。
代码中的简单socket应用只运行了10次,而阻塞的过程也就重复的10次,这在网络交互十分频繁的程序和系统中,是无法忍受的。
如果顺序执行过于耗时,我们可以理所当然的这么想,如果开10个进程去处理刚才socket应用,那么速度会不会快很多?
来看看多进程下改写的代码
...
from multiprocessing import Process
# 一个简单的同步socket应用
def blocking_way(number): ...
# 多进程方式(大约耗时3~6s)
def process_way():
processes = []
for i in range(10):
p = Process(target=blocking_way, args=(i,))
processes.append(p)
for p in processes:
p.start()
# p.join() # join() 方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步
return len(processes)
同样,去执行一下
start time: 1565680474.7305129
Parent process 35704.
task 6 end (35711) time: 1565680475.795624
task 9 end (35714) time: 1565680476.0854862
task 0 end (35705) time: 1565680476.407744
task 7 end (35712) time: 1565680476.4183
task 2 end (35707) time: 1565680476.474165
task 3 end (35708) time: 1565680476.8856
task 8 end (35713) time: 1565680476.997073
task 1 end (35706) time: 1565680477.266001
task 5 end (35710) time: 1565680477.470528
task 4 end (35709) time: 1565680477.756924
use time: 3.0275731086730957
可以看到,效果是非常的明显的,但是仍然存在一些问题,照理说,10个进程执行的效率应该是同步情况下的10倍左右,然而从我们运行的实际情况来看,效率只提升了7~8倍,那么损耗的时间到哪里去了,答案是进程间的切换
,因为任意一个时刻上,单个CPU核心只能执行一个进程。当进程数量大于核心数量时,进程的切换是不可避免的。
回到上面观察一下CPU的时间观念表格,我们发现,CPU的上下文切换也是需要话费一定的时间的,而在实际运行过程中,这个时间的消耗是要比表格所列的时间要大的多。
下面给出 知乎 上一个大神给出的关于进程切换的时序图
从上图可以看出
P0
的在CPU的上下文(程序计数器,寄存器
)保存到PCB0
中PCB1
中取出进程P1
的上下文的,执行P1
的指令这么一系列的读写操作下来,浪费的时间是可想而知的,在并发不高的情况下还能hold的住,但是面对高并发的场景,进程的切换开销将会变的十分巨大。
此外,每创建一个进程都会消耗一定的内存空间,一般服务器能够同时处理的进程规模也就在数十到数百个,当进程超过一定的数量,系统的运行将会变的不稳定。
和多进程的方案比较类似,但是多线程的方案更加的轻量级,线程是依赖于进程而存在,同一个进程可以容纳多个线程,并且不同线程共享同一个进程空间。
那么继续来看看代码吧
...
from threading import Thread
# 一个简单的同步socket应用
def blocking_way(number): ...
# 多线程方式(大约耗时2.5~5.5s)
def threading_way():
threads = []
for i in range(10):
p = Thread(target=blocking_way, args=(i,))
threads.append(p)
for t in threads:
t.start()
# t.join()
return len(threads)
再看看执行时间
start time: 1565683676.944701
Parent process 36035.
task 4 end (36035) time: 1565683677.234011
task 3 end (36035) time: 1565683677.269881
task 6 end (36035) time: 1565683677.542932
task 1 end (36035) time: 1565683678.26774
task 5 end (36035) time: 1565683678.5796092
task 0 end (36035) time: 1565683679.201456
task 7 end (36035) time: 1565683679.384767
task 2 end (36035) time: 1565683679.392001
task 8 end (36035) time: 1565683679.526201
task 9 end (36035) time: 1565683679.5626528
use time: 2.6182520389556885
从执行时间上可以看出,比多进程耗时要少一些,另外线程可以支持的任务数量,也有了质的提升。
在高并发场景下,线程带来的性能提升是十分明显的。这是由于线程的上下文开销是低于进程的。
值得一提的是,虽然执行效率得到了极大的提升,但是在单个线程或者进程中,阻塞调用依旧是阻塞的。
至于为什么,已经偏离了本篇的主题,这里放两个链接,可以参考:
进程切换与线程切换的代价比较
啃碎并发(三):Java线程上下文切换
在来看看非阻塞的方案,我们继续对上面的代码进行改写
import os
import socket
import time
import random
from tech_share.time_deco import TimeLogger
# 一个简单的同步socket应用
@TimeLogger()
def non_blocking_way(number):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 将socket调用设置为非阻塞
sock.setblocking(False)
try:
# time.sleep(random.random() * 3) # 此处加time_sleep模拟耗时操作不合适
sock.connect(('www.baidu.com', 443))
except BlockingIOError:
# 忽略非阻塞连接中抛出的异常
pass
request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
while True:
try:
sock.send(request)
# 当send不抛异常时,则发送完成
break
except OSError:
pass
response = b''
# 此时并不知晓socket何时就绪,所以需要不断尝试发送
while True:
try:
chunk = sock.recv(4096)
while chunk:
response += chunk
chunk = sock.recv(4096)
break
except OSError:
pass
print('task {} end ({}) time: {}'.format(number, os.getpid(), time.time()))
return response
# 非阻塞方式(大约耗时13~17秒)
def run():
res = []
for i in range(10):
res.append(non_blocking_way(i))
return len(res)
其中:
sock.setblocking(False)
将socket上的阻塞调用都改为非阻塞,非阻塞在运行时,不妨碍调用它的程序做别的事情。
上述代码在执行完sock.connect()
和sock.recv()
后的确不再阻塞,可以继续往下执行请求准备的代码或者是执行下一次读取。
比较麻烦的是,socket在发送非阻塞连接的过程中,系统底层会抛出异常,需要通过try
语句包裹,connect()
被调用之后,立即可以往下执行代码。
后面写两个while
循环是由于socket已经变成了非阻塞,在执行send()
和receive()
的时候,程序并不知道socket是否已经就绪,所以需要不停的循环尝试发送和接收
执行,看看什么效果
task 0 end (38062) time: 1565692133.393855
task 1 end (38062) time: 1565692134.593825
task 2 end (38062) time: 1565692135.383206
task 3 end (38062) time: 1565692136.024352
task 4 end (38062) time: 1565692136.091566
task 5 end (38062) time: 1565692136.897728
task 6 end (38062) time: 1565692137.917314
task 7 end (38062) time: 1565692138.845514
task 8 end (38062) time: 1565692141.672517
task 9 end (38062) time: 1565692143.136748
use time: 12.892203092575073
emmmmm,那么问题来了,好像非阻塞的执行效果和同步阻塞的耗时没多大区别。
这段代码有以下几个问题:
connect()
和recv()
不再阻塞主程序,但是CPU本身还是没有得到有效的使用,主要体现在程序在while中不断循环尝试读写socket(因为不知道socket是否就绪)。在上面的程序中,socket状态的判断是交由程序来执行的,导致代码效率十分低下,那么,如果我们能够把这一步交给操作系统去判断,我们就能充分利用非阻塞空闲的时间来做别的事情。
实现这一功能,我们需要用到Python的 selector'
模块,这个模块在底层封装了系统模块select
(select
是用来监视文件描述符的变化情况——读写或是异常的一个底层函数)
程序可以通过select注册文件描述符(形式上是一个非负的整数
,内核kernel
利用它来访问文件。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。)和回调函数,当文件描述符发生变化时,select就调用先前注册好的回调函数
select
因其算法效率比较低,后来改进成了poll
,再后来又有进一步改进,BSD内核改进成了kqueue
模块,而Linux内核改进成了epoll
模块
这里主要是使用基于linux的epoll模块,我们目前只需要知道,高并发情况下并且有大量空闲连接时,epoll
的性能是是要高于select
和poll
,但是它们都有一个共同点,三者都是I/O多路复用机制(既可以监视多个描述符)
至于它们的区别,这里不过多的赘述,可以看下面这篇总结。
select、poll、epoll之间的区别总结[整理]
python标注库select模块提供了IO多路复用支持,包括select,poll,epoll。
OK,利用epoll
和回调
进行改写,我们来继续看看代码
import socket
import time
from tech_share.time_deco import TimeLogger
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
# 根据环境选择最佳模块
selector = DefaultSelector()
stopped = False
count = 10
class Creeper:
def __init__(self, task):
self.sock = None
self.response = b''
self.task = task
def fetch(self):
# 初始化的两个参数含义分别为地址簇和套接字类型(TCP/UDP)
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setblocking(False)
try:
# time.sleep(random.random() * 3) # 这里再模拟阻塞就不合适了,应为time.sleep并不会立即返回
self.sock.connect(('www.baidu.com', 443))
# self.sock.connect(('www.google.com', 443))
except BlockingIOError:
pass
selector.register(self.sock.fileno(), EVENT_WRITE, self.connected)
def connected(self, key):
"""
:param key: 一个具名元祖,内容包括文件对象,文件描述符,事件,回调
:return:
"""
selector.unregister(key.fd)
request = b'GET / HTTP/1.0\r\n Host: www.baidu.com\r\n\r\n'
self.sock.send(request)
selector.register(key.fd, EVENT_READ, self.read_response)
def read_response(self, key):
global stopped, count
# 如果响应大于4kb, 下一次循环会继续
chunk = self.sock.recv(4096)
if chunk:
self.response += chunk
else:
selector.unregister(key.fd)
print('task {} end time: {}'.format(self.task, time.time()))
count -= 1
if count == 0:
stopped = True
# 建立事件循环(Event loop)
@TimeLogger()
def loop():
while not stopped:
# 这个地方,其实还是阻塞的,直到一个事件发生
event = selector.select()
for event_key, event_mask in event:
callback = event_key.data
callback(event_key)
def run():
for task_id in range(count):
creeper = Creeper(task_id)
creeper.fetch()
if __name__ == '__main__':
# 启动10个socket应用
run()
# 事件循环
loop()
简单分析一下这段代码
首先,加入了select的I/O多路复用机制之后,之前的while循环总算是没有了,socket状态的监听交给了epoll
去执行
另外,可以看到,原来的socket应用的不同阶段被拆分成了不同的任务,每个任务划分的也很明确, 我们来一个一个看一下
fetch()
connect()
read_response()
chunk
没有数据了,则注销对应的事件count == 0
),则将中止变量stop
置为True
另外两个方法run()
和loop()
,其中run()
比较好理解,就是开10个任务,每个任务调用一下fetch()
方法,剩下的交给epoll
去处理就行了。
至于loop()
,他的作用是创建了一个事件循环(Event loop),在这个循环下,我们去访问select
模块,不断的去询问当前是否有事件发生,如果没有,则会返回一个空列表。
当事件发生变化时,我们在事件循环中获取当前的事件(下面简写为key)和事件类型(mask),本例中,我们只关注key。
通过观察selector的源代码可知,key是一个包含了事件详情的具名元组,
key中包含的内容分别为
fileobj
:文件对象fd
:文件描述符event
:事件类型data
:回调函数根据不同的事件,调用不同的回调函数,我们实现了基于I/O多路复用的socket应用。下面来看看代码的执行效果:
task 0 end time: 1565851610.7112331
task 5 end time: 1565851610.711699
task 1 end time: 1565851610.7117581
task 2 end time: 1565851610.711793
task 6 end time: 1565851610.716775
task 7 end time: 1565851610.716991
task 9 end time: 1565851610.7170382
task 3 end time: 1565851610.722931
task 4 end time: 1565851610.723006
task 8 end time: 1565851610.7242408
use time: 0.06382608413696289
从结果上可以看出,I/O多路复用使得程序的性能获得了极大的提升,线程的切换开销也省了,同时能够支持的任务规模也能够达到数万到数十万。
观察上面的代码,我们发现,在loop()
函数中,这段代码仍然是阻塞的:
event = selector.select()
那么为什么代码的执行效率仍然能够获得极大提升呢,这得益于I/O多路复用机制的强大。
我们在事件循环中监听socket事件的过程和同步阻塞的I/O模型并没有多大的区别
但是,使用selector
最大的优势就是我们可以在一个线程内同时处理多个socket的I/O请求,注册多个socket,通过不断的调用select()
方法读取被激活的socket,我们就实现了在同一个线程内同时处理多个I/O请求的目的。
但是这么做仍然存在一个不好的地方,那就是回调
之前的例子都很简单,然而在实际生产项目当中,我们的代码要复杂的多,相应的,回调函数的设计和调试难度也会大大增加。
最直接的,如果我们的回调函数中又嵌套了回调,我们可能会面临经典的回调地狱的问题,代码的可读性差不说,回调函数出错排查的代价也是十分高昂。
此外,共享状态管理也变得十分困难,在上面的代码中,我们使用OOP的变成风格,在creeper
实例化之后主动保存了自己socket对象,这只是一个简单的实例,实际生产过程中,回调之间需要共享的数据可能要多得多,我们需要仔细考虑哪些数据需要共享,在链式回调的过程中,共享数据就像接力似的,从一个回调传递给另一个回调。
假如我们已经精心设计好了一个看似完美的调用链,在实际运行过程中,万一调用的某一个环节出现了错误,调用链不幸断掉了,回调函数的状态也会丢失,然后就是一连串的报错,从异常的那一层开始,自底向上不断抛出异常。此时我们只能看到最顶层的异常,真正出错的那一层被隐藏了起来!这种情况称为调用栈断裂。
所以,为了防止上述情况,我们必须捕获每一个可能出现的回调异常,将异常以数据的形式返回,而不是直接抛出异常,然后每个回调中需要检查上次调用的返回值,以防错误吞没。
总体来说,基于回调的异步编程真的是困难重重。
我们使用框架和Python,目的就是让开发更加的高(舒)效(坦),一个困难重重的开发模式,终究会被开发者们干掉的。随着Python生态的不断的演化,在事件循环+回调的基础上,我们有了新的选择:
协程
比较著名的有tornado,asyncio等。
基于协程的介绍,我们放到下一篇 :)
深入理解Python异步编程
怎样理解阻塞非阻塞与同步异步的区别?
Python实现socket的非阻塞式编程
Python网络编程-IO阻塞与非阻塞及多路复用