身为计算机专业的学生,在学习 Linux 系统编程的过程中,我们总会遇到文件 IO 的性能瓶颈问题。传统的read/write系统调用虽然简单易用,但在处理大文件、高频访问场景时,频繁的用户态内核态切换、数据拷贝开销总会成为性能的拦路虎。
这时候,mmap这个神奇的系统调用就进入了我们的视野。它通过将文件直接映射到进程的虚拟地址空间,让我们可以像操作内存一样操作文件,实现了近乎 "零拷贝" 的高效访问。今天我们就从原理到实战,彻底搞懂 mmap 的核心知识点。
一、什么是 mmap?
mmap的全称是 Memory Map,也就是内存映射,它的核心作用是:允许用户空间程序将文件或设备的内容直接映射到进程的虚拟地址空间中。
通过这种映射,程序访问文件数据的时候,就不再需要通过传统的read或write系统调用进行数据拷贝了,而是直接通过内存指针来读写,效率得到了极大的提升。除此之外,mmap 还可以用来实现进程间的共享内存,让不同进程可以直接共享同一块内存区域的数据。
我们可以通过这张工作原理图,直观地理解 mmap 和传统 IO 的区别:
传统的文件 IO 中,数据需要经过 "磁盘→内核页缓存→用户缓冲区" 两次拷贝,而 mmap 则直接建立了用户虚拟地址到内核页缓存的映射,省去了中间的用户态拷贝步骤,这也是它性能优势的核心来源。
二、mmap 参数解析
要使用 mmap,首先得搞懂它的函数原型和各个参数的含义,这是我们正确使用它的基础:
cpp
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void *addr, size_t length);
其中munmap是用来解除映射的函数,相对简单,我们重点来看mmap的 6 个参数:
1. addr:映射起始地址提示
这个参数是给内核的一个提示,告诉内核我们希望映射区域从哪个地址开始。不过内核不一定会遵守这个提示,尤其是当我们没有足够权限指定特定地址的时候。
如果我们把addr设为NULL,系统会自动帮我们选择一个合适的空闲地址,这也是我们最常用的方式,避免手动指定地址带来的兼容性问题。
2. length:映射区域长度
这个参数表示我们要映射到进程地址空间的字节数。这里有一个很重要的点:这个长度必须是系统页面大小的整数倍,通常 x86 系统的页面大小是 4KB。
如果我们指定的长度不是页大小的整数倍,系统会自动向上舍入到最近的页面边界。比如我们要映射 3500 字节的内容,系统实际会分配 4096 字节的内存区域,这一点一定要注意,避免越界访问的问题。
3. prot:内存保护属性
这个参数用来指定映射区域的内存保护权限,它可以是以下几个值的按位或组合:
-
PROT_READ:映射区域可读
-
PROT_WRITE:映射区域可写
-
PROT_EXEC:映射区域可执行
这个权限必须和我们打开文件时的权限匹配,比如我们要对映射区域进行写操作,那打开文件的时候就必须用读写模式,否则会报错。
4. flags:映射类型标志
这个参数是最核心的参数之一,它决定了映射的类型和行为,最常用的两个标志是:
-
MAP_PRIVATE:创建私有映射。对映射区域的修改不会写入到底层文件中,而是会触发写时复制(Copy-on-Write),修改的内容只会在当前进程可见,其他进程看不到。
-
MAP_SHARED:创建共享映射。对映射区域的修改会直接反映到底层文件中,同时其他映射了同一个文件的进程也能看到这些修改,这也是实现进程间共享内存的基础。
除此之外还有MAP_ANONYMOUS标志,用来创建匿名映射,也就是不与任何文件关联的内存区域,通常用来做内存分配,我们后面会讲到。
5. fd:文件描述符
这个是要映射的文件的文件描述符,通过open函数打开文件得到的。如果我们是创建匿名映射的话,这个参数可以设为 - 1,会被内核忽略。
6. offset:文件偏移量
这个参数表示我们要从文件的哪个位置开始映射,它和length一起,定义了我们要映射的文件片段。注意,这个偏移量也必须是页对齐的,否则 mmap 会调用失败。
返回值
调用成功的话,mmap 会返回映射区域的起始地址,我们可以直接用指针来操作这块内存。如果失败的话,会返回MAP_FAILED,也就是(void *)-1,同时会设置errno来指示错误原因。
而munmap调用成功的话返回 0,失败返回 - 1,用来解除映射,释放对应的虚拟地址空间。
三、基础实战:mmap 的文件读写
讲完了参数,我们来看最基础的用法:用 mmap 来实现文件的读写,这也是最常见的场景。
(1) 写入映射
下面的代码演示了如何通过 mmap 向文件中写入数据,我们可以看到,整个过程就像操作数组一样简单:
cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <cstring>
#define SIZE 4096
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " filename" << std::endl;
return 1;
}
std::string filename = argv[1];
// 注意:要进行写入映射,必须以O_RDWR模式打开文件
int fd = ::open(filename.c_str(), O_CREAT | O_RDWR, 0666);
if(fd < 0)
{
std::cerr << "open error" << std::endl;
return 2;
}
// 重点:默认新建的文件大小是0,无法直接映射,需要先调整文件大小
::ftruncate(fd, SIZE);
// 创建共享映射,读写权限
char *mmap_addr = (char*)::mmap(nullptr, SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if(mmap_addr == MAP_FAILED)
{
perror("mmap");
return 3;
}
// 直接像操作内存一样操作文件!
for(int i = 0; i < SIZE; i++)
mmap_addr[i] = 'a' + i%26;
// 也可以直接用memcpy之类的内存操作函数
// memcpy(mmap_addr, "hello", 5);
// 解除映射
::munmap(mmap_addr, SIZE);
// 关闭文件
close(fd);
return 0;
}
这里有两个很关键的点:
-
写入的时候必须用O_RDWR打开文件,同时映射的权限要设为PROT_WRITE,否则没有写权限
-
新建的文件默认大小是 0,必须先用ftruncate调整文件大小,否则 mmap 之后访问内存会触发总线错误
(2)读取映射:直接读取文件内容
对应的,我们也可以用 mmap 来读取文件,同样非常简单:
cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <cstring>
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " filename" << std::endl;
return 1;
}
std::string filename = argv[1];
// 只读打开文件即可
int fd = ::open(filename.c_str(), O_RDONLY);
if(fd < 0)
{
std::cerr << "open error" << std::endl;
return 2;
}
// 获取文件的真实大小,用来确定映射长度
struct stat st;
::fstat(fd, &st);
// 创建只读的共享映射
char *mmap_addr = (char*)::mmap(nullptr, st.st_size, PROT_READ,
MAP_SHARED, fd, 0);
if(mmap_addr == MAP_FAILED)
{
perror("mmap");
return 3;
}
// 直接打印映射的内存,就相当于读取了文件内容
std::cout << mmap_addr <<std::endl;
// 解除映射
munmap(mmap_addr, st.st_size);
// 关闭文件
::close(fd);
return 0;
}
可以看到,整个过程我们完全没有调用read函数,就把文件的内容读取出来了,这就是 mmap 的神奇之处。
四、用 mmap 极简实现 malloc
除了文件映射,mmap 还可以用来做内存分配,这就要用到我们之前提到的MAP_ANONYMOUS标志,创建匿名映射,也就是不关联任何文件的内存区域。
我们可以用这个特性,自己实现一个极简版本的malloc和free:
cpp
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>
// 使用mmap分配内存
void* my_malloc(size_t size) {
void* ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED) {
perror("mmap");
exit(EXIT_FAILURE);
}
return ptr;
}
// 使用munmap释放内存
void my_free(void* ptr, size_t size) {
if (munmap(ptr, size) == -1) {
perror("munmap");
exit(EXIT_FAILURE);
}
}
int main() {
size_t size = 1024; // 分配1KB内存
char* ptr = (char*)my_malloc(size);
// 打印分配到的内存地址
printf("Allocated memory at address: %p\n", ptr);
// 使用这块内存
memset(ptr, 'A', size);
for(int i = 0; i < size; i++)
{
printf("%c ", ptr[i]);
sleep(1);
}
// 释放内存
my_free(ptr, size);
return 0;
}
我们可以用 gdb 来验证一下,在分配内存前后,进程的地址空间映射的变化:
分配内存之前,进程的映射区域是这样的:
cpp
Start Addr End Addr Size Offset objfile
0x555555554000 0x555555555000 0x1000 0x0 /home/xxx/a.out
0x555555555000 0x555555556000 0x1000 0x1000 /home/xxx/a.out
0x555555556000 0x555555557000 0x1000 0x2000 /home/xxx/a.out
0x555555557000 0x555555558000 0x1000 0x2000 /home/xxx/a.out
0x555555558000 0x555555559000 0x1000 0x3000 /home/xxx/a.out
0x7ffff7dcd000 0x7ffff7def000 0x22000 0x0 /usr/lib/libc.so
...
0x7ffff7ffb000 0x7ffff7ffc000 0x1000 0x0
0x7ffff7ffc000 0x7ffff7ffd000 0x1000 0x2c000 /usr/lib/ld.so
...
而在我们调用my_malloc分配内存之后,我们可以看到,进程的地址空间里多了一块新的映射区域,就是我们分配的 1KB 内存:
cpp
Allocated memory at address: 0x7ffff7ffb000
这正好对应了上面映射表中的0x7ffff7ffb000这个地址,说明我们的 mmap 确实成功分配了内存,而且这个内存是匿名的,没有关联任何文件,这就是匿名映射的作用。
实际上,glibc 的malloc函数,在分配大于 128KB 的大内存的时候,底层就是用的 mmap 来实现的,这样分配的内存可以在释放的时候直接归还给系统,而不是留在堆里,避免了内存碎片的问题。
五、性能对比
说了这么多,mmap 的性能到底有多强?我们来看一组实测的性能数据,就能直观地感受到它的优势。
1. 大文件读取性能对比
我们以读取 1GB 的大文件为例,对比传统的read/write和 mmap 的性能:
|------------|-----------------|---------|-----------|
| 操作方式 | 1GB 文件读取耗时 (ms) | CPU 占用率 | 内存占用 (MB) |
| read/write | 1200 | 85% | 1024 |
| mmap | 450 | 45% | 1024 |
可以看到,mmap 的耗时只有传统 IO 的不到一半,CPU 占用率更是直接降了一半还多,这就是因为它省去了数据拷贝和频繁的系统调用开销。
2. 小批量写入性能对比
而在小批量写入的场景下,mmap 的优势更加明显,我们来看不同写入大小下的耗时对比:
|------------|-------------|--------------|
| 每次写入大小 | mmap 耗时 (s) | write 耗时 (s) |
| 1 byte | 22.14 | >300 |
| 100 bytes | 2.84 | 22.86 |
| 512 bytes | 2.51 | 5.43 |
| 1024 bytes | 2.48 | 3.48 |
| 2048 bytes | 2.47 | 2.34 |
| 4096 bytes | 2.48 | 1.74 |
我们可以看到,当每次写入的字节数很小的时候,传统的write调用的性能极差,因为每次写入都要触发一次系统调用,上下文切换的开销极大。而 mmap 则不需要,直接写内存就可以了,所以性能优势非常大,比如每次写 1 字节的时候,mmap 的速度是 write 的十几倍!
而当写入的大小达到了页大小(4KB)之后,传统的 write 性能反而追上来了,这是因为这时候系统的预读机制可以发挥作用,而且每次系统调用可以处理更多的数据,上下文切换的开销被摊薄了
六、优缺点
通过上面的内容,我们可以总结一下 mmap 的优缺点,以及它适合用在什么场景:
(1)优点
-
减少数据拷贝:省去了内核到用户态的内存拷贝,实现了零拷贝,降低 CPU 开销
-
减少系统调用:访问映射内存的时候不需要陷入内核,减少了用户态内核态的上下文切换
-
进程间共享:通过共享映射,可以轻松实现进程间的共享内存,高效的 IPC 方式
-
按需加载:只有当我们真正访问到某一页的时候,才会触发缺页中断加载数据,节省内存
(2)缺点
-
页错误开销:第一次访问映射区域的时候会触发缺页中断,有一定的初始化开销
-
内存对齐要求:映射的大小必须页对齐,小文件会有一定的内存浪费
-
顺序访问劣势:对于顺序访问的场景,传统 IO 的预读机制比 mmap 更有优势
-
错误处理复杂 :如果映射的文件被截断了,访问超出的部分会触发
SIGBUS信号,需要额外处理 -
32 位地址限制:在 32 位系统下,虚拟地址空间有限,大文件映射可能会失败
(3)场景
基于这些特点,mmap 最适合用在这些场景:
-
大文件的随机访问:比如数据库的索引文件,需要频繁随机读取大文件的内容
-
进程间共享内存:多进程之间需要高效共享数据的场景
-
高频小批量写入:比如需要频繁写入小数据的场景,避免频繁的系统调用
-
大内存分配:比如 malloc 分配大内存的时候,用 mmap 来避免堆内存碎片
七、总结
mmap 是 Linux 系统编程中一个非常强大的工具,它通过虚拟内存映射的方式,把文件 IO 变成了内存操作,解决了传统 IO 的性能瓶颈,在很多高性能的应用中都有广泛的应用,比如数据库、缓存、进程间通信等等。