IO模型
引子
在学完协程之后,了解到它最优也是解决IO操作的,那么俩个点、
协程:遇到IO操作就切换。
但什么时候切回去呢?怎么确定IO操作完了?
诸多诸多
很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。
这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题
一、事件驱动模型介绍
线性模式:
开始--->代码块A--->代码块B--->代码块C--->代码块D--->......--->结束
每一个代码块里是执行各种功能的代码,单编程者知道代码块A,B,C,D...的执行顺序,唯一能够改变这个流程的是数据。输入不同的数据,根据条件语句判断,流程或许就改为A--->C--->E...--->结束。每一次程序运行顺序或许都不同,但它的控制流程是由输入数据和你编写的程序决定的。如果你知道这个程序当前的运行状态(包括输入数据和程序本身),那你就知道接下来甚至一直到结束它的运行流程。
对于事件驱动型程序模型,它的流程大致如下:
开始--->初始化--->等待
与上面传统编程模式不同,事件驱动程序在启动之后,就在那等待,等待什么呢?等待被事件触发。传统编程下也有“等待”的时候,比如在代码块D中,你定义了一个input(),需要用户输入数据。但这与下面的等待不同,传统编程的“等待”,比如input(),你作为程序编写者是知道或者强制用户输入某个东西的,或许是数字,或许是文件名称,如果用户输入错误,你还需要提醒他,并请他重新输入。事件驱动程序的等待则是完全不知道,也不强制用户输入或者干什么。只要某一事件发生,那程序就会做出相应的“反应”。这些事件包括:输入信息、鼠标、敲击键盘上某个键还有系统内部定时器触发。
事件驱动模型
通常,写服务器处理模型的程序时,有以下几种模型:
(1)每收到一个请求,创建一个新的进程,来处理该请求; (2)每收到一个请求,创建一个新的线程,来处理该请求; (3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
第三种就是协程、事件驱动的方式,一般普遍认为第(3)种方式是大多数网络服务器采用的方式
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <p onclick="fun()">点我呀</p> <script type="text/javascript"> function fun() { alert('约吗?') } </script> </body> </html> 事件驱动模型之鼠标点击事件
在ui编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击
两种方式:
1.创建一个线程循环检测是否有鼠标点击
那么这个方式有以下几个缺点:
1.cpu资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
2.如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
3.如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题;
所以,该方式是非常不好的.
2、就是事件驱动模型
目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
- 有一个事件(消息)队列;
- 鼠标按下时,往这个队列中增加一个点击事件(消息);
- 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
- 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数;

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
- 让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。
最初的问题:怎么确定IO操作完了切回去了呢?通过回调函数
1.要理解时间驱动和程序,就需要与非事件驱动的程序进行比较。实际上,现代的程序大多是时间驱动的,比如多线程的程序,肯定是事件驱动的,早起则存在许多费时间驱动的程序,这样的程序,在需要等待某个条件出发时,会不断地检查这个条件,直到条件满足,这是很浪费cpu时间的,而时间驱动的程序,则有机会释放cpu从而进入睡眠态(注意是有机会,当然程序也可自行决定不释放cpu),当事件触发时被操作系统唤醒,这样就能更加有效地使用cpu.
2.再说什么是事件驱动的程序,一个典型的时间驱动的程序,就是一个死循环,并以一个线程的形式存在,这个死循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是时间的处理过程,程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu
3.事件驱动的程序,必定会直接或者间接拥有一个事件队列,用于存储未能及时处理的事件。
4.事件驱动的程序的行为,完全受外部输入的事件控制,所以,事件驱动的系统中,存在大量这种程序,并以事件作为主要的通信方式。
5.事件驱动的程序,还有一个最大的好处,就是可以按照一定的顺序处理队列中的事件,而这个顺序则是由事件的触发顺序决定的,这一特性往往被用于保证某些过程的原子化。
6.目前windows,linux,nucleus,vxworks都是事件驱动的,只有一些单片机可能是非事件驱动的。
注意:事件驱动的监听事件是由操作系统调用的cpu来完成的
二、IO模型
用协程实现的IO阻塞自动切换,那么协程又是怎么实现的,在原理是是怎么实现的。如何去实现事件驱动的情况下IO的自动阻塞的切换,这个学名叫什么呢? => IO多路复用
比如socketserver,多个客户端连接,单线程下实现并发效果,就叫多路复用。
IO模型又划分为: 阻塞IO、非阻塞IO、同步IO、异步IO 它们是如何定义的,之间的区别是什么?
解释之前,声明一些概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O
用户空间和内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换,这种切换是由操作系统来完成的。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
保存处理机上下文,包括程序计数器和其他寄存器。
更新PCB信息。
把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
选择另一个进程执行,并更新其PCB。
更新内存管理的数据结构。
恢复处理机上下文。
注:总而言之就是很耗资源的
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
文件描述符
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存I/O
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。用户空间没法直接访问内核空间的,内核态到用户态的数据拷贝
思考:为什么数据一定要先到内核区,直接到用户内存不是更直接吗?
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?这个问题其实不同的人给出的答案都可能不同
由于signal driven IO(信号驱动IO模型)在实际中并不常用,所以只提及剩下的四种IO Model。
有五种IO模型:
blocking IO 阻塞 IO
nonblocking IO 非阻塞 IO
IO multiplexing IO多路复用
signal driven IO
asynchronous IO
由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。
再说一下IO发生时涉及的对象和步骤。
对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
1 等待数据准备 (Waiting for the data to be ready)
2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。
blocking IO (阻塞IO)
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

当用户进程调用recvfrom这个系统,kernel就开始IO的第一个阶段:准备数据。。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。
文件运行:
import socket sk = socket.socket() sk.bind(('127.0.0.1',8080)) sk.listen(5) conn,addr = sk.accept() data = conn.recv(1024) print(data.decode('utf8')) ''' IO阻塞:全程阻塞的,以server端为主,等待连接 '''
import socket sk = socket.socket() sk.connect(('127.0.0.1',8080)) sk.send(b'hello')
non-blocking IO (非阻塞IO)
linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

非阻塞IO:发送多次系统调用;
优点:不用等待,无阻塞,可以执行其他的代码
缺点:1.系统调用发送太多,2.数据不是实时接收的
占内存,不能第一时间拿到数据
两个阶段:wait for data非阻塞的阶段
copy data:阻塞的阶段
文件操作:
import socket import time sk = socket.socket() sk.bind(('127.0.0.8',8080)) sk.listen(5) sk.setblocking(False)#True:阻塞;False:非阻塞 while True: try: conn, addr = sk.accept() data = conn.recv(1024) print(data.decode('utf8')) except Exception as e: print(e) time.sleep(3) conn.close() sk.close() '''' sk.setblocking(False)#True:阻塞;False:非阻塞 在连接处做的处理,不会再出现没有连接client端的时候一直等待,而是没有连接的时候,也可以去执行其他的代码 '''
import socket sk = socket.socket() sk.connect(('127.0.0.1',8080)) sk.send(b'hello')
IO multiplexing(IO多路复用)
IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大概就都能明白了。有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
注意1:select函数返回结果中如果有文件可读了,那么进程就可以通过调用accept()或
recv()来让kerne将位于内核中准备到的数据copy到用户区
注意2:select的优势在于可以处理多个连接,不适用于单个连接
1 #!/usr/bin/python 2 # -*- coding: UTF-8 -*- 3 4 import select 5 import socket 6 7 8 sk = socket.socket() 9 sk.bind(('192.168.16.40',9999)) 10 sk.listen(5) 11 sk.setblocking(False) 12 inputs = [sk,] 13 while True: 14 r,w,x = select.select(inputs,[],[]) #监听套接字对象的连接, 15 16 for obj in r:#遍历r r就是inputs 第一次inputs = [sk,] 。第二次inputs = [sk,conn1] 17 if obj == sk: #第一次为True,运行子代码 第二次就运行else里面的代码 18 conn,addr = obj.accept() #建立连接, 19 print(conn) 20 inputs.append(conn) #添加到我定义好的list中 21 else: 22 data = obj.recv(1024) #接收数据 23 print(data.decode('utf8')) #打印 24 res = input('>>>') #输入数据 25 obj.send(res.encode('utf8')) #发送数据 26 27 """ 28 流程: 29 当连接一个client端的时候,是一对一通信, 30 如果连接多个client端,就会实现并发通信,同一时段运行多个 31 第一个客户端还是在通信时,其余的client端的通信都会阻塞在obj.recv(1024)处, 32 等待第一次server的数据传输过去,才会将第二次的通信数据从内核空间,传输到用户空间,同时删掉内核空间的数据, 33 34 """ 35 36 ''' 37 单独总结: 38 IO多路复用 (重点)监听多个连接 39 40 对于文件描述符(套接字对象): 41 1.是一个非零的整数,不会改变。 42 43 2.收发数据的时候,对于接收端而言,数据先到内核空间 44 然后copy到用户空间,同时内核空间的数据清空 45 46 特点:1.全程(wait for data,copy data)阻塞 47 2.能监听多个文件描述符 48 49 实现并发 50 '''
1 #!/usr/bin/python 2 # -*- coding: UTF-8 -*- 3 import socket 4 5 6 sk = socket.socket() 7 8 sk.connect(('192.168.16.40',9999)) 9 10 11 while True: 12 data = input('>>>:') 13 sk.send(data.encode('utf8')) 14 data_revc = sk.recv(1024) 15 print(data_revc.decode('utf-8'))
IO多路复用 (重点)监听多个连接
对于文件描述符(套接字对象):
1.是一个非零的整数,不会改变。
2.收发数据的时候,对于接收端而言,数据先到内核空间
然后copy到用户空间,同时内核空间的数据清空
特点:1.全程(wait for data,copy data)阻塞
2.能监听多个文件描述符
实现并发
Asynchronous I/O(异步IO)
linux下的asynchronous IO其实用得很少。先看一下它的流程:

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
IO模型比较分析
到目前为止,已经将四个IO Model都介绍完了。现在回过头来回答最初的那几个问题:blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别在哪。
先回答最简单的这个:blocking vs non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。
在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operationcompletes;
An asynchronous I/O operation does not cause the requesting process to be blocked;
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
各个IO Model的比较如图所示:

经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。
总结:
同步:阻塞IO 非阻塞IO IO多路复用 ,只要有阻塞的就是同步操作
异步:异步IO
selector模块
很简单直接上代码,慢慢看
import selectors import socket sock = socket.socket() sock.bind(('127.0.0.1',8080)) sock.listen(5) sock.setblocking(False) sel =selectors.DefaultSelector()#根据具体的平台选择最佳的IO多路机制。 def read(conn,mask): data = conn.recv(1204) print(data.decode('utf-8')) inp = input('>>>') conn.send(inp.encode('utf-8')) def accept(sock,mask): conn,addr=sock.accept() sel.register(conn,selectors.EVENT_READ,read) sel.register(sock,selectors.EVENT_READ,accept) #注册 while True: events = sel.select()#监听 for key,mask in events: func = key.data #conn obj = key.fileobj #read func(obj,mask) #1.accept(sock,mask) 2.read(conn,mask)
client自己想......
ending.....................

浙公网安备 33010602011771号