如何完成一次 IO
哪个男孩不想来一场异步非阻塞的甜蜜恋爱?
21 点,你打开微信,开心地对女孩说:“晚上好”。女孩说:“我在洗澡”。
你抱着手机等待晚点聊,此刻,你是阻塞的,也是同步的。为什么?
写在前面
谈起 IO, Javaer 会说起 BIO、NIO、AIO,也会提到同步异步、阻塞非阻塞。但到底什么是 IO, IO又是怎么完成的?
1. 什么是 I/O
学术的说 I/O 是信息处理系统(计算机)与外界(人或信息处理系统)间的通信。如计算机,即 CPU 访问任何寄存器和 Cache 等封装以外的数据资源都可当成 I/O ,包括且不限于内存,磁盘,显卡。
软件开发中的 I/O 则常指磁盘、网络 IO。
Unix 系统下,不论是标准输入还是借助套接字接受网络输入,都有两个步骤:
- 等待数据准备好(Waiting for the data to be ready)
- 从内核向进程复制数据(Copying the data from the kernel to the process)
等待数据准备好还比较好理解,从内核向进程复制数据是什么东东?
2. 计算机内存
计科、软工的同学都知道,修电脑是我们的对口工种,加内存条这种事更是入职基本要求。这的内存条又叫物理内存。那一般来说,有实就有虚,所以就有虚拟内存。
2.1 虚拟内存
操作系统中进程间是共享 CPU 和内存资源的,就需要一套完善的内存管理机制防止进程间内存泄漏。
现代操作系统提供了对主存的抽象概念:虚拟内存(Virtual Memory)。虚拟内存为每个进程提供一个一致私有的地址空间,每个进程拥有一片连续完整的内存空间,让进程有种在独享主存的美好错觉。
实际上,虚拟内存通常被分隔成多个物理内存碎片,还有部分暂存在外部磁盘存储器,在需要时进行数据交换,加载到物理内存中来。大致如下图:
当用户进程发出内存申请请求,系统会为进程分配虚拟地址,并创建内存映射放入页表中,如果对应的数据不在物理内存上就会发生缺页异常,需要把进程需要的数据从磁盘上拷贝到物理内存中。
2.2 内核空间与用户空间
上图有看到,虚拟内存分为内核和用户地址空间两部分,因为需要避免用户进程直接操作内核。
操作系统的核心是内核,独立于普通应用程序,可访问受保护的内存空间,也可访问底层硬件设备。 在 Linux 系统中,内核模块运行在内核空间,当进程经过系统调用而陷入内核代码中执行时,称进程处于内核运行态,即内核态;反之,运行在用户空间执行用户自己的代码时,处于用户态。
上图可以看到,应用程序和内核间无法直接通信,必须通过系统调用,而系统调用的成本很高。
当用户进程想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。而系统调用会产生中断陷入到内核,也就是进行了一次上下文切换操作。
2.3 进程切换
到了内核,为了控制进程执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
需要注意:这里的进程切换和上文 2.2 的用户态转内核态的上下文切换并不一样,后者只是同一个进程的 CPU 权限等级的修改。
进程是资源分配的基本单位, 因此进程切换时,需保存、装载各种状态数据等资源, 代价就比较高。
3. Linux I/O 读写方式
现在我们知道用户进程需要通过系统调用转为内核态,才能在 CPU 上运行,进而访问底层如磁盘等硬件设备。其中磁盘等 I/O 设备的控制器中有寄存器,负责与 CPU 进行通信。
那么,I/O 设备与 CPU 能用哪些方法进行通信呢?主要通过两种。
3.1 I/O中断
在 DMA 技术出现前,应用程序与磁盘间的 I/O 操作都通过 CPU 中断完成。外部存储设备采用中断方式主动通知 CPU,CPU 负责拷贝数据到内核缓冲区,再拷贝到用户缓冲区,每次就会有上下文切换的开销及 CPU 拷贝的时间。
3.2 DMA
DMA 全称叫直接内存存取(Direct Memory Access),是一种允许外围设备直接访问系统主存的机制。
CPU 通知 DMA 控制器拷贝外部存储设备数据到内核缓冲区,完成后再通知 CPU 拷贝到用户缓冲区。和 I/O 中断方式相比,改由内存来执行外部存储器数据的 I/O 操作,减轻了CPU负担,且 CPU 读取内存比读取外部存储设备速度要快。
目前大多数硬件设备,包括磁盘、网卡、声卡等都支持 DMA 技术。
4. 零拷贝
一次 I/O ,无论是读还是写数据,都要经过硬盘 - 内核 - 用户空间,有了 DMA,磁盘到内核空间的拷贝问题得以解决,CPU 可以摸会鱼了。但用户空间和内核空间之间的传输怎么办呢,CPU 觉得要做就做一个摸鱼到下班的 CPU。于是有了零拷贝。
零拷贝
是基于 DMA 的, 其目的就是优化多次数据拷贝的过程,避免 CPU 将数据从一块存储拷贝到另外一块存储。有 3 个实现思路:
- 用户态直接 I/O : 应用程序直接访问硬件存储,内核只辅助数据传输。硬件上的数据直接拷贝给用户空间,也就不存在内核空间缓冲区和用户空间缓冲区间的数据拷贝了。
- 减少数据拷贝次数:在数据传输过程中,减少数据在用户空间缓冲区和系统内核空间缓冲区之间的 CPU 拷贝次数,同时也避免数据在内核空间内部的 CPU 拷贝。
- 写时复制:多个进程共享同一块数据时,如果某进程要对这份数据修改,那将其拷贝到自己的进程地址空间中。
下面来看看这三种思路的具体实现。
1. 传统 I/O
先来看看传统方式,在进行一次读写时共涉及了4次上下文切换,2次 DMA 拷贝以及2次 CPU 拷贝。
2. 用户态直接IO
这是第一种思路,使应用进程或处于用户态下的库函数跨过内核直接访问硬件,内核在数据传输过程除了进行必要的虚拟存储配置工作外,不参与任何其他工作。
但只适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,又称为自缓存应用程序,如数据库管理系统。其次,因 CPU 和磁盘 I/O 之间的性能差距,就会造成资源的浪费,一般是会配合异步 I/O 使用。
3. mmap
这属于第二类优化,减少了 1 次 CPU 拷贝。MMAP 是数据不会到达用户空间内存,只会存在于系统空间的内存上,用户空间与系统空间共用同一个缓冲区,两者通过映射关联。
整个 MMAP 过程,发生了 4 次上下文切换 + 1 次 CPU 拷贝 + 2 次 DMA 拷贝。
4. sendfile
这也是第二类优化。用户进程不需要单独调用 read/write ,而是直接调用 sendfile() ,sendfile 再帮用户调用 read/write 操作。数据可以直接在内核空间进行 I/O 传输,省去了数据在用户空间和内核空间之间的拷贝。
与 mmap 内存映射方式不同的是, sendfile() 调用中数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。
整个过程发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
5. sendfile + DMA gather copy
在前面的 sendfile() 方式中,CPU 仍需要一次拷贝,从 Linux 2.4 版本开始,DMA 自带了收集功能,可以将对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer),由DMA 根据这些信息直接将内核缓冲区的数据拷贝到网卡设备中,省下了最后一次 CPU 拷贝。
这次只发生 2 次上下文切换 + 2 次 DMA 数据拷贝。
6. splice
sendfile 只适用于将数据从文件拷贝到网卡上,限定了使用范围。
splice 系统调用可以在内核空间的读缓冲区和网络缓冲区之间建立管道,支持任意两个文件之间互连,可以在操作系统地址空间中整块地移动数据。
同样发生 2 次上下文切换 + 2 次 DMA 数据拷贝。
7. 写时复制
这个就是第三种思路了,COW 写时复制。
当用户进程有写操作时,就把这块共享的内存空间复制一份到其他区域,给写进程专用。这种方法在能够降低系统开销,如果某个进程永远不会对数据进行更改,那就永远不需要拷贝。
Java 中的实现
其实这个策略对于 Javaer 来说不应该陌生,在解决并发问题时,最简单的策略莫过于不变性模式,对象一旦被创建之后,状态就不再发生变化。
比如 包装类和 String 的线程安全就是依赖不变性,基于享元模式创建对象池,读的时候是共用的(这也是为什么包装类不适合做锁的原因),写的时候比如String
的 replace()
,并没有更改原字符串里面数组的内容,而是创建了一个新字符串,这就是写时复制策略了。
尤其是从 Java8 开始的函数式编程,基础就是不可变性,所以修改操作都需要 COW 策略。当然早期 Java 就有类似容器,比如CopyOnWriteArrayList
, 不过实现有点笨,不是按需复制。
8. 对比
拷贝方式 | CPU拷贝 | DMA拷贝 | 上下文切换 |
---|---|---|---|
传统方式 | 2 | 2 | 4 |
mmap | 1 | 2 | 4 |
sendfile | 1 | 2 | 2 |
sendfile + DMA gather copy | 0 | 2 | 2 |
splice | 0 | 2 | 2 |
此刻,CPU 觉得还行。
5. Unix IO模型
前面说了那么多,想必现在应该知道 I/O 是怎么一回事了,接着再瞧瞧啥叫阻塞啥叫同步。
5.1 阻塞 IO - 同步阻塞
等待数据、拷贝数据都是处于阻塞状态的。 这就是同步阻塞。
5.2 非阻塞 - 同步非阻塞
在I/O执行的第一个阶段(等待数据)不会阻塞线程,但在第二阶段(复制数据)会阻塞。
这就是同步非阻塞,其实就是轮询,当数据没准备好则返回 EWOULDBLOCK
。
5.3 信号驱动 - 同步非阻塞
前一个非阻塞模型中,需要调用者轮询,怎么避免呢?
首先要开启 socket 的信号驱动式 IO 功能,应用进程通过 sigaction 系统调用注册 SIGIO 信号处理函数,该系统调用会立即返回。当数据准备好时,内核会为该进程产生一个 SIGIO 信号通知,之后再把数据拷贝到用户空间中。
这也是同步非阻塞。虽然等待数据期间用户态进程不被阻塞,但当收到信号通知时是阻塞并拷贝数据,所以还是同步的。
5.4 多路复用 - 同步阻塞
也称事件驱动IO,在单个线程里同时监控多个套接字,通过 select 或 poll 轮询查看所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。
多个进程的 IO 可以注册到同一个管道上,关键是select
函数,多个进程的 IO 可以注册到同一个select
上,当用户进程调用该select
,select
会监听所有注册好的 IO,如果所有被监听的 IO 需要的数据都没有准备好时,调用进程会阻塞,等待有套接字变为可读。当任意一个 IO 需要的数据准备好后,即当有套接字可读以后,select
调用就会返回,然后进程再通过recvfrom
来把对应的数据拷贝到用户进程缓冲区。
IO 复用模型,并没向内核注册信号处理函数,所以是阻塞的。进程在发出select
后,要等到select
监听的所有 IO 操作中的至少一个需要的数据准备好,才会返回,也需要再次发送请求去进行文件拷贝。整个用户进程其实是一直被阻塞的,但 IO 复用的优势在于可以等待多个描述符就绪。
IO 复用的特点是进行了两次系统调用,进程先是阻塞在 select 上,再阻塞在读操作的第二个阶段上。这是同步阻塞的。
多路复用机制还是值得细说的,比如重点的 select/poll/epoll,这里就不展开了,有兴趣的可以自行阅读相关资料。
5.5 异步IO - 异步非阻塞
如图, 用户进程在发起调用后,内核会立即返回。接着用户进程就干别的事去了。
然后内核等待数据准备完毕,自动将数据拷贝到用户内存,接着给用户进程发了个信号,通知 IO 操作已完成,这才是五个 I/O 模型中唯一一个异步模型。
可能会有疑问,为啥信号驱动模型是同步模型,这是因为信号驱动是由内核通知何时启动一个 IO 操作,还需要用户进程再拷贝数据。而异步 IO 是由内核是在所有工作做完后,通知 IO 操作已完成。
异步 IO 特点是 IO 执行的两个阶段(等待数据、拷贝数据)都由内核去完成,用户进程无需干预,也不会被阻塞。这就是异步非阻塞了。也就是 Java 中的 AIO。
5.6 模型比较
6. Java 及其他
前面说了这么多,或许你更想知道 “AIO 是不是异步”,“哪个框架用了这些东西”。
1. BIO
BIO 属于同步阻塞,一客户端一线程。该模型下常见优化的方案就是用线程池。
2. NIO
NIO 属于同步非阻塞,收到的请求会先注册到多路复用器 Selector 上,多路复用器轮询直到连接有 I/O 请求时才启动一个线程进行处理。也就是前文中的多路复用 I/O 模型,虽然说多路复用模型是阻塞的,但在 NIO 这里,因为有Selector,read 和 write 操作都是非阻塞的,其中 Selector 其实就是 select/poll/epoll 的外包类。
不仅如此,NIO 除了面向流和非阻塞外,还有一个效率高的原因就是前文中也有提到的零拷贝。
NIO 中的 Channel(通道)相当于操作系统中的内核缓冲区, Buffer 就相当于操作系统中的用户空间缓冲区。零拷贝在 NIO 这里重要的是两个实现:
- FileChannel.map() : 基于内存映射 mmap 方式一种实现,可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。
- FileChannel.transferTo() : 通过调用 sendfile 方式实现的零拷贝。
关于 NIO 还有一个常见的实现。那就是 Netty , Netty 是一个高性能、异步事件驱动的 NIO 框架,但为啥不直接用 JDK 中的 NIO ,而要再造轮子呢,那当然是 Netty 比 JDK NIO 做的更多,比如解决了粘包半包、断连和 idle 处理、支持流量整形等。
另外说起 NIO 的零拷贝,消息队列现在基本是标配,常用有 Kafka、RocketMQ、RabbitMQ,排名按性能分先后。其中 Kafka 和 RocketMQ 分别是基于 sendfile 和 mmap + write实现的零拷贝,这也是吞吐量较大的原因之一。
3. AIO
AIO 属于异步非阻塞。在 NIO 的基础上引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。
7. 总结
OK,到了这里,文章要结束了。
本文主要讲述的其实是 Linux IO 的基本原理,这其中会涉及到 IO 模型、零拷贝、Java IO 等等,而这些比如 NIO 的多路复用、 Netty 的 Reactor 模型、Kafka 的高性能都值得用更多的文字去阐述,更多的时间去学习。
我觉得,无论是技术还是生活,如果能把自己的知识或资源串起来,就是一件很棒的事。就像从 Linux IO 出发,看到内存条想到修电脑(笑~~);看到零拷贝写时复制想起 Java 并发实现;看到不可变想到对象池想到 GC;看到多路复用 IO 模型想起 NIO......希望自己有一天能够做到。
8. 最后
读到这里,大概九点十五,开头九点发出的“晚上好”有了下文吗?
如果没有的话,不妨大胆假设,其实女孩并没有去洗澡。
那这是一次什么 I/O 呢?
参考
- 《现代操作系统》
- 《UNIX网络编程.卷1》 6章第 2 节 IO 模型
- 零拷贝实现 https://rianico.tech/2019/12/03/Linux零拷贝实现
- NIO效率高的原理之零拷贝与直接内存映射https://cloud.tencent.com/developer/article/1488087
大佬 请问是用什么制图的呢?
draw.io
66666666666666666666666
感谢大佬的干货文章,我还是没太搞清楚 同步、异步 与 阻塞、非阻塞 怎么区分, 看大佬讲的unix io模型中,只要 准备数据和拷贝数据这两个阶段中 只要有一个是非阻塞的,这个模型就可以叫非阻塞模型, 然后这两个阶段中只要有一个是阻塞的,这个模型就叫同步, 两个阶段都是非阻塞的,这个模型就叫做异步, 可以这样理解么? 或者有没有更容易理解的方式?
那这是一次什么 I/O 呢?我觉得是一次失败的I/O |´・ω・)ノ