上节课问题:
协程:遇到IO阻塞就切换
但是什么时候切换回来?怎么确定IO阻塞结束了?
- 一、事件驱动模型
传统的编程是如下线性模式的:
开始--->代码块A--->代码块B--->代码块C--->代码块D--->......--->结束
每一个代码块里是完成各种各样事情的代码,但编程者知道代码块A,B,C,D...的执行顺序,唯一能够改变这个流程的是数据。输入不同的数据,根据条件语句判断,流程或许就改为A--->C--->E...--->结束。每一次程序运行顺序或许都不同,但它的控制流程是由输入数据和你编写的程序决定的。如果你知道这个程序当前的运行状态(包括输入数据和程序本身),那你就知道接下来甚至一直到结束它的运行流程。
对于事件驱动型程序模型,它的流程大致如下:
开始--->初始化--->等待
与上面传统编程模式不同,事件驱动程序在启动之后,就在那等待,等待什么呢?等待被事件触发。传统编程下也有“等待”的时候,比如在代码块D中,你定义了一个input(),需要用户输入数据。但这与下面的等待不同,传统编程的“等待”,比如input(),你作为程序编写者是知道或者强制用户输入某个东西的,或许是数字,或许是文件名称,如果用户输入错误,你还需要提醒他,并请他重新输入。事件驱动程序的等待则是完全不知道,也不强制用户输入或者干什么。只要某一事件发生,那程序就会做出相应的“反应”。这些事件包括:输入信息、鼠标、敲击键盘上某个键还有系统内部定时器触发。
事件驱动模型介绍
通常,我们写服务器处理模型的程序时,有以下几种模型:
(1)每收到一个请求,创建一个新的进程,来处理该请求;
(2)每收到一个请求,创建一个新的线程,来处理该请求;
(3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
第三种就是协程、事件驱动的方式,一般普遍认为第(3)种方式是大多数网络服务器采用的方式
论事件驱动模型
在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢? 两种方式:
1创建一个线程循环检测是否有鼠标点击
那么这个方式有以下几个缺点:
- CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
- 如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
- 如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题;
所以,该方式是非常不好的。
2 就是事件驱动模型
目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
- 有一个事件(消息)队列;
- 鼠标按下时,往这个队列中增加一个点击事件(消息);
- 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
- 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数;
事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
最初的问题:怎么确定IO操作完了切回去呢?通过回调函数
注意,事件驱动的监听事件是由操作系统调用的cpu来完成的
IO多路复用
前面是用协程实现的IO阻塞自动切换,那么协程又是怎么实现的,在原理是是怎么实现的。如何去实现事件驱动的情况下IO的自动阻塞的切换,这个学名叫什么呢? => IO多路复用
比如socketserver,多个客户端连接,单线程下实现并发效果,就叫多路复用。
同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。
-
1 IO模型前戏准备
在进行解释之前,首先要说明几个概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O
用户空间与内核空间
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
浅显理解:内存切成两部分,内核态放操作系统 ,用户态存放应用程序
深层理解:CPU并不是一个单纯的硬件,而是内嵌了一段代码,有了这段代码,才能连接操作系统,从而实现操作系统操作硬件的能力
本质上内存就是一块内存,不可能通过软件切成两块,其实是CPU里面有个指令集,将内存标识为0和1,其中0代表内核态,权限最高,1代表用户态
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换,这种切换是由操作系统来完成的。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
保存处理机上下文,包括程序计数器和其他寄存器。
更新PCB信息。
把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
选择另一个进程执行,并更新其PCB。
更新内存管理的数据结构。
恢复处理机上下文。
注:总而言之就是基础切换很耗资源的
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存 I/O
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。用户空间没法直接访问内核空间的,内核态到用户态的数据拷贝
思考:为什么数据一定要先到内核区,直接到用户内存不是更直接吗?
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
- 二、四种IO
同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?这个问题其实不同的人给出的答案都可能不同,比如wiki,就认为asynchronous IO和non-blocking IO是一个东西。这其实是因为不同的人的知识背景不同,并且在讨论这个问题的时候上下文(context)也不相同。所以,为了更好的回答这个问题,我先限定一下本文的上下文。
1、阻塞IO
之前学的socket中accept和recv(recvfrom)就是阻塞IO
下面就以accept为例讲阻塞IO
socket 中 accept流程:server端发了一次系统调用,向操作系统要数据,而这个时候client端还没有connect,这是出于wait状态(阻塞状态),client端connect之后,操作系统(内核态)从内存拷贝了数据,再将数据发送给server端,这个过程也是阻塞状态,recv也是这个过程。所以阻塞IO有一次系统调用,两次阻塞过程。
阻塞IO缺点:下面的代码必须要等到阻塞状态解除才能往下走,全程阻塞。
2、非阻塞IO
linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
1 #############server端 2 import socket,time 3 server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 4 server.bind(('192.168.43.182',8080)) 5 server.listen(5) 6 server.setblocking(False) ## 设置阻塞状态,默认为True 7 while 1: 8 try: 9 conn,addr = server.accept() 10 data = conn.recv(1024) 11 print(data.decode('utf8')) 12 conn.send(b'hello') 13 except Exception as e: 14 print(e) 15 time.sleep(5) 16 17 ########### client端 18 import socket 19 client = socket.socket() 20 client.connect(('192.168.43.182',8080)) 21 client.send(b'hello server') 22 data = client.recv(1024) 23 print(data.decode('utf8'))
非阻塞IO解决了第一段等待的问题,但是随着而来的也出现了两个问题:
1、系统调用太多次
2、未能够及时处理accept过来的数据,当accept到数据的时候正好是在处理其他代码时,要等到其他代码处理完才能处理accept的数据
3、IO多路复用
我们先来看一下IO多路复用的流程图
(1)select篇
select在Linux、Windows都能用,最多能监听1024个
这一看,还不如阻塞IO呢。总共发起两次系统调用,也是两次阻塞状态,怎么回事?但是我们也发现了第一次是有select发起系统调用的,我们来看一下具体的流程
1 #################server端 2 import socket,time,select 3 server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 4 server.bind(('192.168.43.182',8080)) 5 server.listen(5) 6 7 while 1: 8 r,w,e = select.select([server,],[],[],5) ## select(输入,输出,异常,多久) 9 for i in r: 10 #conn,addr = i.accept() 11 #print(conn) 12 print('hello') 13 print('>>>') 14 15 ############### client端 16 import socket 17 client = socket.socket() 18 client.connect(('192.168.43.182',8080)) 19 client.send(b'hello server') 20 data = client.recv(1024) 21 print(data.decode('utf8'))
select监听server端的变化,一旦有client端连接到server端,server端就会发生变化,select就会监听到。这里注意,client端连接server是将数据发送到server端的内存中,accept其实是server端向内存要数据,而select也是监听内存中有没有client发来的数据。思考一下:上面代码10和11行注释之后,select会发生什么样的变化呢?
由于server没有accept,也就是没有从内核态取数据,所以select在内核态会一直感知到数据的存在,也就是接收到的输入列表里一直存在server连接。
我们可以看到select接收的参数是一个列表,也就是可以同时监听多个输入对象,我们将conn也监听起来,对方send之后select就可以感知到,这时就达到了并发的效果了
1 import socket,select 2 server = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 3 server.bind(('192.168.43.182',8080)) 4 server.listen(5) 5 inp = [server] 6 7 while 1: 8 r,w,e = select.select(inp,[],[],5) ## select(输入,输出,异常,多久) 9 print(inp) 10 for i in r: 11 if i == server: 12 conn,addr = i.accept() 13 print(conn) 14 print('hello') 15 inp.append(conn) ## 将conn加入列表 16 else: 17 data = i.recv(1024) 18 print(data.decode('utf8')) 19 i.send(data.upper()) 20 print('>>>')
select的触发方式:水平触发
触发方式有两种:水平触发和边缘触发
select和poll的区别:poll解决了select最多只能监听1024个端口的问题
select和epoll的区别:select的实现机制是这样的,比如有select监听四十个端口,其中有一个端口发了数据并告诉select我已经发了数据,select就会问40个端口是不是他发的,也就是要问四十遍;而epoll不一样,端口发数据给epoll,必须告诉epoll是谁,这样就少了中间问的这个过程,所以epoll是效率最高的
(2)select poll epoll IO多路复用介绍
首先列一下,sellect、poll、epoll三者的区别
- select
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
select目前几乎在所有的平台上支持
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
- poll
它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。
一般也不用它,相当于过渡阶段
- epoll
直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll。被公认为Linux2.6下性能最好的多路I/O就绪通知方法。windows不支持
没有最大文件描述符数量的限制。
比如100个连接,有两个活跃了,epoll会告诉用户这两个两个活跃了,直接取就ok了,而select是循环一遍。
(了解)epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。
所以市面上上见到的所谓的异步IO,比如nginx、Tornado、等,我们叫它异步IO,实际上是IO多路复用。
注意1:select函数返回结果中如果有文件可读了,那么进程就可以通过调用accept()或recv()来让kernel将位于内核中准备到的数据copy到用户区。
注意2: select的优势在于可以处理多个连接,不适用于单个连接
4、异步IO
异步IO的流程:
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
异步IO特点:全程无阻塞
- 三、几种 IO模型比较
- 四、selectors模块(IO多路复用)
1 ########## server端 2 import selectors,socket 3 sel = selectors.DefaultSelector() ## 创建一个selectors对象 4 def accept(sock,mask): ## 连接函数 5 conn,addr = sock.accept() 6 conn.setblocking(False) ## 设置为非阻塞 7 sel.register(conn,selectors.EVENT_READ,read) ## 将conn进行注册,监听到conn发生变化就运行read函数,也就是回调函数 8 9 def read(conn,mask): ## 通信函数 10 try: 11 data = conn.recv(1024) 12 if not data: 13 raise Exception 14 print('收到客户端发来的消息:',data) 15 conn.send(data.upper()) 16 except Exception as e: 17 print(e) 18 sel.unregister(conn) ## 解除监听 19 20 sock = socket.socket() 21 sock.bind(('192.168.43.182',8080)) 22 sock.listen(50) 23 sock.setblocking(False) ## 设置为非阻塞 24 25 sel.register(sock,selectors.EVENT_READ,accept) ## 将sock进行注册,监听到sock发生变化就运行accept函数,也就是回调函数 26 print('server 开始启动') 27 while True: 28 events = sel.select() 29 for key,mask in events: 30 callback = key.data ## 回调函数 31 callback(key.fileobj,mask) 32 33 ######### client端 34 import socket 35 client = socket.socket() 36 client.connect(('192.168.43.182',8080)) 37 while 1: 38 data = input('>>>>') 39 client.send(data.encode('utf8')) 40 data = client.recv(1024) 41 print(data.decode('utf8'))
selectors在所有操作系统都可以用,默认会加载该操作系统最好的模块,Windows的是select,Linux的是 epoll。