当前位置: 首页 > 科技观察

网络编程-吃透网络IO模型

时间:2023-03-21 14:42:20 科技观察

让人头疼的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);//如果可以读取,则执行读取事件}}}MaphandlerMap;//所有通道对应的事件处理器}Reactor模型目前多为高性能网络IO框架主要是基于IO多路复用+池化技术的Reactor模型。Reactor其实只是一个网络模型的概念,并不是一个具体的具体技术。常见的有三种,单Reactor+单进程/单线程,单Reactor+多线程,多Reactor+多进程/多线程。单Reactor+单进程/单线程多路复用器Select返回结果后,有一个分发结果事件的Dispatch。如果是连接建立事件,Acceptor接受连接并创建对应的Handler处理后续事件。如果不是连接事件,则直接调用相应的Handler,由Handler完成数据读取read、process、send的完整业务流程。这种模式的优点是简单,不需要考虑进程间通信、线程安全、资源竞争等问题,但也有其局限性,即不能充分利用多线程。核心资源,适用于业务场景处理速度快的场景。比如Redis就采用了这种方案。单Reactor+多线程与之前的方案相比,不同的是Handler只负责数据读取不负责事件处理,而是有一个单独的Worker线程池来做具体的事情。处理器之所以需要隔离一个单独的线程池,是因为`read`方法本身需要消耗cpu资源,通常不适合大于cpu核数的情况,用户中可能会有各种网络请求定义处理器逻辑,例如RPC请求,如果隔离,处理器可以设置更大数量的线程来提高吞吐量。这种模式可以充分利用多核资源,但问题是主线程承担了所有的事件监听和响应。瞬间高并发可能成为瓶颈,需要多reactor解决方案。Multi-Reactor+多进程/多线程处理步骤:父进程中的mainReactor对象通过select监听连接建立事件,通过Acceptor接收事件,将新的连接分配给子进程。子进程的subReactor将mainReactor分配的连接添加到连接队列中进行监听,并创建一个Handler来处理连接的各种事件。当有新事件发生时,subReactor会调用连接对应的Handler进行响应。Handler完成读取→处理→发送的完整业务流程。目前知名的开源系统Nginx采用了多反应器和多进程,多反应器和多线程的实现有Memcache和Netty。不过需要注意的是,Nginx中的方案与上图中的方案略有不同。具体来说,主进程中没有mainReactor建立连接,而是子进程中的subReactor建立连接。异步非阻塞I/O服务器的实现方式是一个有效请求一个线程。客户端的I/O请求首先由OS完成,然后通知服务器应用程序启动线程进行处理。AIO也叫NIO2.0,只有JDK7才有。开始支持。但是由于AIO在Linux上的底层实现不好,所以目前并没有得到广泛的应用。比如著名的Netty框架也是用NIO代替AIO的。综上所述,本文从socket编程入手。您学习了如何使用socket编写服务器端代码,然后找到了socket编程中的痛点。一是accept在建立连接时会阻塞线程,二是读数据时会阻塞。为了解决阻塞我们尝试使用多线程的方法来解决可能的低效率问题。但是在那之后,我们看到BIO无法应对海量连接,所以我们引入了NIO模型。这里我们解释一下从NIO到multiplexer的过程,相当于操作系统为我们监听海量的连接事件。这种模式也称为Reactor模式。最后,我们谈到了异步I/O。理想虽然美好,但底层基础设施并不完善。目前这种模式在生产中使用的还是比较少的。我写这篇文章,并没有描述很多具体的API,因为我希望通过这篇文章帮助你理解IO模型的本质,而不是罗列话题或者死板的内存API,因为编程语言有很多种,思想解决方案是统一的。这也是我们在学习中应该注意的。我们应该更多地了解方法而不是技术。