让人头疼的IO说起网络IO相关的开发,很多人包括我自己写了好几年的代码,对IO相关的术语也很熟悉很明显,什么NIO,IO复用等术语相继出现。但是据我所知,这些概念都是乱七八糟的,而且网上的各种文章都没有一篇是权威通俗易懂的,而且很多文章都在讲IO,涉及到Java的NIO封装。大部分的重点是如何使用(技术)而不是IO的本质(道)。所以写这篇文章来梳理网络IO模型,从socket编程的痛点,到NIO的解决方案,再到多路复用器的开发。说起Socket编程,做业务开发的同学经常会遇到SpringBoot框架帮我们搭建好的Server框架,但是如果我们再往下看框架帮我们实现的代码,最终还是会看到Socket相关的源码,Socket相关的代码其实就是TCP网络编程。目前主流的HTTP框架,比如Golang原生的HTTPnet/http,都是基于TCP编程实现的。根据HTTP协议,对TCP传输的数据进行解析,最后将传输的数据转化为我们业务的HttpRequestModel。Handler逻辑处理。例如,Golang的原生Http框架net/http有这样的代码片段作为示例:l,err=sl.listenTCP(ctx,la)//监听连接请求rw,e:=l.Accept()//创建一个connectiongoc.serve(connCtx)//调用新协程处理请求逻辑w,err:=c.readRequest(ctx)//读取请求serverHandler{c.server}.ServeHTTP(w,w.req)//执行业务逻辑并返回结果。Socket编程过程的服务端需要先绑定(Bind)和监听(Listen)一个端口。这时候就会出现欢迎套接字(welcomeSocket)。welcomeSocket调用Accept方法来接受客户端的请求。如果没有请求,则会阻塞客户端请求指定端口,welcomeSocket从阻塞中返回一个连接套接字(connectionSocket)专门处理客户端请求。客户端向请求套接字(Stream)服务器写入数据,可以从连接的套接字中不断读取数据,TCP底层保证数据的顺序。服务器可以向连接的套接字写入数据,客户端可以从请求的套接字中读取数据。客户端关闭连接。服务器也可以主动关闭连接。如果Socket服务器是用代码手写的,用Java可以这样实现:字符串大写的句子;ServerSocketwelcomeSocket=newServerSocket(6789);while(true){套接字connectionSocket=welcomeSocket.accept();//没有请求时阻塞System.out.println("connectionbuildsucc!");BufferedReaderinFromClient=newBufferedReader(newInputStreamReader(connectionSocket.getInputStream()));DataOutputStreamoutToClient=newDataOutputStream(connectionSocket.getOutputStream());clientSentence=inFromClient.readLine();//已连接但客户端未写入数据BlockingSystem.out.println("readsucc!");capitalizedSentence=clientSentence.toUpperCase()+'\n';outToClient.writeBytes(capitalizedSentence);System.out.println("写成功!");}}}我们可以使用Telnet连接试试看,但很快就会发现两个问题:Accept被阻塞,如果客户端网络差,三次握手时间长,整个服务器会卡住;读取被阻塞,如果客户端连接上了,但是长时间没有发送数据(比如我们在telnet,但是没有写)整个服务器就卡住了。优化思路:多线程处理,避免读阻塞是Read的阻塞问题,我们开线程来处理,这样当一个请求连接长时间不写入数据时,不会影响其他连接的处理。当然,这里要考虑量级。如果量级太大,需要改为线程池,避免线程过多。公共类服务器{publicstaticvoidmain(String[]args)throwsException{ServerSocketwelcomeSocket=newServerSocket(6789);while(true){套接字connectionSocket=welcomeSocket.accept();//块系统。out.println("连接成功!");新线程(新Runnable(){@Overridepublicvoidrun(){try{StringclientSentence;StringcapitalizedSentence;BufferedReaderinFromClient=newBufferedReader(newInputStreamReader(connectionSocket.getInputStream()));DataOutputStreamoutToClient=newDataOutputStream(connectionSocket.getOutputStream());clientSentence=inFromClient.readLine();//已连接但客户端未写入数据会阻塞System.out.println("readsucc!");capitalizedSentence=clientSentencece.toUpperCase()+'\n';outToClient.writeBytes(capitalizedSentence);System.out.println("写成功!");}catch(Exceptione){e.printStackTrace();}}})。开始();}}}这时候我们在使用telnet客户端连接时已经感受不到服务器的瓶颈了。它是一种线程池模式。如果有很多连接(尤其是空闲连接),最终会发生阻塞。如果你的业务场景连接数少,需要频繁交互数据,那么使用BIO模式无论是延迟还是资源占用都有很好的效果(相当于VIP1v1服务)。但是我们的服务器端代码通常会面临大量的连接,连接的客户端很多,不会立即发送请求,比如聊天室应用,用户发送一条消息需要很长时间。如果这时候还采用1v1模式,那么有100w个用户,需要维护100个连接,显然是不合适的。这是一种资源浪费,效率很低。大多数线程都在等待Block中的用户数据。于是,这个时候NIO(asynchronousio)诞生了。网上有个比较好的对比图,可以很好的说明区别:上面我们说的是阻塞I/O,现在要说的是非阻塞I/O。图中主要说明了`read()`方法的流程,主要包括两部分:第一阶段:等待TCPRecvBuffer数据准备好。传统BIO如果数据没有准备好,会阻塞等待,不消耗CPU。第二阶段:从内核拷贝数据到用户空间,消耗CPU但速度很快,属于内存拷贝。非阻塞I/O所以对于非阻塞I/O,主要要优化的是调用`read()`方法,数据还没有准备好导致阻塞问题。这个解决方案非常简单。大多数编程语言都提供了nio方法。只要数据没有准备好,就不要阻塞,直接返回给调用者。这样我们的线程就可以接着处理其他连接的数据了,这样就不需要每个连接都只有一个线程来服务了。I/O多路复用对于非阻塞I/O模式,开发者仍然需要不断轮询事件状态。如果请求级别很大,这样的机制还是会浪费很多资源,开发难度大。其实想一想,我们作为开发者的诉求无非就是监听某些事件,比如连接完成(acceptcompletion),数据就绪(readable)等,其实对事件的监听是没有关系的与编程语言。它可以在操作系统级别完成,并且可以更有效地完成。操作系统提供了一系列的系统调用,比如select/poll/epool,这些系统调用之后会阻塞,当相应的事件到来时,触发我们注册到该事件的Handler逻辑。所以简单来说,上面提到的非阻塞I/O用户自己写轮询检查状态的逻辑,都汇聚到操作系统提供的I/O多路复用器上,整个程序执行的逻辑大概就是像这样。接口ChannelHandler{voidchannelReadable(Channelchannel);voidchannelWritable(通道通道);}类Channel{套接字套接字;Eventevent;//read,writeorconnect}//IO线程主循环:classIoThreadextendsThread{publicvoidrun(){Channelchannel;while(channel=Selector.select()){//选择准备好的事件和对应的连接if(channel.event==accept){registerNewChannelHandler(channel);//如果是新连接,则注册新的读写processor}if(channel.event==write){getChannelHandler(channel).channelWritable(channel);//如果可以写入,则执行写入事件}if(channel.event==read){getChannelHandler(channel).channelReadable(channel);//如果可以读取,则执行读取事件}}}Map
