注:本文内容部分摘自《Netty 4 核心原理与手写 RPC 实践》
1. IO 简介
在 UNIX 的设计哲学中,一切皆文件。文件就是一串二进制流。对文件的操作包括有:读、写、打开、关闭 等。计算机对这些流进行数据的收发操作,简称为 IO(Input and Output)操作。
IO 的种类:内存 IO、网络IO、磁盘IO。
2. IO 模型
2.1 基本概念
2.1.1 同步 vs 异步
同步和异步是指 CPU 时间片的使用,主要看请求发起方对消息结果的获取是主动发起的,还是被动通知的。如果是服务方在处理完成后主动告知请求方,那么就是异步。异步通知的方式一般通过状态改变、消息通知或者回调函数来完成,大多时候采用的都是回调函数。
2.1.2 阻塞 vs 非阻塞
阻塞和非阻塞通常指对 IO 的操作。阻塞就是当我们发起一个请求调用时,在请求结果返回之前,如果当前线程是处于挂起状态,那么就是阻塞。如果是运行状态,那么就是非阻塞。
2.2 IO 模型
2.2.1 同步阻塞
应用程序发起调用 recvfrom -> 内核准备数据->数据就绪->数据从内核拷贝到用户空间。
特点 | 在 I/O 执行的两个阶段(等待数据和拷贝数据)都被阻塞 |
---|---|
典型应用 | Java BIO 、阻塞 Socket |
优点 | 1. 进程阻塞挂起不消耗 CPU 资源 2. 实现难度低 3. 适合并发量小的网络应用开发 |
缺点 | 1. 不适合并发量大的应用,因为一个请求 I/O 会阻塞进程 2. 需要为每个请求分配一个处理进程(线程)以及时响应,系统开销大 |
在阻塞 I/O 的场景中,当调用 InputStread.read() 方法时是阻塞的,它会一直等到数据到来(或超时)时才会返回;同样,在调用 SeverSocket.accept() 方法时,也会一直阻塞到有客户端连接才会返回,每个客户端连接成功后,服务端都会启动一个线程去处理该客户端的请求。
阻塞 I/O 的缺点:
- 当客户端多时,会创建大量的处理线程。且每个线程都要占用栈空间和一些 CPU 时间。
- 阻塞可能带来频繁的上下文切换,且大部分上下文切换可能是无意义的。
2.2.2 同步非阻塞
应用程序发起调用 recvfrom -> 内核返回数据准备结果 -> 重复 recvfrom… -> 数据就绪->数据从内核拷贝到用户空间。
相较于同步阻塞,同步非阻塞会在被调用之后即刻返回一个结果。当用户进程发起 read 操作时,如果内核中的数据还没有准备好,那么它不会阻塞进程而是立刻返回一个 error。
特点 | 用户进程需要不断地主动询问内核(Kernel)数据是否已准备好 |
---|---|
典型应用 | Socket 设置成 NON_BLOCK |
优点 | 1. 实现难度,开发应用相较于阻塞 I/O 模型较难 |
缺点 | 1. 进程轮询(重复)调用,消耗 CPU 资源 2. 适合并发量较小且不需要及时响应的网络应用开发 |
2.2.3 IO 多路复用
多个进程可以注册到一个 Selector(复用器)上,当用户进程调用该 Selector,Selector 会监听注册进来的所有 I/O,如果 Selector 监听的所有 I/O 在内核缓冲区都没有可读数据,select 调用进程会被阻塞,而当任一 I/O 在内核缓冲区有可读数据时,select 调用就会返回,而后 select 调用进程可以自己或通知另外的进程再次发起读取 I/O,读取内核中准备好的数据。多个进程注册 I/O 后,只有一个 select 调用进程被阻塞。
特点 | 对于每一个 Socket,一般都设置成非阻塞,但是整个用户的进程其实是一直被阻塞的,只不过进程是被 select 函数阻塞,而不是被 Socket I/O 阻塞 |
---|---|
典型应用 | Java NIO |
优点 | 1. 专一进程解决多个进程 I/O 的阻塞问题,性能好,Reactor 模式 2. 适合高并发服务应用开发,一个进程/线程处理多个请求 |
缺点 | 1. 实现和开发应用难度大 |
目前流行的多路复用 I/O 的实现主要包括四种:select、poll、epoll、kqueue。
多路复用 I/O 技术最适用的是『高并发』场景,所谓『高并发』是指 1ms 内至少同时有上千个连接请求准备好。
Java NIO 是从 JDK 1.4 开始使用的。下面是 Java NIO 的工作原理:
- 由一个专门的线程来处理所有的 I/O 事件,并负责分发。
- 事件驱动机制:事件到的时候触发,而不是同步地去监视事件。
- 线程通信:线程之间通过 wait、notify 等方式通信。保证每次上下文切换都是有意义的,减少无谓的线程切换。
2.2.4 异步非阻塞
异步 IO 的工作机制:用户进程发起 aio_read 操作,给内核传递与 read 相同的描述符、缓冲区指针、缓冲区大小三个参数及文件偏移,告诉内核当整个操作完成时,如何通知我们立刻就可以开始去做其他的事。
特点 | 真正实现了 异步 IO |
---|---|
典型应用 | Java7 AIO |
优点 | 1. 不阻塞,采用 Proactor 模式 2. 非常适合高性能、高并发服务应用 |
缺点 | 1. 实现和开发应用难度大 2. 需要操作系统的底层支持,Linux 2.5 内核首现,Linux 2.6 产品的内核标准特性 |
2.3 Java BIO 到 NIO 到 AIO
IO 模型 | BIO | NIO |
---|---|---|
通信 | 面向流 | 面向缓冲区 |
处理 | 阻塞 | 非阻塞 |
触发 | 无 | 选择器 |
2.3.1 面向流 vs 面向缓冲区
- 面向流:在面向流 IO 系统中,所有数据都是直接写入或者直接将数据读取到 Stream 对象中。每次从流中读取一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。
- 面向缓冲区:在面向缓冲区 IO 系统中,所有数据都是用缓冲区处理的。在数据读取或写入时,都是直接读取或写入缓冲区。
在 NIO 中,所有的缓冲区类型都继承于抽象类 Buffer,最常用的是 ByteBuffer。
缓冲区实际上是一个容器对象,其本质是一个特殊的数组。缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化。在缓冲区种,最重要的属性有以下三个。
- position:指定下一个要被写入或读取的元素索引。
- limit:指定还有多少数据需要取出/放入。
- capacity: 指定了可以存储在缓冲区中的最大数据容量。
2.3.2 阻塞 vs 非阻塞
-
阻塞:当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有一些数据被读出,或者数据完全写入。该线程在此期间不能再做任何事情。
-
非阻塞:一个线程从某通道(Channel)发送请求读取/写入数据,这个线程同时可以去做其他事情。线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以一个单独的线程现在可以管理多个通道。
2.3.3 选择器
选择器(Selector)允许一个线程监听多个通道。可以注册多个通道使用一个选择器,然后使用一个单独的线程来选择通道。
NIO 可以只使用一个线程来管理多个通道(网络连接或文件)。
2.3.4 AIO
JDK 1.7 开始实现了真正的异步 AIO、把 IO 读写操作完全交给操作系统,学习了 Linux Epoll 模式。
在多路 IO 复用模型中,事件循环将文件句柄的状态事件通知给用户线程,由用户线程自行读取数据、处理数据。而在 AIO 模型中,当用户线程收到通知时,数据已经被内核读取完毕,并放在了用户线程指定的缓冲区内,内核在 IO 完成后通知用户线程直接使用即可。
AIO 模型使用 Proactor 设计模式实现这一机制。