[译] C程序员该知道的内存知识 (3) - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
请不要在回答技术问题时复制粘贴 AI 生成的内容
felix021
V2EX    程序员

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

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

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


    续上篇:

    这是本系列的第 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     Solana     3733 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 28ms UTC 00:44 PVG 08:44 LAX 17:44 JFK 20:44
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86