V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
felix021
V2EX  ›  程序员

[译] C程序员该知道的内存知识 (3)

  •  
  •   felix021 ·
    felix021 · 2020-05-16 11:33:09 +08:00 · 2177 次点击
    这是一个创建于 1657 天前的主题,其中的信息可能已经有所发展或是发生改变。

    这个系列太干了,自己挖的坑,含泪也要填完,觉得有趣或者有疑惑的同学欢迎多交流~


    续上篇:

    这是本系列的第 3 篇,预计还会有 1 篇,感兴趣的同学记得关注,以便接收推送,等不及的推荐阅读原文。


    照例放图镇楼:

    linuxFlexibleAddressSpaceLayout.png

    来源:Linux 地址空间布局 - by Gustavo Duarte

    关于图片的解释参见第一篇

    开始吧。

    有趣的内存映射

    工具箱:

    • sysconf() - 在运行时获取配置信息
    • mmap() - 映射虚拟内存
    • mincore() - 判断页是否在内存中
    • shmat() - 共享内存操作

    有些事情是内存分配器没法完成的,需要内存映射来救场。比如说,你无法选择分配的地址范围。为了这个,我们得牺牲一些舒适性 —— 接下来将和整页内存打交道了。注意,虽然一页通常是 4KB,但你不应该依赖这个“通常”,而是应该用 sysconf() 来获取的实际大小:

    long page_size = sysconf(_SC_PAGESIZE); /* Slice and dice. */
    

    备注 —— 即使系统宣称使用统一的 page size (译注:这里指 sysconf 的返回值),它在底层可能用了其他尺寸。例如 Linux 有个叫 transparent huge page ( THP )[2]的概念,可以减少地址翻译的开销(译注:地址翻译指 虚拟地址->线性地址->物理地址,细节比较多,涉及到多级页表、MMU 、TLB 等,详情可参考知乎这篇文章《虚拟地址转换》[3])和连续内存块访问导致的 page fault (译注:本来 4KB 一次,现在 4MB 一次,少了 3 个量级)。但这里还要打个问号,尤其是当物理内存碎片化,导致连续的大块内存较少的情况。一次 page fault 的开销也会随着页面大小提高,因此对于少量随机 IO 负载的情况,huge page 的效率并不高。很不幸这对你是透明的,但 Linux 有一个专有的 mmap 选项 MAP_HUGETLB 允许你明确指定使用这个特性,因此你应该了解它的开销。

    固定内存映射

    举个栗子,假如你现在得为一个小可怜的进程间通信( IPC )建立一个固定映射(译注:两个进程都映射到相同的地址),你该如何选择映射的地址呢?这有个在 x86-32 上可能有点风险的提案,但是在 64 bit 上,大约在 TASK_SIZE 2/3 位置的地址(用户空间最高的可用地址;译注:见镇楼图右上方)大致是安全的。你可以不用固定映射,但是就别想用指向共享内存的指针了(译注:不固定起始地址的话,共享内存中同一个对象在两个不同进程的地址就不一样了,这样的指针无法在两个进程中通用)。

    #define TASK_SIZE 0x800000000000
    #define SHARED_BLOCK (void *)(2 * TASK_SIZE / 3)
    
    void *shared_cats = shmat(shm_key, SHARED_BLOCK, 0);
    if(shared_cats == (void *)-1) {
        perror("shmat"); /* Sad :( */
    }
    

    译注:shmat 是“shared memory attach”的缩写,表示将 shm_key 指定的共享内存映射到 SHARED_BLOCK 开始的虚拟地址上。shm_key 是由 shmget(key, size, flag) 创建的一块共享内存的标识。详细用法请 google 。

    OKay,我知道,这是个几乎无法移植的例子,但是大意你应该能理解了。固定地址映射通常被认为至少是不安全的,因为它不检查那里是否已经映射了其他东西。有一个 mincore() 函数可以告诉你一个页面是否被映射了,但是在多线程环境里你可能不那么走运(译注:可能你刚检查的时候没被映射,但在你映射之前被另一个线程映射了;作者这里使用 mincore 可能不太恰当,因为它只检查页面是否在物理内存中,而一个页面可能被映射了、但是被换出到 swap )。

    然而,固定地址映射不仅在未使用的地址范围上有用,而且对已用的地址范围也有用。还记得内存分配器如何使用 mmap() 来分配大块内存吗?由于按需调页机制,我们可以实现高效的稀疏数组。假设你创建了一个稀疏数组,然后现在你打算释放掉其中一些数据占用的空间,该怎么做呢?你不能 free() 它(译注:因为不是 malloc 分配的),而 mmap () 会让这段地址空间不可用(译注:因为这段地址空间属于稀疏数组,仍可能被访问到,不能被 unmap )。你可以调用 madvise() ,用 MADV_FREE /  MADV_DONTNEED 将这些页面标记为空闲(译注:页面可被回收,但地址空间仍然可用),从性能上来讲这是最佳解决方案,因为这些页面可能不再会因触发 page fault 被载入,不过这些“建议”的语义可能根据具体的实现而变化(译注:换句话说就是虽然性能好,但可移植性不好,例如在 Linux 不同版本以及其他 Unix-like 系统这些建议的语义会有差别;关于这些建议的说明详见上一篇)。

    一种可移植的做法是在这货上面覆盖映射:

    void *array = mmap(NULL, length, PROT_READ|PROT_WRITE,
                       MAP_ANONYMOUS, -1, 0);
    
    /* ... 某些魔法玩脱了 ... */
    
    /* Let's clear some pages. */
    mmap(array + offset, length, MAP_FIXED|MAP_ANONYMOUS, -1, 0);
    

    译注:如前文所述,开头用 mmap() 创建了一个稀疏数组 array ;第四行应该是指代前述需要清理掉其中一部分数据;第 7 行用 mmap 重新映射从 array + offset 开始、长度为 length 字节的空间,注意这行的 length 应当是需要清理的数据长度,不同于第一行的 length (整个稀疏数组的长度)。

    这等价于取消旧页面的映射,并将它们重新映射到那个特殊页面(译注:指上一篇说到的全 0 页面)。这会如何影响进程的内存消耗呢——进程仍然占用同样大小的虚拟内存,但是驻留在物理内存的尺寸减少了(译注:取消旧页面映射时,对应的真实页面被 OS 回收了)。这是我们能做到的最接近 内存打洞 的办法了。

    基于文件的内存映射

    工具箱:

    • msync() - 将映射到内存的文件内容同步到文件系统
    • ftruncate() - 将文件截断到指定的长度
    • vmsplice() - 将用户页面内容写入到管道

    到这里我们已经知道关于匿名内存的所有知识了,但是在 64bit 地址空间中真正让人亮瞎眼的还是基于文件的内存映射,它可以提供智能的缓存、同步和写时复制( copy-on-write ;译注:常缩写为 COW )。是不是太多了点?

    对于大多数人来说,相比直接使用文件系统,LMDB 就像是魔法般的性能如雨点般撒落。

    Baby_Food[4] on r/programming

    译注:LMDB ( Lightning Memory-mapped DataBase )是一个轻量级的、基于内存映射的 kv 数据库,由于可以直接返回指针、避免值拷贝,所以性能非常高;更多细节详见 wikipedia 。

    基于文件的共享内存映射使用一个新的模式 MAP_SHARED,表示你对页面的修改会被写回到文件,从而可以和其他进程共享。具体何时同步取决于内存管理器,不过还好有个 msync() 可以强制将改动同步到底层存储。这对于数据库来说很重要,可以保证被写入数据的持久性( durability )。但不是谁都需要它,尤其是不需要持久化的场景下,完全不需要同步,你也不用担心丢失 写入数据的可见性(译注:这里应该是指修改后立即可读取)。这多亏了页面缓存,得益于此你也可以用内存映射来实现高效的进程间通信。

    /* Map the contents of a file into memory (shared). */
    int fd = open(...);
    void *db = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
                    MAP_SHARED, fd, 0);
    if (db == (void *)-1) {
      /* Mapping failed */
    }
    
    /* Write to a page */
    char *page = (char *)db;
    strcpy(page, "bob");
    /* This is going to be a durable page. */
    msync(page, 4, MS_SYNC);
    /* This is going to be a less durable page. */
    page = page + PAGE_SIZE;
    strcpy(page, "fred");
    msync(page, 5, MS_ASYNC);
    

    译注:MS_SYNC 会等待写入底层存储后才返回; MS_ASYNC 会立即返回,OS 会异步写回存储,但期间如果系统异常崩溃就会导致数据丢失。

    注意,你不能映射比文件内容更长的内存,所以你无法通过这种方式增加或者减少文件的长度。不过你可以提前用 ftruncate() 来创建(或加长)一个稀疏文件(译注:稀疏文件是指,你可以创建一个很大的文件,但文件里只有少量数据;很多文件系统如 ext*、NTFS 系列都支持只存储有数据的部分)。但稀疏文件的坏处是,会让紧凑的存储更困难,因为它同时要求文件系统和 OS 都支持才行。

    在 Linux 下,fallocate(FALLOC_FL_PUNCH_HOLE) 是最佳选项,但最适合移植(也最简单的)方法是创建一个空文件:

    /* Resize the file. */
    int fd = open(...);
    ftruncate(fd, expected_length);
    

    一个文件被内存映射,并不意味着不能再以文件来用它。这对于需要区分不同访问情况的场景很有用,比如说你可以一边把这个文件用只读模式映射到内存中,一边用标准的文件 API 来写入它。这对于有安全要求的情况很有用,因为暴露的内存映射是有写保护的,但还有些需要注意的地方。msync() 的实现没有严格定义,所以 MS_SYNC 往往就是一系列同步的写操作。呸,这样的话速度还不如用标准文件 API,异步的 pwrite() 写入,以及 fsync() 或 fdatasync() 完成同步或使缓存失效。(译注:pwrite(fd, buf, count, offset) 往 fd 的 offset 位置写入从 buf 开始的 count 个字节,适合多线程环境,不受 fd 当前 offset 的影响; fsync(fd)、fdatasync(fd) 用于将文件的改动同步写回到磁盘)

    照例这有个警告——系统应当有一个统一的缓冲和缓存( unified buffer cache )。历史上,页面缓存( page cache,按页缓存文件的内容)和块设备缓存( block device cache,缓存磁盘的原始 block 数据)是两个不同的概念。这意味着同时使用标准 API 写入文件和使用内存映射读文件,二者会产生不一致,除非你在每次写入之后都使缓存失效。摊手。不过,你通常不用担心,只要你不是在跑 OpenBSD 或低于 2.4 版本的 Linux 。

    写时复制( Copy-On-Write )

    前面讲的都还是关于共享的内存映射,但其实还有另一种用法——映射文件的一份拷贝,且对它的修改不会影响原文件。注意这些页面不会立即被复制,因为这没啥意义,而是在你修改时才被复制(译注:一方面,通常来说大部分页面不会被修改,另一方面,延迟到写时才复制,可以降低 STW 导致的延时)。这不仅有助于创建新进程(译注:fork 新进程的时候只需要拷贝页表)或者加载共享库的场景,也有助于处理来自多个进程的大数据集的场景。

    int fd = open(...);
    
    /* Copy-on-write mapping */
    void *db = mmap(NULL, file_size, PROT_READ|PROT_WRITE,
                        MAP_PRIVATE, fd, 0);
    if (db == (void *)-1) {
      /* Mapping failed */
    }
    
    /* This page will be copied as soon as we write to it */
    char *page = (char *)db;
    strcpy(page, "bob");
    

    译注:MAP_PRIVATE 这个 flag 用于创建 copy-on-write 映射,对该映射的改动不影响其他进程,也不会写回到被映射的文件。当写入该映射时,会触发 page fault,内核的中断程序会拷贝一份该页,修改页表,然后再恢复进程的运行。

    零拷贝串流( Zero-copy streaming )

    由于(被映射的)文件本质上就是一块内存,你可以将它“串流”( stream )到管道(也包括 socket ),用零拷贝模式(译注:“零拷贝”不是指完全不拷贝,而是避免在内核空间和用户空间之间来回拷贝,其典型实现是先 read(src, buf, len) 再 write(dest, buf, len) )。和 splice() 不同的是,vmsplice 适用于 copy-on-write 版本的数据(译注:splice 的源数据用 fd 指定,vmsplice 的源数据用指针指定)。免责声明:这只适用于使用 Linux 的老哥!

    int sock = get_client();
    struct iovec iov = { .iov_base = cat_db, .iov_len = PAGE_SIZE };
    int ret = vmsplice(sock, &iov, 1, 0);
    if (ret != 0) {
      /* No streaming :( */
    }
    

    译注:vmsplice 第二个参数 iov 是一个指针,上例只指向一个 struct iovec,实际上它可以是一个数组,数组的长度由第三个参数标明。

    译注:举几个具体的场景,例如 nginx 使用 sendfile (底层就是 splice )来提高静态文件的性能; php 也提供了一个 readfile() 方法来实现零拷贝发送文件; kafka 将 partition 数据发送给 consumer 时也使用了零拷贝技术,consumer 数量越多,节约的开销越显著。

    mmap 不顶用的场景

    还有些奇葩的场景,映射文件性能会比常规实现差得多。按理来说,处理 page fault 会比简单读取文件块要慢,因为除了读取文件还需要做其他事情(译注:修改页表等)。但实际上,基于映射的文件 IO 也可能更快,因为可以避免对数据的双重甚至三重缓存(译注:可能是指文件库的缓存,例如 os 本身会有缓存,c 的 fopen/fread 还内建了缓存),并且可以在后台预读数据。但有时这也有害。一个例子是“小块随机读取大于可用内存的文件”(译注:如 2G 内存,4G 的文件,每次从随机位置读取几个字节),在这个场景下,系统预读的块大概率不会被用上,而每一次访问都会触发 page fault 。当然你也可以用 madvise() 做一定程度的优化(译注:用上 MADV_RANDOM 这个建议,告诉 OS 预读没用)。

    还有 TLB 抖动( thrashing )的问题。将虚拟页的地址翻译到物理地址是有硬件辅助的,CPU 会缓存最近的翻译 —— 这就是 TLB ( Translation Lookaside Buffer ;译注:可译作“后备缓冲器”,CPU 中的 MMU 专用的缓存,用来加速地址翻译)。随机访问的页面数量超过缓存能力必然会导致抖动( thrashing )_,_因为(在缓存不顶用时)系统必须遍历页表才能完成地址翻译。对于其他场景可以考虑使用 huge page,但这里行不通,因为仅仅为了访问几个字节而读取几 MB 的数据会让性能变得更糟。


    下一篇会继续翻译最后一节《 Understanding memory consumption 》,敬请关注~

    以及照例再贴下之前推送的几篇文章:

    欢迎关注

    weixin2s.png

       ▄▄▄▄▄▄▄   ▄      ▄▄▄▄ ▄▄▄▄▄▄▄  
       █ ▄▄▄ █ ▄▀ ▄ ▀██▄ ▀█▄ █ ▄▄▄ █  
       █ ███ █  █  █  █▀▀▀█▀ █ ███ █  
       █▄▄▄▄▄█ ▄ █▀█ █▀█ ▄▀█ █▄▄▄▄▄█  
       ▄▄▄ ▄▄▄▄█  ▀▄█▀▀▀█ ▄█▄▄   ▄    
       ▄█▄▄▄▄▄▀▄▀▄██   ▀ ▄  █▀▄▄▀▄▄█  
       █ █▀▄▀▄▄▀▀█▄▀█▄▀█████▀█▀▀█ █▄  
        ▀▀  █▄██▄█▀  █ ▀█▀ ▀█▀ ▄▀▀▄█  
       █▀ ▀ ▄▄▄▄▄▄▀▄██  █ ▄████▀▀ █▄  
       ▄▀▄▄▄ ▄ ▀▀▄████▀█▀  ▀ █▄▄▄▀▄█  
       ▄▀▀██▄▄  █▀▄▀█▀▀ █▀ ▄▄▄██▀ ▀   
       ▄▄▄▄▄▄▄ █ █▀ ▀▀   ▄██ ▄ █▄▀██  
       █ ▄▄▄ █ █▄ ▀▄▀ ▀██  █▄▄▄█▄  ▀  
       █ ███ █ ▄ ███▀▀▀█▄ █▀▄ ██▄ ▀█  
       █▄▄▄▄▄█ ██ ▄█▀█  █ ▀██▄▄▄  █▄  
    

    参考链接:

    [1] What a C programmer should know about memory https://marek.vavrusa.com/memory/

    [2] Linux - Transparent huge pages https://lwn.net/Articles/423584/

    [3] 虚拟地址转换 https://zhuanlan.zhihu.com/p/65298260

    [4] Reddit - What every programmer should know about solid-state drives https://www.reddit.com/r/programming/comments/2vyzer/what_every_programmer_should_know_about/comhq3s

    1 条回复    2020-05-16 20:28:33 +08:00
    cortexm3
        1
    cortexm3  
       2020-05-16 20:28:33 +08:00   ❤️ 1
    支持一下
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5589 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 06:44 · PVG 14:44 · LAX 22:44 · JFK 01:44
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.