从NIO到NettyIO是编程中的一个重要概念。无论是数据存储还是网络通信,都会在底层用到。了解IO对面试和工作有很大帮助,也可以从基础理论层面扎实。有了基础,理解它的上层应用就简单多了。常用的软件,如Nginx、Redis、Dubbo、Kafka,都涉及到NIO的一些基础知识。本文从简单的IO入手,从BIO到NIO再到Netty,从理论到实践深入理解。计算机组成计算机的组成包括CPU、内存存储、网卡、磁盘存储和其他外部设备。在Linux操作系统中,一切皆文件(即文件描述符fd,文件描述符),当服务启动时,内核程序会被加载到CPU中运行。为了保证服务的正常运行,内核程序具有更高的优先级,其占用的空间为内核空间,其他应用程序占用的空间为用户空间。以Java程序为例,它也是一个程序,占用一定的内存空间。应用程序在运行过程中,如果有IO操作或计算需求,需要交由内核程序完成。由于内核(kernel)保护模式的存在,应用程序无权调用CPU,所有操作都需要通过内核程序完成。只有这样才能保证一旦应用程序出现故障,不会影响到内核程序,也不会影响到整个系统。停机风险。内核程序和应用程序之间的操作切换是通过中断(通常是80中断)来完成的。应用系统通过内核程序提供系统调用(SystemCall,是一系列系统操作函数,是内核系统对外暴露的API)实现CPU或IO操作,由CPU分配每个任务的时间片通过FCFS(非抢占式先来先服务算法)实现各个任务的并发运行。在Java的多线程应用中,有一个上下文切换的概念,即应用线程将任务切换到内核线程,在CPU的时间片内继续运行,之后将内核线程切换到应用线程操作完成。进程是系统分配资源的基本单位,线程是cpu执行调度的基本单位。线程也称为轻量级进程(LWP)。Java线程是在操作系统中通过内核系统调用获得的轻量级进程。阻塞和非阻塞/同步和异步这里先说说小编理解的阻塞和非阻塞以及同步和异步的概念:阻塞和非阻塞描述的是用户线程调用内核IO操作的方式,而阻塞是发起调用后需要等待直到内核给出结果数据是否可读或可写,非阻塞是指发起调用后不需要等待结果,状态值为-1给出表示正在处理中。同步和异步描述了用户线程和内核之间数据交互的方式。同步需要用户线程自己获取数据。即使是多路复用器也能解决阻塞问题。也需要用户线程自己去获取数据,还是同步IO模型。异步是指用户线程发起调用后,不需要主动获取数据,而是内核处理完数据后,将数据放入用户空间,然后通知用户线程继续进行业务处理。在常见的socket编程中,是这样的://启动Socket服务器ServerSocketserver=newServerSocket(8986);while(true){//阻塞方法,等待客户端访问Socketclient=server.accept();//获取输入流InputStreaminput=client.getInputStream();//创建缓冲区byte[]buff=newbyte[4096];intlen=input.read(buff);//只要一直有数据写入,len就会一直大于0if(len>0){Stringmsg=newString(buff,0,len);System.out.println("收到"+msg);}}在操作系统中运行时,如何监听操作系统层面的指令呢?首先需要创建一个java文件#创建一个java文件Bio001Test.java#然后使用javac命令编译成Bio001Test.classjavacBio001Test.java#执行java代码javaBio001Test#使用strace命令监控系统调用,底层是strace-ff-ooutjavaBio001Test,利用内核的ptrace特性实现,BIO是阻塞的,NIO是非阻塞的,BIO是面向流的,只能单向读写,NIO是buffer-oriented,可以双向读写。#bio的阻塞方式server.accept()#nio的非阻塞方式,为了解决io提出了channelselectorbuffer的概念,利用事件注册状态来处理请求信息。selecter.select()使用mansocket查看操作系统中的socket传输输入参数如下:#操作系统的函数都是用C语言写的,java也是类C语言socket()#Createafiledescriptorforcommunicationcreateanendpointforcommunicationandreturnsadescriptor....#设置非阻塞参数项SOCK_NONBLOCK在新打开的文件描述上设置O_NONBLOCK文件状态标志。使用此标志可以节省对fcntl(2)的额外调用以实现相同的结果。socket称为套接字或套接字,属于WebAPI。它是应用层到传输层的接口,也是用户进程和系统内核之间的接口。一个TCP连接标记为四元组,即源ip:源端口+目标ip:目标端口,我们都知道电脑的端口范围是0-65535,也就是说一个客户端可以发起最多65535个到目标服务器的连接。BIO模型当应用程序发起调用时,应用程序进程会一直阻塞block,在内核还没有准备好数据之前进入等待阶段。当内核准备数据时,它会返回数据。这时候申请进程就被阻塞了。NIO模型因为引入了内核而被阻塞。引入nio后,应用程序发起调用后会立即返回结果-1,表示内核还没有准备好数据,应用程序进程不需要等待。您可以轮询结果,直到数据准备就绪,此时应用进程被阻塞获取数据。即使多路复用器nio解决了阻塞问题,无效的轮询也会导致CPU空闲,浪费资源。利用IO多路复用技术,当内核准备数据时,通知应用进程去获取数据。为了解决这个问题,根据操作方式的不同,分为三种多路复用器:select/poll/epoll。内核监视所有的套接字,当数据准备好后,发起系统调用,即系统调用将数据从内核复制到用户进程。因此,I/O多路复用的特点是一个进程可以通过一种机制同时等待多个文件描述符,其中任意一个文件描述符(套接字描述符)进入read-ready状态,select()函数可以返回。I/O多路复用的优点是可以同时处理多个连接请求。select是操作系统提供的系统调用函数。通过它可以向操作系统发送一个文件描述符数组,让操作系统遍历,判断哪些文件描述符可以读写,然后告诉我们处理:select是一个操作提供的调用函数系统可以通过这个函数传递一组fd给操作系统,操作系统遍历fd,返回准备好的文件描述符个数给用户线程,用户线程一个一个遍历fd,查看哪个fd已经是进入就绪状态,然后进入流程。select的特点是:用户需要将监听到的fd列表传入操作系统内核,由内核完成遍历操作并返回解,在high-的copy操作下会消耗过多的资源并发场景数组。select这个操作只是解决了系统上下文切换的开销,遍历数组还是存在的。select返回的结果是就绪的fd的个数,用户线程需要判断哪个fd处于就绪状态。select可以传入一组socket等待内核的处理结果,但是它的listsize只有1024,每次调用select都需要将fd数组从用户态复制到内核态,而开销比较大。调用select后返回就绪fd的个数,用户需要再次遍历。针对select的缺点,poll为了增加单个监听socket的数量,采用了链表的结构,而不是数组的结构,但是其遍历的核心缺点依然没有解决。针对select和poll的不足,epoll应运而生。它的核心主要包括三个方法:#在内核中开辟一块区域存放需要监控的fdepoll_create#添加、修改、删除内核中需要监控的fdepoll_ctl#返回就绪fdepoll_wait的核心如下:文件描述符fd的集合保存在内核中,不需要用户每次都从用户态传入,只需要告诉内核修改什么即可。内核不再通过轮询找到就绪文件描述符fd,而是通过异步IO事件唤醒。内核会把IO事件发生的文件描述符fd返回给用户,用户不需要自己去遍历。epoll的数据操作有两种模式:水平模式LT(leveltrigger)和边沿模式ET(edgetrigger)。LT是epoll默认的运行方式。LT模式:当epollwait函数检测到有事件发生时,需要通知应用程序,但应用程序不一定及时处理。当epollwait函数再次检测到事件时,会通知应用程序,直到事件被处理。可以理解为mq发送消息的至少一次模型。ET模式:epollwait函数只会在事件发生时通知应用一次,后续的epollwait函数将不再监听该事件。所以ET模式减少了epoll触发同一个事件的次数,效率比LT模式高。可以理解为mq发送消息的exactlyonce模型。IO多路复用方式包括select、poll和epoll。这个函数在内核级别。从BIO代码中可以看到accept函数。从前面的分析可以知道这个方法是阻塞的。大家在Netty实战中可能会关注一下。到了,在实际操作中,NIO的代码比较复杂,Netty对NIO进行了封装,保证在实际操作中使用方便。Server端的代码如下://Netty的Reactor线程池初始化一个NioEventLoop数组来处理I/O操作,比如接受新连接,读写数据EventLoopGroupboss=newNioEventLoopGroup(1);EventLoopGroupwork=newNioEventLoopGroup(8);try{//用于启动NIO服务ServerBootstrapserverBoot=newServerBootstrap();serverBoot.group(boss,work)//通过工厂方法设计模式实例化一个channel.channel(NioServerSocketChannel.class)//设置监听端口.localAddress(newInetSocketAddress(port))//在服务端设置一些参数.childOption(ChannelOption.CONNECT_TIMEOUT_MILLIS,30000).childOption(ChannelOption.MAX_MESSAGES_PER_READ,16).childOption(ChannelOption.WRITE_SPIN_COUNT,16)设置监听器的处理通道初始化器.childHandler(newAppServerChannelInitializer());//绑定服务器,此实例将提供有关IO操作的结果或状态的信息ChannelFuturechannelFuture=serverBoot.bind().sync();System.out.println("开启监听"+channelFuture.channel().localAddress()+"");//阻塞操作,closeFuture()打开一个通道Listener(通道在这期间做各种任务),直到断开连接channelFuture.channel().closeFuture().sync();}catch(Exceptione){log.error("遇到异常,详情为{}",e.getMessage());}finally{boss.shutdownGracefully().sync();//关闭EventLoopGroup并释放所有资源,包括所有创建的线程work.shutdownGracefully().sync();//关闭EventLoopGroup并释放所有资源,包括所有创建的线程}一般IO压力在server端,client端也默认使用BIO,除非client端也需要提供服务//配置相应的参数并提供连接远程的方法//I/O线程池EventLoopGroupgroup=newNioEventLoopGroup();try{//客户端辅助启动类Bootstrapbs=newBootstrap();bs.group(group)//实例化一个Channel.channel(NioSocketChannel.class).remoteAddress(newInetSocketAddress(host,port))//Channel初始化configuration.handler(newAppClientChannelInitializer());//连接到远程节点;等待连接完成ChannelFuturefuture=bs.connect().sync();//向服务器发送消息,编码格式为utf-8future.channel().writeAndFlush(Unpooled.copiedBuffer("HelloWorld",CharsetUtil.UTF_8));//阻塞操作,closeFuture()打开一个通道监听器(期间通道正在执行各种任务)直到链接断开future.channel().closeFuture().sync();}最后{组。shutdownGracefully().sync();}总结IO的瓶颈从一开始就是操作系统的read数据读取方式。这种阻塞方式导致了BIO的产生。为了解决阻塞IO的问题,提高效率,a为了使用多线程技术来操作IO来提高性能,但是IO瓶颈问题并没有解决。后来操作系统做了改动,提供了非阻塞读的功能,让应用程序在发起调用后不需要等待解决,而是通过轮询的方式查询数据是否就绪,这样可以同时完成比BIO更多的fd操作,也就是NIO。但是在高并发场景下,文件描述符的遍历和读取带来了更多的轮询操作,额外的系统调用增加了CPU的负担,并没有带来预期的性能提升。后来操作系统做了改进,将遍历文本描述符的操作实现到内核中。这就是IO复用技术。多路复用技术分为三个函数,select、poll和epoll。poll解决了选择单个输入文件描述符的限制,但是没有解决客户端遍历查询文件描述符的问题。epoll的产生解决了这个问题,只将准备好的数据的fd返回给客户端,减少了客户端遍历Terminal的操作。IO模型的演进也是根据应用的需要进行升级,迫使操作系统的内核增加更多的操作来提高性能。
