说到NIO,涉及到的知识点有很多,我们来一一捋一捋。
IO
IO(InputStream/OutputStream)指的是读出/写入数据,IO可以分为磁盘IO和网络IO,围绕我们今天主题讲的是网络IO。网络IO包括了等待数据传输和读写数据的过程,等待数据传输其实就是等待数据经由网线、网卡、内核空间的过程,读写数据的过程是内核空间和用户空间的互相拷贝的过程。
举个例子,在read发生时,很关键的两点是:
- 等待准备数据。
- 将数据从内核拷贝到用户进程中去。
正如万事万物并不是你想要的时候就有的一样,当内核空间想要拿到数据时,可能数据还没有传进来,这时,它只能等着数据的到达,而用户进程也因此而阻塞。当内核空间一直等到数据准备好了,它就会将数据从内核空间中拷贝到用户内存,然后内核空间返回结果,用户进程才解除阻塞状态,重新运行起来。那网络IO的网络通信是借由谁完成的呢?
Socket
Java中的网络通信是通过Socket实现的,我们都熟悉TCP/IP协议、Http协议等,Socket是TCP/IP协议的一个具体的实现。Socket分为ServerSocket和Socket两大类,ServerSocket用于服务端,可以通过方法监听请求,监听到请求后返回Scoket,Socket用于具体完成数据传输,客户端直接使用Socket发起请求并传输数据。 说白了,网络IO就是通过socket来进行通信的。
如何得知已经收到数据
Socket是我们传输数据的工具,那我们如何得知已经收到数据呢?数据从网线传过来,在我们电脑上第一步是到达网卡,此时控制网卡的驱动(网卡驱动程序就是CPU控制和使用网卡的程序)开始发挥作用,网卡通过中断通知CPU数据已达的消息。
中断允许让设备,如键盘,串口卡,并口等设备表明它们需要CPU。一旦CPU接收了中断请求,CPU就会暂时停止执行正在运行的程序,并调用一个称为中断服务程序(interrupt service routine)的特定程序。之后,CPU会恢复执行之前被中断的程序。那么接下来会发生什么?内核空间还在等着它需要的数据呢,我们继续往下走。
阻塞
操作系统也已经知道,数据到达了。我们现在来到内核空间,操作系统为了实现进程调度,会把进程分为“运行”、“等待”几种不同的状态:
运行的进程能轮流获得CPU的资源,“等待”状态其实不难理解,回溯一下,我们 前文里一直在说的就是等待网络数据的到来啊,很好理解,拥有CPU资源的当前进程执行到创建socket语句时,操作系统会创建一个由文件系统管理的对象,这个对象包含了发送缓冲区、接收缓冲区、等待队列等,这个等待队列指向所有需要等待该socket的进程。当程序执行到recv()方法时(不论是客户端还是服务器应用程序都用recv函数从TCP连接的另一端接收数据),如果等待的数据还未到达,当前进程将会被加入等待队列,它的状态也就变成了阻塞。
此时,可能你会疑问?操作系统如何知道数据到底对应的是哪个socket?我们知道网络数据包里都包含了IP和端口号,操作系统会给每个socket对象的索引维护一个端口号以变快速读取,从而内核通过端口号找到对应的socket。
阻塞一般就是用等待队列来实现,这个等待队列是给进程添加“等待中”队列的引用,将进程停止在此处并睡眠下,直到条件满足时,才可通过此处,继续运行。在睡眠等待期间,wake up时,唤起来检查条件,条件满足解除阻塞,不满足继续睡下去。
所以我们来总结一下刚刚说的过程:
- 等待数据:进程A拥有CPU资源,当它执行到socket生成一个socket对象(包含接收buffer、发送buffer、等待队列),继续执行到recv()方法时,操作系统讲进程A从工作队列移到等待队列,其他进程继续轮流执行,A被阻塞,不往下执行代码,也不占用CPU资源。
- 数据来了:网络数据经由网卡传进内存,网卡驱动中断信号,CPU做出响应,执行中断程序,socket接收数据。
- 唤醒进程:socket收到数据后,操作系统将该socket的等待队列中的进程A的状态改为“运行中”,进程回到工作队列中继续执行代码。由于socket的接收buffer有了数据,recv()方法返回接收到的数据。
总算是完整梳理了内核接收数据的全过程,但你有发现问题吗?如何做到监视全部的socket?
Select
任何事情都不可能一蹴而就,解决问题先从笨办法开始——select,它的思路是:维护一个名为fds的数组,数组里放了全部需要监视的socket对象,等待数据时,进程挂起,任意socket收到数据后,进程被唤醒。看起来不难,再捋一下它的过程:
- 等待数据:维护一个名为fds的数组,数组里加入所有需要监视的socket对象。调用select() 方法,操作系统会把A加入到数组中所有socket对象的等待队列中。
- 数据来了:网卡收到数据后,网卡驱动发出中断,CPU响应,启动中断程序,socket接收数据。
- 唤醒进程:select()返回,进程A被唤醒,重回工作队列。
补充
当程序调用select时,内核会先遍历一遍socket,如果有一个以上的socket接收缓冲区有数据,那么select直接返回,不会阻塞。这也是为什么select的返回值有可能大于1的原因之一。如果没有socket有数据,进程才会阻塞。
监视所有的socket是做到了,又有新问题来了:如何得知具体是哪个socket接收到了数据?是需要遍历一遍fds才能得知的,此外,进程被唤醒,要从所有的socket对象的等待队列中移除,又需要去遍历fds并移除被唤醒的进程,再加上补充情况里说的那一次遍历,简直可以凑成遍历三连。所以,不用我再明说,你肯定知道了,接下来,是这个系统需要再次进化的时候了。
epoll
Select遍历次数多,开销大的原因有以下两点:
- 维护等待队列和阻塞进程关联在一起
解决方案:功能分离
将维护等待队列和阻塞进程分离开,在epoll方法中,使用epoll_create创建一个epoll对象epdf,epoll_ctl将需要监视的socket加入epdf中,调用epoll_wait接收数据。
- 需要遍历才能知道接收到数据的socket
解决方案:就绪列表
epoll维护一个rdlist,list引用收到socket的对象,这样就避免了为寻找接收到数据的socket而去遍历全部socket的低效。进程被唤醒后,只要获取rdlist的内容,就能获取接收到数据的socket对象。
epoll的过程:
- 创建epoll对象epdf:当某个进程调用epoll_create方法时,内核会创建一个epoll对象epdf(eventpoll),eventpoll跟socket一样,是文件系统中的一员,也有等待队列,还维护了rdlist作为它的成员。
- 维护监视列表:创建epoll对象后,epoll_ctl方法可以添加和删除所监听的socket,内核将eventpoll需要的socket加入监视列表。所以,socket收到数据后,中断程序会直接操作eventpoll对象,而不是直接操作进程。
- 接收数据:当socket接收到数据后,中断程序会给rdlist里添加收到数据的socket的引用。eventpoll相当于socket和进程的中介,socket接收数据并不直接影响进程,而是通过rdlist来改变进程的状态。rdlist:有引用,epoll_wait返回;无引用,rdlist空,阻塞进程A。
-
阻塞和唤醒进程:
阻塞:进程运行到epoll_wait时,内核将进程加入eventpoll的等待队列。
唤醒:socket接收到数据后,中断程序会做两件事:(1)修改rdlist(知道是哪个socket发生改变)(2) 唤醒eventpoll中的等待队列中的进程。
epoll值得配图一张,引用自知乎罗培羽
值得思考的两个问题:
rdlist的数据结构:
需要满足的条件是,能够快速添加socket,而且epoll_ctl还要监听,需要频繁添加、删除socket,无疑是双向链表最为合适。
索引结构:
维护等待队列和进程阻塞分离,所以需要一个便于监听管理socket的数据结构,需要满足便于查找、防止重复添加、方便删除的数据结构。之前二叉树家族的文章里讲过的数据结构很满足这个要求,红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构。
NIO及epoll
NIO
NIO(new IO) ,这个new是相当于BIO来说的,BIO(Blocking IO)是同步阻塞式IO,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。为了降低开销的问题,就产生了NIO,同步非阻塞式IO,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。
epoll
epoll是在select的基础上,改良了几个不够高效的点,引用了先进的数据结构,实现了更高效的多路复用。