Netty原理(一)BIO,NIO,AIO

同步和异步

Java提供了三种IO模型,分别是BIO,NIO和AIO。所谓的IO就是选择用什么样的通道进行数据的收发。在这三种IO之前需要弄清楚阻塞和异步的概念。阻塞和异步描述的对象是不一样的,前者描述的是一行代码,后者描述的是多行代码。阻塞就是某行代码的特性,核心是等不等待这行代码执行完,显然如果等待则必然是同步,而不等待不一定是异步。异步就是描述多行代码的执行顺序,核心是按不按顺序来执行这多行代码,按顺序则是前面代码执行完再执行接下来的代码,而不按顺序是不管前面任务是否执行完,都走接下来的代码,当前面执行完时再回调来处理之前任务的结果。

BIO

所谓的BIO,也就是JDK在1.4之前使用的IO模型,如InputStream和OutputStream,他是同步阻塞的,且没有多路复用,一个客户端对应Server端起的一个线程,当我们在使用他的时候,服务端的new 一个ServerSocket,之后就操作拿到的serverSocket用他的accept()接收客户端连接,用他的read()接收客户端的数据,但ServerSocket提供的accept()和read()方法他们都是阻塞的方法,也就是说,执行他们的时候,如果没有响应,那么就无法执行下面的代码,而异步和同步的意思是一行代码执行时,不管执行完没有,直接执行下一行代码,显然,因为BIO的accept和read是阻塞方法,肯定是同步的。BIO他之所以阻塞,他内部的阻塞逻辑就在于,BIO的Server端一直在主线程accept等待线程来连接,却不能确定客户端线程一定会来连接,显得很一厢情愿,在主线程就堵住了,而连接上后他的read逻辑代码又直接跟在accept后面,也就是两个阻塞方法放在一起,又显得自作主张得认为客户端在accept连接上后,一定会发数据过来。而NIO就是改善了这两点一厢情愿和自作主张。还是先来说说BIO的缺点吧,首先用户体验上就不好,一个服务器很容易陷入阻塞状态,要么是等客户端来连接,要么是等客户端传入数据很浪费资源,而且,即便是用多线程来accept处理客户端的请求,但当客户端多的时候,服务端的线程数就很多很多,因为来一个请求就会创建一个线程,而线程的数量肯定是有限的,比如来了10万个请求那么就要开10万个线程,显然不支持大规模并发连接,所以BIO性能很差,勉强算得上优点的就是他的代码书写至少比NIO简单。

NIO

说完BIO再来说JDK1.4之后出现的NIO吧,他是同步非阻塞的IO,他实现了一个线程处理多个请求。他提出很多的概念,有三大组件如channel,buffer,selector,一个客户端对应一个buffer和channel,而多个channel注册到一个selector上,一个selector对应一个或多个线程,其中selector有两个,分别由服务端和客户端维护,channel由客户端维护。其中的channel就是服务端和客户端之间的双向通道,类似于BIO中的Socket,而buffer是缓冲区,底层就是一个数组,相当于在BIO中使用的byte数组来缓冲读到的数据。

源码

服务器角度

站在服务端的视角,说一下他的的执行流程大体是。首先服务端会持有一个ServerSocketChannel和selector,并且把自己的channel也就是ServerSocket注册到selector里面,注册时需要说明自己这个channel对哪些事件信号感兴趣,比如连接事件,所谓的事件信号在NIO中被称为SelectedKey。注册完后在while死循环中selector通过select()方法监听channel事件,并调用处理函数handler,至此main函数结束。
当客户端有事件发生时,selector监听到这些事件,并收集一段时间内的所有事件将其封装成SelectionKeys,然后进入handle(key)中来处理这些key。handle是一个if分支结构,在handle中如果是连接事件,那么说明我们需要拿到SocketChannel并将其注册到selector上并说明自己server感兴趣的事件信号,而我们的参数只有key,所以通过selectionKey的channel()拿到ServerSocketChannel,再通过ServerSocketChannel拿到SocketChannel,至此就再将SocketChannel注册到Selector上并说明自己对channel的读事件感兴趣。如果handle中的是读或者事件信号,那就比较简单了,因为我们此时是站在服务端的视角,那么核心就需要拿到SocketChannel,然后用三大组件中的Buffer进行操作,显然想要拿到客户端的channel的步骤和之前的注册事件一样,用selectionKey.channel()就可以了。

再来细节说一下main中关于while循环中的细节。当selector,他在被注册了客户端后,就会监听这些客户端,用的是selector的select方法,他的底层后面再说,先说他的作用,他的作用就在于轮询得接收各个channel传过来的信号,这里的信号是指,客户端发起的请求,他包括连接和读和写请求。接收这些信号的时候,不是接收一个就完了,是完整轮询一遍,接收一批的信号,把他们封装在自己的SelectionKey中,其实NIO使用时需要两层循环,第一层循环就是维持住不停的接收这些key,而第二层循环就是来handle处理这些key。处理完一批key也就是信号之后,在处理的过程中可能还会收到新的信号,但是没关系,select方法比较厉害,他可以一直等着,并存储这些信号,等到下一批再来处理。处理信号的逻辑就是NIO为什么可以非阻塞的原因所在。至此服务端的NIO写法就说完了。

客户端角度

再来站在客户端说一下流程,客户端的需求就是监听服务端的读写请求,所以也是要一个selector,但这个和服务端的selector不是同一个,也就是说selector是单向监听的,不同点在于Server的selector累一下需要监听多个channel,而Client的selector轻松一些只需要监听一个channel。大致流程和服务端一样都是用selector收集seletionKey,然后用if分支分别处理。

总结

总结一下,他之所以非阻塞,就是前面所说的他解决了BIO的一相亲缘和自作主张,核心就是让将客户端的连接和读写请求抽象为事件信号也就是SeletionKey,利用select()方法强大的非阻塞性来非阻塞地接收信号,再用if分支来分别处理这些信号。他不认为客户端会来连接,他也不会在那里等,同样的他对于客户端的读写请求也不会等,NIO在从select方法拿到信号后,他会把信号分门别类,在他的处理函数中,不会把两个阻塞的函数放在一起,而是放在了不同的if分支中,而想进入这些分支,就必须保证分支中的阻塞行为一定会发生,也就是必须要收到信号,这样分支中的阻塞方法就能保证得到执行,从而不会阻塞,也就是说他把阻塞方法隔离开,而且只有当阻塞方法肯定能顺利执行时,才会允许执行这些方法,当处理完一批之后,服务端当然不能结束线程,而是回到了第一次的while死循环中,select阻塞等待一下轮的事件收集。当然这个阻塞是不可避免的,但不影响NIO的非阻塞,如果这个方法都阻塞的话,说明没有Client需要Server,也就是说Server此时就该是空闲的。NIO比BIO适用于连接数多的场景,如聊天服务器和弹幕系统,缺点在于编码复杂。他之所以是同步是因为异步的特征在于回调函数,而NIO没有回调函数,所以是按顺序来的。

最后来强调一下select方法的厉害之处吧,首先他实现了监听channel的功能,这个不算什么,关键是,当线程在执行其他逻辑,比如处理客户端的传输请求时,他还能够继续监听,把在处理客户端请求时,其他客户端发来的请求也收集起来。之所以他这么特殊离不开底层Linux操作系统级别的API支持,而且select方法在发展过程中也有升级的地方,在NIO刚刚出来的时候,也就是JDK1.4,他使用的Linux提供的selectAPI,他每次调用时都会进行线性遍历,所以时间复杂度是O(n),selectAPI是用数组实现的,所以最大连接数是有上限的,而后来select又使用了poll,他执行上的IO效率和selectAPI一样,都是线性遍历的O(n)时间复杂度,但他解决了selectAPI的连接上限问题,他采用链表连接,所以没有上限。但这两种方式都有一个共同的问题,就是说他们都是线性遍历的,如果注册监听的channel很多,只要其中有一个channel传来信号,那么就要把所有channl都遍历一遍,有点多余。所以JDK1.5之后,又改用了epoll,他提高了时间复杂度,从以前的主动轮训转换成被动收取通知,实现了O(1)级的时间复杂度,他很类似于观察者模式,而且epoll他有一个事件队列,所以调用select方法时,他内部函数会有个延时,等一批信号才会执行整个事件队列,而非只执行一个,这样他就能一次执行多个channel发来的信号。

举一反三啦

联系Redis,对于NIO而言,不难看出他是对Server端友好而对Client端不友好的,因为在while中Client端发出的事件信号SelectionKey只有当之前的那一批信号处理完后,才会在再次进入while时被处理,此间Client是阻塞的,甚至于新的连接请求也会阻塞而且很可能因为超时而连接失败,这是Netty优化的点,之后说。Redis的线程模型他就是这样,单线程,自己忙时把新连接放到连接队列中,当实在是一次轮询请求的处理时间太长,而连接队列放满时,Redis就会拒绝连接。所以在Redis中读写的key和value要尽量小,不要一次性操作大量数据读写或者并发连接太多。

AIO

说完NIO,其实还有一种IO,他在JDK1.7后支持,前面所说的BIO和NIO,他们都是同步的,他们函数之间的顺序不能变,也就是说,他们的执行顺序是一定的,必须等上件事做完之后,才能做下件事,就比如,我们在使用BIO的时候,在服务端的if条件中执行accept阻塞方法时,虽然他虽然确保了一定会accept到客户端,但还是要accept完成后才继续执行if里面的其他代码。而AIO就不一样了,他的优化思路和Netty一样,将accept和read区分开,不同点在于Netty是用专门的线程组来优先处理accept而AIO是改造accept让他尽可能快执行完其实没啥用,他在定义accept的时候,直接将accept和他accept成功后的回调处理函数定义好,然后也不管是否accept是否成功,都直接进入下面的代码来执行,当accept成功后,再回过头来执行之前定义好的回调方法。一定程度上加快了accept所在if分支的执行效率。

但AIO我们平常也不咋用,因为AIO的性能并不比NIO高,之前说了嘛,反正accept是放在if中的,也就是说accept是肯定能顺利执行的,所以NIO的性能已经够高了,而AIO无非是在他上面封装,性能没有提高,唯一的好处就在于AIO封装后,代码能简单点,就比如不会在通过selector去拿channel,而是封装好了,在与accept绑定的处理函数中直接用就好,这一点仅有的优点也在Netty面前毫无优点。Netty和AIO都是对NIO的封装,他们是一个级别的,而Netty封装得比AIO更好,所以AIO用得少,而Netty用的多。至于为什么Netty去封装NIO而非封装AIO,就在于NIO和AIO性能上没什么差别,那就封装更底层的比较好。

你可能感兴趣的:(Netty,java)