☕️【Java 技术之旅】360 度全方位的教你认识网络 IO 模型
每日努力
请一定要有自信。你就是一道风景,没必要在别人风景里面仰视。
网络 IO 模型的分类
BIO
伪异步
NIO
AIO
BIO
BIO 是一个典型的网络编程模型,是通常我们实现一个服务端程序的过程。
步骤如下
主线程 accept 请求阻塞
请求到达,创建新的线程来处理这个套接字,完成对客户端的响应。
主线程继续 accept 下一个请求
这种模型有一个很大的问题是:当客户端连接增多时,服务端创建的线程也会暴涨,系统性能会急剧下降。
伪异步(线程池模式的 BIO)
在 BIO 模型的基础上,类似于 tomcat 的 bio connector,采用的是线程池来避免对于每一个客户端都创建一个线程:把请求抛到线程池中异步等待处理。
NIO(非阻塞机制)
NIO API 主要是三个部分:缓冲区(Buffers)、通道(Channels)和 Selector。
NIO 基于事件驱动思想来实现的,它采用 Reactor 模式实现,主要用来解决 BIO 模型中一个服务端无法同时并发处理大量客户端连接的问题。
NIO 基于 Selector(事件循环器)进行轮训,当 socket 有数据可读、可写、连接完成、新的 TCP 请求接入事件时,操作系统内核会触发(通过 JNI 回调给 Java 线程栈中的方法栈帧),Selector 返回准备就绪的 SelectionKey 集合,通过 SelectableChannel 进行读写操作。(通过 SelectionKey 作为事件的标志字段)
由于 JDK 的 Selector 底层基于 epoll 实现,理论可以同时处理操作系统最大文件句柄个数的连接。
SelectableChannel 的读写操作都是=非阻塞的,当由于数据没有就绪导致读半包时(数据准备阶段),立即返回,不会同步阻塞等待数据就绪,当 TCP 缓冲区数据就绪之后,会触发 Selector 的读事件,驱动下一次读操作。
因此,一个 Reactor 线程就可以同时处理 N 个客户端的连接,使得 Java 服务器的并发读写能力得到极大的提升。 JDK1.4 开始引入了 NIO 类库,这里的 NIO 指的是 Non-block IO,主要是使用 Selector 多路复用器来实现。Selector 在 Linux 等主流操作系统上是通过 epoll 实现的。
NIO 的实现流程,类似于 select:
创建 ServerSocketChannel 监听客户端连接并绑定监听端口,设置为非阻塞模式。
创建 Reactor 线程,创建多路复用器(Selector)并启动线程。
将 ServerSocketChannel 注册到 Reactor 线程的 Selector 上。监听 accept 事件。
Selector 在线程 run 方法中无线循环轮询准备就绪的 Key。
Selector 监听到新的客户端接入,处理新的请求,完成 tcp 三次握手,建立物理连接。
将新的客户端连接注册到 Selector 上,监听读操作。读取客户端发送的网络消息。
客户端发送的数据就绪则读取客户端请求,进行处理。
AIO
JDK1.7 引入 NIO2.0,提供了异步文件通道和异步套接字通道的实现。其底层在 windows 上是通过 IOCP,在 Linux 上是通过 epoll 来实现的
创建 AsynchronousServerSocketChannel,绑定监听端口
调用 AsynchronousServerSocketChannel 的 accept 方法,传入自己实现的 CompletionHandler。包括上一步,都是非阻塞的
连接传入,回调 CompletionHandler 的 completed 方法,在里面,调用 AsynchronousSocketChannel 的 read 方法,传入负责处理数据的 CompletionHandler。
数据就绪,触发负责处理数据的 CompletionHandler 的 completed 方法。继续做下一步处理即可。写入操作类似,也需要传入 CompletionHandler。
同步和异步
这两个概念与消息的通知机制 (synchronous communication/ asynchronous communication)有关。
同步
一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么都成功,要么都失败,两个任务的状态可以保持一致。
在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。
对于同步型的调用,应用层需要自己去向系统内核问询,如果数据还未读取完毕,那此时读取文件的任务还未完成。
应用层根据其阻塞和非阻塞的划分,或挂起或去做其他事情(所以同步和异步并不决定其等待数据返回时的状态);
如果数据已经读取完毕,那此时系统内核将数据返回给应用层,应用层即可以用取得的数据做其他相关的事情。
异步
不需要等到被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
调用在发出之后,这个调用就直接返回了,所以没有返回结果。
换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。
而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
而对于异步型的调用,应用层无需主动向系统内核问询,在系统内核读取完文件数据之后,会主动通知应用层数据已经读取完毕,此时应用层即可以接收系统内核返回过来的数据,再做其他事情。也就是说,是否是同步还是异步,关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用。
消息的三种通知机制:状态、通知和回调。
前者低效,后两者高效、类似。
阻塞与非阻塞
这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。
阻塞调用
调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。函数只有在得到结果之后才会返回。
非阻塞调用
指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回去完成其他任务。
总结来说,是否是阻塞还是非阻塞,关注的是接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的,可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。
阻塞和同步的讨论
有人也许会把阻塞调用和同步调用等同起来,实际上它们是不同的。
对于同步调用来说,很多时候当前线程可能还是激活的,只是从逻辑上当前函数没有返回而已,此时,这个线程可能也会处理其他的消息。还有一点,在这里先扩展下:
如果这个线程在等待当前函数返回时,仍在执行其他消息处理,那这种情况就叫做同步非阻塞;
如果这个线程在等待当前函数返回时,没有执行其他消息处理,而是处于挂起等待状态,那这种情况就叫做同步阻塞;
所以同步的实现方式会有两种:同步阻塞、同步非阻塞;同理,异步也会有两种实现:异步阻塞、异步非阻塞;
对于阻塞调用来说,则当前线程就会被挂起等待当前函数返回;
虽然表面上看非阻塞的方式可以明显的提高 CPU 的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的 CPU 执行时间能不能补偿系统的切换成本需要好好评估。
同步异步/阻塞非阻塞
在说明 synchronous IO 和 asynchronous IO 的区别之前,需要先给出两者的定义。Stevens 给出的定义(其实是 POSIX 的定义)是这样子的:
两者的区别就在于 synchronous IO 做”IO operation”的时候会将 process 阻塞。按照这个定义,之前所述的 blocking IO,non-blocking IO,IO multiplexing 都属于 synchronous IO。
有人可能会说,non-blocking IO 并没有被 block 啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的 IO 操作,就是例子中的 recvfrom 这个 system call。
non-blocking IO 在执行 recvfrom 这个 system call 的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。
但是,当 kernel 中数据准备好的时候,recvfrom 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内,进程是被 block 的。
而 asynchronous IO 则不一样,当进程发起 IO 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说 IO 完成。在这整个过程中,进程完全没有被 block。
同步异步/阻塞非阻塞
"同步异步"和"阻塞非阻塞"是两个不同范围的概念。
同步异步/阻塞非阻塞
同步和异步是一个非常广的概念,它们的重点在于多个任务和事件发生时,一个事件的发生或执行是否会导致整个流程的暂时等待。
阻塞和非阻塞的区别关键在于当发出请求一个操作时,如果条件不满足,是会一直等待还是返回一个标志信息。
在讨论 IO(硬盘、网络、外设)时,一个完整的 IO 读请求操作包括两个阶段:
1)查看数据是否就绪;
2)进行数据拷贝(内核将数据拷贝到用户线程)
阻塞(blocking IO)和非阻塞(non-blocking IO)的区别就在于第一个阶段,如果数据没有就绪,在查看数据是否就绪的过程中是一直等待,还是直接返回一个标志信息。
在《Unix 网络编程》一书中对同步 IO 和异步 IO 的定义是这样的:
事实上,同步 IO 和异步 IO 模型是针对用户线程和内核的交互来说的:
同步 IO:当用户发出 IO 请求操作之后,如果数据没有就绪,需要通过用户线程或者内核不断地去轮询数据是否就绪,当数据就绪时,再将数据从内核拷贝到用户线程;
异步 IO:只有 IO 请求操作的发出是由用户线程来进行的,IO 操作的两个阶段都是由内核自动完成,然后发送通知告知用户线程 IO 操作已经完成。也就是说在异步 IO 中,不会对用户线程产生任何阻塞。
这是同步 IO 和异步 IO 关键区别所在,同步 IO 和异步 IO 的关键区别反映在数据拷贝阶段是由用户线程完成还是内核完成。 所以说<u>异步 IO 必须要有操作系统的底层支持</u>。
注意同步 IO 和异步 IO 与阻塞 IO 和非阻塞 IO 是不同的两组概念。
阻塞 IO 和非阻塞 IO 是反映在当用户请求 IO 操作时,如果数据没有就绪,是用户线程一直等待数据就绪,还是会收到一个标志信息这一点上面的。也就是说,阻塞 IO 和非阻塞 IO 是反映在 IO 操作的第一个阶段,在查看数据是否就绪时是如何处理的。
同步/异步与阻塞/非阻塞
同步阻塞:效率是最低的。
异步阻塞:异步操作是可以被阻塞住的,只不过它不是在处理消息时阻塞,而是在等待消息通知时被阻塞。
同步非阻塞:实际上是效率低下的,这个程序需要在两种不同的行为之间来回的切换,效率可想而知是低下的。
异步非阻塞:效率更高。
用户空间与内核空间
现在操作系统都是采用虚拟存储器,对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对 linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换/任务切换/上下文切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
保存处理机上下文,包括程序计数器和其他寄存器。
更新 PCB 信息。
把进程的 PCB 移入相应的队列,如就绪、在某事件阻塞等队列。
选择另一个进程执行,并更新其 PCB。
更新内存管理的数据结构。
恢复处理机上下文。
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得 CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。
文件描述符 fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。
缓存 IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 IO 的缺点
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/aaac1c37a9738468fc8b2fae7】。文章转载请联系作者。
评论