近段在看 Kafka 的网络模型时,遇到了很多 Java NIO 的内容,在学习 Java NIO 的过程中,发现需要把 UNIX 的这几种网络 IO 模型以及 Linux 的 IO 多路复用理解清楚,才能更好地理解 Java NIO,本文就是在学习 UNIX 的五种网络 IO 模型以及 Linux IO 多路复用模型后,做的一篇总结。
本文主要探讨的问题有以下两个:
在介绍网络模型之前,先简单介绍一些基本概念。
文件描述符(file descriptor,简称 fd)在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
在 Linux 中,内核将所有的外部设备都当做一个文件来进行操作,而对一个文件的读写操作会调用内核提供的系统命令,返回一个 fd,对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符),实际上描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。
这个是经常提到的概念,具体含义可以参考这篇文章用户空间与内核空间,进程上下文与中断上下文【总结】,大概内容如下:
现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操心系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言(以32位操作系统为例)
每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。
当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。
当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在 Linux 中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。
根据 UNIX 网络编程对 IO 模型的分类,UNIX 提供了以下 5 种 IO 模型。
最常用的 IO 模型就是阻塞 IO 模型,在缺省条件下,所有文件操作都是阻塞的,以 socket 读为例来介绍一下此模型,如下图所示。
在用户空间调用 recvfrom,系统调用直到数据包达到且被复制到应用进程的缓冲区中或中间发生异常返回,在这个期间进程会一直等待。进程从调用 recvfrom 开始到它返回的整段时间内都是被阻塞的,因此,被称为阻塞 IO 模型。
recvfrom 从应用到内核的时,如果该缓冲区没有数据,就会直接返回 EWOULDBLOCK 错误,一般都对非阻塞 IO 模型进行轮询检查这个状态,看看内核是不是有数据到来,流程如下图所示。
也就是说非阻塞的 recvform 系统调用调用之后,进程并没有被阻塞,内核马上返回给进程。
轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
在 Linux 下,可以通过设置 socket 使其变为 non-blocking。
Linux 提供 select、poll、epoll,进程通过讲一个或者多个 fd 传递给 select、poll、epoll 系统调用,阻塞在 select 操作(这个是内核级别的调用)上,这样的话,可以同时监听多个 fd 是否处于就绪状态。其中,
这个后面详细讲述,具体流程如下图所示。
多路复用的特点是通过一种机制一个进程能同时等待 IO 文件描述符,内核监视这些文件描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll 函数就可以返回,它最大的优势就是可以同时处理多个连接。
首先需要开启 socket 信号驱动 IO 功能,并通过系统调用 sigaction 执行一个信号处理函数(非阻塞,立即返回)。当数据就绪时,会为该进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环喊出处理数据,流程如下图所示。
告知内核启动某个事件,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通过我们,流程如下图所示。
与信号驱动模式的主要区别是:
内核是通过向应用程序发送 signal 或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉用户 read 操作已经完成,在 Linux 中,通知的方式是信号:
IO 多路复用通过把多个 IO 阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下,可以同时处理多个 client 请求,与传统的多线程/多进程模型相比,IO 多路复用的最大优势是系统开销小,系统不需要创建新的额外的进程或线程,也不需要维护这些进程和线程的运行,节省了系统资源,IO 多路复用的主要场景如下:
IO 多路复用实际上就是通过一种机制,一个进程可以监视多个描 fd,一旦某个 fd 就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,目前支持 IO 多路复用的系统有 select、pselect、poll、epoll,但它们本质上都是同步 IO。
在 Linux 网络编程中,最初是选用 select 做轮询和网络事件通知,然而 select 的一些固有缺陷导致了它的应用受到了很大的限制,最终 Linux 选择 epoll。
select 函数监视的 fd 分3类,分别是 writefds、readfds、和 exceptfds。调用后select 函数会阻塞,直到有 fd 就绪(有数据 可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当select函数返回后,可以通过遍历 fdset,来找到就绪的 fd。
select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select 的一个最大的缺陷就是单个进程对打开的 fd 是有一定限制的,它由 FD_SETSIZE 限制,默认值是1024,如果修改的话,就需要重新编译内核,不过这会带来网络效率的下降。
select 和 poll 另一个缺陷就是随着 fd 数目的增加,可能只有很少一部分 socket 是活跃的,但是 select/poll 每次调用时都会线性扫描全部的集合,导致效率呈现线性的下降。
poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样以下两个缺点:
epoll 支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些 fd 变为就绪态,并且只会通知一次。还有一个特点是,epoll 使用【事件】的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。
epoll的优点:
epoll 对 fd 的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT 模式是默认模式,LT 模式与 ET 模式的区别如下:
介绍完 IO 多路复用之后,后续我们看一下 Java 网络编程中的 NIO 模型及其背后的实现机制。