续上篇:
这是本系列的第3篇,预计还会有1篇,感兴趣的同学记得关注,以便接收推送,等不及的推荐阅读原文。
照例放图镇楼:
来源: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》,敬请关注~
以及照例再贴下之前推送的几篇文章:
参考链接:
[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