一个IO的旅程

作者 | 李一帆

初级秃头后端工程师。

21 点,你打开微信,开心地对女孩说:“晚上好”。女孩说:“我在洗澡”。

你抱着手机等待晚点聊,此刻,你是阻塞的,也是同步的。为什么?

1. 什么是 I/O

谈起 IO, Javaer 会说起 BIO、NIO、AIO,也会提到同步异步、阻塞非阻塞。但到底什么是 IO, IO又是怎么完成的?

学术的说 I/O 是信息处理系统(计算机)与外界(人或信息处理系统)间的通信。如计算机,即 CPU 访问任何寄存器和 Cache 等封装以外的数据资源都可当成 I/O ,包括且不限于内存,磁盘,显卡。

软件开发中的 I/O 则常指磁盘、网络 IO。

Unix 系统下,不论是标准输入还是借助套接字接受网络输入,都有两个步骤:

  1. 等待数据准备好**(Waiting for the data to be ready)**

  2. 从内核向进程复制数据**(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 个实现思路:

  1. 用户态直接 I/O : 应用程序直接访问硬件存储,内核只辅助数据传输。硬件上的数据直接拷贝给用户空间,也就不存在内核空间缓冲区和用户空间缓冲区间的数据拷贝了。

  2. 减少数据拷贝次数:在数据传输过程中,减少数据在用户空间缓冲区和系统内核空间缓冲区之间的 CPU 拷贝次数,同时也避免数据在内核空间内部的 CPU 拷贝。

  3. 写时复制:多个进程共享同一块数据时,如果某进程要对这份数据修改,那将其拷贝到自己的进程地址空间中。

下面来看看这三种思路的具体实现。

4.1. 传统 I/O

先来看看传统方式,在进行一次读写时共涉及了4次上下文切换,2次 DMA 拷贝以及2次 CPU 拷贝。

4.2. 用户态直接IO

这是第一种思路,使应用进程或处于用户态下的库函数跨过内核直接访问硬件,内核在数据传输过程除了进行必要的虚拟存储配置工作外,不参与任何其他工作。

但只适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,又称为自缓存应用程序,如数据库管理系统。其次,因 CPU 和磁盘 I/O 之间的性能差距,就会造成资源的浪费,一般是会配合异步 I/O 使用。

4.3. mmap

这属于第二类优化,减少了 1 次 CPU 拷贝。MMAP 是数据不会到达用户空间内存,只会存在于系统空间的内存上,用户空间与系统空间共用同一个缓冲区,两者通过映射关联。

整个 MMAP 过程,发生了 4 次上下文切换 + 1 次 CPU 拷贝 + 2 次 DMA 拷贝。

4.4. sendfile

这也是第二类优化。用户进程不需要单独调用 read/write ,而是直接调用 sendfile() ,sendfile 再帮用户调用 read/write 操作。数据可以直接在内核空间进行 I/O 传输,省去了数据在用户空间和内核空间之间的拷贝。与 mmap 内存映射方式不同的是, sendfile() 调用中数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。

整个过程发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。

4.5. sendfile + DMA gather copy

在前面的 sendfile() 方式中,CPU 仍需要一次拷贝,从 Linux 2.4 版本开始,DMA 自带了收集功能,可以将对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer),由DMA 根据这些信息直接将内核缓冲区的数据拷贝到网卡设备中,省下了最后一次 CPU 拷贝。

这次只发生 2 次上下文切换 + 2 次 DMA 数据拷贝。

4.6. splice

sendfile 只适用于将数据从文件拷贝到网卡上,限定了使用范围。

splice 系统调用可以在内核空间的读缓冲区和网络缓冲区之间建立管道,支持任意两个文件之间互连,可以在操作系统地址空间中整块地移动数据。

同样发生 2 次上下文切换 + 2 次 DMA 数据拷贝。

4.7. 写时复制

这个就是第三种思路了,COW 写时复制。

当用户进程有写操作时,就把这块共享的内存空间复制一份到其他区域,给写进程专用。这种方法在能够降低系统开销,如果某个进程永远不会对数据进行更改,那就永远不需要拷贝。

Java 中的实现

其实这个策略对于 Javaer 来说不应该陌生,在解决并发问题时,最简单的策略莫过于不变性模式,对象一旦被创建之后,状态就不再发生变化。

比如 包装类和 String 的线程安全就是依赖不变性,基于享元模式创建对象池,读的时候是共用的(这也是为什么包装类不适合做锁的原因),写的时候比如String 的 replace() ,并没有更改原字符串里面数组的内容,而是创建了一个新字符串,这就是写时复制策略了。

尤其是从 Java8 开始的函数式编程,基础就是不可变性,所以修改操作都需要 COW 策略。当然早期 Java 就有类似容器,比如CopyOnWriteArrayList, 不过实现有点笨,不是按需复制。

4.8. 对比

拷贝方式CPU拷贝DMA拷贝上下文切换
传统方式224
mmap124
sendfile122
sendfile + DMA gather copy022
splice022

此刻,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上,当用户进程调用该selectselect会监听所有注册好的 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 是不是异步”,“哪个框架用了这些东西”。

6.1. BIO

BIO 属于同步阻塞,一客户端一线程。该模型下常见优化的方案就是用线程池。

6.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实现的零拷贝,这也是吞吐量较大的原因之一。

6.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 

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值