Linux I/O 接口可以分为以下几种类型:
下面以最常用的 read() 和 write() 函数来介绍 Linux 的 I/O 处理流程。
read() 和 write() 函数,是最基本的文件 I/O 接口,也可用于在 TCP Socket 中进行数据读写,属于阻塞式 I/O(Blocking I/O),即:如果没有可读数据或者对端的接收缓冲区已满,则函数将一直等待直到有数据可读或者对端缓冲区可写。
函数原型:
fd 参数:指示 fd 文件描述符。
buf 参数:指示 read/write 缓冲区的入口地址。
count 参数:指示 read/write 数据的大小,单位为 Byte。
函数返回值:
#include
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
下面以同时涉及了 Storage I/O 和 Network I/O 的一次网络文件下载操作来展开 read() 和 write() 的处理流程。
read() 的处理流程:
write() 的处理流程:
可见,在一次常规的 I/O(read/write)操作流程中 处理流程中,总共需要涉及到:
Linux Kernel 为了提高 I/O 性能,划分了一部分物理内存空间作为 I/O buff/cache,也就是内核缓冲区。当 Kernel 接收到 read() / write() 等读写请求时,首先会到 buff/cache 查找,如果找到,则立即返回。如果没有则通过驱动程序访问 I/O 外设。
查看 Linux 的 buff/cache:
$ free -mh
total used free shared buff/cache available
Mem: 7.6G 4.2G 2.9G 10M 547M 3.1G
Swap: 4.0G 0B 4.0G
实际上,Cache(缓存)和 Buffer(缓冲)从严格意义上讲是 2 个不同的概念,Cache 侧重加速 “读”,而 Buffer 侧重缓冲 “写”。但在很多场景中,由于读写总是成对存在的,所以并没有严格区分两者,而是使用 buff/cache 来统一描述。
Page Cache(页缓存)是最常用的 I/O Cache 技术,以页为单位的,内容就是磁盘上的物理块,用于减少 Application 对 Storage 的 I/O 操作,能够令 Application 对文件进行顺序读写的速度接近于对内存的读写速度。
页缓存读策略:当 Application 发起一个 Read() 操作,Kernel 首先会检查需要的数据是否在 Page Cache 中:
页缓存写策略:当 Application 发起一个 write() 操作,Kernel 首先会将数据写到 Page Cache,然后方法返回,即:Write back(写回)机制,区别于 Write Through(写穿)。此时数据还没有真正的写入到文件中去,Kernel 仅仅将已写入到 Page Cache 的这一个页面标记为 “脏页(Dirty Page)”,并加入到脏页链表中。然后,由 flusher(pdflush,Page Dirty Flush)kernel thread(回写内核线程)周期性地将脏页链表中的页写到磁盘,并清理 “脏页” 标识。在以下 3 种情况下,脏页会被写回磁盘:
flusher 刷新策略由以下几个内核参数决定(数值单位均为 1/100 秒):
# flush 每隔 5 秒执行一次
$ sysctl vm.dirty_writeback_centisecs
vm.dirty_writeback_centisecs = 500
# 内存中驻留 30 秒以上的脏数据将由 flush 在下一次执行时写入磁盘
$ sysctl vm.dirty_expire_centisecs
vm.dirty_expire_centisecs = 3000
# 若脏页占总物理内存 10% 以上,则触发 flush 把脏数据写回磁盘
$ sysctl vm.dirty_background_ratio
vm.dirty_background_ratio = 10
综上可见,Page Cache 技术在理想的情况下,可以在一次 Storage I/O 的流程中,减少 2 次 DMA Copy 操作(不直接访问磁盘)。
下图展示了一个 C 程序通过 stdio 库中的 printf() 或 fputc() 等输出函数来执行数据写入的操作处理流程。过程中涉及到了多处 I/O Buffer 的实现:
零拷贝技术(Zero-Copy),是通过尽量避免在 I/O 处理流程中使用 CPU Copy 和 DMA Copy 的技术。实际上,零拷贝并非真正做到了没有任何拷贝动作,它更多是一种优化的思想。
下列表格从 CPU Copy 次数、DMA Copy 次数以及 SCI 次数这 3 个方面来对比了几种常见的零拷贝技术。可以看见,2 次 DMA Copy 是不可避免的,因为 DMA 是外设 I/O 的基本行为。零拷贝技术主要从减少 CPU Copy 和 CPU 模式切换这 2 个方面展开。
Userspace Direct I/O(用户态直接 I/O)技术的底层原理由 Kernel space 中的 ZONE_DMA 支持。ZONE_DMA 是一块 Kernel 和 User Process 都可以直接访问的 I/O 外设 DMA 物理内存空间。基于此, Application 可以直接读写 I/O 外设,而 Kernel 只会辅助执行必要的虚拟存储配置工作,不直接参与数据传输。因此,该技术可以减少 2 次 CPU Copy。
Userspace Direct I/O 的缺点:
具体流程看下图:Using Direct I/O with DMA
mmap() SCI 用于将 I/O 外设(e.g. 磁盘)中的一个文件、或一段内存空间(e.g. Kernel Buffer Cache)直接映射到 User Process 虚拟地址空间中的 Memory Mapping Segment,然后 User Process 就可以通过指针的方式来直接访问这一段内存,而不必再调用传统的 read() / write() SCI。
申请空间函数原型:
addr 参数:分配 MMS 映射区的入口地址,由 Kernel 指定,调用时传入 NULL。
length 参数:指示 MMS 映射区的大小。
prot 参数:指示 MMS 映射区的权限,可选:PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE 类型。
flags 参数:标志位参数,可选:
fd 参数:指示 MMS 映射区的文件描述符。
offset 参数:指示映射文件的偏移量,为 4k 的整数倍,可以映射整个文件,也可以只映射一部分内容。
函数返回值:
void *mmap(void *adrr, size_t length, int prot, int flags, int fd, off_t offset);
释放空间函数原型:
addr 参数:分配 MMS 映射区的入口地址,由 Kernel 指定,调用时传入 NULL。
length 参数:指示 MMS 映射区的大小。
函数返回值:
int munmap(void *addr, size_t length)
可见,mmap() 是一种高效的 I/O 方式。通过 mmap() 和 write() 结合的方式,可以实现一定程度的零拷贝优化。
// 读
buf = mmap(diskfd, len);
// 写
write(sockfd, buf, len);
mmap() + write() 的 I/O 处理流程如下。
mmap() 映射:
write() 写入:
可见,mmap() + write() 的 I/O 处理流程减少了一次 CPU Copy,但没有减少 CPU 模式切换的次数。另外,由于 mmap() 的进程间共享特性,非常适用于共享大文件的 I/O 场景。
mmap() + write() 的缺点:当 mmap 映射一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump。解决这个问题通常需要使用文件租借锁实现。在 mmap 之前加锁,操作完之后解锁。即:首先为文件申请一个租借锁,当其他进程想要截断这个文件时,内核会发送一个实时的 RT_SIGNAL_LEASE 信号,告诉当前进程有进程在试图破坏文件,这样 write 在被 SIGBUS 杀死之前,会被中断,返回已经写入的字节数,并设置 errno 为 success。
Linux Kernel 从 v2.1 开始引入了 sendfile(),用于在 Kernel space 中将一个 in_fd 的内容复制到另一个 out_fd 中,数据无需经过 Userspace,所以应用在 I/O 流程中,可以减少一次 CPU Copy。同时,sendfile() 比 mmap() 方式更具安全性。
函数原型:
out_fd 参数:目标文件描述符,数据输入文件。
in_fd 参数:源文件描述符,数据输出文件。该文件必须是可以 mmap 的。
offset 参数:指定从源文件的哪个位置开始读取数据,若不需要指定,传递一个 NULL。
count 参数:指定要发送的数据字节数。
函数返回值:
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
sendfile() 处理流程:
上文知道 sendfile() 还具有一次 CPU Copy,通过结合 DMA Gather Copy 技术,可以进一步优化它。
DMA Gather Copy 技术,底层有 I/O 外设的 DMA Controller 提供的 Gather 功能支撑,所以又称为 “DMA 硬件辅助的 sendfile()“。借助硬件设备的帮助,在数据从 Kernel Buffer Cache 到 Kernel Socket Buffer 之间,并不会真正的数据拷贝,而是仅拷贝了缓冲区描述符(fd + size)。待完成后,DMA Controller,可以根据这些缓冲区描述符找到依旧存储在 Kernel Buffer Cache 中的数据,并进行 DMA Copy。
显然,DMA Gather Copy 技术依旧是 ZONE_DMA 物理内存空间共享性的一个应用场景。
sendfile() + DMA Gather Copy 的处理流程:
splice() 与 sendfile() 的处理流程类似,但数据传输方式有本质不同。
函数原型:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
splice() 的处理流程如下:
缓冲区共享技术,是对 Linux I/O 的一种颠覆,所以往往需要由 Application 和设备来共同实现。
其核心思想是:每个 Applications 都维护着一个 Buffer Pool,并且这个 Buffer Pool 可以同时映射到 Kernel 虚拟地址空间,这样 Userspace 和 Kernel space 就拥有了一块共享的空间。以此来规避掉 CPU Copy 的行为。
DMA Copy 到 I/O 网卡设备。
5. splice() 返回,CPU 从内核态切换到用户态。
[外链图片转存中…(img-8MX8EL7q-1703510997886)]
缓冲区共享技术,是对 Linux I/O 的一种颠覆,所以往往需要由 Application 和设备来共同实现。
其核心思想是:每个 Applications 都维护着一个 Buffer Pool,并且这个 Buffer Pool 可以同时映射到 Kernel 虚拟地址空间,这样 Userspace 和 Kernel space 就拥有了一块共享的空间。以此来规避掉 CPU Copy 的行为。