3. munmap与内存同步:解除映射、同步机制与共享模式

在 Linux 内存映射的生命周期中,解除映射(munmap) 和 内存同步(msync) 是决定数据完整性与资源释放的关键环节。本章深入分析 munmap 的底层行为、msync 的同步策略,以及 MAP_SHARED 与 MAP_PRIVATE 在写回机制上的本质差异。

3.1 munmap:解除映射的完整流程

munmap 系统调用用于删除指定地址区间的所有页表项,并释放对应的虚拟内存区域(VMA)。其原型如下:

#include <sys/mman.h>
int munmap(void *addr, size_t length);

解除映射时,内核执行以下关键步骤:

  1. VMA 查找与校验:根据 addr 在进程的 VMA 红黑树中定位对应的 vma 结构体,校验区间合法性。
  2. 页表解除:遍历该区间内的所有页表项,清除 PTE 中的 present 位,并刷新 TLB。
  3. 物理页引用计数递减:对于 MAP_SHARED 映射,递减物理页的引用计数;若计数归零且页为脏,则触发回写。
  4. VMA 分裂或合并:若解除区间位于 VMA 中间,内核将原 VMA 分裂为两个独立的 VMA。
  5. 文件映射的脏页回写:对于 MAP_SHARED 的文件映射,内核在 munmap 时自动将脏页写回文件(但不等同于 msync 的强制同步)。
关键陷阱:munmap 不会自动调用 msync。对于 MAP_SHARED 映射,如果 munmap 前没有显式调用 msync,脏页可能尚未写回磁盘。系统崩溃或意外断电会导致数据丢失。
munmap 解除映射流程 用户调用 munmap VMA 查找与校验 页表解除 & TLB 刷新 物理页引用计数递减 MAP_SHARED 脏页自动回写 (非强制同步,不等同 msync) MAP_PRIVATE 丢弃脏页 (写时复制页直接释放) VMA 分裂/合并 & 返回

3.2 msync:内存同步的精细控制

msync 系统调用用于将内存映射区域中的修改同步回底层文件,是保证 MAP_SHARED 映射数据持久化的核心手段。

#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);

flags 参数控制同步行为,三种模式对比如下:

标志 行为描述 阻塞行为 典型场景
MS_ASYNC 调度脏页写回,立即返回 非阻塞 批量写入后异步落盘,不阻塞业务
MS_SYNC 等待所有脏页写回完成 阻塞直到 I/O 完成 关键数据持久化,确保断电安全
MS_INVALIDATE 使其他映射的缓存行失效 非阻塞(常与 MS_SYNC 组合) 多进程共享文件映射的一致性维护
最佳实践:在 munmap 之前,对 MAP_SHARED 映射调用 msync(addr, length, MS_SYNC) 确保数据落盘。对于数据库或日志系统,建议使用 MS_SYNC 而非 MS_ASYNC,避免系统崩溃导致数据丢失。

3.3 MAP_SHARED 与 MAP_PRIVATE 的本质差异

两种映射模式在写回策略、内存开销和进程间可见性上存在根本区别。下表从多个维度进行对比:

对比维度 MAP_SHARED MAP_PRIVATE
写回行为 修改直接写入物理页,脏页可写回文件 触发写时复制(COW),修改在私有副本上进行
进程间可见性 所有映射同一文件的进程可见修改 修改仅对当前进程可见
msync 效果 将脏页写回文件,持久化修改 msync 无实际效果(私有副本不关联文件)
munmap 行为 脏页可能自动回写(非保证) 私有副本直接丢弃,不写回文件
内存开销 共享同一物理页,节省内存 写时复制产生额外物理页,内存开销增大
典型用途 进程间共享内存、文件 I/O 加速 加载可执行文件、动态库、配置读取
MAP_SHARED vs MAP_PRIVATE 写回机制 MAP_SHARED 进程 A 写入共享页 直接修改物理页(无 COW) 进程 B 立即可见修改 msync(MS_SYNC) 写回文件 munmap 自动回写(非保证) 数据持久化到磁盘文件 MAP_PRIVATE 进程 A 写入私有页 触发写时复制(COW) 创建私有副本物理页 进程 B 不可见修改 msync 无效果 munmap 丢弃私有副本

3.4 调试案例:共享内存同步丢失

以下是一个典型的调试场景,展示未正确使用 msync 导致的数据丢失问题:

/* 错误示例:未调用 msync 直接 munmap */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    int fd = open("/tmp/shared.dat", O_RDWR | O_CREAT, 0644);
    ftruncate(fd, 4096);

    char *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);

    /* 写入数据 */
    sprintf(addr, "Critical log entry #%d", 42);

    /* 错误:没有 msync 直接 munmap */
    munmap(addr, 4096);
    close(fd);

    /* 此时数据可能尚未落盘,系统崩溃将丢失 */
    return 0;
}
/* 正确示例:munmap 前显式调用 msync */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    int fd = open("/tmp/shared.dat", O_RDWR | O_CREAT, 0644);
    ftruncate(fd, 4096);

    char *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);

    /* 写入数据 */
    sprintf(addr, "Critical log entry #%d", 42);

    /* 正确:强制同步到磁盘 */
    if (msync(addr, 4096, MS_SYNC) == -1) {
        perror("msync failed");
    }

    munmap(addr, 4096);
    close(fd);
    return 0;
}
调试技巧:使用 strace -e mmap,munmap,msync ./program 跟踪系统调用序列,确认 msync 是否被调用以及返回状态。结合 cat /proc/[pid]/maps 查看映射区域的权限和偏移。

3.5 内核视角:VMA 与页表操作

从内核源码角度看,munmap 的核心路径位于 mm/mmap.c 中的 do_munmap() 函数。关键操作包括:

  • vma 查找:通过 find_vma() 在红黑树中定位起始 VMA。
  • 页表遍历:调用 unmap_region() 遍历页表,清除 PTE 并释放页表页。
  • 脏页处理:对于 MAP_SHARED 映射,filemap_write_and_wait() 尝试回写脏页,但不等同于 msync 的强制等待。
  • VMA 操作:split_vma() 和 remove_vma() 维护 VMA 链表和红黑树。

msync 在内核中对应 mm/msync.c 的 sys_msync(),其核心逻辑:

/* 内核 msync 简化流程 */
SYSCALL_DEFINE3(msync, unsigned long, start, size_t, len, int, flags)
{
    /* 1. 校验地址区间合法性 */
    /* 2. 遍历区间内所有 VMA */
    /* 3. 对 MAP_SHARED 的 VMA 调用 filemap_write_and_wait_range() */
    /* 4. 若 flags 包含 MS_INVALIDATE,调用 invalidate_mapping_pages() */
    /* 5. 返回 0 或错误码 */
}
关键结论:MAP_SHARED + msync(MS_SYNC) 是保证数据持久化的唯一可靠组合。MAP_PRIVATE 的修改永远不会写回文件,munmap 时直接丢弃。在驱动与用户空间共享内存的场景中,务必使用 MAP_SHARED 并在 munmap 前调用 msync。