2024-09-21
Go & 后端
00

目录

1 Mmap 是什么
1.1 在 Mmap 之前
1.2 Mmap原理
1.3 两者的区别
2 Mmap 在 Windows 的实现
3 Mmap 在 Linux 的实现

主要内容:

  1. Mmap 在 Windows 的实现
  2. Mmap 在 Linux 的实现

提示

先说明一个知识点://go:build windows能够指定编译器在某一系统下才编译该文件。比如可以这样:

  • mmap_windows.go中的最靠前地方(比包声明还靠前的地方)加入//go:build windows声明。
  • mmap_linux.go中的最靠前地方(比包声明还靠前的地方)加入//go:build linux声明。

这样就可以达成让编译器自动选择文件进行编译的效果。

再说明一个知识点:syscall包就是 go 用于直接调用操作系统的接口用的接口,这在下面的代码中会有很明确的体现。

1 Mmap 是什么

提示

前置知识:操作系统的段页式存储。

以下讨论都是以 Linux 为基础进行讨论,当然有一些概念基本上是共通的,只是实际举例的时候用 Linux。

1.1 在 Mmap 之前

请先思考这么一个问题:如果没有 Mmap 这个读写文件(磁盘)的方式,那么用户想要读写磁盘的时候(比如说在 Go 中直接os.OpenFile("/usr/test.txt", os.O_CREATE|os.O_RDWR, 0644)),在这一过程中都发生了什么?

在我个人的理解中,操作系统是所有硬件的,向上为用户软件提供的一个抽象的软件层(较为现代的操作系统另说,我个人认为,这里的操作系统在严格意义上仅指代内核)。用户进程可以通过操作系统中的库(比如说 Windows 中的 Win32 编程所提供的所有头文件)或者别的什么调用方式(比如说 Go 中的syscall包)来调用操作系统所提供的接口,从而间接操作硬件,这就叫做系统调用,而读写磁盘就是一个典型的系统调用操作(Mmap 也是)。注意,这里存在一个运行控制权的交接,在调用操作系统的接口时,运行控制权从用户程序手上交接到操作系统上了。这个交接的两端,就是“用户态”,“内核态”。

上面的关于内核态和用户态的说法其实相当的不精确且简略,但是理解意思就好。用户态和内核态实质上指的是用户和内核的进程/线程所处的区域,所以也有叫法是用户空间和内核空间。这两者的区别主要在于对资源的访问、代码的执行的权限。内核态几乎拥有无限的权限,它可以任意操作所有的硬件,可以访问任意地址的内存,可以执行所有的 CPU 指令;相反用户态就不同了,它只能操作被操作系统所分配的硬件资源,内存访址也是通过操作系统进行。

既然操作系统是所有硬件的抽象层,那么硬盘被抽象成什么呢?在 Linux 中,硬盘被抽象为虚拟文件系统(VFS),它一方面为用户态提供统一的接口来访问磁盘,另一方面和不同的文件系统进行适配,以兼容不同的文件系统(exFAT,FAT32,NTFS 等等)

一个 VFS 由以下部分组成:

  • 超级块(super_block),用于保存一个文件系统的所有元信息,在任意时刻它都在内存中并且被缓存。处于文件系统下的任何文件的元信息修改都要同步修改该文件系统的超级块。
  • 目录项模块,管理路径的目录项。它是一个树形结构,树上的每个节点都代表一个具体的文件/文件夹。举例来说/home/foo/hello.txt,在树上就有三个节点:homefoohello.txt。每个节点内部存储的是该目录下的所有文件的 inode 和文件名信息。按路径查找文件时从根目录出发。
  • inode 模块,管理一个具体的文件,是一个文件的唯一标识,一个文件对应一个 inode。通过 inode 可以找到该文件在磁盘上的具体位置。同时 inode 模块还和 address_space 模块有链接,可以查找该文件是否在页缓存中。
  • 打开文件列表模块,包含了内核已经打开的所有文件的信息。该列表下每个元素都是一个file文件结构体,用于存储各种文件参数。
  • file_operations 模块,它存储着所有的可对文件执行的函数的指针,比如opencloseread等等。每个在打开文件列表的元素都可以连接到该模块,从而对打开的文件执行各种操作。
  • address_space 模块,它表示一个文件在页缓存中已经缓存的物理页。

运行中的用户进程在内核中用task_struct结构来存储其所有的信息。该结构体中维护一个files指针,该指针指向files_struct结构体,该结构体中包含文件描述符表和打开的文件对象信息。这个“文件描述符表”就是上文提到的,打开文件列表模块中的元素file结构体指针所构成的列表。这些元素每个都能链接到 file_operations 模块和 inode 模块,从而访问文件或者对文件进行操作。

综上所述,一个用户进程读取文件需要以下步骤:

  1. 用户进程通过系统调用读取一个文件,用户态切换到内核态。
  2. 内核通过task_struct结构找到对应的file结构体,从而调用file_operations 模块中的read函数。
  3. read函数根据传入的路径,在目录项模块中找到文件对应的 inode。
  4. 在 inode 中,通过文件内容偏移量计算出要读取的页。
  5. 通过 inode 访问 address_space 模块,查找有无对应的页缓存。如果有,则直接返回内容;如果没有,那么会产生一个异常,创建一个缓存页,通过 inode 拿到文件在磁盘中的地址,读取进页缓存中,并且重新查找页缓存。
  6. 读取成功。

一个用户进程写入文件需要以下步骤:

前五步和读取文件都是一样的,最后保证文件内容是在页缓存中。

  1. 将文件的修改内容写入到页缓存中。
  2. 被修改后的页缓存会被标记为脏页,它和磁盘上的内容并不相同。有两种方式能够将脏页写回磁盘:
    • 内核态中的 pdflush 进程会定期将脏页写回磁盘。
    • 用户态调用sync或者fsync强制将脏页内容写回到磁盘上。

脏页在写回磁盘的过程中是被锁定的,任何写入请求都会阻塞到写回磁盘完成。

以上就是正常的读写文件的全部过程。注意不论是读还是写,文件是一定要走一遍页缓存的,即文件内容先从磁盘复制到页缓存,再从页缓存复制到用户态内存上。有没有什么方法能够绕过页缓存直接读写磁盘的?有,就是 Mmap。

1.2 Mmap原理

Mmap(Memory Map) 一言以蔽之,即为一种内存映射的方法。放到文件读写上,即为将一个文件或者其他对象映射到进程的地址空间的方法。实现文件磁盘地址和进程虚拟地址空间中一段虚拟内存地址的一一对应关系

进程虚拟地址空间

在了解 Mmap 整个过程之前,必须先了解进程的虚拟地址空间。

虚拟地址空间,即为段页式存储为程序提供的一个分段式的虚拟内存结构。在一般情况下,程序想要的内存是一段连续的内存空间,而底层的真实存在的内存是按页进行管理。经过段页式存储进行转换,程序就无需关心底层存储,直接用段页式提供的连续的虚拟地址空间即可。

上文提到,一个用户进程在内核中是以task_struct结构来存储其所有信息的,其中除了有上文的files指针外,还有一些vm_area_struct指针用于表示一个独立的虚拟内存区域,由于虚拟内存区域的功能和性质都不太相同(堆栈,数据段,代码段或者别的一类的),所以一个进程通常会持有多个vm_area_struct指针,这些指针以链表或者树的形式进行组织。

这个vm_area_struct结构中,包括了一段虚拟内存地址空间的起始地址,终止地址,一个指向操作该内存地址空间的函数的指针还有权限信息一类的其他信息。通过这个结构,用户进程可以直接操作该虚拟内存地址空间。

Mmap 实质上就是这么一个与文件的物理磁盘地址相联的vm_area_struct结构

相关信息

实际上,还有一个点可以提,就是 Mmap 理论上不止可以用户映射文件内容,两个进程映射同一片共享内存也是可以的。另外 Linux 内万物皆文件,映射一个别的句柄进去我不知道行不行,没试过。

使用 Mmap 对文件进行读写的过程如下:

  1. 用户进程发起mmap系统调用,用户态转换为内核态。该函数会进行以下操作:
    • 在当前的用户进程中寻找一段符合条件的虚拟内存地址(这个条件一般是够大并且连续),为此空间创建一个vm_area_struct结构,并且插入该进程原有的vm_area_struct链表/树中。
    • 通过用户进程传入的文件指针,在进程内的文件描述符表中找到对应的文件描述符(即文件句柄,这俩是一个东西),通过这个文件描述符找到打开文件列表模块中的file文件结构体,再通过这个file文件结构体,调用 file_operations 模块里的mmap函数(区别于开放给用户态的mmap函数),该函数会根据文件 inode 定位到该文件的磁盘物理地址。最后,通过remap_pfn_range函数建立页表,实现文件磁盘物理地址和虚拟进程空间地址的映射关系。映射关系建立后退出mmap函数,内核态转换为用户态,注意此时用户态内的这片虚拟内存空间内还没有一点数据。
    • 用户进程发起对该虚拟内存空间的读写操作,引发缺页异常,再从用户态转换为内核态,尝试从页缓存中调入文件内容。如果页缓存中没有文件内容,就从磁盘调入。调入后内核态再转换为用户态。
    • 进程对这块虚拟内存空间内的写入操作(脏页面)会被系统自动同步到磁盘中或者调用msync函数强制同步,这块和不用 Mmap 的方法是一样的。

1.3 两者的区别

传统的文件读写方式,所有的数据都要走一遍缓存页,当然这在绝大部分情况下是性能够用且能够延长硬盘寿命的,但是在一些对速度要求极高的情况下,Mmap 读写就有其优势了。它的读写可以不走缓存页,少两次数据拷贝,并且也少了读写过程中的内核态用户态转换(第一次读写不算的话)。但是 Mmap 在某些场景下也不太好用,比如说我就是单纯读个文件,读完就完了;或者我需要频繁修改文件大小/在文件已有内容的情况下频繁追加内容。后者需要多次取消再重新映射,性能浪费比传统的文件读写方式严重地多得多。


在这个项目中,根据对文件的读写需求,MMap 读写文件的接口是这样定义的:

go
// FileWriter 定义封装文件操作的基本行为 因为bitcask模型只有对文件的读 追加和整理 所以这里并不涉及对文件的已经写入的数据进行修改 type FileWriter interface { Write(input []byte, offset int) error // 在文件的指定位置进行写入 Read(buf []byte, offset int) error // 在文件的指定位置进行读取 Sync() error // 强制内存和文件同步一次 以保证一致性 Delete() error // 删除文件 Close() error // 关闭文件 Length() (int64, error) // 返回该文件的长度 }

之后的讨论都意在实现上面的功能。

2 Mmap 在 Windows 的实现

先说明以下几个函数的作用,再说总的实现思路。

CreateFileMapping

它的本质是memoryapi.h中的CreateFileMappingW函数,作用是为指定文件创建或打开命名或未命名的文件映射对象。(这个文件映射对象指的就是mmap那种方式所创建出来的进程内存空间)

它的参数依次为:

  • hFile:要创建文件映射对象的文件的句柄。
  • lpFileMappingAttributes:指向SECURITY_ATTRIBUTES结构的指针,起到一个设定文件映射对象安全性的作用,一般不用管,默认为空即可。
  • flProtect:指定文件映射对象的页保护,说人话就是读写权限。
  • dwMaximumSizeHigh:文件映射对象最大大小的高序。
  • dwMaximumSizeLow:文件映射对象最大大小的低序。使用的数据类型为DWORD
  • lpName:文件映射对象的名称,一般也不用起名字,进程间通信除外。

它的返回值是:

  • 如果创建成功,那么返回值即为新创建的文件映射对象的句柄。
  • 如果对象之前就存在,那么返回之前存在的那个句柄。
  • 如果没创建成功,返回null。(鉴于syscall.Handle底层的类型是uintptr,返回一个null其实是返回0吧)

具体信息参照Microsoft Learn

DWORD

DWORD类型一般是unsigned long,具体长度随操作系统而定,一般是uint32。至于函数参数中说的“高序/低序”,指的是当传入的参数长度太长以至于已经无法用一个DWORD存储时,就会使用两个DWORD一起表示。

MapViewOfFile

它的本质是memoryapi.h中的MapViewOfFile函数,作用是将文件映射的视图映射到调用进程的地址空间中。

它的参数依次为:

  • hFileMappingObject:文件映射对象的句柄。
  • dwDesiredAccess:文件映射对象的访问类型。FILE_MAP_WRITEFILE_MAP_ALL_ACCESS这俩是一样的,即为允许读写。FILE_MAP_READ,即为只读。允许读写的时候,上面的函数的flProtect参数必须是PAGE_READWRITE
  • dwFileOffsetHigh:视图开始位置的文件偏移量的高阶DWORD
  • dwFileOffsetLow:视图开始位置的文件偏移量的低序DWORD
  • dwNumberOfBytesToMap:要映射到视图的文件映射的字节数,该字节数必须小于等于CreateFileMapping函数指定的最大大小。

它的返回值是:

  • 如果函数成功,返回映射视图的起始地址。
  • 如果函数失败,返回 null。

具体信息参照Microsoft Learn

CloseHandle

它的本质是handleapi.h里的closeHandle函数,作用是关闭一个打开的对象句柄,参数也只有一个要被关闭的句柄。

具体信息参照Microsoft Learn

MMap 要实现上面的那个接口,大体思路如下:

  • 先创建/打开一个文件,将其映射到一个固定长度的数组上,并且记录文件有效内容的长度,保存那个*os.File指针。
  • 读取就是读取数组内的内容,写入就是向数组内写入内容。需要注意的是,写入的时候必须要更新文件有效内容的长度,并且限制写入的位置。具体来说,写入的位置必须是在当前内容后追加或者修改当前内容,不能造成有效内容的断档。比如说,文件有效内容是前1024个字节,写入位置就只能是在前1025个字节的范围内。这样做是为了维护文件有效内容的长度。
  • 获取文件长度就直接返回当前维护的文件有效内容的长度。
  • 关闭文件的时候,先关闭映射,之后调用*os.FileTruncate方法,将文件大小修改为文件有效内容大小。这样做的目的是为了删去后面的0。在 Windows 中,MMap 映射出的长度是取决于我传入的要映射的长度而不是文件的实际长度。如果要映射的长度小于文件实际长度还好说,如果正相反,那么文件大小会变为要映射的长度,并且后面加大的空间里存储的全部是字节0,这些0也会随着取消映射直接保存进文件。这样就完全无法确定文件实际的内容长度,所以要维护文件有效内容大小,以在关闭文件的时候重新修改文件大小。

注意

这里实际上有一个潜在的问题:如果要避免文件大小被增加,传入的时候直接指定只映射文件实际的大小不就可以了?而且如果文件比映射长度小得多,或者文件实际内容比要映射的长度大又该如何?如果过度占用内存怎么办?

这些我都想过,但是目前想不到什么有效的解决方法。

  • 第一个问题,因为这个项目对文件的读写一般是追加内容和随机读。如果我只映射文件实际的大小,每次写的时候我都需要再映射一次,会不会严重影响效率?
  • 第二个问题,就是小得多也没有办法,要占用的内存空间还是要占。也许可以调整映射方式,每次只映射一个指定的合适的大小,避免过度占用内存的同时也避免太多次的重新映射。问题就在于我掌握不好这个度,没法指定一个最好的能够适应大部分场景的“合适的大小”。就算时间上可以通过 Benchmark 测出来,内存上我不知道怎么占用才是最有效的。
  • 第三个问题,要映射的长度直接就指定为文件的最大长度。
  • 第四个问题,只能是在还原索引的时候及时地关闭文件,没有好的办法。

有了思路,可以直接实现了:

go
type MMapFile struct { file *os.File mmapArray []byte // 从底层上 将一个数组转换而来的切片 所以严禁! 严禁! 严禁!对该切片调用 append 函数 fileContentSize int64 fileMaxSize int64 actualAddr uintptr } func NewFileMMap(filePath string, MaxFileSize int64) (*MMapFile, error) { maxFileSizeUint32 := uint32(MaxFileSize) file, e := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR, 0644) if e != nil { return nil, e } s, e := file.Stat() if e != nil { return nil, e } result := &MMapFile{ file: file, fileContentSize: s.Size(), fileMaxSize: MaxFileSize, } h, e := syscall.CreateFileMapping(syscall.Handle(file.Fd()), nil, syscall.PAGE_READWRITE, 0, maxFileSizeUint32, nil) if e != nil || h == 0 { return nil, e } addr, e := syscall.MapViewOfFile(h, syscall.FILE_MAP_WRITE, 0, 0, uintptr(maxFileSizeUint32)) if addr == 0 { return nil, e } result.actualAddr = addr e = syscall.CloseHandle(h) if e != nil { return nil, e } // 这个写法很值得参考 从数组转 slice 不再需要重新申请内存空间 var customSlice = struct { addr uintptr len int cap int }{addr, int(maxFileSizeUint32), int(maxFileSizeUint32)} result.mmapArray = *(*[]byte)(unsafe.Pointer(&customSlice)) return result, nil } func (mf *MMapFile) Write(input []byte, offset int) error { if len(input)+offset > int(mf.fileMaxSize) { return io.EOF } if int64(offset) > mf.fileContentSize { return logger.OffsetIsIllegal } i := 0 for i < len(input) { mf.mmapArray[i+offset] = input[i] i += 1 } if int64(offset) < mf.fileContentSize { mf.fileContentSize += int64(offset+len(input)) - mf.fileContentSize } else { mf.fileContentSize += int64(len(input)) } return nil } func (mf *MMapFile) Read(buf []byte, offset int) error { i := 0 for i+offset < int(mf.fileMaxSize) && i < len(buf) { buf[i] = mf.mmapArray[i+offset] i += 1 } if i < len(buf) { return io.EOF } return nil } func (mf *MMapFile) Sync() error { return syscall.FlushViewOfFile(mf.actualAddr, uintptr(mf.fileContentSize)) } func (mf *MMapFile) Delete() error { e := mf.Close() if e != nil { return e } e = os.Remove(mf.file.Name()) if e != nil { logger.GenerateErrorLog(false, false, e.Error(), mf.file.Name()) return e } return nil } func (mf *MMapFile) Close() error { e := syscall.UnmapViewOfFile(mf.actualAddr) if e != nil { return e } e = mf.file.Truncate(mf.fileContentSize) // 只保留前 mf.fileContentSize 数量的字节 if e != nil { return e } return mf.file.Close() } func (mf *MMapFile) Length() (int64, error) { return mf.fileContentSize, nil }

这里面要注意的就是那个切片的写法:

go
var customSlice = struct { addr uintptr len int cap int }{addr, int(maxFileSizeUint32), int(maxFileSizeUint32)} result.mmapArray = *(*[]byte)(unsafe.Pointer(&customSlice))

这个写法也许并不安全,但是真的很新颖,而且如果真的是从数组转切片这么写,直接省去了复制的内存和时间开销,是很值得参考的。对于 Mmap 来说,不安全的点就体现在这个切片绝对不能调用append,否则会丢掉实际映射的数组。至于这里面没写到的UnmapViewOfFileFlushViewOfFile函数,见名知意即可,实际上它们俩的参数都很简单,含义也一望而知,不用单独拿出来写了。

3 Mmap 在 Linux 的实现

Mmap 在 Linux 中的实现和在 Windows 中的实现在思路上并没有太大的区别,都要维护那个文件有效内容的长度。但是 Linux 有一点和 Windows 不同,Mmap 映射只会映射文件的实际长度。也就是说,Windows 是根据指定的映射长度来扩展文件长度,Linux 是根据文件长度来判断映射长度是否有效。如果要沿用之前的思路,还要用Truncate来修改文件长度。

解决了这一点,别的实现起来就好说了。

go
type MMapFile struct { file *os.File mmapArray []byte // 从底层上 将一个数组转换而来的切片 所以严禁! 严禁! 严禁!对该切片调用 append 函数 fileContentSize int64 fileMaxSize int64 } func NewMMapFile(filePath string, MaxFileSize int64) (*MMapFile, error) { file, e := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR, 0644) if e != nil { return nil, e } fileInfo, e := file.Stat() if e != nil { return nil, e } fileActualSize := fileInfo.Size() e = file.Truncate(MaxFileSize) if e != nil { return nil, e } mmapData, e := syscall.Mmap(int(file.Fd()), 0, int(MaxFileSize), syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED) result := &MMapFile{ file: file, mmapArray: mmapData, fileContentSize: fileActualSize, fileMaxSize: MaxFileSize, } return result, nil } func (mf *MMapFile) Write(input []byte, offset int) error { if len(input)+offset > int(mf.fileMaxSize) { return io.EOF } if int64(offset) > mf.fileContentSize { return logger.OffsetIsIllegal } i := 0 for i < len(input) { mf.mmapArray[i+offset] = input[i] i += 1 } if int64(offset) < mf.fileContentSize { mf.fileContentSize += int64(offset+len(input)) - mf.fileContentSize } else { mf.fileContentSize += int64(len(input)) } return nil } func (mf *MMapFile) Read(buf []byte, offset int) error { i := 0 for i+offset < int(mf.fileMaxSize) && i < len(buf) { buf[i] = mf.mmapArray[i+offset] i += 1 } if i < len(buf) { return io.EOF } return nil } func (mf *MMapFile) Sync() error { _, _, e := syscall.Syscall(syscall.SYS_MSYNC, uintptr(mf.mmapArray[0]), uintptr(mf.fileMaxSize), syscall.MS_SYNC) // syscall 调用没封装好的系统接口一般就这么调用 return e } func (mf *MMapFile) Delete() error { e := mf.Close() if e != nil { return e } e = os.Remove(mf.file.Name()) if e != nil { logger.GenerateErrorLog(false, false, e.Error(), mf.file.Name()) return e } return nil } func (mf *MMapFile) Close() error { e := syscall.Munmap(mf.mmapArray) if e != nil { return e } e = mf.file.Truncate(mf.fileContentSize) if e != nil { return e } e = mf.file.Close() if e != nil { return e } return nil } func (mf *MMapFile) Length() (int64, error) { return mf.fileContentSize, nil }

本文作者:御坂19327号

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!