主要内容:
提示
先说明一个知识点://go:build windows
能够指定编译器在某一系统下才编译该文件。比如可以这样:
mmap_windows.go
中的最靠前地方(比包声明还靠前的地方)加入//go:build windows
声明。mmap_linux.go
中的最靠前地方(比包声明还靠前的地方)加入//go:build linux
声明。这样就可以达成让编译器自动选择文件进行编译的效果。
再说明一个知识点:syscall
包就是 go 用于直接调用操作系统的接口用的接口,这在下面的代码中会有很明确的体现。
提示
前置知识:操作系统的段页式存储。
以下讨论都是以 Linux 为基础进行讨论,当然有一些概念基本上是共通的,只是实际举例的时候用 Linux。
请先思考这么一个问题:如果没有 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 由以下部分组成:
/home/foo/hello.txt
,在树上就有三个节点:home
,foo
,hello.txt
。每个节点内部存储的是该目录下的所有文件的 inode 和文件名信息。按路径查找文件时从根目录出发。file
文件结构体,用于存储各种文件参数。open
,close
,read
等等。每个在打开文件列表的元素都可以连接到该模块,从而对打开的文件执行各种操作。运行中的用户进程在内核中用task_struct
结构来存储其所有的信息。该结构体中维护一个files
指针,该指针指向files_struct
结构体,该结构体中包含文件描述符表和打开的文件对象信息。这个“文件描述符表”就是上文提到的,打开文件列表模块中的元素file
结构体指针所构成的列表。这些元素每个都能链接到 file_operations 模块和 inode 模块,从而访问文件或者对文件进行操作。
综上所述,一个用户进程读取文件需要以下步骤:
task_struct
结构找到对应的file
结构体,从而调用file_operations 模块中的read
函数。read
函数根据传入的路径,在目录项模块中找到文件对应的 inode。一个用户进程写入文件需要以下步骤:
前五步和读取文件都是一样的,最后保证文件内容是在页缓存中。
sync
或者fsync
强制将脏页内容写回到磁盘上。脏页在写回磁盘的过程中是被锁定的,任何写入请求都会阻塞到写回磁盘完成。
以上就是正常的读写文件的全部过程。注意不论是读还是写,文件是一定要走一遍页缓存的,即文件内容先从磁盘复制到页缓存,再从页缓存复制到用户态内存上。有没有什么方法能够绕过页缓存直接读写磁盘的?有,就是 Mmap。
Mmap(Memory Map) 一言以蔽之,即为一种内存映射的方法。放到文件读写上,即为将一个文件或者其他对象映射到进程的地址空间的方法。实现文件磁盘地址和进程虚拟地址空间中一段虚拟内存地址的一一对应关系。
进程虚拟地址空间
在了解 Mmap 整个过程之前,必须先了解进程的虚拟地址空间。
虚拟地址空间,即为段页式存储为程序提供的一个分段式的虚拟内存结构。在一般情况下,程序想要的内存是一段连续的内存空间,而底层的真实存在的内存是按页进行管理。经过段页式存储进行转换,程序就无需关心底层存储,直接用段页式提供的连续的虚拟地址空间即可。
上文提到,一个用户进程在内核中是以task_struct
结构来存储其所有信息的,其中除了有上文的files
指针外,还有一些vm_area_struct
指针用于表示一个独立的虚拟内存区域,由于虚拟内存区域的功能和性质都不太相同(堆栈,数据段,代码段或者别的一类的),所以一个进程通常会持有多个vm_area_struct
指针,这些指针以链表或者树的形式进行组织。
这个vm_area_struct
结构中,包括了一段虚拟内存地址空间的起始地址,终止地址,一个指向操作该内存地址空间的函数的指针还有权限信息一类的其他信息。通过这个结构,用户进程可以直接操作该虚拟内存地址空间。
Mmap 实质上就是这么一个与文件的物理磁盘地址相联的vm_area_struct
结构
相关信息
实际上,还有一个点可以提,就是 Mmap 理论上不止可以用户映射文件内容,两个进程映射同一片共享内存也是可以的。另外 Linux 内万物皆文件,映射一个别的句柄进去我不知道行不行,没试过。
使用 Mmap 对文件进行读写的过程如下:
mmap
系统调用,用户态转换为内核态。该函数会进行以下操作:
vm_area_struct
结构,并且插入该进程原有的vm_area_struct
链表/树中。file
文件结构体,再通过这个file
文件结构体,调用 file_operations 模块里的mmap
函数(区别于开放给用户态的mmap
函数),该函数会根据文件 inode 定位到该文件的磁盘物理地址。最后,通过remap_pfn_range
函数建立页表,实现文件磁盘物理地址和虚拟进程空间地址的映射关系。映射关系建立后退出mmap
函数,内核态转换为用户态,注意此时用户态内的这片虚拟内存空间内还没有一点数据。msync
函数强制同步,这块和不用 Mmap 的方法是一样的。传统的文件读写方式,所有的数据都要走一遍缓存页,当然这在绝大部分情况下是性能够用且能够延长硬盘寿命的,但是在一些对速度要求极高的情况下,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) // 返回该文件的长度
}
之后的讨论都意在实现上面的功能。
先说明以下几个函数的作用,再说总的实现思路。
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_WRITE
,FILE_MAP_ALL_ACCESS
这俩是一样的,即为允许读写。FILE_MAP_READ
,即为只读。允许读写的时候,上面的函数的flProtect
参数必须是PAGE_READWRITE
。dwFileOffsetHigh
:视图开始位置的文件偏移量的高阶DWORD
。dwFileOffsetLow
:视图开始位置的文件偏移量的低序DWORD
。dwNumberOfBytesToMap
:要映射到视图的文件映射的字节数,该字节数必须小于等于CreateFileMapping
函数指定的最大大小。它的返回值是:
具体信息参照Microsoft Learn
MMap 要实现上面的那个接口,大体思路如下:
*os.File
指针。*os.File
的Truncate
方法,将文件大小修改为文件有效内容大小。这样做的目的是为了删去后面的0。在 Windows 中,MMap 映射出的长度是取决于我传入的要映射的长度而不是文件的实际长度。如果要映射的长度小于文件实际长度还好说,如果正相反,那么文件大小会变为要映射的长度,并且后面加大的空间里存储的全部是字节0,这些0也会随着取消映射直接保存进文件。这样就完全无法确定文件实际的内容长度,所以要维护文件有效内容大小,以在关闭文件的时候重新修改文件大小。注意
这里实际上有一个潜在的问题:如果要避免文件大小被增加,传入的时候直接指定只映射文件实际的大小不就可以了?而且如果文件比映射长度小得多,或者文件实际内容比要映射的长度大又该如何?如果过度占用内存怎么办?
这些我都想过,但是目前想不到什么有效的解决方法。
有了思路,可以直接实现了:
gotype 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
}
这里面要注意的就是那个切片的写法:
govar customSlice = struct {
addr uintptr
len int
cap int
}{addr, int(maxFileSizeUint32), int(maxFileSizeUint32)}
result.mmapArray = *(*[]byte)(unsafe.Pointer(&customSlice))
这个写法也许并不安全,但是真的很新颖,而且如果真的是从数组转切片这么写,直接省去了复制的内存和时间开销,是很值得参考的。对于 Mmap 来说,不安全的点就体现在这个切片绝对不能调用append
,否则会丢掉实际映射的数组。至于这里面没写到的UnmapViewOfFile
,FlushViewOfFile
函数,见名知意即可,实际上它们俩的参数都很简单,含义也一望而知,不用单独拿出来写了。
Mmap 在 Linux 中的实现和在 Windows 中的实现在思路上并没有太大的区别,都要维护那个文件有效内容的长度。但是 Linux 有一点和 Windows 不同,Mmap 映射只会映射文件的实际长度。也就是说,Windows 是根据指定的映射长度来扩展文件长度,Linux 是根据文件长度来判断映射长度是否有效。如果要沿用之前的思路,还要用Truncate
来修改文件长度。
解决了这一点,别的实现起来就好说了。
gotype 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 许可协议。转载请注明出处!