[Linux翻译]为什么mmap比系统调用快

2,569 阅读9分钟

原文地址:sasha-f.medium.com/why-mmap-is…

原文作者:sasha-f.medium.com/

发布时间:2019年12月19日-8分钟阅读

当我问同事为什么mmap比系统调用快时,答案必然是 "系统调用开销":跨越用户空间和内核之间边界的成本。事实证明,这个开销比我以前想象的要细微,所以让我们看看下面的内容来了解性能差异。

背景(如果你是操作系统专家,请跳过)。

系统调用。系统调用是一个特殊的函数,可以让你跨保护域。当一个程序在用户模式(非特权保护域)下执行时,它不允许做在内核模式(特权保护域)下执行的代码所允许的事情。例如,在用户空间中运行的程序通常不能在没有内核帮助的情况下读取文件。当用户程序向操作系统请求服务时,系统会通过系统调用来保护自己免受恶意或错误程序的侵害。系统调用会执行一个特殊的硬件指令,通常被称为 "陷阱",将控制权转移到内核。然后内核就可以决定是否履行这个请求。

虽然这种保护超级有用,但它是有代价的。当我们从用户空间进入内核时,我们必须保存硬件寄存器,因为内核可能需要使用它们。此外,由于直接取消引用用户级指针是不安全的(如果它们是空的怎么办--那会使内核崩溃!),这些指针所引用的数据必须被复制到内核中。

当我们从系统调用中返回时,我们必须以相反的顺序重复这个序列:复制出用户请求的任何数据(因为我们不能只给用户程序指向内核内存的指针),恢复寄存器并跳转到用户模式。

页面故障。操作系统和硬件一起将你的程序可执行文件中写下的地址(这些地址称为虚拟地址)翻译成实际物理内存中的地址(物理地址)。对于编译器来说,直接生成物理地址是相当不方便的,因为它不知道你可能在什么机器上运行你的程序,它有多少内存,以及在你的程序运行时,有哪些其他程序可能在使用物理内存。因此就需要这种虚拟地址到物理地址的转换。这些转换,或者说映射,都是在你的程序的页表中设置的。当你的程序开始运行时,这些映射都没有被设置好。所以当你的程序试图访问一个虚拟地址时,它会产生一个页面故障,这就给内核发出信号,让它去设置映射。内核会通过一个陷阱通知它需要处理页面故障,所以从这个角度来说,它有点类似于系统调用。不同的是,系统调用是显式的,而页面故障是隐式的。

缓冲区缓存。缓冲区缓存是内核内存的一部分,用来保存最近访问的文件块(这些文件块称为块或页)。当用户程序请求读取一个文件时,文件的页面(通常)首先被放入缓冲区缓存。然后在系统调用返回的过程中,将数据从缓冲区缓存中复制出来到用户提供的缓冲区中。

Mmap。Mmap是内存映射文件的缩写。它是一种无需调用系统调用就能读写文件的方式。操作系统预留了程序虚拟地址的一块,直接 "映射 "到文件中的一块。因此,如果程序从该部分地址空间读取数据,就会获得驻留在文件相应部分的数据。如果文件的那部分数据恰好驻留在缓冲区缓存中,那么在第一次访问时,只需将映射后的块的虚拟地址映射到相应的缓冲区缓存页的物理地址即可,以后不会再调用系统调用或其他陷阱。如果文件数据不在缓冲区缓存中,访问映射区域会产生一个页面故障,提示内核去从磁盘中获取相应的数据。

为什么mmap要更快

让我们先提出假设。为什么我们希望mmap更快?有两个明显的原因。首先,它不需要显式穿越保护域,尽管当我们出现页面故障时,仍然会有隐式穿越。也就是说,如果文件中的一个给定范围被访问了不止一次,我们有可能在第一次访问后不会招致页面故障。然而,在我的实验中并没有出现这种情况,所以我确实希望每次读取文件中的新块时都能遇到页面故障。

其次,如果应用程序被写成可以直接访问映射区域中的数据,我们就不需要执行内存拷贝。不过在我的实验中,我有兴趣测量的是应用程序为其读取的数据单独设置目标缓冲区的情况。因此,即使文件被mmapped,应用程序仍然会将映射区域的数据复制到目标缓冲区。

因此,在我的实验环境中,我希望mmap比系统调用略快,因为我认为处理页面故障的代码会比系统调用的代码更精简一些。

实验结果

我按以下方式设置实验。我创建了一个4GB的文件,然后使用4KB、8KB或16KB的块大小依次或随机地读取它。我使用读取系统调用或mmap读取文件。在mmap的情况下,数据从映射区域复制到一个单独的 "目标 "缓冲区。我使用冷缓冲区缓存运行这些测试,这意味着文件没有被缓存在那里,或者使用暖缓冲区缓存,这意味着文件在内核内存中。存储介质是一个SSD,你可能期望在一个典型的服务器中找到。所有的读取都是使用单线程进行的。这里是我的基准测试的源代码。

测试结果

下面的图表显示了顺序/温、顺序/冷、随机/温和随机/冷运行的读取基准的吞吐量。

除了少数例外,mmap比系统调用快2-6倍。让我们来分析一下在温暖的实验中会发生什么,因为在那里mmap提供了一个更稳定的改进。

解释

下图是在块大小为16KB的顺序/温态syscall实验中收集到的CPU曲线。在这个实验过程中,CPU利用率为100%,所以CPU曲线告诉了我们整个故事。

顺序/温态系统调用实验的CPU概况。

我们看到,大约60%的时间花在copy_user_enhanced_fast_string--一个将数据复制到用户空间的函数上。大约15%的时间花在了跨越系统调用边界时发生的其他工作上(函数do_syscall_64、entry_SYSCALL_64和syscall_return_via_sysret),大约6%的时间花在了查找缓冲区缓存中数据的函数上(find_get_entry和generic_file_buffered_read)。

现在让我们看看在相同参数的mmap测试过程中会发生什么。

顺序/温热mmap实验的CPU配置文件

这个配置文件有很大的不同。大约 60% 的时间花在 __memmove_avx_unaligned_erms 中,还有一堆时间花在设置页面映射的各种函数中。

我们稍后将回到 __memmove_avx_unaligned_erms,但目前让我们试着弄清楚到底有多少时间花在映射页面上。我的袖子里有一个很好的技巧可以做到这一点。在 Linux 上,mmap 系统调用可以接受一个 MAP_POPULATE 标志。这个标志的作用是强制mmap在实际的系统调用中预先填充所有的页面映射,所以当我的测试实际运行时,没有任何页面映射工作会被完成。所以我把我的测试改成用map_populate来调用mmap,得知实验完成的速度快了36%左右。我只测量了主循环的时间,而不是mmap系统调用的时间)。因此,我假设在上面的配置文件中,所有这些映射函数大约占了36%。

让我们总结一下到目前为止的情况。使用CPU配置文件,我们能够解释syscall实验的约82%的执行时间(60%用于复制输出,15%用于其他域交叉操作,8%用于实际从缓冲区缓存中读取文件),而mmap实验的执行时间约为96%。60%用于用户级内存拷贝,约36%用于映射页。

我还得告诉你,如果我把实验调整到运行时间很长,反复循环访问同一个文件,mmap实验完全是以内存拷贝为主。

长mmap实验的CPU概况

syscall实验的配置文件,如果我运行的时间长了,基本保持不变。

长距离系统调用实验的CPU配置文件

这里是事情变得非常有趣的地方。很大一部分时间--至少60%--是花在复制数据上。然而,用于 syscall 和 mmap 的函数是非常不同的,不仅仅是在名称上。

mmap实验中调用的 __memmove_avx_unaligned_erms,是使用高级向量扩展(AVX)实现的(这里是它所依赖的函数的源代码)。而copy_user_enhanced_fast_string的实现则要简陋得多。在我看来,这就是mmap快的巨大原因。使用宽向量指令进行数据复制,有效的利用了内存带宽,再加上CPU的预取,使得mmap真的非常快。

为什么内核实现不能使用AVX?好吧,如果它这样做了,那么它将不得不在每次系统调用时保存和恢复这些寄存器,这将使域交叉更加昂贵。所以这是Linux内核有意识的决定

同时,将你的应用程序转换为使用mmap而不是系统调用可以使其运行得更快。也就是说,mmap在编程时并不总是很方便,但这是另一篇文章的主题......。


通过www.DeepL.com/Translator(免费版)翻译