mmap

mmap 在进程的虚拟地址空间开辟一块区域,这块区域映射文件在磁盘上的物理地址,是将内存地址空间映射到磁盘地址空间的一种方法

读/写操作访问虚拟地址空间这一段映射地址,通过查询页表发现这一段地址并不在物理页面上(因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中),因此引发缺页异常,内核发起请求调页过程。调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用 nopage 函数把所缺的页从磁盘装入到主存中。

之后如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程;修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用 msync() 来强制同步, 这样所写的内容就能立即保存到文件里了

它的优点有:

  1. 常规文件操作需要从磁盘到页缓存再到用户主存的 两次数据拷贝,而 mmap 操控文件只需要从磁盘到用户主存的 一次数据拷贝 过程
  2. 实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉。
  3. 提供进程间共享内存及相互通信的方式。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域。从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。如果进程 A 和进程 B 都映射了区域 C,当 A 第一次读取 C 时通过缺页从磁盘复制文件页到内存中;但当 B 再读 C 的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。
  4. 可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件 I/O 操作,极大影响效率。这个问题可以通过 mmap 映射很好的解决。换句话说,但凡是需要用磁盘空间代替内存的时候,mmap 都可以发挥其功效。

MemoryFile

MMKV 使用 MemoryFile 包装 mmap 相关逻辑

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

class MemoryFile {
    File m_diskFile;    // 磁盘上的文件
    void *m_ptr;        // 映射到文件物理地址的区域(在内存地址空间里),它的起始地址
    size_t m_size;      // 内存空间区域的大小
}

using MMKVFileHandle_t = HANDLE;
using MMKVPath_t = std::wstring;

class File {
    MMKVPath_t m_path;        // 文件路径
    MMKVFileHandle_t m_fd;    // 打开的文件描述符
}

// 通过系统调用 open 打开文件拿到文件描述符 fd,并用系统调用 fstat 拿到文件大小,然后 mmap 这整个文件获得映射区域的内存地址

using MMKVPath_t = std::wstring;

MemoryFile::MemoryFile(MMKVPath_t path) : m_diskFile(std::move(path), OpenFlag::ReadWrite | OpenFlag::Create), m_ptr(nullptr), m_size(0) {
    reloadFromFile();
}

void MemoryFile::reloadFromFile() {
    if (!m_diskFile.open()) {
        MMKVError("fail to open:%s, %s", m_diskFile.m_path.c_str(), strerror(errno));
    } else {
        FileLock fileLock(m_diskFile.m_fd);
        InterProcessLock lock(&fileLock, ExclusiveLockType);
        SCOPED_LOCK(&lock);

        mmkv::getFileSize(m_diskFile.m_fd, m_size);
        if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {    // 确保文件大小是内存页大小的整数倍
            size_t roundSize = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
            truncate(roundSize);
        } else {
            auto ret = mmap();
            if (!ret) {
                doCleanMemoryCache(true);
            }
        }
    }
}

bool File::open() {
    if (isFileValid()) {
        return true;
    }
    m_fd = ::open(m_path.c_str(), OpenFlag2NativeFlag(m_flag), S_IRWXU);
    if (!isFileValid()) {
        MMKVError("fail to open [%s], %d(%s)", m_path.c_str(), errno, strerror(errno));
        return false;
    }
    MMKVInfo("open fd[%p], %s", m_fd, m_path.c_str());
    return true;
}

bool getFileSize(int fd, size_t &size) {
    struct stat st = {};
    if (fstat(fd, &st) != -1) {
        size = (size_t) st.st_size;
        return true;
    }
    return false;
}

bool MemoryFile::mmap() {
    m_ptr = (char *) ::mmap(m_ptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_diskFile.m_fd, 0);
    if (m_ptr == MAP_FAILED) {
        MMKVError("fail to mmap [%s], %s", m_diskFile.m_path.c_str(), strerror(errno));
        m_ptr = nullptr;
        return false;
    }

    return true;
}

Encoding - 数据格式

深入 SharedPreferences:架构、缺点和优化 研究过 SharedPreferences 本质上是内存中的 HashMap 和磁盘上的 XML 文件,Java HashMap 提供了 CURD Api,持久化时序列化为 XML 格式,本质上是以字符串存储

但在 MMKV 里面对的是一整块内存区域,怎么对这块区域进行 CURD 操作呢?怎么实现 Key-Value Mapping 呢?

在内存空间里,MMKV 使用 std::unordered_map 这一数据结构实现 Key-Value Mapping,而 Key-Value 对的内容则是以一种很紧凑的格式存储在 mmap 开辟的内存区域

[(key-size)(key-data)(value-size)(value-data)][(key-size)(key-data)(value-size)(value-data)]...

因为 mmap 内存区域是对文件物理地址的映射,所以持久化在磁盘上的格式也是上面这种紧凑格式

Varints - 变长的 size