19、第七 - 网络编程基础 - IO阻塞、非阻塞、异步、同步介绍
在事件驱动模型中,只要一遇到IO就注册一个事件,然后主程序就可以继续干其它的事情了,只到io处理完毕后,继续恢复之前中断的任务,这本质上是怎么实现的呢?同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?(背景是介绍Linux环境下的network IO)
概念名词解析:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O
1、用户空间和内核空间
例如:采用虚拟存储器,对于32bit操作系统,它的寻址空间(虚拟存储空间为4G,即2的32次方)。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也可以访问底层硬件的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分:一部分为内核空间,另一部分为用户空间。
操作系统是如何分配空间的?这里就会涉及到内核态和用户态的两种工作状态。
1G: 0 --->内核态
3G: 1 --->用户态
注:CPU的指令集,是通过0和1 决定你是用户态,还是内核态。
插入 ----> 名词解析:计算机的两种工作状态 --- 内核态和用户态
cpu的两种工作状态:
现在的操作系统都是分时操作系统,分时的根源,来自于硬件层面操作系统内核占用的内存与应用程序占用的内存彼此之间隔离。cpu通过psw(程序状态寄存器)中的一个2进制位来控制cpu本身的工作状态,即内核态与用户态。
- 内核态:操作系统内核只能运作于cpu的内核态,这种状态意味着可以执行cpu所有的指令,可以执行cpu所有的指令,这也意味着对计算机硬件资源有着完全的控制权限,并且可以控制cpu工作状态由内核态转成用户态。
- 用户态:应用程序只能运作于cpu的用户态,这种状态意味着只能执行cpu所有的指令的一小部分(或者称为所有指令的一个子集),这一小部分指令对计算机的硬件资源没有访问权限(比如I/O),并且不能控制由用户态转成内核态。
2、进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
- 保存处理机上下文,包括程序计数器和其他寄存器。
- 更新PCB信息。
- 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
- 选择另一个进程执行,并更新其PCB。
- 更新内存管理的数据结构。
- 恢复处理机上下文。
总而言之:进程切换很耗资源
3、进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
4、文件描述符fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
5、缓存 I/O
缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 I/O 的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1、IO发生时涉及的对象和步骤:
A、对于一个network IO (这里我们以read举例),它会涉及到两个系统对象:
- 一个是调用这个IO的process (or thread),
- 另一个就是系统内核(kernel)。
B、当一个read操作发生时,它会经历两个阶段:
- 等待数据准备 (Waiting for the data to be ready)
- 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
2、正是上面第二步(B)中的这两个阶段,IO Model的区别就是在两个阶段上会各有不同的情况,所以在linux系统产生了下面五种网络模式的方案:
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
A、不常用IO模式,只有一种:
(1)、信号驱动IO模型(Signal-driven IO)
使用信号,让内核在描述符就绪时发送SIGIO信号通知应用程序,称这种模型为信号驱动式I/O(signal-driven I/O)。
原理过程:
首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说进程没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。随后就可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已经准备好待处理,也可以立即通知主循环,让它读取数据报。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行 ,只要等到来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
B、常用到IO模式,有四种:
(1)、阻塞 I/O(blocking IO)
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
总结:blocking IO的特点就是在IO执行的两个阶段都被block了。
举例:一收一发程序会进入死循环
服务端:
#!/usr/bin/env python # -*- coding:utf-8 -*- import socket sk=socket.socket() sk.bind(("127.0.0.1",8080)) sk.listen(5) while 1: conn,addr=sk.accept() while 1: conn.send("hello client".encode("utf8")) data=conn.recv(1024) print(data.decode("utf8"))
客户端:
#!/usr/bin/env python # -*- coding:utf-8 -*- import socket sk=socket.socket() sk.connect(("127.0.0.1",8080)) while 1: data=sk.recv(1024) print(data.decode("utf8")) sk.send(b"hello server")
(2)、非阻塞IO(non-blocking IO)
Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程如下:
当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
总结:nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。缺点:发送了太多系统调用数据,数据有时候处理不及时。
注意:
在网络IO时候,非阻塞IO也会进行recvform系统调用,检查数据是否准备好,与阻塞IO不一样,”非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会 ‘被’ CPU光顾”。即每次recvform系统调用之间,cpu的权限还在进程手中,这段时间是可以做其他事情的, 也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
举例:
服务端:
import time import socket sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM) sk.bind(('127.0.0.1',6667)) sk.listen(5) sk.setblocking(False) #设置成非阻塞状态 while True: try: print ('waiting client connection .......') connection,address = sk.accept() # 进程主动轮询 print("+++",address) client_messge = connection.recv(1024) print(str(client_messge,'utf8')) connection.close() except Exception as e: #捕捉错误 print (e) time.sleep(4) #每4秒打印一个捕捉到的错误
客户端:
import time import socket sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM) while True: sk.connect(('127.0.0.1',6667)) print("hello") sk.sendall(bytes("hello","utf8")) time.sleep(2) break
(3)、I/O 多路复用( IO multiplexing)
IO multiplexing 就是常说的 select、poll、epoll(三者的区别,请看下一章),有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,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。
I/O 多路复用的特点:是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
注意:1、select函数返回结果中如果有文件可读了,那么进程就可以通过调用accept()或recv()来让kernel将位于内核中准备到的数据copy到用户区;2、 select的优势在于可以处理多个连接,不适用于单个连接。
举例:
服务端:
#server.py import socket import select sk=socket.socket() sk.bind(("127.0.0.1",9904)) sk.listen(5) while True: # sk.accept() #文件描述符 r,w,e=select.select([sk,],[],[],5) #输入列表,输出列表,错误列表,5: 是监听5秒 for i in r: #[sk,] conn,add=i.accept() print(conn) print("hello") print('>>>>>>')
客户端:
import socket sk=socket.socket() sk.connect(("127.0.0.1",9904)) while 1: inp=input(">>").strip() sk.send(inp.encode("utf8")) data=sk.recv(1024) print(data.decode("utf8"))
4、异步 I/O(asynchronous IO)
Linux下的asynchronous IO其实用得很少。流程图如下:
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
异步最大特点:全程无阻塞
blocking和non-blocking的区别:
调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。
synchronous IO(同步IO)和asynchronous IO(异步IO)的区别:
两者的定义:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。(有一丁点阻塞,都是同步IO)按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO(同步IO)。
同步IO:包括 blocking IO、non-blocking、select、poll、epoll(故:epool只是伪异步而已)(有阻塞)
异步IO:包括:asynchronous (无阻塞)
五种IO模型比较:
可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。