最近项目中需要实现一个进程间共享的动态增长队列(单写多读),采用的是文件 mmap 的方案,有这么几点考虑:
- 进程间可以共享 mmap 文件映射的内存页,省去额外的内核态到用户态的内存拷贝及可能的 IO 开销,且修改能“实时”被看到(页表已经建好时)
- mmap 使用方便,就跟访问本地内存一样;而普通文件 read/write 处理动态增长的数据很麻烦
- mmap 的数据能被 kernel 在后台自动同步回文件,解决了持久化的问题
- 底层文件可以放在 tmpfs 上,这样性能就与正常的共享内存无异了(事实上 Linux 的 POSIX shared memory 就是这么实现的)
看起来不错,便捷、高效。鱼掌可以兼得,就是这么任性——直到上线后发现系统指标异常,一路怀疑到这里可能有性能问题,才知道事实并不尽然。
初步调优
我们测量了每个数据从入队列到出队列的时间差,数据分布见下表的第一行数据(Disk),其中大于 1ms 的毛刺点竟达到 3.8‰,均值和 90% 分位点也比预期的要高。初步猜测有两个因素可能影响比较大:
- 底层存储在磁盘上,发生 major pagefault 时 IO 开销比较大
- 运行环境系统比较繁忙,CPU 资源紧张12345678+-----------+-------+-----------+---------+----------+---------+| configure | store | bind_core | >1ms(‰) | mean(us) | 90%(us) |+-----------+-------+-----------+---------+----------+---------+| Disk | disk | N | 3.8 | 18 | 9 || Bind-d | disk | Y | 1.6 | 19 | 12 || Tmpfs | mem | N | 2.2 | 16 | 9 || Bind-m | mem | Y | 0.5 | 9 | 7 |+-----------+-------+-----------+---------+----------+---------+
随后我们分别尝试了用 tmpfs 替代磁盘,绑定进程运行的 CPU 核等不同配置组合。从上表可以看到绑核对毛刺影响较大,再加上用 tmpfs 存储,可以大幅度优化延迟。
其实我们创建 mmap 时,已经用了 MADV_SEQUENTIAL 来提示 kernel 我们是顺序访问,事实上也是如此。但如果这样有效的话,换成 tmpfs 并不能带来多大的加速,这和我们预期不符。所以有必要进一步搞清楚 mmap 的使用方式。
细化分析
我们先来梳理下 mmap 的机制。mmap 分两种,匿名的和有文件映射的,我们只讨论第二种。mmap 的语义是将指定文件区间映射到当前进程的虚拟地址空间,调用返回空间起始指针,后续对这段空间的内存读写就相当于对底层文件内容的读写。默认情况下,mmap 调用时并不会帮你把整个文件都映射进内存,而是按需分配页表:因为你的文件可能很大以至于超过可用内存大小;也可能你只需要随机访问其中一小部分,没必要都映射进来。
那么当你访问到一个尚未分配页表的虚拟地址,CPU 就会触发一次 page fault,当前进程进入相应的内核 page fault handler。在这里,内核需要
- 在文件系统中分配该文件区域对应的 block(如果还未分配的话)
- 分配一个空闲物理内存页
- 读取该段文件内容到对应物理内存页
- 更新内存页表,以建立物理内存页到虚拟内存页的映射
当然如果只是 minor page fault(比如访问的共享数据已经被其他进程加载进内存),就只需要第 4 步操作。除此外,大范围的 mmap 还有个问题就是会给 TLB 带来很大负担,影响到整个系统的性能。
从使用者的角度,mmap 的这几项开销的优化思路主要就是预处理了,比如预先分配文件内容(Prealloc),预先触发 page fault(Prefault),让操作系统协助预取(Prefetch)。实现预处理的手段也有好多种:
Prealloc
- 调用 fallocate 预先分配文件空间
Prefetch
- 调用 madvise 设置 MADV_SEQUENTIAL 策略(Seq)
- 调用 madvise 设置 MADV_WILLNEED 策略(Need)
Prefault
- mmap 调用中设置 MAP_POPULATE 标志位(Prefault)
- 自行预先访问 mmap 出来的内存区间(ManualPrefault)
Prealloc 节省了步骤 1 的开销,Prefault 节省了步骤 1-4 的开销,Prefetch 最理想的情况下和 Prefault 效果一致。为此我们设计了一组对照实验,测量每一组配置下用 128 Bytes 的数据块去写入 256MB mmap 区间所需的时间,以模拟我们真实的使用模式。底层的存储都使用 tmpfs,我们还分别测量了 tmpfs 使用的内存与测试进程在不同 / 相同 NUMA node 的情况。
我们可以看到,Prefech 的行为没有明显的效果,主要是因为操作系统需要一定时间去做预取,并且这个行为我们是不可控的,所以也没有特别安排不同等待时间的实验。Prealloc 能带来 20% 不到的提升,这也是步骤 1 的开销。其他各种 Prefault 的组合效果差不多,大约 60% 出头,这基本就是 page fault 的所有开销了。ManualPrefault 效果更好应该是因为它同时也做了 cache prefetch。
感兴趣的同学可以跑下我的 实验代码,比较不同环境的测试结果。
用户态动态预处理
从实验结果我们可以看到,Prefault 能极大地减少 mmap 的开销,但它的代价也是很大的——需要把整个文件都事先加载进来。对于我们这个动态增长队列的用法就更糟糕了,相当于我们必须加载进可能的最大队列长度,而实际上大多数队列只使用了一小部分空间,这就造成极大的浪费。
比较折衷的办法是把不可控的 kernel pretch 搬到用户态来:预先分配一小部分空间,在运行的过程中根据使用情况预先处理。每次预处理长度的算法可以根据实际应用具体调整,丰俭由人。由于 page fault 是在 kernel 态完成的,天然就是线程安全,所以多线程的预取实现起来很方便,也不会影响主线程的正常访问。
常见错误
在设计实验前,参考了些其他相关的测试代码,发现不少 madvise 的错误用法,一般有这么两种错误类型:
用 | 连接两个不同的策略。事实上,madvise 的策略枚举值是互斥的,不是比特标志位所以不能用 | 连用。这是一部分枚举值的定义:
12345# define MADV_NORMAL 0 /* No further special treatment. */# define MADV_RANDOM 1 /* Expect random page references. */# define MADV_SEQUENTIAL 2 /* Expect sequential page references. */# define MADV_WILLNEED 3 /* Will need these pages. */# define MADV_DONTNEED 4 /* Don't need these pages. */连续调用多次 madvise 以实现多种策略混搭的效果。其实只有最后一条 madvise 语句生效。Linux glibc madvise 实现 中,madvise 直接调用 syscall,并透传参数。 而在 Linux madvise syscall 代码 中也可以看出每次调用都会清空并覆盖之前策略的设置。
另外一个是 man mmap 里对 MAP_POPULATE 的解释有歧义:
它其实想说的是,从 Linux 2.6.23 版本以后才开始支持私有映射,共享映射一直都是支持的。但乍一看很容易理解成从 Linux 2.6.23 版本以后只支持私有映射。吐槽 的人不只我一个哦。
大页支持
传统页表大小只有 4KB,其实现代处理器架构可以处理更大的页表,从而减少访问同样内存大小所需的 page fault 数量和 TLB 压力。Linux 有两种大页支持,hugetlbpage 和 transparent hugepage。hugetlbpage 需要创建一个 hugetlb 文件系统,但是只能读不能写,不符合我们的需求;transparent hugepage 灵活一些,可以在 mount tmpfs 时指定 huge 参数,但我们现在生产环境版本还没有这项功能。