当前位置: 首页 > Linux

【翻译】C程序员应该知道的内存知识(三)

时间:2023-04-06 11:41:23 Linux

系列更新:C程序员应该知道的内存知识(一)C程序员应该知道的内存知识(二)C程序员应该知道的内存知识(三)内存C程序员应该知道的知识(四)这是本系列的第三篇文章,预计还会有下一篇。有兴趣的同学记得关注接收推送,等不及的推荐阅读原文。和往常一样,把城镇建筑放在图中:来源:LinuxAddressSpaceLayout-byGustavoDuarte关于图片的解释见第一篇文章。开始吧。有趣的内存映射工具箱:sysconf()-在运行时获取配置信息mmap()-映射虚拟内存mincore()-判断页面是否在内存中shmat()-共享内存操作有些事情不能由内存分配器完成是的,需要内存映射来挽救局面。例如,您无法选择要分配的地址范围。为此,我们必须牺牲一些舒适度——接下来我们将处理整页内存。请注意,虽然一个页面通常是4KB,但您不应该依赖这个“正常”,而应该使用sysconf()来获取实际大小:longpage_size=sysconf(_SC_PAGESIZE);/*切片和切块。*/Remarks——即使系统声称使用统一的页面大小(译注:这里指的是sysconf的返回值),它在后台也可能使用其他大小。例如,Linux有一个叫做透明大页面(THP)的概念[2],可以减少地址转换的开销。,MMU,TLB等。详见知乎文章《虚拟地址转换》[3])和连续内存块访问导致的pagefault(译注:原来4KB一次,现在4MB一次,少了3个数量级)。但是这里还有一个问号,尤其是当物理内存碎片化,导致连续的大块内存变少的时候。pagefault的开销也随着pagesize的增加而增加,所以对于少量的随机IO负载,hugepages的效率并不高。不幸的是,这对您来说是透明的,但是Linux有一个专有的mmap选项MAP_HUGETLB允许您明确指定此功能的使用,因此您应该了解它的开销。固定内存映射例如,如果你现在必须为一个糟糕的小进程间通信(IPC)创建一个固定映射,你如何选择映射地址?这里有一个提议,在x86-32上可能有点冒险,但在64位上,大约2/3的TASK_SIZE地址(用户空间最高可用地址;译注:见城镇建筑右上角)是大致安全的。您不需要修复映射,但甚至不要考虑使用指向共享内存的指针。两个过程共有)。#defineTASK_SIZE0x800000000000#defineSHARED_BLOCK(void*)(2*TASK_SIZE/3)void*shared_cats=shmat(shm_key,SHARED_BLOCK,0);if(shared_cats==(void*)-1){perror("shmat");/*Sad:(*/}译注:shmat是“sharedmemoryattach”的缩写,意思是将shm_key指定的共享内存映射到从SHARED_BLOCK开始的虚拟地址。shm_key由shmget(key,size,flag)一块共享内存的标识,详细用法请自行googleOK,我知道,这是一个几乎无法移植的例子,但是你应该能理解大概的思路,一般认为是固定地址映射至少不安全,因为它不检查是否有其他东西已经被映射。有一个mincore()函数可以告诉你一个页面是否被映射,但你可能在多线程环境中运气不佳之前被映射byanotherthread;作者在这里使用mincore可能不合适,因为它只检查页面是否在物理内存中,一个页面可能被映射,但换出到swap)。但是,固定地址映射不仅对未使用的地址范围有用,而且对已使用的地址范围也有用。还记得内存分配器是如何使用mmap()分配大块内存的吗?得益于需求分页机制,我们可以实现高效的稀疏数组。假设你创建了一个稀疏数组,现在你想释放一些数据占用的空间,怎么办?你不能free()它(译注:因为它不是malloc分配的),而mmap()会让这个地址空间无法使用(译注:因为这个地址空间属于稀疏数组,所以仍然有可能被访问,不能未映射)。可以调用madvise()用MADV_FREE/MADV_DONTNEED将这些页面标记为空闲(译注:页面可以被回收,但地址空间仍然可用),这是性能方面最好的解决方案,因为这些页面可能不再可用由于触发了要加载的页面错误,但是这些“建议”的语义可能会因具体实现而异这些建议的语义会有所不同;有关这些建议的说明,请参阅上一篇文章)。一种可移植的方法是将映射覆盖在此之上:void*array=mmap(NULL,length,PROT_READ|PROT_WRITE,MAP_ANONYMOUS,-1,0);/*...一些魔法关闭了...*//*让我们清除一些页面。*/mmap(array+offset,length,MAP_FIXED|MAP_ANONYMOUS,-1,0);该行应该是指需要清理的部分数据;第七行使用mmap重新映射array+offset开始的空间,长度为length字节。注意这一行的长度应该是需要清理的数据的长度,不同于第一行一行的长度(整个稀疏数组的长度)。这相当于取消映射旧页面并将它们重新映射到那个特殊页面。这如何影响进程的内存消耗——进程仍然占用相同数量的虚拟内存,但驻留在物理内存中的大小减少了。这是我们最接近内存打孔的方法。基于文件的内存映射工具箱:msync()-将内存映射文件内容同步到文件系统ftruncate()-将文件截断到指定长度vmsplice()-将用户页面内容写入管道这里我们了解了所有关于匿名内存的知识,但是什么真正让64bit地址空间大放异彩的是基于文件的内存映射,它提供了智能缓存、同步和写时复制(copy-on-write;译注:常缩写为COW)。是不是太多了?对于大多数人来说,与直接使用文件系统相比,LMDB就像魔术般的下雨性能。Baby_Food[4]onr/programming译注:LMDB(LightningMemory-mappedDataBase)是一个轻量级的内存映射kv数据库,因为它可以直接返回指针,避免值复制,所以性能非常高;更多详细信息请参阅维基百科了解详细信息。基于文件的共享内存映射使用了一种新模式MAP_SHARED,这意味着您对页面的修改将被写回到文件中,以便与其他进程共享。何时同步取决于内存管理器,但幸运的是有msync()可以强制将更改同步到底层存储。这对于数据库保证写入数据的持久性非常重要。但不是每个人都需要,尤其是不需要持久化的场景,根本不需要同步,也不用担心写入数据丢失可见性(译注:这里应该是指可以立即读取修改后)。这要归功于页面缓存,有了它,您还可以使用内存映射来进行高效的进程间通信。/*将文件内容映射到内存(共享)。*/intfd=open(...);void*db=mmap(NULL,file_size,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);if(db==(void*)-1){/*映射失败*/}/*写入一个页面*/char*page=(char*)db;strcpy(page,"bob");/*这个将成为一个持久的页面。*/msync(page,4,MS_SYNC);/*这将是一个不太耐用的页面。*/page=page+PAGE_SIZE;strcpy(page,"fred");msync(page,5,MS_ASYNC);译注:MS_SYNC会等待写入底层存储后再返回;MS_ASYNC会立即返回,OS会异步回写存储,但如果在此期间系统异常崩溃,数据就会丢失。请注意,您不能映射比文件内容更长的内存,因此您不能通过这种方式增加或减少文件的长度。但是你可以使用ftruncate()预先创建(或加长)一个稀疏文件两者都支持只存储有数据的部分)。但稀疏文件的缺点是它使压缩存储变得更加困难,因为它需要文件系统和操作系统都支持它。在Linux下,fallocate(FALLOC_FL_PUNCH_HOLE)是最佳选择,但最便携(也是最简单)的方法是创建一个空文件:/*调整文件大小。*/intfd=open(...);ftruncate(fd,expected_length);仅仅因为文件是内存映射的并不意味着它不能再用作文件。这对于需要区分不同访问情况的场景很有用,例如,您可以将此文件以只读模式映射到内存,同时使用标准文件API写入它。这对于安全要求很有用,因为暴露的内存映射是写保护的,但有一些警告。msync()的实现并没有严格的定义,所以MS_SYNC往往只是一系列的同步写操作。呸,这不如使用标准文件API、异步pwrite()写入和fsync()或fdatasync()进行同步或使缓存无效那么快。(译注:pwrite(fd,buf,count,offset)从buf开始向fd的offset位置写入count个字节,适用于多线程环境,不受fd当前offset的影响;fsync(fd),fdatasync(fd)用于将文件变化同步写回磁盘)像往常一样,有一个警告——系统应该有一个统一的缓冲区缓存(unifiedbuffercache)。从历史上看,页面缓存(pagecache,页面缓存文件内容)和块设备缓存(blockdevicecache,缓存磁盘原始块数据)是两个不同的概念。这意味着使用标准API写入文件并同时使用内存映射读取它会造成不一致,除非您在每次写入后使缓存无效。摊开双手。然而,您通常不必担心它,只要您运行的不是OpenBSD或早于2.4的Linux版本。写时复制(Copy-On-Write)上面还是讲了共享内存映射,其实还有一个用法——映射文件的一个副本,它的修改不会影响到原文件。注意,这些页面并不是立即复制,因为没有意义,而是在你修改时复制(Replication可以减少STW带来的延迟)。这不仅对创建新进程的场景(译注:fork一个新进程时,只需要复制页表)或加载共享库时有帮助,而且对处理来自多个进程的大数据集的场景也有帮助。intfd=open(...);/*写时复制映射*/void*db=mmap(NULL,file_size,PROT_READ|PROT_WRITE,MAP_PRIVATE,fd,0);if(db==(void*)-1){/*映射失败*/}/*我们一写入它就会复制这个页面*/char*page=(char*)db;strcpy(page,“鲍勃”);标志用于创建写时复制映射,对映射的更改不会影响其他进程,也不会写回映射文件。当映射写入时,会触发页面错误,内核的中断程序会复制页面,修改页表,然后恢复进程的运行。零拷贝流(Zero-copystreaming)由于(映射)文件本质上是一块内存,你可以将它“流”(stream)到管道(包括socket),使用零拷贝模式(译注:“”“零拷贝”并不是说完全不拷贝,而是避免了在内核空间和用户空间之间来回拷贝。它的典型实现是先读(src,buf,len),再写(dest,buf,len))。不像splice(),vmsplice适用于数据的copy-on-write版本(译注:splice的源数据由fd指定,vmsplice的源数据由pointer指定)免责声明:仅供大佬使用Linux!intsock=get_client();structioveciov={.iov_base=cat_db,.iov_len=PAGE_SIZE};intret=vmsplice(sock,&iov,1,0);if(ret!=0){/*否streaming:(*/}译注:vmsplice的第二个参数iov是一个指针,上面的例子只是指向了一个structiovec,其实也可以是一个数组,数组的长度由第三个参数表示。译注:举几个具体的场景,比如nginx使用sendfile(底层是splice)来提高静态文件的性能;PHP还提供了readfile()方法来实现文件的零拷贝发送;Kafka还向消费者发送分区数据使用零拷贝技术,消费者越多,节省的费用就越显着。还有一些奇怪的场景,mmap没有用,映射文件的性能会比常规实现差很多。按理说处理pagefault会比单纯读取文件块慢,因为除了读取文件还需要做其他事情(译注:修改页表等)。但实际上,基于映射的文件IO也可能更快,因为它可以避免数据缓存的双重甚至三重缓存),并且可以在后台预读数据。但有时它也可能是有害的。一个例子是“以小块随机读取一个大于可用内存的文件”(译注:比如2G内存,4G文件,一次从随机位置读取几个字节),在这种场景下,系统预读blocksize概率不会被使用,每次访问都会触发pagefault。当然你也可以使用madvise()做一定程度的优化(译注:使用MADV_RANDOM的建议告诉OS预读没用)。还有TLB抖动的问题。虚拟页地址到物理地址的翻译是硬件辅助完成的,CPU会缓存最新的翻译——这就是TLB(TranslationLookasideBuffer;译注:可以翻译为“backsidebuffer”,MMU-CPU中的特定缓存,用于加速地址转换)。随机访问的页数超过缓存容量必然导致抖动(thrashing)_,_因为(当缓存没有用完时)系统必须遍历页表来完成地址转换。对于其他场景考虑使用大页面,但它在这里不起作用,因为读取几MB数据只是为了访问几个字节会使性能变差。下一篇继续翻译最后一节《Understanding memory consumption》,请关注~并照常贴出之前的文章:《踩坑记:go服务内存暴涨》《TCP:学得越多越不懂》《UTF-8:一些好像没什么用的冷知识》《关于RSA的一些趣事》《程序员面试指北:面试官视角》欢迎关注参考链接:【1】C程序员应该知道的内存知识https://marek.vavrusa.com/mem...[2]Linux-透明大页面https://lwn.net/Articles/423584/[3]虚拟地址转换https://zhuanlan.zhihu.com/p/..[4]Reddit-每个程序员都应该知道的固态驱动https://www.reddit.com/r/prog...