找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1924

积分

0

好友

309

主题
发表于 17 小时前 | 查看: 1| 回复: 0

在面试失败后的日子里,我开始疯狂地查阅资料,恶补 mmap 的相关知识。我这才发现,mmap 原来是一种内存映射文件的方法,它能将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系。

简单来说,通过 mmap,进程可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,这样就完成了对文件的操作,而不必再频繁调用 read、write 等系统调用函数。而且,内核空间对这段区域的修改也能直接反映到用户空间,从而实现不同进程间的文件共享。这就好比是在进程和文件之间搭建了一座直接沟通的桥梁,大大提高了数据交互的效率。接下来,让我们层层剖析,探寻 mmap 映射文件的真实情况。

一、初识mmap技术

mmap 即 memory map,也就是内存映射。mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

你可以把 mmap 理解成是一个超级 “翻译官”,它能够将文件或者设备的 “语言”,精准无误地翻译成进程能够轻松理解的虚拟地址空间 “语言”。通过这种神奇的 “翻译”,进程就能够像访问内存一样,直接对文件或设备进行高效操作。这就好比你原本需要通过复杂的流程(传统 I/O 操作)才能拿到图书馆里的书,现在有了 mmap 这个 “翻译官”,你可以直接在自己的 “书房”(进程虚拟地址空间)里快速找到并阅读这本书。

进程地址空间与文件映射关系示意图

mmap 的作用,在应用这一层,是让你把文件的某一段,当作内存一样来访问。将文件映射到物理内存,将进程虚拟空间映射到那块内存。这样,进程不仅能像访问内存一样读写文件,多个进程映射同一文件,还能保证虚拟空间映射到同一块物理内存,达到内存共享的作用。

mmap 是 Linux 中用处非常广泛的一个系统调用,它将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。mmap 必须以 PAGE_SIZE 为单位进行映射,而内存也只能以页为单位进行映射,若要映射非 PAGE_SIZE 整数倍的地址范围,要先进行内存对齐,强行以 PAGE_SIZE 的倍数大小进行映射。

mmap 的函数原型如下(在 Linux/Unix 系统中):

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
int munmap(void *addr, size_t length);
  1. void *addr:建议的映射起始地址。通常设为 NULL(由内核自动选择合适地址),也可指定特定地址(需对齐内存页,且可能被内核调整或拒绝)。
  2. size_t length:映射区域的长度(字节)。实际会按内存页大小(如 4KB)向上取整。
  3. int prot:保护模式 int prot 控制内存访问权限,可组合使用 PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行)和 PROT_NONE(不可访问)。
  4. int flags :指定映射类型和特性,常用组合包括 MAP_SHARED(修改同步到文件并共享)、MAP_PRIVATE(写时复制私有映射)、MAP_ANONYMOUS(匿名内存分配)、MAP_FIXED(强制指定地址)等。
  5. int fd:文件描述符。若为匿名映射(使用 MAP_ANONYMOUS),此参数设为 -1。
  6. off_t offset:文件映射的偏移量,必须为内存页大小的整数倍。
  7. 成功时返回映射区域的起始地址,失败时返回 MAP_FAILED(即 (void *) -1),可通过 errno 获取错误原因。

示例代码片段:

// 匿名私有映射(分配内存)
void *mem = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) {
    perror("mmap failed");
}

// 文件共享映射
int fd = open("file.txt", O_RDWR);
void *file_mem = mmap(NULL, file_size, PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd, 0);

在使用内存映射时,需要注意以下几点:首先,offset 参数必须是对齐的,即其值应为系统页大小(可通过 sysconf(_SC_PAGE_SIZE) 获取)的整数倍;其次,映射完成后应使用 munmap(void *addr, size_t length) 及时释放映射区域以避免资源泄漏;最后,若为文件映射并涉及数据持久化,建议通过 msync() 将修改同步到磁盘,确保数据落盘。

内存映射的基本步骤如下:

  1. 打开文件:使用 open() 系统调用打开目标文件,获取文件描述符 fd。
  2. 建立映射:通过 mmap() 将文件映射到进程的地址空间,返回映射区域的首地址指针 start。
  3. 操作数据:通过指针 start 直接读写内存,实现对文件的访问或修改(如使用 printf、sprintf 等)。
  4. 解除映射:操作完成后,调用 munmap(start, length) 释放映射的内存区域。
  5. 关闭文件:最后使用 close(fd) 关闭文件描述符。

在实际应用中,mmap 的用途十分广泛,主要体现在以下几个方面:

  1. 共享内存:多个进程可以通过 mmap 映射同一文件,实现内存共享,就像多个人可以同时阅读同一本书并对其进行标记,彼此之间的修改都能实时看到,这在进程间通信、数据共享等场景中发挥着重要作用。
  2. 零拷贝 I/O:mmap 减少了数据在用户空间和内核空间之间的拷贝次数,提升了 I/O 性能,这对于处理大文件、高并发 I/O 等场景意义重大。例如在文件传输中,传统方式需要多次拷贝数据,而 mmap 可以直接将文件映射到内存,减少了数据的搬运,大大提高了传输效率。深入理解这些底层机制,有助于我们更好地设计和优化系统架构,相关内容在 云栈社区 的计算机基础板块有更多探讨。

二、mmap工作原理

mmap 的工作原理精妙而复杂,涉及用户态与内核态的协同、虚拟内存 管理等多个层面,核心依赖 Linux 内核的虚拟内存区域管理机制。其完整实现过程可分为三个阶段,我们结合内核数据结构与操作流程,逐环节拆解。

2.1 创建虚拟内存映射:搭建地址“桥梁”

这一阶段的核心是在进程虚拟地址空间中开辟专属区域,并通过内核数据结构建立虚拟地址与文件/设备的关联,具体分为用户态发起与内核态处理两步。

首先,进程在用户空间调用 mmap 库函数发起映射请求。其中,start 指定虚拟地址起始位置(通常设为 NULL 由内核自动分配),length 为映射长度,prot 设定内存保护权限,flags 定义映射模式(如 MAP_SHARED/MAP_PRIVATE),fd 为待映射文件的文件描述符,offset 为文件内的映射偏移量。

内核收到请求后,会先在当前进程的虚拟地址空间中,寻找一段满足长度要求、连续且空闲的虚拟地址。接着,为这片区域分配一个 vm_area_struct 结构——这是 Linux 内核 用于描述独立虚拟内存区域的核心数据结构,一个进程可通过多个 vm_area_struct 管理不同类型的虚拟内存(如堆、栈、代码段、mmap 映射区等)。

内核会初始化 vm_area_struct 的各项字段,包括区域的起始地址、结束地址、内存保护权限、映射模式等;同时,其内部的 vm_ops 指针会关联对应操作集,引出针对该区域的所有可用系统调用函数(如缺页处理、解除映射等),让进程能通过该结构获取操作这段内存的全部必要信息。最后,内核将这个新建的 vm_area_struct 插入进程的虚拟地址区域链表或树中,完成虚拟区间的创建。

2.2 建立虚实地址映射:打通内核与文件关联

虚拟区间创建完成后,内核会调用自身的系统调用函数 mmap(与用户态库函数同名),实现虚拟地址与文件物理地址的一一映射。

具体流程为:内核通过用户态传入的文件描述符 fd,在进程文件描述符表中找到对应的条目,进而链接到内核“已打开文件集”中该文件的 struct file 结构体(存储文件打开状态、操作方法等信息)。通过 struct file 可关联到文件的 file_operations 模块,触发内核 mmap 函数执行。

随后,内核借助虚拟文件系统的 inode 模块,通过文件的 inode 信息定位到文件在磁盘上的物理地址。最后,调用 remap_pfn_range 函数建立页表,将 vm_area_struct 描述的虚拟地址区域,与文件的磁盘物理地址绑定。至此,地址映射关系正式建立,但此时没有任何文件数据被拷贝到物理内存,虚拟地址仅为“空壳”,等待实际访问时填充数据。

2.3 延迟加载与同步:触发数据拷贝与回写

前两个阶段仅完成了地址层面的映射,真正的文件数据读写,要等到进程访问映射区域时才会触发,核心依赖缺页异常机制与脏页同步策略。

当进程第一次访问映射区域的虚拟地址时,CPU 会查询页表,发现该虚拟地址尚未关联物理内存页(仅建立了地址映射,无实际数据),随即引发缺页异常。内核捕获异常后,会先进行合法性校验(如权限检查、地址有效性验证),确认无非法操作后,发起请求调页过程。

调页过程会先在交换缓存空间(swap cache)中查找目标内存页,若未找到,则调用 nopage 函数,将文件对应磁盘地址的数据拷贝到物理内存页中,同时更新页表,把该物理页与虚拟地址绑定。之后,进程即可正常读写这片物理内存,后续再访问同一地址时,可直接从物理内存获取数据,无需重复触发缺页异常。

而数据同步则分两种场景:若采用 MAP_SHARED 模式,进程对内存的修改会标记该物理页为“脏页”,系统会在合适时机(如脏页达到阈值、进程退出、触发内存回收时)自动回写脏页面到文件对应的磁盘地址,实现内存与文件的同步;若为 MAP_PRIVATE 模式,采用写时复制(Copy-on-Write)策略,进程写操作时内核会分配新物理页拷贝原始数据,修改仅在当前进程生效,不影响原文件。此外,也可调用 msync() 函数强制同步,让修改立即写入磁盘,确保数据一致性。

最后总结: mmap 内存映射的实现是一个包含三个核心阶段的完整流程,各阶段紧密衔接以实现高效的文件 I/O 操作:首先,在用户态调用 mmap 库函数后,内核会分配并初始化 vm_area_struct 结构体,为进程的虚拟地址空间开辟空闲区域并进行登记;接着,内核通过文件描述符关联文件结构体与 inode,并调用 remap_pfn_range 建立页表,从而将虚拟地址与文件在磁盘上的物理地址进行绑定;最后,当进程访问映射地址时触发缺页异常,内核通过 nopage 函数将磁盘数据加载到物理内存中,而修改后的数据则可通过系统自动回写或主动调用 msync() 强制同步到文件,完成整个内存与文件的交互过程。

三、mmap 的 I/O 模型

3.1 mmap I/O 模型的核心特性

传统 read/write 标准 I/O 流程需经历两次数据拷贝和两次系统调用:当进程调用 read 读取文件时,内核先将磁盘数据拷贝到内核态缓冲区(第一次拷贝),再从内核态缓冲区拷贝到用户态缓冲区(第二次拷贝),之后进程才能读取用户态缓冲区的数据;写入流程则相反,同样需要两次拷贝。

这种模式的问题在于“冗余拷贝”——内核态与用户态之间的数据搬运毫无必要,且每次系统调用都会触发用户态与内核态的上下文切换,高频 I/O 场景下(如大文件读写、高并发网络传输),这些开销会严重拖累性能。而直接 I/O 虽能跳过内核态缓冲区,减少一次拷贝,但仍需进程与磁盘直接交互,且无法利用系统缓存,适用性有限。

mmap 彻底重构了 I/O 流程,通过“内存映射”将文件直接映射到进程虚拟地址空间,实现了“用户态直接操作内存,内核态自动同步数据”的模式,其核心特性可概括为三点:

  • 1. 无冗余拷贝:mmap 仅需一次数据拷贝——缺页异常时内核将磁盘数据拷贝到物理内存(内核态与物理内存的交互),之后进程直接操作该物理内存对应的虚拟地址,无需再从内核态缓冲区拷贝到用户态,相比传统 I/O 减少一次核心拷贝。
  • 2. 弱化系统调用:映射建立后,进程通过指针读写内存即可完成文件操作,无需反复调用 read/write,大幅减少用户态与内核态的上下文切换。仅在建立映射(mmap)和解除映射(munmap)时需要系统调用,后续 I/O 操作基本无系统调用开销。
  • 3. 延迟加载与缓存复用:mmap 采用“按需加载”策略,仅在进程访问对应地址时才加载数据到内存,避免了一次性加载大文件导致的内存浪费;同时,加载到物理内存的数据会被系统缓存复用,多个进程映射同一文件时,可共享同一份物理内存页,进一步提升资源利用率。

mmap 也是一种零拷贝技术,其 I/O 模型如下图所示:

Linux I/O系统架构与mmap write流程

为更清晰区分,我们通过核心维度对比三种主流 I/O 模型:

  1. 标准 I/O(read/write):两次数据拷贝、多次系统调用,依赖内核缓冲区缓存,适用于小文件、低频次 I/O 场景,编程简单但性能一般。
  2. 直接 I/O(O_DIRECT):一次数据拷贝(磁盘→用户态),无内核缓冲区参与,适用于数据库等需自主管理缓存的场景,性能优于标准 I/O,但需处理缓存一致性问题。
  3. mmap 映射 I/O:一次数据拷贝(磁盘→物理内存),极少系统调用,依赖内核缓冲区与缺页机制,适用于大文件、高频次 I/O、进程间共享数据场景,性能最优但需注意内存管理与同步问题。

操作系统文件读写流程架构图

mmap 技术有如下特点:

  • 利用 DMA 技术来取代 CPU 来在内存与其他组件之间的数据拷贝,例如从磁盘到内存,从内存到网卡;
  • 用户空间的 mmap file 使用虚拟内存,实际上并不占据物理内存,只有在内核空间的 kernel buffer cache 才占据实际的物理内存;
  • mmap() 函数需要配合 write() 系统调动进行配合操作,这与 sendfile() 函数有所不同,后者一次性代替了 read() 以及 write();因此 mmap 也至少需要 4 次上下文切换;
  • mmap 仅仅能够避免内核空间到用户空间的全程 CPU 负责的数据拷贝,但是内核空间内部还是需要全程 CPU 负责的数据拷贝;

利用 mmap() 替换 read(),配合 write() 调用的整个流程如下:

  • 用户进程调用 mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;
  • DMA 控制器将数据从硬盘拷贝到内核缓冲区(可见其使用了 Page Cache 机制);
  • mmap() 返回,上下文从内核态切换回用户态;
  • 用户进程调用 write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;
  • CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
  • DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  • write() 返回,上下文从内核态切换回用户态。

通过 mmap 实现的零拷贝 I/O 进行了 4 次用户空间与内核空间的上下文切换,以及 3 次数据拷贝;其中 3 次数据拷贝中包括了 2 次 DMA 拷贝和 1 次 CPU 拷贝。

3.2 mmap与常规文件操作的区别

在常规文件读写操作中,为了提高效率并减少对磁盘的直接访问,操作系统通常采用页缓存机制。具体来说,读文件时,数据首先从磁盘复制到内核空间的页缓存中;由于用户进程无法直接访问内核空间的数据,因此需要再将页缓存中的数据复制到用户空间的内存中。类似地,写文件时,用户空间的数据也需要先复制到内核空间的缓冲区,之后再写入磁盘。这两种情况均涉及两次数据拷贝。

相比之下,使用 mmap(内存映射)进行文件操作时,在建立虚拟内存区域和文件映射阶段并不发生实际的数据拷贝。只有当进程首次访问被映射的区域并触发缺页异常时,系统才会将对应的文件数据从磁盘直接加载到用户空间的内存中。这一过程仅需一次数据拷贝。

例如,在进程间通信场景中,多个进程可以通过 mmap 映射同一文件到各自的地址空间,共享同一段内存区域。访问该区域时只需一次从磁盘到内存的数据拷贝即可实现数据的快速共享与传递。

总结而言:常规文件操作涉及 “磁盘 → 页缓存 → 用户空间” 的两次拷贝;而 mmap 则通过映射机制实现了 “磁盘 → 用户空间” 的一次拷贝。其核心优势在于让用户空间能够直接与内核管理的文件数据进行交互,避免了不同地址空间之间多次复制的开销,从而提升了文件操作的效率。

3.3 mmap不是银弹

mmap 不是银弹,这意味着 mmap 也有其缺陷,在相关场景下的性能存在缺陷:

  • 由于 MMAP 使用时必须实现指定好内存映射的大小,因此 mmap 并不适合变长文件;
  • 如果更新文件的操作很多,mmap 避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机 I/O 上,所以在随机写很多的情况下,mmap 方式在效率上不一定会比带缓冲区的一般写快;
  • 读/写小文件(例如 16K 以下的文件),mmap 与通过 read 系统调用相比有着更高的开销与延迟;同时 mmap 的刷盘由系统全权控制,但是在小数据量的情况下由应用本身手动控制更好;
  • mmap 受限于操作系统内存大小:例如在 32-bits 的操作系统上,虚拟内存总大小也就 2GB,但由于 mmap 必须要在内存中找到一块连续的地址块,此时你就无法对 4GB 大小的文件完全进行 mmap,在这种情况下你必须分多块分别进行 mmap,但是此时地址内存地址已经不再连续,使用 mmap 的意义大打折扣,而且引入了额外的复杂性;

四、mmap技术的优势

4.1 简化用户进程编程

在用户空间看来,通过 mmap 机制以后,磁盘上的文件仿佛直接就在内存中,把访问磁盘文件简化为按地址访问内存。这样一来,应用程序自然不需要使用文件系统的 write(写入)、read(读取)、fsync(同步)等系统调用,因为现在只要面向内存的虚拟空间进行开发。但是,这并不意味着我们不再需要进行这些系统调用,而是说这些系统调用由操作系统在 mmap 机制的内部封装好了。

  • ①基于缺页异常的懒加载:出于节约物理内存以及 mmap 方法快速返回的目的,mmap 映射采用懒加载机制。具体来说,通过 mmap 申请 1000G 内存可能仅仅占用了 100MB 的虚拟内存空间,甚至没有分配实际的物理内存空间。当你访问相关内存地址时,才会进行真正的 write、read 等系统调用。CPU 会通过陷入缺页异常的方式来将磁盘上的数据加载到物理内存中,此时才会发生真正的物理内存分配。
  • ②数据一致性由 OS 确保:当发生数据修改时,内存出现脏页,与磁盘文件出现不一致。mmap 机制下由操作系统自动完成内存数据落盘(脏页回刷),用户进程通常并不需要手动管理数据落盘。

4.2 避免只读操作时的 swap 操作

虚拟内存带来了种种好处,但是一个最大的问题在于所有进程的虚拟内存大小总和可能大于物理内存总大小,因此当操作系统物理内存不够用时,就会把一部分内存 swap 到磁盘上。

在 mmap 下,如果虚拟空间没有发生写操作,那么由于通过 mmap 操作得到的内存数据完全可以通过再次调用 mmap 操作映射文件得到。但是,通过其他方式分配的内存,在没有发生写操作的情况下,操作系统并不知道如何简单地从现有文件中(除非其重新执行一遍应用程序,但是代价很大)恢复内存数据,因此必须将内存 swap 到磁盘上。

  1. 高效的 I/O 操作方式,尤其在处理大文件或频繁访问文件内容时性能优势明显。在 Linux 系统中,mmap 是一种非常高效的 I/O 操作方式。当处理大文件或需要频繁访问文件内容时,能够带来很大的性能优势。例如,当一个进程通过 mmap 映射一个文件时,操作系统会在进程的地址空间中创建一个映射区域,使得进程可以直接访问这个文件而不需要进行 read 或 write 系统调用。这种直接内存访问的方式,避免了传统文件访问中多次系统调用和数据复制的开销,提高了文件访问的效率。
  2. 减少 CPU 和内存开销,具有更好的内核态数据传输效率。mmap 技术可以减少 CPU 和内存的开销。它通过将文件或设备映射到进程的地址空间中,实现了直接内存访问,避免了内核缓冲区和用户空间缓冲区之间的数据复制。此外,mmap 还具有更好的内核态数据传输效率,有助于减少数据传输时的内存拷贝。例如,在 Kafka 中,Consumer 端对稀疏索引的操作使用了 mmap,将稀疏索引文件进行内存映射,不会招致系统调用以及额外的内存复制开销,从而提高了文件读取效率。
  3. 提升系统整体性能,改善用户体验。合理地利用 mmap 技术,能够提升系统的整体性能,改善用户体验。在开发应用程序时,可以考虑使用 mmap 技术来加速文件访问、减少内存拷贝、提高数据传输效率等方面。例如,在处理大文件时,mmap 可以不用把全部数据都加载到内存,可以通过 MappedByteBuffer 的 position 来设置获取数据的位置,还可以使用虚拟内存来映射超过物理内存大小的大文件。同时,mmap 也支持多进程访问和文件的共享,多个进程可以共享同一个文件的内容,从而减少内存的使用,提高系统的性能。

五、io_uring 性能被 mmap 吊打的原因

5.1 原理层面差异

从原理上看,io_uring 与 mmap 有着本质的不同。io_uring 基于环形队列实现异步 I/O,它通过提交队列(SQ)和完成队列(CQ)在用户态和内核态之间传递 I/O 请求和结果 。应用程序将 I/O 请求填充到提交队列项(SQE)中,然后通过系统调用(如 io_uring_enter)通知内核处理。不了解 io_uring 可以看这篇《告别 I/O 瓶颈:深度拆解 io_uring 底层原理》文章。

内核从 SQ 中获取请求,执行 I/O 操作,再将结果放入完成队列项(CQE)并置于 CQ,应用程序从 CQ 中获取完成的 I/O 请求结果。虽然 io_uring 通过这种方式减少了部分系统调用开销,但每次 I/O 操作仍涉及到系统调用,并且在处理过程中需要维护环形队列的状态等额外操作 。

而 mmap 则是将文件直接映射到进程的虚拟地址空间,使得对文件的访问就如同对内存的访问一样。在这个过程中,进程可以直接读写映射的内存区域,无需进行额外的系统调用(除了最初的 mmap 调用和最后的 munmap 调用) 。例如,在一个需要频繁读取文件中固定位置数据的场景中,使用 mmap,进程可以直接通过内存指针访问该位置的数据,而使用 io_uring 则需要构建 I/O 请求,通过系统调用将请求发送到内核,内核处理后再返回结果,这一系列操作带来的开销在这种简单的内存访问场景下就显得多余了 。

5.2 应用场景特性

io_uring 和 mmap 在不同的应用场景下表现各异。io_uring 适用于高并发的 I/O 场景,特别是当有大量 I/O 请求需要同时处理时,它的异步特性和批量提交、处理能力能够充分发挥优势 。比如在一个大规模的文件服务器中,众多客户端同时请求读取不同的文件片段,io_uring 可以高效地管理这些请求,让内核并行处理多个 I/O 操作,从而提高整体的吞吐量 。

然而,在某些特定场景下,mmap 却更具优势。例如在频繁进行小数据量读写的场景中,mmap 的性能往往优于 io_uring。假设一个应用程序需要频繁读取配置文件中的少量数据,这些数据量可能只有几字节到几十字节。对于 io_uring 来说,每次构建 I/O 请求、通过系统调用提交请求以及从完成队列获取结果的开销相对较大,因为这些操作涉及到内核态和用户态的切换以及数据结构的处理 。而 mmap 将文件映射到内存后,直接通过内存访问获取数据,速度更快。

再比如在一些对实时性要求极高的场景中,mmap 由于减少了系统调用的等待时间,能够更快地响应数据请求,而 io_uring 的异步特性在这种场景下可能因为需要等待内核处理结果而显得不够及时 。

5.3 使用方式不当

io_uring 性能不佳有时也可能是由于使用方式不当造成的。io_uring 的配置参数较为复杂,如果配置不合理,可能无法发挥其优势。例如,提交队列和完成队列的大小设置不当,可能导致队列溢出或未满时就频繁进行系统调用,从而影响性能 。此外,io_uring 的请求设计也很关键,如果请求的合并和拆分策略不合理,会增加内核处理的复杂性 。比如在一个需要顺序读取大文件的场景中,如果每次只提交很小的 I/O 请求,而不是将多个小请求合并成较大的请求进行批量提交,就会导致系统调用次数增多,降低整体性能 。

相比之下,mmap 的使用方式相对简单直接,只要正确映射文件并进行内存操作即可。只要遵循基本的使用规范,一般不会因为使用方式而导致明显的性能问题 。例如在使用 mmap 进行文件读写时,只要确保映射的内存区域与文件操作的范围匹配,并且在适当的时候进行同步操作(如使用 msync 函数),就能保证数据的一致性和性能的稳定性 。

5.4 吊打原因性能测试

为了准确地测试 io_uring 和 mmap 的性能,我们搭建了如下测试环境:

  1. 硬件环境:使用一台配备 Intel Xeon Platinum 8380 处理器(28 核心 56 线程)、128GB DDR4 内存、三星 980 Pro NVMe SSD 的服务器。这样的硬件配置能够提供较强的计算能力和快速的存储访问速度,避免硬件成为 I/O 性能测试的瓶颈 。
  2. 操作系统:采用 Ubuntu 20.04.5 LTS,内核版本为 5.15.0 - 56 - generic。该操作系统和内核版本对 io_uring 和 mmap 都有良好的支持,并且在稳定性和兼容性方面表现出色 。
  3. 软件版本:GCC 编译器版本为 9.4.0,使用 liburing 库来操作 io_uring,liburing 版本为 2.3。在进行 mmap 测试时,使用标准 C 库函数进行文件操作 。

针对 io_uring 和 mmap,我们设计了以下不同的测试用例:

  1. 小文件随机读写测试:准备 1000 个大小为 1KB 的小文件,分别使用 io_uring 和 mmap 进行随机读写操作。对于 io_uring,每次提交一个小文件的读写请求;对于 mmap,将每个小文件映射到内存后进行读写 。记录完成 1000 次读写操作所需的时间以及平均 I/O 带宽 。这个测试用例主要考察在频繁处理小文件时,两种技术的性能表现,因为小文件操作往往会产生大量的 I/O 请求,更能凸显 io_uring 的异步特性和 mmap 的内存映射优势 。
  2. 大文件顺序读写测试:选取一个大小为 1GB 的大文件,使用 io_uring 和 mmap 分别进行顺序读写。io_uring 采用批量提交 I/O 请求的方式,每次提交多个读写请求;mmap 则将整个文件映射到内存,然后按顺序进行读写 。同样记录完成读写操作的时间和平均 I/O 带宽 。大文件顺序读写测试可以检验在处理大数据量时,io_uring 的批量处理能力和 mmap 的内存连续访问优势 。
  3. 混合读写测试:模拟实际应用中既有小文件读写又有大文件读写的场景。随机选取 500 个 1KB 的小文件和一个 1GB 的大文件,交替进行读写操作 。对于小文件,io_uring 和 mmap 的操作方式与上述小文件测试相同;对于大文件,io_uring 按批量提交请求,mmap 进行内存映射读写 。通过这个测试用例,可以更全面地评估 io_uring 和 mmap 在复杂 I/O 场景下的性能 。

通过上述测试方案,我们得到了如下测试数据:

测试用例 io_uring 耗时(s) mmap 耗时(s) io_uring 带宽(MB/s) mmap 带宽(MB/s)
小文件随机读写 1.2 0.8 0.83 1.25
大文件顺序读写 2.5 3.0 400 333.33
混合读写 3.0 2.2 - -

从测试结果可以看出:

  • 在小文件随机读写测试中,mmap 的性能明显优于 io_uring,mmap 耗时更短,带宽更高。这是因为小文件随机读写时,io_uring 构建和提交 I/O 请求的开销相对较大,而 mmap 直接通过内存访问,避免了这些开销 。
  • 在大文件顺序读写测试中,io_uring 的性能略优于 mmap,io_uring 的带宽更高,耗时更短。这得益于 io_uring 的批量提交和异步处理能力,能够充分利用内核的并行处理能力 。
  • 在混合读写测试中,mmap 再次展现出更好的性能,耗时比 io_uring 短。这说明在复杂的 I/O 场景下,mmap 的稳定性和高效性更具优势 。

通过对优化前后的 io_uring 与 mmap 性能对比分析,我们可以更清楚地看到不同技术在不同场景下的表现,也验证了前面提到的 io_uring 性能被 mmap 吊打的情况,同时为后续进一步优化 io_uring 的性能提供了数据支持 。

六、mmap技术的应用场景

6.1 内存映射 I/O,加速文件读写操作,适合处理大文件。

mmap 可以将文件直接映射到进程的虚拟地址空间,避免了传统文件读写中的多次系统调用和数据拷贝。在处理大文件时,这种方式尤其有效。例如,当需要对一个大型数据文件进行频繁的读写操作时,使用 mmap 可以大大提高效率。通过内存映射,进程可以像访问内存一样访问文件数据,减少了磁盘 I/O 的开销。

参考资料中提到,进程读写数据时,使用 mmap 进行文件映射可以减少一次拷贝操作。磁盘文件直接加载到用户空间,进程可以通过指针直接操作文件,理论上比传统的 read 和 write 操作要快。虽然在读写过程中可能会触发大量中断,但对于大文件的处理,mmap 仍然具有很大的优势。

6.2 进程间通信,多个进程可通过共享内存实现快速通信。

多个进程可以通过共享内存的方式,使用 mmap 来共享内存段,实现进程间快速通信。例如,在父子进程或无亲缘关系的进程中,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域,从而实现进程间通信。

参考资料中提到,在进程间通信的场景下,可以使用 mmap 将文件映射到内存,多个进程通过对同一文件的读写达到进程间通信的目的。同时,共享匿名内存也可以让相关进程共享一块内存区域,通常用于父子进程。

6.3 内存分配,匿名映射可提供比 malloc 更灵活的内存管理机制。

当需要大块的内存,或者特定对齐要求的内存时,mmap 的匿名映射可以提供比 malloc 更灵活的内存管理机制。例如,当需要分配的内存大于一定阈值(如 128KB)时,glibc 会默认使用 mmap 代替 brk 来分配内存。

私有匿名映射最常见的用途是在 glibc 分配大块的内存中。同时,共享匿名映射也可以让相关进程共享一块内存区域,为内存分配提供了更多的灵活性。

七、如何使用mmap技术

7.1 mmap使用细节

使用 mmap 需要注意的一个关键点是,mmap 映射区域大小必须是物理页大小(page_size)的整倍数(32位系统中通常是4k字节)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap 从磁盘到虚拟地址空间的映射也必须是页。

内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。

映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。

在上面的知识前提下,我们下面看看如果大小不是页的整倍数的具体情况:

①情形一:一个文件的大小是 5000 字节,mmap 函数从一个文件的起始位置开始,映射 5000 字节到虚拟内存中。

分析:因为单位物理页面的大小是 4096 字节,虽然被映射的文件只有 5000 字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此 mmap 函数执行后,实际映射到虚拟内存区域 8192 个 字节,5000~8191 的字节部分用零填充。映射后的对应关系如下图所示:

文件大小为5000字节时的内存映射布局

此时:
(1)读/写前 5000 个字节(0~4999),会返回操作文件内容。
(2)读字节 5000~8191 时,结果全为 0。写 5000~8191 时,进程不会报错,但是所写的内容不会写入原文件中 。
(3)读/写 8192 以外的磁盘部分,会返回一个 SIGSECV 错误。

②情形二:一个文件的大小是 5000 字节,mmap 函数从一个文件的起始位置开始,映射 15000 字节到虚拟内存中,即映射大小超过了原始文件的大小。

分析:由于文件的大小是 5000 字节,和情形一一样,其对应的两个物理页。那么这两个物理页都是合法可以读写的,只是超出 5000 的部分不会体现在原文件中。由于程序要求映射 15000 字节,而文件只占两个物理页,因此 8192 字节~15000 字节都不能读写,操作时会返回异常。如下图所示:

映射大小超过原始文件时的内存映射布局

此时:
(1)进程可以正常读/写被映射的前 5000 字节(0~4999),写操作的改动会在一定时间后反映在原文件中。
(2)对于 5000~8191 字节,进程可以进行读写过程,不会报错。但是内容在写入前均为 0,另外,写入后不会反映在文件中。
(3)对于 8192~14999 字节,进程不能对其进行读写,会报 SIGBUS 错误。
(4)对于 15000 以外的字节,进程不能对其读写,会引发 SIGSEGV 错误。

③情形三:一个文件初始大小为 0,使用 mmap 操作映射了 1000*4K 的大小,即 1000 个物理页大约 4M 字节空间,mmap 返回指针 ptr。

分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为 0,并没有合法的物理页对应,如同情形二一样,会返回 SIGBUS 错误。但是如果,每次操作 ptr 读写前,先增加文件的大小,那么 ptr 在文件大小内部的操作就是合法的。例如,文件扩充 4096 字节,ptr 就能操作 ptr ~ [ (char)ptr + 4095] 的空间。只要文件扩充的范围在 1000 个物理页(映射范围)内,ptr 都可以对应操作相同的大小。这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费。

7.2 函数定义及参数解释

在 Linux 中,mmap 函数定义如下:void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);。参数解释如下:

  • addr:希望映射的起始地址,通常为 NULL,表示由内核决定映射的地址。
  • length:映射区域的大小(以字节为单位)。
  • prot:映射区域的保护权限,决定映射的页面是否可读、可写等。常见的权限选项包括:PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行)、PROT_NONE(无权限)。
  • flags:映射的类型和行为控制。常见的标志包括:MAP_SHARED(共享映射,对该内存的修改会同步到文件)、MAP_PRIVATE(私有映射,对该内存的修改不会影响原文件,写时拷贝)、MAP_ANONYMOUS(匿名映射,不涉及文件,通常用于分配未初始化的内存)。
  • fd:文件描述符,指向要映射的文件。如果使用匿名映射,应将 fd 设置为 -1,并且需要设置 MAP_ANONYMOUS 标志。
  • offset:文件映射的偏移量,必须是页面大小的整数倍(通常为 4096 字节)。

返回值:返回映射区域的起始地址,如果映射失败,则返回 MAP_FAILED。

7.3 mmap映射

在内存映射的过程中,并没有实际的数据拷贝,文件没有被载入内存,只是逻辑上被放入了内存,具体到代码,就是建立并初始化了相关的数据结构(struct address_space),这个过程有系统调用 mmap() 实现,所以建立内存映射的效率很高。既然建立内存映射没有进行实际的数据拷贝,那么进程又怎么能最终直接通过内存操作访问到硬盘上的文件呢?

那就要看内存映射之后的几个相关的过程了。mmap() 会返回一个指针 ptr,它指向进程逻辑地址空间中的一个地址,这样以后,进程无需再调用 read 或 write 对文件进行读写,而只需要通过 ptr 就能够操作文件。但是 ptr 所指向的是一个逻辑地址,要操作其中的数据,必须通过 MMU 将逻辑地址转换成物理地址,这个过程与内存映射无关。

前面讲过,建立内存映射并没有实际拷贝数据,这时,MMU 在地址映射表中是无法找到与 ptr 相对应的物理地址的,也就是 MMU 失败,将产生一个缺页中断,缺页中断的中断响应函数会在 swap 中寻找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则会通过 mmap() 建立的映射关系,从硬盘上将文件读取到物理内存中。这个过程与内存映射无关。如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘上,这个过程也与内存映射无关。

mmap 内存映射的实现过程:

  • 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
  • 调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
  • 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

适合的场景

  • 您有一个很大的文件,其内容您想要随机访问一个或多个时间
  • 您有一个小文件,它的内容您想要立即读入内存并经常访问。这种技术最适合那些大小不超过几个虚拟内存页的文件。(页是地址空间的最小单位,虚拟页和物理页的大小是一样的,通常为 4KB。)
  • 您需要在内存中缓存文件的特定部分。文件映射消除了缓存数据的需要,这使得系统磁盘缓存中的其他数据空间更大 当随机访问一个非常大的文件时,通常最好只映射文件的一小部分。映射大文件的问题是文件会消耗活动内存。如果文件足够大,系统可能会被迫将其他部分的内存分页以加载文件。将多个文件映射到内存中会使这个问题更加复杂。

不适合的场景

  • 您希望从开始到结束的顺序从头到尾读取一个文件
  • 这个文件有几百兆字节或者更大。将大文件映射到内存中会快速地填充内存,并可能导致分页,这将抵消首先映射文件的好处。对于大型顺序读取操作,禁用磁盘缓存并将文件读入一个小内存缓冲区
  • 该文件大于可用的连续虚拟内存地址空间。对于 64 位应用程序来说,这不是什么问题,但是对于 32 位应用程序来说,这是一个问题
  • 该文件位于可移动驱动器上
  • 该文件位于网络驱动器上

示例代码

//
//  ViewController.m
//  TestCode
//
//  Created by zhangdasen on 2020/5/24.
//  Copyright © 2020 zhangdasen. All rights reserved.
//
#import "ViewController.h"
#import <sys/mman.h>      // 内存映射相关头文件
#import <sys/stat.h>      // 文件状态相关头文件
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // 1.构建测试文件路径:在应用沙盒主目录下创建test.data文件
    NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"test.data"];
    NSLog(@"文件路径: %@", path);
    // 2.向测试文件中写入初始字符串
    NSString *str = @"test str2";
    [str writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil];
    // 3.调用ProcessFile函数处理文件(通过内存映射追加内容)
    ProcessFile(path.UTF8String);
    // 4.读取并打印处理后的文件内容
    NSString *result = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
    NSLog(@"处理结果:%@", result);
}
/**
 * @brief  将文件映射到内存空间
 * @param inPathName    输入文件路径(C字符串格式)
 * @param outDataPtr    输出参数:映射到内存的起始地址指针
 * @param outDataLength 输出参数:原始文件的长度(映射后的大小=原始长度+追加长度)
 * @param appendSize    需要追加的数据大小(字节数)
 * @return int          错误码,0表示成功,非0为系统错误码(errno)
 */
int MapFile(const char * inPathName, void ** outDataPtr, size_t * outDataLength, size_t appendSize)
{
    int outError;          // 错误码存储变量
    int fileDescriptor;    // 文件描述符
    struct stat statInfo;  // 用于存储文件状态信息
    // 初始化输出参数为安全值(出错时返回这些值)
    outError = 0;
    *outDataPtr = NULL;
    *outDataLength = 0;
    // Step1:以读写模式打开指定路径的文件,获取文件描述符
    fileDescriptor = open(inPathName, O_RDWR, 0);
        if(fileDescriptor < 0) {            //打开失败的情况处理  
        outError = errno;                  //记录系统错误码  
        } else {                          //成功打开的情况  
        /* Step2:获取当前文件的详细状态信息 */  
            if(fstat(fileDescriptor, &statInfo) !=0 ) {  
                outError=errno;              
            } else {                      
            /* Step3:扩展原文件的尺寸以便容纳要追加的内容 */  
                ftruncate(fileDescriptor , statInfo.st_size + appendSize);  
                fsync(fileDescriptor);      /*确保数据同步到磁盘*/  
            /* Step4:建立内存映射区域 */  
                void* mappedPtr=mmap(NULL ,                     /*由内核自动选择映射起始地址*/  
                                     statInfo.st_size+appendSize ,/*总映射大小=原大小+追加大小*/  
                                     PROT_READ|PROT_WRITE ,      /*可读可写权限*/  
                                     MAP_FILE|MAP_SHARED ,       /*基于文件的共享式映射*/  
                                     fileDescriptor ,            /*对应的已打开的文件描述符*/  
                                     0 );                        /*从该文件的偏移量零处开始*/  
                    if(mappedPtr==MAP_FAILED){                
                        outError=errno ;                        
                    } else{                                     
                    /*Step5 :设置正确的输出参数值 */   
                        (*outDataPtr)=mappedPtr ;                
                        (*outDataLength)=statInfo.st_size ;      
                    }
            }
        close(fileDescriptor);              /*关闭不再需要的原始fd(不影响已建立的mmap)*/ 
        }
    return(outError);                        
}
/**
*@brief  对指定路径下的文本型文档进行尾部内容添加操作。
*@param inPathName C风格字符串表示的待操作文档完整物理位置。
*/
void ProcessFile(const char*inPathName){
 size_t originalLen ;                       /**<原始文档字节数**/
 void*mappedAddr=NULL ;                     /**<被成功建立起来的虚拟地址空间首址**/
 const char*appendContent=" append_key2";   /**<待附加至末尾处的固定串常量**/
 const int appendBytes=(int)strlen(appendContent);
 if(MapFile(inPathName,&mappedAddr,&originalLen,appendBytes)==SUCCESS){
     char*targetPos=(char*)mappedAddr+originalLen;/**<计算得到应放置新内容的起始位置**/
     memcpy(targetPos,appendContent,(size_t)appendBytes);/**执行实际复制动作**/
     munmap(mappedAddr,(size_t)(originalLen+appendBytes));/**解除之前建立的整个区域与物理页面间联系*/
 }
}
@end 
/*补充说明:
1.mmap机制允许将普通磁盘上某个连续区间直接关联至进程虚拟地址空间中,
  后续所有针对该段虚拟地址之读写均会由OS透明地转换为对应盘区I/O。
2.MAP_SHARED标志确保修改能同步回写到底层支撑介质,
  并且其他进程若以同样方式同时挂载则可立即观察到变化。
3.munmap调用并非必须但建议显式执行以尽早释放占用的页表项资源;
  否则将在本进程终止时由内核自动回收。
4.此示例展示了如何高效完成“原地扩展并修改”类任务而无需传统read/write循环带来的多次缓冲拷贝开销。
*/

7.5 解除映射的方法

使用 mmap 后,必须调用 munmap 来解除映射,释放分配的虚拟内存。其函数定义如下:int munmap(void *addr, size_t length);

  • addr:要解除映射的内存区域的起始地址。
  • length:要解除映射的大小。

返回值:成功返回 0,失败返回 -1。

(1)利用 mmap 访问硬件,减少数据拷贝次数

mmap 可以将文件、设备等外部资源映射到内存地址空间,进程可以像访问内存一样访问文件数据或硬件资源。当使用 mmap 访问硬件时,数据可以直接从硬件设备通过 DMA 拷贝到内核缓冲区,然后进程可以直接访问这个缓冲区,减少了数据拷贝的次数。

例如,在嵌入式系统中,可以使用 mmap 将物理地址映射到用户虚拟地址空间,实现对硬件设备的直接访问。在进行数据传输时,避免了传统方式中从内核空间到用户空间的多次数据拷贝,提高了数据传输的效率。

(2)通过 mmap 实现将物理地址映射到用户虚拟地址空间

可以通过以下步骤实现将物理地址映射到用户虚拟地址空间:

  • 打开 /dev/mem 文件获得文件描述符 dev_mem_fd。
  • 使用 mmap 函数进行映射,将物理地址映射到用户虚拟地址空间。例如,定义一个函数 dma_mmap 来实现这个功能,函数原型为 int dma_mmap(unsigned long addr_p, unsigned int len, unsigned char** addr_v)。在这个函数中,首先打开 /dev/mem 文件,然后使用 mmap 函数进行映射,最后返回虚拟地址。
  • 使用映射后的虚拟地址进行操作,例如读写硬件设备。
  • 在使用完后,调用 dma_munmap 函数解除映射,释放资源。函数原型为 unsigned int dma_munmap(unsigned char* addr_v, unsigned long addr_p, unsigned int len)

(3)在嵌入式系统中,还可以通过以下方式实现物理地址到用户虚拟地址空间的映射:

  • 在驱动程序中,实现 mmap 方法,建立虚拟地址到物理地址的页表。例如,可以使用 remap_pfn_range 函数一次建立所有页表,或者使用 nopage VMA 方法每次建立一个页表。
  • 在用户空间程序中,使用 mmap 函数进行映射,将文件描述符、映射大小、保护权限等参数传入,获得映射后的虚拟地址。然后可以通过这个虚拟地址对硬件设备进行操作。

通过以上详细的技术解析、应用场景分析和实战代码,相信你对 mmap 有了更深入的理解。无论是应对面试中的深度问题,还是在日常开发中选择合适的 I/O 方案,掌握 mmap 的核心原理都将让你更加从容。




上一篇:稳定高效的 Docker 镜像加速方案:1ms.run 实测与背景解析
下一篇:运行时检测Android APK加固的通用行为识别思路与Frida脚本实践
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-27 19:32 , Processed in 0.375121 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表