阿里P7大牛教你如何面试NIO

但使书种多,会有岁稔时。这篇文章主要讲述阿里P7大牛教你如何面试NIO相关的知识,希望能为你提供帮助。
大家好,欢迎来到Tlog4J课堂,我是Jensen。
我相信有不少同学在IO多路复用这一块跪过,那今天我们还是以面试的方式,听听阿里的P7的大牛“赵总”给大家上NIO这一课。
下面咱们直接进入面试场景——
ACTION?面试官:简单说一下BIO吧,主要聊一下它的缺点?
赵总:好的……BIO中的“B”,表示的是Blocking的意思,就是“阻塞”,作为服务端开发,我们使用ServerSocket绑定完端口号之后,我们会对该端口进行监听,等待Accept事件,Accept会阻塞当前主线程,当我们收到Accept事件时,程序就会拿到客户与当前服务端连接的Socket,针对这个Socket我们可以进行读写……
但是呢,这个Socket读写方法都是会阻塞当前线程的,一般我们会使用多线程的方式来进行C/S交互,但是这个就很难做到C10K……
比如说,1w个客户端就需要服务端1w个线程去支持,这样的话CPU肯定就会爆炸了,线程上下文切换也会把机器负载给拉飞的
?面试官:好的……说到C10K,这个BIO我觉得肯定就难顶了,得靠NIO了对吧,那你说说NIO它靠什么解决C10K的问题呢?
赵总:我们站在java的解决来看,NIO包给我们提供一套非阻塞的API,这样就不需要我们为每一个C/S长连接保留一个单独的处理线程了,阻塞IO之所以需要给每个Socket长连接指定一个线程,就是因为它阻塞嘛……
现在这个NIO API它具备非阻塞特性了,就可以用1个线程去检查N个Socket,那在Java代码层面,NIO包给我们提供了一个选择器Selector,然后我们需要把检查的Socket注册到这个Selector中,主线程阻塞在Selector#select方法里头……
当选择器发现某个Socket就绪了,就会唤醒主线程,然后咱们可以通过Selector获取到就绪状态的Socket,进行相应的处理,基本上是这样
?面试官:嗯,OK,其实我觉得IO这件事站在Java层面去聊,没有太大意义,因为这个Java最终还是映射到内核去完成的这些事,对吧,刚才你说的这个Selector其实它底层是Java包装的这个Native Api,再底层的实现呢,是JVM虚拟机使用的系统调用systemCall kernel去实现的……咱聊聊这个多路复用的底层实现原理吧,咱先聊一下最老的那个版本,也就是操作系统kernel的提供的这个select(..)函数?
赵总:好的……我们每次调用kernel#select函数,它都会涉及到用户态/内核态的切换,还需要传递需要检查的Socket集合,其实就是需要检查的fd(文件描述符id),因为咱们的程序嘛,都是运行在Linux或者Unix操作系统上,这个操作系统上,一切皆文件,Socket也不例外,这里传递的fd其实就是文件系统中对应Socket生成的文件描述符ID号……
操作系统的Select函数被调用以后,首先会按照fd集合,去检查内存中的Socket套接字状态,这个复杂度是O(N)的,然后检查完一遍之后,如果有就绪状态的Socket,那么直接返回,不会阻塞当前线程,否则就说明当前指定fd集合对应的Socket没有就绪状态的,那么就需要阻塞当前调用线程了,直到有某个Socket有数据之后,才唤醒线程
?面试官:大体没有太大问题哈,有几个细节问题哈,我再问下……这个select(..)函数它去监听Socket的时候,这个Socket数量有没有限制呢??
赵总:它默认最大可以监听1024个Socket(PS:实际要小于1024),这是因为fd_set这个结构它是一个bitmap位图结构(PS:fd_set是Select函数的参数之一),这个位图结构就是一个长的二进制数,类似于0101…的这种,这个bitmap默认长度是1024个bit,想要修改这个长度的话非常麻烦,需要重新编译操作系统内核,我觉得编译操作系统内核这种针线活一般人他是搞不定的……
另外一点我认为,默认值给1024个bit是出于性能的考虑吧,因为Select函数它检查到就绪状态的Socket后,它做了两件事,第一件事就是跑到就绪状态的Socket对应的fd文件中设置一个标记,标记一个mask,表示当前fd对应的Socket就绪了;第二件事就是返回Select函数,对应的就是唤醒Java线程,站到Java层面,它会收到一个int结果值,表示有几个Socket处于就绪状态,但具体是哪个Socket就绪,Java程序目前是不知道的……
所以接下来又是一个O(N)的系统调用,检查fd_set集合中每一个Socket的就绪状态,其实就是检查文件系统中指定Socket的文件描述符状态,涉及到用户态/内核态的来回切换,那就非常非常蛋疼了……如果bitmap再大那岂不更恶心了,它就需要更多的系统调用,系统调用会涉及到参数的数据拷贝,如果数据太庞大,它也会降低系统调用速度……
?面试官:挺牛X呀,WC……那我再问些深点的,假设Select函数第一遍O(N)去检查时未发现有就绪状态的Socket,然后过了一会之后有某一个Socket它就绪了,那这个Select函数它是怎么发现的呢?难道这个Select函数它在底层kernel内它是一直占着CPU去轮询去检查这些Socket的么??
赵总:好的,我捋一捋这个问题哈……其实,我觉得要回答这个问题,还得先铺垫一些东西——操作系统调度和操作系统中断的一些知识……
先说这个调度吧,CPU同一时刻它只能运行一个进程,这个毫无疑问了,这个操作系统最主要的任务就是系统调度嘛,就是有N个进程,然后让这N个进程在CPU上切换执行,未挂起的进程都在工作队列内,都有机会获取到CPU执行权,挂起的进程,就会从这个工作队列内移除出去,反映到咱们的Java层面就是线程阻塞了,Linux系统线程其实就是轻量级进程……
然后咱们再说一下操作系统中断,这个非常重要……
就比如说,咱们用键盘打字,如果CPU正执行着其它程序,一直不释放,那咱这个打字是不是就没法打了呢?咱们都知道,不是这样的,因为有了这个系统中断的存在,你按下一个按键了之后,会给这个主板发送一个电流信号,主板感知到以后,它就会触发这个CPU中断……
所谓中断,其实就是让CPU正在执行的进程先保留程序上下文,然后避让出CPU,给中断程序让道,中断程序就会拿到CPU执行权,进行相应代码执行,比如说,键盘的中断程序,它就会执行输出逻辑等等哈,就是这样……
再回归到咱现在问的这个问题,这个Select函数,它第一遍轮询没有发现就绪状态的Socket,它就会把当前进程保留给需要检查的Socket的等待队列中,也就是说这个Socket结构,它有三块核心区域,分别是读缓存、写缓存还有这个等待队列……
这个Select函数,它把当前进程保留到每个需要检查的Socket#等待队列之后,就会把当前进程从工作队列移除了,移除之后,其实就是挂起当前进程了嘛,然后Select函数了就不会再运行了……
这个阶段完了之后,然后咱们再说下一个阶段——
假设我们客户端往当前服务器发送了数据,数据通过网线到网卡,网卡再到DMA硬件的这个方式,直接将数据写到内存里头,整个过程CPU它是不参与的,当数据完成传输以后,它就会触发网络数据传输完毕的中断程序了,这个中断程序会把CPU正在执行的进程给顶掉,然后CPU就会执行咱这个中断程序的逻辑了……

这个逻辑大概是这样的,根据内存中它有的数据包,然后分析出来数据包是哪个Socket的数据,TCP/IP协议数据包,它又保证传输的时候是有端口号的,然后根据端口号就能找到它对应的Socket实例,找到Socket实例以后,就把数据导入到Socket的读缓冲区里头……
导入完成以后,它就开始去检查Socket的等待队列,是不是有等待者?如果有的话,咱就把这个等待者移动到工作队列,中断程序到这一步就执行完了,咱们的进程又回归到工作队列了,又有机会获取到CPU时间片了……
然后当前进程执行Select函数,再次检查就发现有这个就绪的Socket了,它就会给就绪的Socket的fd文件描述符打标记,然后Select函数就执行完了,它返回到Java层面,就涉及到内核态/用户态的转换,后面的事情就是轮询检查每一个Socket的fd是否被打标记,然后处理被打了标记的Socket就OK了。

?面试官:WC……太强了呀……咱继续聊吧,IO这块还有好多要问的哈,刚才咱们聊的这个多路复用技术是Select函数,其实后面还衍生出来了一个稍微加强版的函数叫poll(..)函数,这俩工作原理其实差不多,你能说下它俩的大概区别么??
赵总:其实最大的区别就是传参不一样了,Select它使用的是bitmap来表示需要检查的Socket集合,Poll使用数组结构来表示,主要就是为了解决bitmap长度是1024这个问题嘛,Poll使用数组就没有这个限制了,它就可以让咱们线程监听超过1024个Socket限制,主要就是这个,基本上和Select没什么区别
?面试官:OK,基本上一针见血了,我也不太想去聊这个Poll,咱们就聊后面出来的这个Epoll吧,你能说下为什么会有Epoll这个技术么?它产生的背景是什么呀??
【阿里P7大牛教你如何面试NIO】赵总:Epoll它主要是为了解决Select和Poll函数的缺陷吧,我们先说下它俩共有的缺陷哈……
第一个缺陷就是,这俩系统函数每次调用都需要我们提供给它所有需要监听的Socket文件描述符集合,而且咱们的程序主线程是死循环调用Select/Poll函数的,这里面涉及到用户空间数据到内核空间拷贝的过程,这个相对来讲还是比较耗费性能的……
还有就是,咱们需要监听的Socket集合,数据变化非常小,可能它每次就1~2个socket_fd需要更改,但是没有办法,因为Select和Poll函数只是一个很单纯的函数,它在kernel层面不会保留任何数据信息,所以说只能每次调用都进行数据拷贝了……
再说第二个缺陷,这个缺陷就更严重了……这个Select和Poll函数它的返回值是个int整型值,只能代表有几个Socket就绪或者是有错误了,它没办法表示出具体是哪个Socket就绪了,这就导致咱们程序被唤醒以后呀,它还需要新一轮系统调用去检查哪个Socket是就绪状态的,然后再进行Socket数据处理逻辑,在这已经走了不少弯路了,因为咱们都清楚系统调用需要涉及到用户态和内核态的来回切换……
主要缺陷就这俩,这也是Epoll产生的背景吧,主要目的就是为了解决这两个问题……
?面试官:那Epoll函数是如何设计的呢?你这块肯定也挺精通的~?
赵总:因为咱们主要是为了提升效率嘛,必须得解决这俩问题,第一个问题是函数调用参数拷贝问题,第二个问题是系统调用返回后不知道哪些Socket就绪的问题……
解决这两个问题,就需要Epoll函数在内核空间内,它有一个对应的数据结构去存储一些数据,这个数据结构其实就是EventPoll对象,EventPoll对象可以通过一个系统函数epoll_create()去创建,创建完成之后,系统函数返回一个EventPoll对象的epfd文件号,相当于我们在内核开辟一小块空间,并且我们也知道这块空间的位置……
我们先说一下这个EventPoll的结构,它主要是两块重要的区域,其中一块是存放需要监听的socket_fd描述符列表,另一块区域就是就绪列表,存放就绪状态的Socket信息……
它还提供了两个函数,一个是epoll_ctl函数,另外一个是epoll_wait函数……
?面试官:那这两个函数你也说下吧,这两个函数是比较核心的?
赵总:行,那我先说下这个epoll_ctl函数,它可以根据eventpoll-id对内核空间上的EventPoll对象的检查列表进行增删改查(即关注的Socket信息),去增加或者修改需要检查的Socket文件描述符……
然后这个epoll_wait函数,它主要的参数是eventpoll-id,表示此次系统调用需要监测的socket_fd集合,是EventPoll中已经指定好的那些Socket信息,epoll_wait默认情况下会阻塞调用线程,直到EventPoll中关联的某些个Socket就绪以后,epoll_wait它才会返回……
?面试官:大体上问题不大,这里边还有一些细节哈,我再问问……刚才你说了kernel空间内,咱们创建的EventPoll对象有两块核心区域对吧,一块呢是存放咱们需要监听的Socket描述符文件号,另一块就是就绪列表嘛,然后存放需要关注的Socket描述符的这块区域,已经知道是使用epoll_ctl函数去维护的对吧,但是这个就绪列表,它是怎么维护的呢??
赵总:好的……前面已经说了,Socket对象它有三块区域嘛——读缓冲区、写缓冲区还有等待队列,Select函数调用时会把当前调用进程从工作队列里面拿出来,然后把进程引用追加到当前进程关注的每一个Socket对象的等待队列中,当Socket连接的客户端发送完数据之后,数据还是通过硬件DMA的方式把数据写入到内存,然后相应的硬件就会向CPU发出中断信号,占用的进程就会让出位置让CPU去执行网络数据就绪的中断程序……
这个中断程序呢,它会把内存中的网络数据写入到对应的Socket的读缓冲区里,把这个Socket等待队列中的进程全部移动到工作队列内,再然后Select函数就返回了……这个是Select函数调用的一个流程,Epoll的工作流程和这个非常相似……
?面试官:已经很清晰了,但是还有疑问哈……我记得epoll_wait函数的返回值是int类型的,它返回0表示没有就绪的Socket,返回大于0表示有几个就绪的Socket,-1表示异常,那也没有表示出来哪个Socket是就绪的,那获取就绪的Socket是怎么实现的呢??
赵总:这个……epoll_wait函数调用的时候会传入一个epoll_event事件数组指针,epoll_wait函数正常返回之前,会把就绪的Socket事件信息拷贝到这个指针表示的数组里,返回到上层程序,这样就可以通过这个数组拿到就绪列表了嘛
?面试官:OK……那这个epoll_wait函数可不可以设置成非阻塞的呢??
赵总:可以的,默认epoll_wait它是阻塞的,然后它有个参数,它表示阻塞时间的长度,如果这个参数设置为0就表示这个epoll_wait是非阻塞调用的,每次调用它都会去检查就绪列表……
?面试官:嗯,好的……EventPoll中它需要监视这个Socket集合信息嘛,这个存放的Socket集合信息它是采用什么数据结构啊??
赵总:这个采用的是红黑树结构,因为这个Socket集合信息经常会有增删改查的需求,这个需求红黑树一定是最合适的了,它能保持一种相对稳定的查找效率,复杂度应该是O(logN)……
?面试官:OK没错哈,多路复用的核心,我觉得我面的差不多了,发现您真的挺牛的,真是大牛,不愧是阿里的P7哈……?
(客套话……)
写在最后OK,我们总结一下面试多路复用的几个非常重要的点:

  1. 聊NIO要跳出Java,从操作系统内核层面聊IO多路复用
  2. 了解Select、Poll和Epoll它们产生的背景,三者之间的区别
  3. 需要具备硬件与操作系统内核相关的知识,把整个流程串连起来去理解底层原理
  4. 了解多路复用底层设计,包括多路复用底层的存储结构、数据流
好了,这次的五千字分享就到这里,感谢各位坚持完看~
老规矩,?一键三连,日入上千,点赞在看,年薪百万!?
?本文作者:Jensen?
7年Java老兵,小米主题设计师,手机输入法设计师,ProcessOn特邀讲师。
曾涉猎航空、电信、IoT、垂直电商产品研发,现就职于某知名电商企业。
技术公众号【架构师修行录】号主,专注于分享日常架构、技术、职场干货,关注回复“DDD”领学习DDD领域建模。

交个朋友,一起成长!

    推荐阅读