OS近距离:mmap给你想要的快!

图片

原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。

I/O问题一般不会被大多数人关注,因为大多数开发都是在做“业务”,也就是在搞计算节点的事情,通常遇到的I/O问题,也就是日志打的有点多了,磁盘写起来有点吃力,所以iowait这个指标,关注的人也不多。

可惜的是,工作并不是只考虑怎么折腾CPU,数据总归要落地的。一旦涉及到高性能的磁盘存储,I/O问题就浮上水面。Redis这么流行,就是为了绕开磁盘性能问题而存在的。

换句话说,如果我的磁盘像内存一样快,那还要内存干什么~

今天,我们就来简单聊一下I/O。当然,更主要的还是倾向于和持久化打交道的磁盘I/O。如非特指,我们说的就都是它。

I/O都干了些啥?

I/O干的是啥?对我们使用者来说,简单来讲,就两点。

  1. 和操作系统索要数据,并加载到缓冲区中。

  2. 填满用户进程缓冲区,并交给操作系统刷盘。

但也不是直接读写,因为操作系统有内核进程和用户进程之分。为了保护内核的内存,用户进程是不能随便去读内核所操作的数据的。想要数据,拷贝一份。

这一进一出,就涉及到用户态和内核态的切换,也就是常说的系统调用,细节上肯定会比较复杂。

图片

如上图,就拿读数据来说,我们可以把读取过程分为以下几个阶段。

  1. Java进程发起读取请求,调用最底层代码发起read()系统调用。

  2. 操作系统通过DMA等从磁盘等硬件读取数据。

  3. DMA读取相关数据,存入到内核的缓冲区中。这部分操作是不需要CPU参与的。

  4. 接下来内核将会把自己缓冲区的内容,拷贝到Java进程的缓冲区中。

可以看到,由于一个读取有操作系统的参与,它的交互过程就变的比较复杂。典型的,当Java进程读取数据的时候,内核发现这部分数据已经存在于缓存中了,那么就直接拷贝出来;当内核缓存不存在这些数据的时候,那么Java进程将会阻塞在那里,直到所需的数据拷贝到用户空间。

总结一下:内核进程所持有的内存,是不能直接访问的,我们需要拷贝一份到用户进程。

虚拟地址来帮忙

从上面的描述中可以看出,磁盘文件上的内容,要想被用户进程所使用,就不得不经过kernel这个中转站。既然这样会影响效率,那么为什么不直接把这些磁盘上的文件直接发送到用户进程呢?

这不是能不能做的问题,而是应不应该做的问题。既然用户进程使用了特定的操作系统,就要按照操作系统的规矩办事。在Linux操作系统上,把这些繁杂的事务交给操作系统,是最安全、最便捷的编程方式。

那么,我现在就是不想按照规矩来,把效率看的更重一些,怎么办?

没别的办法,只有开启一条绿色通道。

如果我能够在用户进程里,和操作系统内核里,读到的是同一份数据,操作的是同一份缓冲区,那么目的就算达到了。

如果让用户进程直接去访问内核所拥有的物理内存地址,是非常危险的。如何共享这些物理内存,这需要借助 虚拟内存。这就是所谓的绿色通道。

虚拟内存,肯定是相对于物理内存来说的。如果你反编译一个二进制文件的话,可以看到它的引用地址是固定的。虚拟内存区域是进程的虚拟地址空间中的一个同质区间,即具有同样特性的连续地址范围。如下图,MMU组件就专门负责虚拟内存到物理内存到翻译,这都是《计算机组成结构》的东西,已经是现代操作系统的默认操作。

图片

借助于虚拟内存,我们就可以使用不同的虚拟内存地址指向同一块物理内存地址,变相的实现了内存数据的共享,避免了kernel和user进程之间的数据拷贝。

如果我们同时mapping一个内核空间的虚拟地址和用户空间的虚拟地址,到同一块内核实际的物理内存地址上,那么我们就能够同时操作这一块内存区域。

典型应用mmap

mmap (Memory Mapped Files) 就是这样处理映射的一种特殊通道。它可以将一个文件,或者其他对象,映射到进程的虚拟地址空间。

当我们操作这一段内存的时候,就可以直接影响到最终操作系统上的文件。虽然文件的读写仍然由操作系统去做,但明显的,我们不必再调用read、write等函数,从操作系统的内存中拷贝数据,这肯定能够增加文件读写的效率。

MMAP也使得进程间共享编程型内存,进程通信成为了可能,也可以和内核进程进行协同式交互。当我们的物理内存空间不足的时候,甚至可以使用磁盘来模拟内存。

这就是抽象的魔力。

mmap的映射区域必须是物理页大小(page_size)的整数倍,这也是操作系统为了增加处理效率所采取的批量处理模式(内存管理的最小粒度就是页)。同时,mmap不能映射超过文件大小的区域,所以当文件大小发生变化时,就需要重新映射。

Java中有专门处理mmap的类MappedByteBuffer,我们可以通过FileChannel的map函数来获取这个变量。

MappedByteBuffer mb = new RandomAccessFile("test", "rw")
    .getChannel()
    .map(FileChannel.MapMode.READ_WRITE, 0, 256);
    
//...    
public abstract MappedByteBuffer map(MapMode mode,
            long position, long size)

复制代码

可以看到,通过position和size两个参数,就可以直接将文件的内容映射到mb变量中。假如我们不同的进程做了同样的映射,内存的使用量也不会翻倍,因为它们都是虚拟的地址。

假如你的文件非常大40GB,但操作系统的内存只有2GB,通过这种方式,依然能够快速的读取和修改文件。

在使用top命令的时候,我们经常看到swap区域,也就是使用文件去模拟内存的区域。当你使用2GB内存去操作40GB的文件时,通常会引起swap out,内存的数据要写入到磁盘中。在mmap模式下,就不必再使用额外的swap去保证这个操作。当需要swap的时候,操作系统会直接使用原始文件,这些映射也会在要操作的目标文件上生效。

这整个过程中,除了操作系统缺页引起文件读写,没有其他任何的缓冲区参与,所以是非常高效的。

怎么用?

如果你仔细翻一下mmap相关的代码,可以发现默认提供的函数非常非常非常的稀少,要拿它来搞事情的话,使用起来各种限制。

所以我们需要配合索引文件,来配合mmap完成高效的操作。

在一些数据库和中间件中,我们经常看到mmap的身影,尤其是那些涉及到大文件读写的场景。

在kafka和rocketmq中,commitlog需要根据偏移量读取数据,mmap无疑是非常好的加速方式。就拿kafka的索引文件来说,就大量使用了mmap;消费时Kafka 直接把文件发送给消费者,配合mmap 作为文件读写方式,直接把它传给 sendfile。

包括主流的ES,也大量使用了mmap。这是一种作弊的行为。

不要高兴的太早。

经过很多benchmark的测试,mmap在不同的Linux平台上,并不总是有这么好的表现。当文件大小不被内存所容下的时候,频繁的文件交换和缺页依然会发生,这需要经过实际验证才能确认服务真正的表现。

所以,在内嵌数据库rocksdb中,mmap相关的优化参数是默认关闭的。mmap应该作为一种魔法存在,而不能作为一种通用的优化方法。

allow_mmap_reads=false
allow_mmap_writes=false

复制代码

另外,mmap在作为写入时,也并不是十分可靠。因为写入到mmap中的数据,并没有被真正的写到硬盘,它需要操作系统在调用flush函数的时候才真正的刷到硬盘上。所以,作为数据恢复用的wal日志或者translog、redolog等,并没有采用mmap这种方式。

mmap另外一个比较严重的问题,就是不可预料的I/O停顿。有了操作系统这一环,加上应用中的各种Buffer的参与,再加上预读这种操作,应用在操作文件的时候,会比较平滑。但一旦使用了mmap,你可能在不可预料的情况下被阻塞,或者被不合理的预读干扰,发生频繁的I/O。

End

性能优化从来都是一把双刃剑。这把剑,到底是能杀掉敌人,还是手残伤了队友,那就要看掌剑人的水平了。mmap也不例外,它有好处,也有缺点,多一点敬畏,结论从实践中来,才是正确的态度。

作者简介:小姐姐味道  (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道。我的个人微信xjjdog0,欢迎添加好友,进一步交流。

推荐阅读:

1. 玩转Linux
2. 什么味道专辑

3. 蓝牙如梦
4. 杀机!
5. 失联的架构师,只留下一段脚本
6. 架构师写的BUG,非比寻常
7. 有些程序员,本质是一群羊!

小姐姐味道

不羡鸳鸯不羡仙,一行代码调半天

325篇原创内容

公众号

Supongo que te gusta

Origin juejin.im/post/7069751071425429534
Recomendado
Clasificación