基本概念
在学习 linux IO 模型 以前,我们先看一组概念,便于大家更好的理解。
linux IO 模型
linux系统IO分为内核准备数据和将数据从内核拷贝到用户空间两个阶段。
用户空间与内核空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
-
保存处理机上下文,包括程序计数器和其他寄存器。
-
更新PCB信息。
-
把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。 选择另一个进程执行,并更新其PCB。
-
更新内存管理的数据结构。
-
恢复处理机上下文。
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。
可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
文件描述符
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。
实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
Linux网络I/O模型间介
Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令, 返回一个filedescriptor (fd, 文件描述符)。
而对一个socket的读写也会有相应的描述符,称为socketfd(socket描述符), 描述符就是一个数字, 它指向内核中的一个结构体(文件路径,数据区等一些属性)。
根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型, 分别如下。
阻塞I/O模型:
最常用的I/O模型就是阻塞I/O模型,缺省情形下,所有文件操作都是阻塞的。
我们以套接字接口为例来讲解此模型:在进程空间中调用recvfrom, 其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间一直会等待, 进程在从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞I/O模型,如图1-1所示。
非阻塞I/O模型
recv from从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误,一般都对非阻塞I/O模型进行轮询检查这个状态,看内核是不是有数据到来,如图1-2所示。
I/O复用模型
Linux提供select/poll, 进程通过将一个或多个fd传递给select或poll系统调用, 阻塞在select操作上, 这样select/poll可以帮我门侦测多个fd是否处于就绪状态。
select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。
Linux还提供了一个epoll系统调用,epoll使用基于事件驱动方式代替顺序扫描,因此性能更高。
当有fd就绪时, 立即回调函数rollback,如图1-3所示。
信号驱动I/O模型
首先开启套接口信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。
当数据准备就绪时, 就为该进程生成一个SIGIO信号, 通过信号回调通知应用程序调用recvfrom来读取数据,并通知主循环函数处理数据,如图1-4所示。
异步I/O
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时已经完成,如图1-5所示。
如果想要了解更多的UNIX系统网络编程知识, 可以阅读《UNIX网络编程》,里面有非常详细的原理和API介绍。
对于大多数Java程序员来说,不需要了解网络编程的底层细节,大家只需要有个概念,知道对于操作系统而言,底层是支持异步I/O通信的。
只不过在很长一段时间Java并没有提供异步I/O通信的类库,导致很多原生的Java程序员对这块儿比较陌生。
当你了解了网络编程的基础知识后,理解Java的NIO类库就会更加容易一些。
下面我们重点讲下I/O多路复用技术, 因为Java NIO的核心类库多路复用器Selector就是基于epoll的多路复用技术实现。
I/O多路复用技术
在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。
I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。
与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
应用场景
I/O多路复用的主要应用场景如下。
-
服务器需要同时处理多个处于监听状态或者多个连接状态的套接字;
-
服务器需要同时处理多种网络协议的套接字。
ps: 也就是我们常说的高并发场景。
目前支持I/O多路复用的系统调用有select、pselect、poll、epoll, 在Linux网络编程过程中, 很长一段时间都使用select做轮询和网络事件通知, 然而select的一些固有缺陷导致了它的应用受到了很大的限制,最终Linux不得不在新的内核版本中寻找select的替代方案, 最终选择了epoll。
内核接收网络数据全过程
为了理解 epoll 究竟比 select 优秀在哪里,我们首先要理解网络数据的接收过程。
如下图所示,进程在 recv 阻塞期间,计算机收到了对端传送的数据(步骤①),数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤③)。
此处的中断程序主要有两项功能,先将网络数据写入到对应 socket 的接收缓冲区里面(步骤④),再唤醒进程 A(步骤⑤),重新将进程 A 放入工作队列中。
- 内核接收数据全过程
- 内存唤醒
以上是内核接收数据全过程
思考的问题
这里留有两个思考题,大家先想一想。
(1)操作系统如何知道网络数据对应于哪个socket?
因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。
当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。
(2)如何同时监视多个socket的数据?
这个问题就是本文的重点。
如何同时监视多个socket的数据?
服务端需要管理多个客户端连接,而 recv 只能监视单个 socket,这种矛盾下,人们开始寻找监视多个 socket 的方法。
先理解不太高效的 select,才能够更好地理解 epoll 的本质。
select 的流程
select 的实现思路很直接,假如程序同时监视如下图的 sock1、sock2 和 sock3 三个 socket,那么在调用 select 之后,操作系统把进程 A 分别加入这三个 socket 的等待队列中。
- 操作系统把进程 A 分别加入这三个 socket 的等待队列中
当任何一个 socket 收到数据后,中断程序将唤起进程。
下图展示了 sock2 接收到了数据的处理流程:
注:recv 和 select 的中断回调可以设置成不同的内容。
- sock2接收到了数据,中断程序唤起进程A
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。
如下图所示。
- 将进程A从所有等待队列中移除,再加入到工作队列里面
经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 socket 接收了数据。
优点
程序只需遍历一遍 socket 列表,就可以得到就绪的 socket。
这种简单方式行之有效,在几乎所有操作系统都有对应的实现。
缺点
但是简单的方法往往有缺点,主要是:
其一,每次调用 select 都需要将进程加入到所有监视 socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 fds 列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定 select 的最大监视数量,默认只能监视 1024 个 socket。
其二,进程被唤醒后,程序并不知道哪些 socket 收到数据,还需要遍历一次。
待改进之处
那么,有没有减少遍历的方法?
有没有保存就绪 socket 的方法?
这两个问题便是 epoll 技术要解决的。
epoll 的设计思路
epoll 是在 select 出现 N 多年后才被发明的,是 select 和 poll(poll 和 select 基本一样,有少量改进)的增强版本。
epoll 通过以下一些措施来改进效率:
措施一:功能分离
select 低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。
如下图所示,每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 相对固定,并不需要每次都修改。
epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。
显而易见地,效率就能得到提升。
措施二:就绪列表
select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历。
如果内核维护一个“就绪列表”,引用收到数据的 socket,就能避免遍历。
如下图所示,计算机共有三个 socket,收到数据的 sock2 和 sock3 被就绪列表 rdlist 所引用。
当进程被唤醒后,只要获取 rdlist 的内容,就能够知道哪些 socket 收到数据。
epoll 的原理与工作流程
创建 epoll 对象
如下图所示,当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(也就是程序中 epfd 所代表的对象)。
eventpoll 对象也是文件系统中的一员,和 socket 一样,它也会有等待队列。
创建一个代表该 epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。
维护监视列表
创建 epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 socket。
以添加 socket 为例,如下图,如果通过 epoll_ctl 添加 sock1、sock2 和 sock3 的监视,内核会将 eventpoll 添加到这三个 socket 的等待队列中。
当 socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程。
接收数据
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。
如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。
如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。
也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
小结
本文简单介绍了 linux 最常见的 5 种 IO 模型,并对最核心的多路复用模型进行了展开讲解,下一节我们将展示 java 实现 BIO/NIO/AIO 等不同的网络IO模型。
希望本文对你有所帮助,如果喜欢,欢迎点赞收藏转发一波。
我是老马,期待与你的下次相遇。
参考资料
《Netty 权威指南》