今天来学习下:什么是IO?如何完成一次IO?什么是IO多路复用?
概念说明
在谈IO模型之前,我们先来了解一下这里面涉及一些概念:
Socket套接字和TCP连接
用户空间与内核空间
Socket缓存区
CPU中断
Socket套接字和TCP连接
Socket中文翻译为“插孔”、“插槽”,Socket服务就是连接的插口,两个应用程序通过一个双向的通信连接实现数据的交换,连接的一段就是一个Socket,也叫套接字。
TCP是一个面向连接的、可靠的、基于字节流的传输层通信协议。使用 源IP + 源端口 + 目标IP + 目标端口 四元组来唯一确认一次TCP连接,它有三个状态工作状态:连接创建、数据传输、连接终止状态。连接创建需要三次握手建立连接;数据传输通常在每个TCP报文段中都有一对序号和确认号;在连接终止时还会进行四次挥手断开连接。
用户空间和内核空间
操作系统为了安全性,将内存分为了用户空间和内核空间(比如32位操作系统4G内存时,每个进程的 4G 地址空间中,最高 1G 都是一样的,即内核空间,而只有剩余的 3G 才归进程自己使用),用户空间的应用程序无法直接访问内核空间,必须要通过系统调用,而系统调用的成本很高。
在 Linux 系统中,内核模块运行在内核空间,当进程经过系统调用而陷入内核代码中执行时,称进程处于内核运行态,即内核态;反之,运行在用户空间执行用户自己的代码时,处于用户态。
当用户态的线程想发起IO操作,因为没有权限操作,就需要发送系统调用指令切换到内核态,而系统也会产生中断而进入内核,此时也就进行了上下文切换。
Socket缓存区
每个 Socket 被创建后,无论使用的是TCP协议还是UDP协议,每个Socket都会分配两个缓冲区:输入缓冲区和输出缓冲区。
如下图所示:
这也说明了应用程序并不立即向网络中写入或者读取数据,如果是发送数据,是先将数据写入缓冲区中再由TCP协议将数据从缓冲区发送到目标机器;接收数据时也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取(避免单点交互的影响)
Socket缓冲区导致的问题
因为Socket缓存区存在于内核空间,而用户内存存在于用户空间,这样数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的 (优化方案,零拷贝模式)
CPU中断
中断就是指当计算机中出现了必须要处理的事情时,CPU暂停当前程序的执行而处理新情况的程序的过程就叫中断。
中断分为硬件中断和软件中断:硬件中断即硬件发生的中断,IO中断是由IO设备产生的信号;软件中断主要有异常中断和时钟中断,操作系统每过大约15ms会进行一次线程调度,就是利用时钟中断来实现的(中断也是会将用户态切换到内核态的)。
IO中断的好处:如果没有中断操作,CPU就需要不断检查IO操作是否完成(网络消息是否到达),就不能很好的利用CPU调度其他线程;在实际IO操作中,CPU向I/O模块(DMA控制器)发送读指令(阻塞当前线程),然后就去调度其他线程,直到IO模块执行完成后,会产生中断信号通知CPU,CPU将阻塞线程加入到就绪队列中并恢复线程上下文信息,而此时就绪队列的线程就可以被CPU调度执行,将内核态的数据拷贝到用户空间交给应用程序处理;无论是有中断还是 从而提高了CPU的执行效率
IO模型的问题根源
假如现在客户端A需要和服务端B网络通信,主要会经历下面几步:
- 应用A把消息发送到 IO发送缓冲区
- TCP发送缓冲区 再把消息发送出去,经过网络传递后,消息会发送到B服务器的 TCP接收缓冲区。
- B再从 TCP接收缓冲区 去读取属于自己的数据
不同的应用间发送消息不是连续的,从上面了解Socket缓冲区也可知,我们需要等待缓冲区有数据时才开始进行读取,所以导致读取缓冲区的数据需要不定时的在缓冲区检查或者阻塞在这里等待缓冲区有数据后再进行读取;同理,发送数据时也需要考虑写缓冲区是否已满,如果已满就需要等待到有空间时才能写入。
IO模型
一般来说 IO 模型有如下这些:
- blocking I/O
- nonblocking I/O
- I/O multiplexing (select and poll)
- signal driven I/O (SIGIO)
- asynchronous I/O (the POSIX aio_functions)
进行一次IO,主要是:
- 等待数据到达
- 将数据拷贝到kernel的 缓冲区
- 从 kernel 缓冲区 拷贝到应用在用户空间的 buffer
同步IO(blocking I/O)
你和你朋友想吃火锅,但是到店里说需要排队,这个时候只能在店里等,等排到你们了才开始吃;本来还可以在排队的时间去逛街,但是因为不知道什么排到你们,所以中间的时间浪费掉了。这就是典型的阻塞
在这个IO模型中,用户线程执行一个系统调用(recvform)就会导致这个应用线程阻塞,直到系统内核把数据准备好,将数据从内核空间拷贝到用户空间,再唤醒用户线程处理数据
这里面有两个阶段:
- 内核准备数据(数据未到达,需要等待足够的数据到达,然后拷贝到内核缓冲区)
- 内核拷贝数据到用户空间
同步IO上面阶段都是阻塞的
同步非阻塞IO(nonblocking I/O)
你和你朋友想吃火锅,但是到店里说需要排队,这个时候又不甘心在这里白白浪费时间,于是就去逛一会商场然后过来询问,这样来来回回经过很多次不断询问,终于最后吃上了火锅。这就是典型的同步非阻塞,需要不断的询问,缓存区是否准备好了
同步非阻塞就是“不断询问”的轮询方式,非阻塞IO也通过系统调用(recvform)检查数据是否准备好,不过这里的调用不会阻塞而是立即返回一个error,在返回后可以执行其他任务后再次发起系统调用(recvform),重复上面的动作,直到数据准备好后再拷贝数据到用户进程(拷贝数据仍然是阻塞状态)
同步非阻塞IO在第二个阶段是阻塞的(拷贝数据到用户空间)
IO 多路复用(I/O multiplexing)
你和你朋友想吃火锅,但是到店里说需要排队,这里和上面一样不甘心在这里白白浪费时间,于是就去逛一会商场然后过来查看,不过现在好了,有了电子屏幕可以查看到现在的排号记录,而不用每次询问服务员了,最后也吃上了火锅。这就是IO多路复用,与同步非阻塞不同的时,已经有了“电子屏幕”可以查看到哪些准备好了,而不用每个顾客问服务员了
同步非阻塞需要不断主动轮询,轮询会用很大一部分CPU,而且还可能会有很多连接进来,如果每个Socket都使用轮询会很占用资源;而此时就出现了,循环检查多个连接任务,只要有一个就返回去处理它;这里的轮询不发生在用户态,而是内核态做的事情,那么此时就为IO多路复用。
IO多路复用有三个特别的系统调用select、poll、epoll函数;select函数与同步阻塞函数的区别就是可以同时监听多个Socket连接,当其中一个好了就会返回进行数据拷贝。相比于之前IO模式,IO多路复用可能效率更低效,因为读取数据需要两次系统调用,好处就是可以一次性监听多个Socket,不用轮询消耗CPU;在多线程模式下还可以让一个线程持续执行 select() 操作,用另一个线程池只执行不会阻塞的,拷贝数据到 User Space 的工作(任务分发)
实际上 IO Multiplexing 和 Blocking IO 等待模式很类似,第一阶段和第二阶段都是阻塞的。
信号驱动式IO(signal-driven IO)
你和你朋友想吃火锅,直接去取了一个号码,说排到了叫你,这个时候就去逛一会商场,这样一旦到你了会给你手机发一个信息,你回去后吃上了火锅。这就是典型的信号驱动式,注册一个信号,缓冲区准备好了通知你
首先安装一个信号处理函数,此时进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数读数据,最后拷贝完成后再唤醒用户进程让它去处理数据
信号驱动式看起来是很美好的IO模式,但它有两个缺点:
- 在大量 IO 操作时可能会因为信号队列溢出导致没法通知
- 信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,信号通知意味着到达一个数据报,或者返回一个异步错误,但是对于TCP而言,导致信号通知的情况有非常多种,连接断开,连接可读,连接可写等等都会产生 Signal,每一个来进行判别会消耗很大资源 这篇文章有详细的解释
所以现在还在使用 Signal Driven IO 的基本是 UDP 的。Signal Driven IO第一阶段是非阻塞的,第二阶段仍然是阻塞的
异步非阻塞 IO(asynchronous IO)
你和你朋友想吃火锅,但是又不想去逛街,而且也不想在餐厅等,干脆点一个外卖,这样外卖小哥直接给你送到家里,你回去后还能吃上火锅。这就是典型的异步非阻塞
异步非阻塞 IO 看上去和 Signal Driven IO 很相似,但区别就在于信号驱动式IO在数据准备好后,仍然是用户进程进行系统调用拷贝数据,而AIO是在内核直接将数据拷贝好,才会通知用户进程,直接处理数据
异步非阻塞IO在两阶段上都是非阻塞的,这是真正意义上的非阻塞IO模式。虽然看起来很高级,但实现起来确实最复杂的,也有很多问题:
- 如何去 Cancel 一个读任务
- 用户空间的buffer是一开始就需要指定的,但无法确认输入的IO数据有多大,一次读取不完只有下次再读取了
IO模型总结
POSIX 对同步 IO 和异步 IO 的定义如下:
- A synchronous IO operation causes the requesting process to be blocked until that IO operation completes.
- An asynchronous IO operation does not cause the requesting process to be blocked.
blocking 和 non-blocking 区别
- blocking 会阻塞用户进程直到读取数据完成
- non-blocking 在内核没有准备好数据时是立即返回的,直到数据准备好了,才会阻塞进程拷贝到用户空间
synchronous IO 和 asynchronous IO 区别
- 同步IO和异步IO最大的区别就是两阶段是否会被阻塞,blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO,因为他们在从内核态到用户态拷贝的时候,都是阻塞的
- 异步IO 发起IO 操作之后,就直接返回可以做其他操作了,直到内核(kernel)发送一个信号,告诉进程说IO完成,在这整个IO过程中,用户进程完全没有被block