5

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

 2 years ago
source link: https://blog.51cto.com/Tlog4J/5109666
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

大家好,欢迎来到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就会执行咱这个中断程序的逻辑了……

阿里P7大牛教你如何面试NIO_多路复用

这个逻辑大概是这样的,根据内存中它有的数据包,然后分析出来数据包是哪个Socket的数据,TCP/IP协议数据包,它又保证传输的时候是有端口号的,然后根据端口号就能找到它对应的Socket实例,找到Socket实例以后,就把数据导入到Socket的读缓冲区里头……

导入完成以后,它就开始去检查Socket的等待队列,是不是有等待者?如果有的话,咱就把这个等待者移动到工作队列,中断程序到这一步就执行完了,咱们的进程又回归到工作队列了,又有机会获取到CPU时间片了……

然后当前进程执行Select函数,再次检查就发现有这个就绪的Socket了,它就会给就绪的Socket的fd文件描述符打标记,然后Select函数就执行完了,它返回到Java层面,就涉及到内核态/用户态的转换,后面的事情就是轮询检查每一个Socket的fd是否被打标记,然后处理被打了标记的Socket就OK了。

阿里P7大牛教你如何面试NIO_多路复用_02

面试官:WC……太强了呀……咱继续聊吧,IO这块还有好多要问的哈,刚才咱们聊的这个多路复用技术是Select函数,其实后面还衍生出来了一个稍微加强版的函数叫poll(..)函数,这俩工作原理其实差不多,你能说下它俩的大概区别么?

赵总:其实最大的区别就是传参不一样了,Select它使用的是bitmap来表示需要检查的Socket集合,Poll使用数组结构来表示,主要就是为了解决bitmap长度是1024这个问题嘛,Poll使用数组就没有这个限制了,它就可以让咱们线程监听超过1024个Socket限制,主要就是这个,基本上和Select没什么区别

面试官:OK,基本上一针见血了,我也不太想去聊这个Poll,咱们就聊后面出来的这个Epoll吧,你能说下为什么会有Epoll这个技术么?它产生的背景是什么呀?

赵总: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领域建模。

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

交个朋友,一起成长!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK