目前常用的io通信模型包括四种:阻塞式同步io、非阻塞式同步io、多路复用io和真正的异步io。所有io模式都是要靠操作系统进行支持,应用程序只是提供相应的实现,对操作系统进行调用。
bio就是:blocking io。最容易理解、最容易实现的io工作方式,**应用程序向操作系统请求网络io操作,这时应用程序会一直等待;另一方面,操作系统收到请求后,也会等待,直到网络上有数据传到监听端口;操作系统在收集数据后,会把数据发送给应用程序;最后应用程序受到数据,并解除等待状态。**如下图所示:
注意:上图中交互的两个元素是应用程序和它所使用的操作系统
就tcp协议来说,整个过程实际上分成三个步骤:三次握手建立连接、传输数据(包括验证和重发)、断开连接。
bio存在的问题:
- 同一时间,服务器只能接受来自于客户端a的请求信息;虽然客户端a和客户端b的请求是同时进行的,但客户端b发送的请求信息只能等到服务器接受完a的请求数据后,才能被接受。
- 由于服务器一次只能处理一个客户端请求,当处理完成并返回后(或者异常时),才能进行第二次请求的处理。很显然,这样的处理方式在高并发的情况下,是不能采用的。
- 实际上以上的问题是可以通过多线程来解决的,实际上就是当accept接收到一个客户端的连接后,服务器端启动一个新的线程,来读写客户端的数据,并完成相应的业务处理。但是你无法影响操作系统底层的“同步io”机制。
一定要注意:阻塞/非阻塞的描述是针对应用程序中的线程进行的,对于阻塞方式的一种改进是应用程序将其“一直等待”的状态主动打开,如下图所示:
这种模式下,应用程序的线程不再一直等待操作系统的io状态,而是在等待一段时间后,就解除阻塞。
引入了多线程技术后,io的处理吞吐量大大提高了,但是这样做就真的没有问题了吗,您要知道操作系统可是有“最大线程”限制的:
- 虽然在服务器端,请求的处理交给了一个独立线程进行,但是操作系统通知accept()的方式还是单个处理的(甚至都不是非阻塞模式)。也就是说,实际上是服务器接收到数据报文后的“业务处理过程”可以多线程(包括可以是非阻塞模式),但是数据报文的接受还是需要一个一个的来。
- 在linux系统中,可以创建的线程是有限的。我们可以通过cat /proc/sys/kernel/threads-max 命令查看可以创建的最大线程数。当然这个值是可以更改的,但是线程越多,cpu切换所需的时间也就越长,用来处理真正业务的需求也就越少。
- 创建一个线程是有较大的资源消耗的。jvm创建一个线程的时候,即使这个线程不做任何的工作,jvm都会分配一个堆栈空间。这个空间的大小默认为128k,您可以通过-xss参数进行调整。
- 可以使用threadpoolexecutor线程池来缓解线程的创建问题,但是又会造成blockingqueue积压任务的持续增加,同样消耗了大量资源。另外,如果您的应用程序大量使用长连接的话,线程是不会关闭的。这样系统资源的消耗更容易失控。
最后,无论您是使用的多线程、还是加入了非阻塞模式,这都是在应用程序层面的处理,而底层socketserver所匹配的操作系统的io模型始终是“同步io”,最根本的问题并没有解决。
那么,如果你真想单纯使用线程来解决问题,那么您自己都可以计算出来您一个服务器节点可以一次接受多大的并发了。看来,单纯使用线程解决这个问题不是最好的办法。
目前流程的多路复用io实现主要包括四种:select、poll、epoll、kqueue。下表是他们的一些重要特性的比较:
多路复用io技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好 qps~100w。其他情况下多路复用io技术发挥不出来它的优势。
3.1 重要概念:channel
通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。一个通道会有一个专属的文件状态描述符。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据。
3.2 重要概念:buffer
数据缓存区:在java nio 框架中,为了保证每个通道的数据读写速度java nio 框架为每一种需要支持数据读写的通道集成了buffer的支持。
这句话怎么理解呢?例如serversocketchannel通道它只支持对op_accept事件的监听,所以它是不能直接进行网络数据内容的读写的。所以serversocketchannel是没有集成buffer的。
buffer有两种工作模式:写模式和读模式。在读模式下,应用程序只能从buffer中读取数据,不能进行写操作。但是在写模式下,应用程序是可以进行读操作的,这就表示可能会出现脏读的情况。所以一旦您决定要从buffer中读取数据,一定要将buffer的状态改为读模式。
3.3 重要概念:selector
selector的英文含义是“选择器”,不过根据我们详细介绍的selector的岗位职责,您可以把它称之为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行。
- 事件订阅和channel管理:
应用程序将向selector对象注册需要它关注的channel,以及具体的某一个channel会对哪些io事件感兴趣。selector中也会维护一个“已经注册的channel”的容器。 - 轮询代理:
应用层不再通过阻塞模式或者非阻塞模式直接询问操作系统“事件有没有发生”,而是由selector代其询问。 - 实现不同操作系统的支持:
之前已经提到过,多路复用io技术 是需要操作系统进行支持的,其特点就是操作系统可以同时扫描同一个端口上不同网络连接的时间。所以作为上层的jvm,必须要为不同操作系统的多路复用io实现编写不同的代码。
通过上文的描述,我们知道了多路复用io技术是操作系统的内核实现。在不同的操作系统,甚至同一系列操作系统的版本中所实现的多路复用io技术都是不一样的。那么作为跨平台的java jvm来说如何适应多种多样的多路复用io技术实现呢?面向对象的威力就显现出来了:无论使用哪种实现方式,他们都会有“选择器”、“通道”、“缓存”这几个操作要素,那么可以为不同的多路复用io技术创建一个统一的抽象组,并且为不同的操作系统进行具体的实现。java nio中对各种多路复用io的支持,主要的基础是java.nio.channels.spi.selectorprovider抽象类,其中的几个主要抽象方法包括:
-
public abstract datagramchannel opendatagramchannel():创建和这个操作系统匹配的udp 通道实现。
-
public abstract abstractselector openselector():创建和这个操作系统匹配的nio选择器,就像上文所述,不同的操作系统,不同的版本所默认支持的nio模型是不一样的。
-
public abstract serversocketchannel openserversocketchannel():创建和这个nio模型匹配的服务器端通道。
-
public abstract socketchannel opensocketchannel():创建和这个nio模型匹配的tcp socket套接字通道(用来反映客户端的tcp连接)
多路复用io的优缺点:
- 不用再使用多线程来进行io处理了(包括操作系统内核io管理模块和应用程序进程而言)。当然实际业务的处理中,应用程序进程还是可以引入线程池技术的
- 同一个端口可以处理多种协议,例如,使用serversocketchannel测测的服务器端口监听,既可以处理tcp协议又可以处理udp协议。
- 操作系统级别的优化:多路复用io技术可以是操作系统级别在一个端口上能够同时接受多个客户端的io事件。同时具有之前我们讲到的阻塞式同步io和非阻塞式同步io的所有特点。selector的一部分作用更相当于“轮询代理器”。
- 都是同步io:目前我们介绍的 阻塞式io、非阻塞式io甚至包括多路复用io,这些都是基于操作系统级别对“同步io”的实现。
我们一直在说“同步io”,一直都没有详细说,什么叫做“同步io”。实际上一句话就可以说清楚:只有上层(包括上层的某种代理机制)系统询问我是否有某个事件发生了,否则我不会主动告诉上层系统事件发生了。很明显,这里是可以继续优化的。
上述阻塞式同步io、非阻塞式同步io、多路复用io 说明了io模型是由操作系统提供支持,且这三种io模型都是同步io,都是采用的“应用程序不询问我,我绝不会主动通知”的方式。
异步io则是采用“订阅-通知”模式:即应用程序向操作系统注册io监听,然后继续做自己的事情。当操作系统发生io事件,并且准备好数据后,在主动通知应用程序,触发相应的函数:
和同步io一样,异步io也是由操作系统进行支持的。微软的windows系统提供了一种异步io技术:iocp(i/o completion port,i/o完成端口);linux下由于没有这种异步io技术,可以使用的是epoll对异步io进行模拟。