查看原文
其他

详解 Golang 内存映射文件 mmap

happyxhw Go开发大全 2021-07-20

(给Go开发大全加星标)

【导读】mmap是处理大文件io常用的技术,本文详细介绍了go语言编程中mmap的用法与技术细节

最近在看关于数据库存储的 “数据库系统入门”(https://15445.courses.cs.cmu.edu/fall2019/) 演讲时,我终于掌握了内存映射文件(memory-mapped files) 的概念。对于数据库存储引擎来说,都需要解决的一个重要问题是如何去处理磁盘中的数据远大于机器可用内存的问题。在更高层次上,面向磁盘的存储引擎的主要目的是操纵磁盘中的数据文件,但是假设磁盘中的数据最终都会变得远大于可用内存,这种情况下我们就不能简单地把所有数据导入到内存中,再处理它,然后重新保存到磁盘中。

这并不是计算机科学中的新问题,在上世纪60年代操作系统刚诞生之初就面临着一个类似的问题:我们如何才能运行磁盘中的比内存更大的程序?1961年,曼彻斯特的一个计算机小组在 Atlas 计算机上实现了一种解决方案,就是现在我们所知的虚拟内存技术( virtual memory)。虚拟内存可以让正在运行的程序以为自己有足够大的内存,即使实际上并没有那么大。

我们并不打算去深入了解虚拟内存的工作原理,我们只需要明白运行中的程序访问内存时它实际上访问的是虚拟内存,并且访问的数据有可能并不在内存之中,访问磁盘还是内存这并不重要,操作系统会帮我们处理这一次,操作系统会先访问磁盘,再取出数据,然后用取出的数据替换掉未使用的内存块,这就让程序以为自己在访问的是内存。

所以,数据库存储引擎解决数据远大于内存的一种方法就是使用虚拟内存和内存映射文件。

在 linux 系统中,不管文件有多大,我们可以通过系统调用(system calls) mmap 将这个文件映射到内存。如果你的程序需要操作文件,只需要操作内存,操作系统会负责与磁盘交互。

在某些场景中,开发者还找到了一种比系统调用更加方便的方法:open,read,write,lseek 和 close。

一个简单的例子

下面是一个简单的例子,我们利用 golang 的 mmap-go 包来演示(https://github.com/edsrzf/mmap-go):

package main

import (
 "os"
 "fmt"
 "github.com/edsrzf/mmap-go"
)

func main() {
 f, _ := os.OpenFile("./file", os.O_RDWR, 0644)
 defer f.Close()
 
 mmap, _ := mmap.Map(f, mmap.RDWR, 0 )
 defer mmap.Unmap()
 fmt.Println(string(mmap))
 
 mmap[0] = 'X'
 mmap.Flush()
}

令人高兴的是,不管我们的文件(./file)有多大,这种办法仍然是可以的,我们也无需担心内存会被撑爆。

详细了解 mmap 的功能

我们将从  mmap-go 提供的 API 的角度来详细了解 mmap 的功能。注意,mmap-go 可能会缺少一些原生系统调用提供(https://godoc.org/golang.org/x/sys/unix#Mmap)的功能。

prot参数

下面是 mmap.Map的函数签名:

func Map(f *os.File, prot, flags int) (MMap, error) 

首先让我们看一下prot参数,prot可以让我们指定内存映射的保护级别:RDONLY, RDWR, EXECRDONLY表示只读,RDWR表示可读和可写,EXEC表示可以在映射中执行代码。下面是 linux man 中关于prot参数的描述:

The prot argument describes the desired memory protection of the
mapping (and must not conflict with the open mode of the file).
It is either PROT_NONE or the bitwise OR of one or more of the
following flags:

PROT_EXEC
    Pages may be executed.

PROT_READ
    Pages may be read.

PROT_WRITE
    Pages may be written.

PROT_NONE
    Pages may not be accessed.

在 unix 包(https://godoc.org/golang.org/x/sys/unix)中这些标志是:unix.PROT_EXEC, unix.PROT_READ, unix.PROT_WRITEunix.PROT_NONE

实验 PROT_EXEC 标志

为了更进一步了解EXEC标志,我 google 搜索了相关例子,但是并没有找到,然后尝试在 Github 上搜索PROT_EXEC,终于找到了一个用 c 语言实现的例子。我用 Go 语言和mmap-go重写了这个例子。

第一步就是创建一个函数,然后通过mmap将其分配到内存中,再编译,并获取其汇编机器码。

我在inc.go中创建了inc函数:

package inc

func inc(n int) int {
 return n + 1
}

编译:

go tool compile -S -N inc.go

获取机器码:

go tool objdump -S inc.o
func inc(n int) int {
  0x22b                 48c744241000000000      MOVQ $0x00x10(SP)
        return n + 1
  0x234                 488b442408              MOVQ 0x8(SP), AX
  0x239                 48ffc0                  INCQ AX
  0x23c                 4889442410              MOVQ AX, 0x10(SP)
  0x241                 c3                      RET

利用这个,我们就可以使用字节来表示我们的函数:

code := []byte{
        0x480xc70x440x240x100x000x000x000x00,
  0x480x8b0x440x240x08,
  0x480xff0xc0,
  0x480x890x440x240x10,
  0xc3,
}

使用mmap分配内存:

memory, err := mmap.MapRegion(nillen(code), mmap.EXEC|mmap.RDWR, mmap.ANON, 0)
if err != nil {
    panic(err)
}

如上所示,这次我们使用了一个更加完整的函数MapRegion,它可以指定所需要的内存大小和文件的偏移量。

文章开始,我们就说过mmap的最主要作用就是建立文件和内存之间的映射。但是上述调用中我们并未指定任何文件,当我们想把mmap当作一个普通的内存分配器来用时可以将os.File设置为nil,并将mmap.ANON添加到标志位中,后面我们将进一步讨论mmap.ANON。因为没有指定文件,所以文件偏移量是 0

现在我们已经分配了和len(code)一样大小的内存,并且设置了mmap.RDWR标志位,这样就可以将代码复制到内存中去:

copy(memory, code)

这样我们就已经将 inc函数放置在内存中了,为了执行它,我们必须将内存地址转换为带签名的函数类型,从而与编译后的inc相匹配:

memory_ptr := &memory
ptr := unsafe.Pointer(&memory_ptr)
inc := *(*func(int) int)(ptr)

因为我们已经设置了mmap.EXEC标志位,所以我们可以在内存中执行这段代码,如果没有设置就会出现segmentation violation(段错误)。

fmt.Println(inc(10)) // Prints 11

我并不清楚这是否是一个真实的例子,我仅仅想了解 “在内存中执行代码” 这句话的含义。而且除了mmap还有其他的方式实现了同样的功能,比如 mprotect(https://man7.org/linux/man-pages/man2/mprotect.2.html) 。

还有一个问题:既然代码已经保存在内存中的code变量里了,我们能直接去执行它吗?不能,因为静态分配给code的内存不是可执行的。我尝试使用 mprotect 去执行它,但仍然出现了段错误。

这里是完整的代码:https://gist.github.com/brunoac/b9ff4ad46c27926e5e4f078133d0de79。

flags 参数

我们可以将许多不同的进程映射到相同的内存区域。flags参数可以让我们设定映射更新的可见性。完整的flags参数可以参考 mmap(https://man7.org/linux/man-pages/man2/mmap.2.html)。其中最重要的一些是:unix.MAP_SHARED, unix.MAP_PRIVATEunix.MAP_ANON

  1. MAP_SHARED表示映射的所有改动对所有进程都是可见的,底层的映射文件也是同样的,尽管我们无法控制。
  2. MAP_PRIVATE表示映射的所有改动是私有的,对其他进程是不可见的。同样也不会传递到底层文件上。
  3. MAP_ANON表示不需要映射到文件。这可以当作共享内存,用于不同子进程间的通信。

对于mmap-go包有一点让我感动困惑,它只提供了mmap.ANON,如果你想把你的映射变成私有的,你可以将prot参数设置为mmap.Copy。不管怎样,你仍然可以使用unix包的实现,它提供了更完整的 flags。

锁定(Lock)和刷新(Flush

mmap-go提供的其他两个非常好的方法是,LockFlushLock方法调用系统的 mlock 方法,阻止映射中数据刷新到磁盘中。Flush方法调用系统 msync 方法,强制将内存中的数据刷新到磁盘中。这样就可以控制数据什么时候、以何种方法同步到磁盘中。


 - EOF -

推荐阅读(点击标题可打开)

1、如何让go程序以后台进程或daemon方式运行

2、IO多路复用与Go网络库的实现

3、Go语言错误处理的优雅实现

Go 开发大全

参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。

关注后获取

回复 Go 获取6万star的Go资源库

分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存