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

2020-05-16 11:33:09 +08:00
 felix021

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


续上篇:

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


照例放图镇楼:

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

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

开始吧。

有趣的内存映射

工具箱:

有些事情是内存分配器没法完成的,需要内存映射来救场。比如说,你无法选择分配的地址范围。为了这个,我们得牺牲一些舒适性 —— 接下来将和整页内存打交道了。注意,虽然一页通常是 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 回收了)。这是我们能做到的最接近 内存打洞 的办法了。

基于文件的内存映射

工具箱:

到这里我们已经知道关于匿名内存的所有知识了,但是在 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 》,敬请关注~

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

欢迎关注

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

参考链接:

[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

2007 次点击
所在节点    程序员
1 条回复
cortexm3
2020-05-16 20:28:33 +08:00
支持一下

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/672296

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX