针对腾讯 CSIG 一面的问题做一个总结:包括读写操作在整个内核中的 I/O 请求链路,页缓存、零拷贝技术,以及用户态 I/O 和系统调用的优化。
前置知识|页缓存 & 零拷贝
零拷贝技术(Zero-Copy)
概念 :在传统的数据传输过程中(比如读取文件发送给网络),数据通常会在内核空间和用户空间之间多次拷贝 。零拷贝的目标是尽量减少或避免数据在内核空间与用户空间之间的拷贝操作,从而提升性能。
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
c++
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
首先,期间共发生了 4 次用户态与内核态的上下文切换 ,因为发生了两次系统调用,一次是 read()
,一次是 write()
,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
第一次拷贝
,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。第二次拷贝
,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。第三次拷贝
,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。第四次拷贝
,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
从 Linux 2.1 版本开始,Linux 引入了 sendfile
来简化操作。文件通过 sendfile()
直接发送到 socket,不经过用户空间。
c
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
多路复用技术(I/O Multiplexing)
概念 :允许单个线程同时监听多个 I/O 事件(如 socket
连接),只在有事件发生时才进行处理,避免阻塞等待每个 I/O。
常见接口:
select
poll
epoll
(Linux 高效实现)
用途:
- 高并发服务器(如 Nginx、Redis)
- 网络编程中高效的 I/O 模型
优势:
- 少量线程处理大量连接
- 减少线程切换开销
页缓存(Page Cache)技术
位于「程序内存分布」中的内核空间
概念 :操作系统将磁盘中的数据缓存在内存中,以加快文件读写速度。缓存的单位是"页"(通常是 4KB)。
作用:
- 加快文件读取(命中缓存时无需访问磁盘)
- 写文件时先写到缓存,再异步刷到磁盘(提高写入性能)
相关命令:
sync
:强制把缓存写入磁盘drop_caches
:清除缓存,用于测试或释放内存
Linux 内核 I/O 链路一览图
Linux I/O 存储栈下的读写流程
应用程序通过系统调用访问文件(无论是块设备文件,还是各种文件系统中的文件)。可以通过 open
系统调用,也可以通过 memory map
的方式调用来打开文件。
mmap 与 read/write 的区别可以参考文章:mmap 与 read/write 对比
Linux 内核收到系统调用的软中断,通过参数检查后,会调用虚拟文件系统(Virtual File System,VFS),虚拟文件系统会根据信息把相应的处理交给具体的文件系统,如 ext2/3/4 等文件系统,接着相应的文件 I/O 命令会转化成 bio
命令进入通用块设备层,把针对文件的基于 offset 的读/写转化成基于逻辑区块地址(Logical Block Address,LBA)的读/写,并最终翻译成每个设备对应的可识别的地址,通过 Linux 的设备驱动对物理设备,如硬盘驱动器(Harddisk Drive,HDD)或固态硬盘进行相关的读/写。
用户态文件系统的管理。Linux 文件系统的实现都是在内核进行的,但是用户态也有一些管理机制可以对块设备文件进行相应的管理。例如,使用 parted
命令进行分区管理,使用 mkfs
工具进行文件系统的管理,使用逻辑卷管理器(Logical Volume Manager,LVM)命令把一个或多个磁盘的分区进行逻辑上的集合,然后对磁盘上的空间进行动态管理。
当然在用户态也有一些用户态文件系统的实现,但是一般这样的系统性能不是太高,因为文件系统最终是建立在实际的物理存储设备上的,且这些物理设备的驱动是在内核态实现的。那么即使文件系统放在用户态,I/O 的读和写也还是需要放到内核态去完成的。除非相应的设备驱动也被放到用户态,形成一套完整的用户态 I/O 栈的解决方案,就可以降低 I/O 栈的深度。另外采用一些无锁化的并行机制,就可以提高 I/O 的性能。例如,由英特尔开源的 SPDK(Storage Performance Development Kit)软件库,就可以利用用户态的 NVMe SSD(Non-Volatile Memory express)驱动,从而加速那些使用 NVMe SSD 的应用,如 iSCSI Target 或 NVMe-oF Target 等。
linux IO 存储栈分为 7 层:
- VFS 虚拟文件层: 在各个具体的文件系统上建立一个抽象层,屏蔽不同文件系统的差异。
- PageCache 层: 为了缓解内核与磁盘速度的巨大差异。
- 映射层 Mapping Layer: 内核必须从块设备上读取数据,Mapping layer 要确定在物理设备上的位置。
- 通用块设备层: 通用块层处理来自系统其他组件发出的块设备请求,包含了块设备操作的一些通用函数和数据结构。
- I/O 调度层: IO 调度层主要是为了减少磁盘 IO 的次数,增大磁盘整体的吞吐量,队列中多个 bio 进行排序和合并。
- 块设备驱动层: 每一类设备都有其驱动程序,负责设备的读写。
- 物理设备层: 物理设备层有 HDD、SATA SSD、NVMe SSD 等物理设备。
PageCache 层 ------ 两种策略
-
write back: 写入 PageCache 便返回,不等数据落盘。
-
write through: 同步等待数据落盘。
程序的内存分布,其中包括内核空间(Page Cache 在内存中)
读流程
下面以一次文件读取操作为例,完整详细描述一次 I/O 请求处理链路:
- 虚拟文件系统层(VFS,也是 Linux 一切皆文件的底层原因)
- 文件系统(Ext2/3/4、NFS、Btrfs、xfs)
- 通用块设备层(bio、request)
- I/O 调度器(CFQ、Deadline、noop、BFQ)
- 设备驱动(块设备/字符设备)
- 设备控制器(如 NVMe/SCSI 控制器)
- 中断处理(IRQ 处理流程)
1️⃣ 用户进程调用标准库函数
用户进程发起系统调用 read(fd, buf, count)
,系统陷入内核态。
c
// glibc 中封装的 read() 最终触发 syscall
ssize_t read(int fd, void *buf, size_t count);
内核获取调用参数,内核深入调用 sys_read
,检查文件描述符的有效性并获取内核文件结构体,通过软中断(x86 上是 syscall
指令)进入内核态:
c
// 内核入口点(x86_64 架构)
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count);
}
2️⃣ 虚拟文件系统 VFS
内核通过文件描述符定位 struct file *
:
c
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd); // 从 fd 表中找到 struct file
...
return vfs_read(f.file, buf, count, &f.file->f_pos);
}
VFS 提供统一的文件接口,不管是 ext4、xfs 还是设备文件,统一调用:
c
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
if (file->f_op->read)
return file->f_op->read(file, buf, count, pos); // legacy
else if (file->f_op->read_iter)
return call_read_iter(file, buf, count, pos);
}
3️⃣ 文件系统层
假设文件位于 ext4 文件系统,对应操作函数为:
c
// ext4_file_operations
.read_iter = ext4_file_read_iter
这最终会调用 generic_file_read_iter()
,进入页缓存机制(page cache)。
页缓存流程:
- 在页缓存中查找页是否命中
- 命中则拷贝回用户态(零拷贝优化)
- 未命中则触发 page cache miss → 发起读取请求到块设备
c
// mm/filemap.c
filemap_get_pages() → 调用 readpage(s) → 提交 bio 到块层
4️⃣ 通用块设备层
页缓存 page miss 会创建对应的 bio
结构体表示一次 I/O 请求并提交 submit_bio()
,然后对 bio 进行进一步封装,形成更底层的 request
结构传入 I/O 调度器队列。
✅ request 是 I/O 调度的最小单位,多个 bio 访问存储器件上相邻的区域数据并且是同种类型的(读/写),则会被合并到一个 request 中,所以一个 request 可能包含多个 bio。
bio structure
submit_io
c
submit_bio(bio); // fs 层构造 bio,提交到底层设备
块层主要组件:
- bio:描述一次 I/O 操作(起始扇区、长度、数据缓冲区等)
- request_queue:设备对应的请求队列
- I/O scheduler:调度多个 bio 的先后顺序
5️⃣ I/O 调度器
I/O 调度器决定请求的先后顺序,进行排序和合并优化,以此优化磁盘/硬盘访问。
c
blk_mq_sched_insert_request()
blk_mq_run_hw_queue()
常见调度器如:
- CFQ(完全公平队列)
- Deadline
- noop
- BFQ(新版本)
6️⃣ 块设备驱动
调度器处理完后,接着将 request 分发给具体驱动,调用对应的驱动操作函数(例如 NVMe 驱动):
- SATA 磁盘驱动 (
ahci.ko
) - NVMe 驱动 (
nvme.ko
)
c
// nvme driver: drivers/nvme/host/nvme-core.c
nvme_queue_rq() → 调用控制器提交命令
驱动程序将 request
翻译成硬件控制器能理解的指令集
- 对于磁盘:ATA/SCSI 命令
- 对于 NVMe:NVMe 命令
驱动程序将命令写入设备控制器寄存器,具体一些是控制器的 Submission Queue,并触发 DMA(直接内存访问)数据传输。
7️⃣ 设备控制器/硬件接口层
硬件控制器接收到 SQ 中的命令,解析命令 read
并读取数据到 DMA 缓存,通过 DMA 把数据传输到内存中的内核缓冲区(Page Cache),DMA 数据传输完成后,写入 Completion Queue ,硬件设备发起硬件中断通知 CPU 操作完成。
8️⃣ 中断处理流程
CPU 响应硬件中断,内核执行相应中断处理程序:
- 确认硬件完成状态;
- 根据 CQ 的完成项来找到并标记
request
/bio
已经完成; - 唤醒被该
read
阻塞的上层等待进程(在wait_on_page_locked()
阻塞)。
9️⃣ bio 回传用户空间
唤醒进程后,内核从内核缓冲区(Page Cache)将数据拷贝到用户空间缓冲区。
系统调用返回,用户进程继续执行。
写流程
write()--->sys_write()--->vfs_write()--->通用块层--->IO 调度层--->块设备驱动层--->块设备
mmap 与 read/write
顺便复习下 mmap 与 read/write 的区别:mmap 与 read/write 对比
传统的 file I/O 中 read 系统调用首先从磁盘拷贝数据到 kernel,然后再把数据从 kernel 拷贝到用户定义的 buffer 中。
而 mmap 直接由内核操刀,mmap 返回的指针指向映射内存的起始位置,然后可以像操作内存一样操作文件,而且如果是用 read/write 将 buffer 写回 page cache 意味着整个文件都要与磁盘同步(即使这个文件只有个别 page 被修改了),而 mmap 的同步粒度是 page,可以根据 page 数据结构的 dirty 位来决定是否需要与 disk 同步。这是 mmap 比 read 高效的主要原因。对于那种频繁读写同一个文件的程序更是如此。
面试题:如何不使用库函数完成对底层设备的读写?
用户态 I/O
参考《操作系统 原理与实现》13.4.2 小节 ------ 陈海波
无论是基于系统调用还是 I/O 库接口,应用程序默认操作的设备对象都是操作系统提供的逻辑设备 。用户态和内核态之间需要大量的拷贝,造成性能下降,有没有办法不使用库函数来达到直接对底层物理设备的读写呢?
以网络 I/O 为例,一种直观的思路是允许防火墙软件直接操作网卡的 DMA 缓冲区(应用程序直接操作设备控制器的 DMA 缓冲区),为了实现这一目标,Intel 联合其他网卡制造商共同开发了一套高性能的用户态网络 I/O 框架 ------ 数据平面开发套件(DPDK)。
DPDK 在设计上采用旁路内核的设计,即网络包的收发处理基本不需要 Linux 内核的参与,DPDK 的设计思路如下:
其底层原理同样适用于其他物理设备
用户空间驱动
为了能在用户态同网卡设备进行交互,DPDK 需要在用户态直接执行网卡驱动代码。做法就是将设备寄存器直接映射到应用自身的进程地址空间中,进而让 DPDK 的用户态驱动通过 MMIO 操作设备。
正如操作系统为应用程序的开发提供统一设备文件系统的和 I/O 使用接口一样,Linux 提供了用户态驱动开发框架,即 UIO (Userspace I/O)。
Linux 将 UIO 设备抽象为路径为 /dev/uio
[x] 的设备文件。应用程序通过打开 UIO 设备文件获取设备的 I/O 空间和中断信息,同时自行决定如何操作和响应设备。
注意:UIO 用户驱动通常不使用 write
接口,而是从 uio_driver.h 中已经经过 mmap
处理的内存区间直接与设备交换数据。
更多详细内容可以查看 Linux Kernel 中的 include/linux/uio_driver.h
.
系统调用的优化
系统调用作为应用程序调用操作系统的入口,其性能也非常重要。然而不同于传统的函数调用,系统调用的过程复用了异常机制,因此不可避免地需要执行特权级切换、上下文保存等操作,导致其时延比普通函数调用高1~2 个数量级。对于需要频繁进行系统调用的应用来说,这是很大的性能开销。
Q:那么要怎样绕过费时的异常处理机制来实现系统调用呢?
A:可以通过在用户态和内核态之间共享一小块内存的方式,在应用与内核之间创建一条新的通道。
1️⃣ 方法 1:共享内核只读数据给应用
内核将一部分数据通过只读的形式共享给应用,允许应用直接读取。
这种方法的缺点在于:如果系统调用需要修改内核中的变量,或者在运行过程中需要读取更多内核数据,该方法就不适用了。
2️⃣ 方法 2:允许应用以 "向内存页写入请求" 发起系统调用
第二种方法就是允许应用(用户态)以 "向内存页写入请求" 的方式发起系统调用,并通过轮询来等待系统调用完成;内核(内核态)同样通过轮询来等待用户的请求,然后执行系统调用,并将系统调用的返回值写入同一块内存页以表示完成。
但应用和内核怎么同时轮询呢?
这个设计的关键点在于:让内核独占一个 CPU 核心。
这个核心一直在内核态运行,而其他 CPU 核心则一直在用户态运行。这样从系统整体来看,对于任何一个 CPU 核心都不会发生从用户态到内核态的切换,大大降低系统调用的时延。在应用将请求写入内存页后的下一个时钟周期,处于轮询状态的内核立即可以读到这个请求,并开始运行处理函数;同样,当内核将返回结果写入内存页后,在另一个 CPU 核心处于轮询状态的应用立即可以读到结果并继续运行。
不过这种方式存在两个缺点:
第一个缺点在于,如果有多个应用同时发起请求,内核需要一个个顺序处理,则时延可能会比原来更长,因为没能充分使用多核。解决方法也很直接:让多个 CPU 核心同时运行在内核态并轮询用户的请求,当内核忙不过来时,占用的核心多一些,反之少一些(根据系统负载动态调整内核占用的 CPU 核心数)。
第二个缺点在于,如果整个系统只有一个 CPU 核心怎么办?可以将轮询改为批处理。当 CPU 运行在用户态时,应用程序一次发起多个系统调用请求,同样将请求和参数写入共享内存页。然后 CPU 切换到内核态,内核一次性将所有系统调用处理完,把结果写入共享内存页,再切换回用户态运行。由于特权级的切换次数变少了,所以整体吞吐率提升了。