May 16

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

felix021 @ 2020-5-16 22:01 [IT » 操作系统] 评论(0) , 引用(0) , 阅读(1230) | Via 本站原创 | |
续上篇:

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

这是本系列的第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等,详情可参考知乎这篇文章《https://zhuanlan.zhihu.com/p/65298260" target="_blank">虚拟地址转换》[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 :( */
}
[code]

译注: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系统这些建议的语义会有差别;关于这些建议的说明详见上一篇)。 

一种可移植的做法是在这货上面覆盖映射:
 
[code]
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》,敬请关注~

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

* 《踩坑记:go服务内存暴涨》 
* 《TCP:学得越多越不懂》 
* 《UTF-8:一些好像没什么用的冷知识
* 《关于RSA的一些趣事
* 《程序员面试指北:面试官视角






**参考链接:**

[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



欢迎扫码关注:




转载请注明出自 ,如是转载文则注明原出处,谢谢:)
RSS订阅地址: https://www.felix021.com/blog/feed.php
发表评论
表情
emotemotemotemotemot
emotemotemotemotemot
emotemotemotemotemot
emotemotemotemotemot
emotemotemotemotemot
打开HTML
打开UBB
打开表情
隐藏
记住我
昵称   密码   *非必须
网址   电邮   [注册]