本文转载自微信公众号《每日加油站》,作者:月版飞语。转载本文请联系每日加油站公众号。背景介绍在互联网时代,大部分数据都是通过网络获取的。在服务器端架构中,大部分数据也是通过网络进行交互的。而且作为服务端开发工程师,会进行一系列的服务设计、开发、能力暴露,而服务能力暴露也需要通过网络来完成,所以对网络编程和网络也不是太陌生IO模型。由于有很多优秀的框架(如Netty、HSF、Dubbo、Thrift等)已经对底层网络IO进行了封装,通过提供的API能力或配置即可完成所需的服务能力开发,因此大部分工程师都关心networkIO模型的底层不是很好理解。本文系统的讲解了Linux内核的IO模型,Java网络IO模型以及两者的关系!我们都知道Linux世界里的IO是什么,万物皆文件。而一个文件就是一串二进制流,不管Socket、FIFO、管道还是终端,对于我们来说,一切都是流。在信息交换的过程中,我们都对这些流进行数据的发送和接收操作,简称为I/O操作。要从流中读取数据,系统会调用Read,而要写入数据,系统会调用Write。通常一个用户进程一次完整的IO分为两个阶段:磁盘IO:网络IO:操作系统和驱动程序运行在内核空间,应用程序运行在用户空间。两者不能使用指针传递数据,因为Linux使用虚拟内存机制,必须通过系统调用请求内核完成IO动作。IO有内存IO、网络IO、磁盘IO三种。通常我们所说的IO就是指后两者!为什么需要IO模型?如果使用同步方法进行通信,则所有操作都在一个线程中顺序执行。这样做的坏处很明显:因为同步通信操作会阻塞同一个线程的任何其他操作,只有这个操作完成后,后面的操作才能完成,所以就有了同步阻塞+多线程(每个Socket创建一个线程对应),但是系统中的线程数量是有限的,线程切换很浪费时间,适合Socket少的情况。因此,IO模型需要出现。LinuxIO模型在介绍LinuxIO模型之前,我们先了解一下Linux系统数据读取的过程:以用户请求index.html文件为例,说明用户空间和内核空间的基本概念操作系统的核心是内核,独立的普通应用程序可以访问受保护的内存空间,并拥有访问底层硬件设备的所有权限。为了保证内核的安全,用户进程不能直接操作内核。操作系统将虚拟空间分为两部分,一部分是内核空间,一部分是用户空间。进程切换为了控制一个进程的执行,内核必须有能力挂起运行在CPU上的进程,并恢复之前挂起的进程的执行。这种行为称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,都与内核息息相关。Blockingoftheprocess正在执行的进程,因为一些预期的事件没有发生,比如请求系统资源失败,等待某个操作完成,新数据还没有到达或者没有新的工作要做等,系统自动执行阻塞原语(Block),使自己从运行状态变为阻塞状态。可见,进程的阻塞是进程自身的主动行为,因此只有处于运行状态(获取CPU)的进程才能转为阻塞状态。当进程进入阻塞状态时,不占用CPU资源。文件描述符文件描述符(FileDescriptor)是计算机科学中的一个术语,是一个抽象的概念,用来表示对文件的引用。文件描述符是一个非负整数形式。其实就是一个索引值,指向内核为每个进程维护的进程打开文件的记录表。当程序打开现有文件或创建新文件时,内核会向进程返回一个文件描述符。缓存IO大多数文件系统的默认IO操作是缓存IO。读写过程如下:读操作:操作系统检查内核缓冲区是否有需要的数据。如果已经被缓存,则直接从缓存中返回;否则,它从磁盘、网卡等读取,然后缓存在操作系统中。在缓存中;写操作:将数据从用户空间拷贝到内核空间的缓存中。此时,用户程序的写操作已经完成。至于什么时候写入磁盘、网卡等是由操作系统决定的,除非显式调用了sync同步命令。假设内核空间缓存没有必要的数据,用户进程分两个阶段从磁盘或网络读取数据:第1阶段:内核程序从磁盘、网卡等读取数据到内核空间缓冲区;stage2:用户程序缓存数据,从内核空间拷贝数据到用户空间。cachedIO的缺点:在数据传输过程中,需要在应用程序地址空间和内核空间进行多次数据拷贝操作。这些数据拷贝操作造成的CPU和内存开销非常大。同步阻塞用户空间应用程序执行系统调用,导致应用程序阻塞,直到数据准备好,数据从内核复制到用户进程,最后进程处理数据,等待datatobeprocessed在数据的两个阶段,整个过程都是阻塞的,无法处理其他网络IO。调用应用程序处于不再消耗CPU并且只是在等待响应的状态,因此从处理的角度来看这是非常有效的。这也是最简单的IO模型,平时FD比较少的时候用它是没有问题的,而且准备的很快。调用同步非阻塞非阻塞系统调用后,进程不阻塞,内核立即返回进程。如果数据没有准备好,此时会返回错误。进程返回后,它可以在发起系统调用之前做一些其他事情。重复以上过程,重复进行系统调用。此过程通常称为轮询。轮询检查内核数据,直到数据准备好,然后将数据复制到进程进行数据处理。需要注意的是,在复制数据的整个过程中,进程仍然处于阻塞状态。这样就可以在编程中为Socket设置O_NONBLOCK。IO多路复用IO多路复用是进程提前通知内核的能力,让内核在进程指定的一个或多个IO条件准备好时通知进程。使进程能够等待一系列事件。IO多路复用的实现方法主要有Select、Poll和Epoll。伪代码描述IO多路复用:while(status==OK){//连续轮询ready_fd_list=io_wait(fd_list);//内核缓冲区是否有准备好的数据for(fdinready_fd_list){data=read(fd)//准备好的数据读入用户缓冲区process(data)}}信号驱动首先我们让Socket进行信号驱动IO,并安装一个信号处理函数,让进程不阻塞地继续运行。当数据准备好后,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数来处理数据。流程如下:打开socket信号驱动IO函数。系统调用Sigaction执行信号处理函数(非阻塞,立即返回)。数据准备好,产生Sigio信号,通过信号回调通知应用读取数据。这个IO方法存在量很大。问题:Linux中的信号队列是有限的。如果数量超过此数量,则无法读取数据。异步非阻塞异步IO流程如下:当用户线程调用aio_read系统调用后,可以立即开始做其他事情,用户线程在不阻塞内核的情况下开始IO的第一阶段:准备数据。当内核等待数据准备好时,它会将数据从内核缓冲区复制到用户缓冲区。内核会向用户线程发送信号,或者回调用户线程注册的回调接口,告诉用户线程Read操作完成。用户线程读取用户缓冲区中的数据,完成后续的业务操作。与同步IO相比,异步IO不是按顺序执行的。用户进程进行aio_read系统调用后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程就可以做其他事情了。当数据准备好后,内核直接将数据拷贝给进程,然后内核向进程发送通知。与信号驱动IO相比,异步IO的主要区别是信号驱动告诉我们什么时候可以开始IO操作(数据在内核缓冲区),而异步IO是在IO操作时由内核通知已经完成(数据已经在用户空间)。异步IO也叫事件驱动IO。在Unix中,为异步访问文件定义了一组库函数,定义了一系列AIO接口。使用aio_read或aio_write发起异步IO操作,使用aio_error检查正在运行的IO操作的状态。目前Linux内核实现AIO只对文件IO有效。如果你想实现真正的AIO,你需要自己实现。目前有很多开源的异步IO库,比如libevent、libev、libuv。Java网络IO模型BIOBIO是一个典型的网络编程模型。就是我们通常用来实现服务器程序的方法。对应Linux内核的同步阻塞IO模型。发送和接收数据的过程如下:步骤如下:主线程接受请求到达时,创建一个新的线程处理socket,完成对客户端的响应。主线程继续接受下一个请求。服务器端处理伪代码如下:这是一个经典的模型,一个连接对应一个线程。使用多线程的主要原因是socket.accept()、socket.read()、socket.write()三个主要函数是同步阻塞的。当连接正在处理I/O时,系统将被阻塞。如果是单线程,肯定是阻塞的,但是释放了CPU。开启多线程可以让CPU处理更多的事情。其实这也是所有使用多线程的本质:使用多核,当I/O阻塞,但CPU空闲时,可以使用多线程来使用CPU资源。当面对几十万甚至上百万的连接时,传统的BIO模型就无能为力了。随着移动应用的兴起和各种网络游戏的盛行,百万级长连接越来越普遍。这时候就需要一种更高效的I/O处理模型。NIOJDK1.4开始引入NIO类库,主要使用Selector多路复用器实现。Selector在Linux等主流操作系统上是通过IO多路复用Epoll实现的。NIO的实现过程类似于Select:创建ServerSocketChannel监听客户端连接并绑定监听端口,设置为非阻塞模式创建Reactor线程,创建多路复用器(Selector)并启动线程注册ServerSocketChannel到Reactor线程的Selector上,监听Accept事件Selector在线程run方法中无线轮询就绪的KeySelector,监听新客户端接入,处理新请求,完成TCP三次握手,建立物理连接并注册新的客户端连接到SelectorOn,监听读取操作,读取客户端发送的网络消息,当客户端发送的数据准备好时读取客户端请求,处理简单的处理模型就是使用单-线程无限循环选择就绪事件,执行系统调用(Linux2.6之前是SelectandPoll,2.6之后是Epoll,Windows是IOCP),会blocked等待新事件的到来。当一个新的事件到来时,它会在Selector上注册一个标志,表示它是可读的、可写的或有连接的。简单处理模型的伪代码如下:NIO由原来的阻塞式读写(占用线程)变为单线程轮询事件,找到可以读写的网络描述符进行读写。除了事件的轮询被阻塞(什么事都必须阻塞),其余的I/O操作都是纯CPU操作,不需要开启多线程。并且由于线程的节省,连接数较大时线程切换带来的问题也将得到解决,进而为处理海量连接提供了可能。AIOJDK1.7引入了NIO2.0,提供了异步文件通道和异步套接字通道的实现。其底层在Windows上通过IOCP实现,在Linux上通过IO多路复用Epoll模拟。在JAVANIO框架中,Selector负责替换应用查询中所有注册的通道到操作系统进行IO事件轮询,管理当前注册的通道集合,定位事件发生的通道。但是在JAVAAIO框架中,由于应用程序不是轮询方式,而是订阅-通知方式,所以不再需要Selector(选择器),直接用Channel通道注册监听操作系统。JAVAAIO框架中只实现了两个网络IO通道:AsynchronousServerSocketChannel(服务端监听通道)和AsynchronousSocketChannel(Socket套接字通道)。具体过程如下:创建一个AsynchronousServerSocketChannel,绑定监听端口,调用AsynchronousServerSocketChannel的accpet方法,传入自己实现的CompletionHandler,包括上一步,都是非阻塞连接传入,回调CompletionHandler的completed方法,并调用其中的AsynchronousSocketChannel的read方法,在数据准备好后传入负责处理数据的CompletionHandler,触发负责处理数据的CompletionHandler的completed方法,继续做下一步处理,那么写操作类似,需要传入CompletionHandler
