最近看了《Understanding the Linux® Virtual Memory Manager》里面的第十二章 SHARED MEMORY VIRTUAL FILESYSTEM ,对文件系统以及内存文件管理有了更加深入的了解,下面是看了这一章节之后对其中一些概念的理解以及拓展,要是想了解这一章,建议读原文,配合这篇博客辅助理解
Linux 哲学
- 在 Linux 里面,一切皆文件,所有的东西都可以看作一个文件,而凡是文件,都应该支持或者接近
POSIX
文件操作(比如read()
,write()
,open()
) - 每一块内存对象,都可以被看作一个文件,一旦赋予这块内存对象相对应的文件描述,就可以使用像使用普通文件那样子操作内存对象
- 而这也正是
VFS
(虚拟文件系统virtual file system
,包括内存文件系统以及共享内存管理系统)的设计理念以及实现方向
VMA(virtual memory area)
Linux 内核用
vm_area_struct
结构体描述某一段连续的虚拟内存区域VMA(virtual memory area)
,每个虚拟内存区域VMA
都有自己的vm_area_struct
结构体内存描述符
mm_struct
指向进程的整个地址空间,vm_area_struct
只是指向了虚拟空间的一段,这块虚拟内存区域VMA的地址范围为[vm_start, vm_end)
,左开右闭vm_area_struct
是由双向链表链接起来的,它们是按照虚拟地址降序排序的,每个这样的结构都对应描述一个地址空间范围为了快速根据地址找到对应的
VMA
,内核对其建立了红黑树索引,红黑树的每个叶子结点就是一个VMA区域,引入红黑树的好处是可以提高查找VMA的效率(即便VMA的数量翻倍,VMA的查找次数也只增加一次)之所以通过
VMA
分隔内存区域是因为每个虚拟区间可能来源不同,有的可能来自可执行映像,有的可能来自共享库,而有的可能是动态内存分配的内存区,所以对于每个由vm_area_struct
结构所描述的区间的处理操作和它前后范围的处理操作不同,因此 linux 把虚拟内存分割管理,并利用了虚拟内存处理例程vm_ops
来抽象对不同来源虚拟内存的处理方法不同的虚拟区间其处理操作可能不同,
linux
在这里利用了面向对象的思想,即把一个虚拟区间看成是一个对象,用vm_area_struct
描述这个对象的属性,其中的vm_operation
结构描述了在这个对象上的操作虚拟内存空间管理概括图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
# https://elixir.bootlin.com/linux/v2.6.0/source/include/linux/mm.h#L51 struct vm_area_struct { struct mm_struct * vm_mm; /* The address space we belong to. */ unsigned long vm_start; /* Our start address within vm_mm. */ unsigned long vm_end; /* The first byte after our end address within vm_mm. */ /* linked list of VM areas per task, sorted by address */ struct vm_area_struct *vm_next; pgprot_t vm_page_prot; /* Access permissions of this VMA. */ unsigned long vm_flags; /* Flags, listed below. */ struct rb_node vm_rb; /* * For areas with an address space and backing store, * one of the address_space->i_mmap{,shared} lists, * for shm areas, the list of attaches, otherwise unused. */ struct list_head shared; /* Function pointers to deal with this struct. */ struct vm_operations_struct * vm_ops; /* Information about our backing store: */ unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units, *not* PAGE_CACHE_SIZE */ struct file * vm_file; /* File we map to (can be NULL). */ void * vm_private_data; /* was vm_pte (shared mem) */ };
struct address_space
- 看
linux
内核很容易被struct address_space
这个结构迷惑,它是代表某个地址空间吗?实际上不是的,它是用于管理文件struct inode
映射到内存的页面struct page
的,其实就是每个读入内存的file
都有这么一个结构,将文件系统中这个file
对应的数据与这个file
的磁盘数据与内存页对应起来 - 与之对应,
address_space_operations
就是用来操作该文件映射到内存的页面,比如把内存中的修改写回文件、从文件中读入数据到页面缓冲等 - 一个具体的文件在打开后,内核会在内存中为之建立一个
struct inode
结构(该inode
结构也会在对应的file
结构体中引用),其中的i_mapping
域指向一个address_space
结构 - 一个文件就对应一个
address_space
结构,一个address_space
与一个偏移量能够确定一个page cache
或swap cache
中的一个页面,当要寻址某个数据时,很容易根据给定的文件及数据在文件内的偏移量而找到相应的页面 struct file
和struct inode
结构体中都有一个struct address_space
指针1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
# https://elixir.bootlin.com/linux/v2.6.0/source/include/linux/fs.h#L319 struct address_space { struct inode *host; /* owner: inode, block_device */ struct radix_tree_root page_tree; /* radix tree of all pages */ spinlock_t page_lock; /* and spinlock protecting it */ struct list_head clean_pages; /* list of clean pages */ struct list_head dirty_pages; /* list of dirty pages */ struct list_head locked_pages; /* list of locked pages */ struct list_head io_pages; /* being prepared for I/O */ unsigned long nrpages; /* number of total pages */ struct address_space_operations *a_ops; /* methods */ struct list_head i_mmap; /* list of private mappings */ struct list_head i_mmap_shared; /* list of shared mappings */ struct semaphore i_shared_sem; /* protect both above lists */ atomic_t truncate_count; /* Cover race condition with truncate */ unsigned long dirtied_when; /* jiffies of first page dirtying */ unsigned long flags; /* error bits/gfp mask */ struct backing_dev_info *backing_dev_info; /* device readahead, etc */ spinlock_t private_lock; /* for use by the address_space */ struct list_head private_list; /* ditto */ struct address_space *assoc_mapping; /* ditto */ };
文件系统、文件类型、page
的划分
- 为了方便理解,在此将文件系统划分为内存文件系统(虚拟文件系统)与硬盘文件系统(物理文件系统),在书的这一章节里面,也将广义上的文件分为
virtual file
和physical file
- 在书的这一章节里面,将内存页面
page
划分为anonymous pages
(没有物理文件支持的内存页面)与pages backed by a file
(由物理文件映射到内存的某些pages
)
page cache
与 swap cache
page cache
是与文件映射对应的,而swap cache
是与匿名页对应的- 如果一个内存页面不是文件映射,则在换入换出的时候加入到
swap cache
,如果是文件映射,则不需要交换缓冲 - 这两个的相同点就是它们都是
address_space
,都有相对应的文件操作:一个被访问的文件的物理页面都驻留在page cache
或swap cache
中,一个页面的所有信息由struct page
来描述 page cache
作用- 当文件被读取时,操作系统会把文件内容加载到内存的
page cache
中 - 如果同一个文件被再次访问,操作系统会直接从
page cache
中读取,而不是再次从磁盘读取,从而减少磁盘访问次数,提高性能
- 当文件被读取时,操作系统会把文件内容加载到内存的
swap cache
作用swap cache
缓存的是已经交换到磁盘的数据,它是为了提高交换操作的效率,优化虚拟内存的交换操作- 如果内存中再次需要使用这些页面,操作系统会从
swap cache
里面寻找,然后再从swap
里面查找 - 同时对于那些刚刚从物理内存里面换出来的
page
以及从swap
空间读取的page
也会放在swap cache
里面
- 一般情况下用户进程调用
mmap()
时,只是在进程空间内新增了一块相应大小的缓冲区,并设置了相应的访问标识,但并没有建立进程空间到物理页面的映射 - 因此,第一次访问该空间时,会引发一个缺页异常
- 对于共享内存映射情况
- 缺页异常处理程序首先在
swap cache
中寻找目标页(符合address_space
以及偏移量的物理页) - 如果找到,则直接返回地址
- 如果没有找到,则判断该页是否在交换区 (
swap area
),如果在,则执行一个换入操作 - 如果上述两种情况都不满足,处理程序将分配新的物理页面,并把它插入到
page cache
中,最终将更新进程页表
- 缺页异常处理程序首先在
- 对于映射普通文件情况(非共享映射)
- 缺页异常处理程序首先会在
page cache
中根据address_space
以及数据偏移量寻找相应的页面 - 如果没有找到,则说明文件数据还没有读入内存,处理程序会从磁盘读入相应的页面,并返回相应地址,同时进程页表也会更新
- 缺页异常处理程序首先会在
硬盘(物理)文件系统
在 Linux 文件系统中,dentry
(目录项)和 inode
(索引节点)是两个核心概念,它们是文件系统内部用于表示和管理文件、目录及其属性的数据结构
dentry
(目录项)
dentry
是一个目录项
的数据结构,它表示文件路径名
与文件在文件系统中的位置
之间的映射关系- 每个
dentry
关联了一个路径组件(如文件夹或文件名),并指向文件系统中的inode
,内核通过逐级解析路径来查找文件1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
struct dentry { ...... struct inode * d_inode; /* Where the name belongs to - NULL is negative */ ...... struct list_head d_child; /* child of parent list */ struct list_head d_subdirs; /* our children */ ...... struct qstr d_name; /* file name */ ...... }; struct qstr { const unsigned char * name; unsigned int len; unsigned int hash; char name_str[0]; };
inode
(索引节点)
inode
是文件系统中用于描述文件或目录的元数据的数据结构,是文件的唯一标识符,除了文件名之外,它包含了文件的所有信息,例如:- 文件类型(普通文件、目录、符号链接等)
- 文件权限(读、写、执行权限)
- 文件所有者和用户组
- 文件大小
- 文件的时间戳(创建时间、修改时间、访问时间)
- 文件数据块的位置(指向数据块的指针),包括直接块指针
i_direct
与间接块指针i_indirect
inode
指向文件在磁盘上的物理位置,帮助操作系统定位文件数据块,从而实现文件的读取和写入
i_nlink
i_nlink
是一个存储在inode
结构中的字段,表示指向该inode
的硬链接数量- 对于普通文件
i_nlink
记录的是指向该文件的硬链接数,也就是说,文件的i_nlink
是表示有多少个目录项指向该文件的inode
- 每当创建一个新文件时,系统会为该文件分配一个
inode
,而文件的i_nlink
初始值为 1,这个1
代表着自己
- 对于目录
i_nlink
记录的是指向该目录的硬链接数,它的值还与子目录的存在有关- 每创建一个子目录,父目录的
i_nlink
会增加 - 每个子目录的
inode
被创建时,父目录的nlink
会增加 1,因为子目录会有一个指向父目录的硬链接(即..
链接)
dentry
和 inode
的关系
- 关联性
- 每个
dentry
对应一个路径组件(例如某个文件或目录的名称),并且每个dentry
都指向一个inode
- 通过
inode
,操作系统可以找到文件的实际数据,而dentry
则是通过文件名来指向inode
- 换句话说,
dentry
提供了文件路径到文件数据位置(即inode
)的映射关系
- 每个
- 路径解析
- 当一个路径被访问时,内核会逐步解析路径中的每个目录项(
dentry
),通过目录项找到对应的inode
- 每个目录项(
dentry
)都存储了一个指向对应inode
的指针 - 当最终解析到文件名时,内核通过该
inode
获取文件的元数据并定位文件数据块
- 当一个路径被访问时,内核会逐步解析路径中的每个目录项(
- 缓存机制
- 为了提高性能,内核会缓存路径名和
inode
之间的映射关系,这样在访问同一个文件或目录时,可以避免重复解析路径
- 为了提高性能,内核会缓存路径名和
用 dentry
构建“多叉树”文件系统
在 Linux 内核中只需要用到
struct list_head d_child
和struct list_head d_subdirs
这两个两个关键双向链表就可以实现目录树结构1 2 3 4 5 6 7 8 9 10
struct dentry { ...... struct list_head d_child; /* child of parent list */ struct list_head d_subdirs; /* our children */ ...... }; struct list_head { struct list_head *next, *prev; };
d_child
- 表示当前目录项在父目录的子项链表中的位置
- 它连接到父目录的
d_subdirs
链表,用于形成目录树中的父子关系 d_child -> prev
为父目录或者兄弟d_child -> next
为兄弟或者NULL
d_subdirs
- 表示当前目录的子目录和文件列表
- 用于遍历当前目录下的所有子项
- 当前目录下的每个子项(文件或子目录)的
d_child
都会被挂接到d_subdirs
链表中 d_subdirs -> prev
为NULL
d_subdirs -> next
为当前目录下第一个孩子或者NULL
- 在当前目录新建文件时,创建的文件会以头插法插到
d_subdirs -> next
d_subdirs
与d_child
配合d_subdirs
维护子项列表,d_child
链接到父目录的子项链表,形成一个双向链表结构的目录树
假设文件系统目录结构如下:
1 2 3 4 5 6
/ ├── home/ │ ├── user/ │ │ ├── file1 │ │ └── documents/ └── etc/
那么它们之间的链表关系图应该是这样子的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
(root /) d_child d_subdirs + | +--> (home) d_child | d_subdirs | + | | | +--> (user) d_child | d_subdirs | + | | | +--> (file1) d_child | | | +--> (documents) d_child | +--> (etc) d_child
现在还剩下最后一个问题,
d_subdirs
是list_head
类型的数据结构,它本身只包含两个指针:next
和prev
,应该如何通过list_head
找到包含它的struct dentry
呢?在内核里面,实现这个目标需要依赖嵌套结构和偏移量计算
而这一步的关键函数是
contianer_of
1 2
#define container_of(ptr, type, member) \ ((type *)((char *)(ptr) - offsetof(type, member)))
在
dentry
这个场景下,container_of
里面的各个参数可以这样子理解ptr
:list_head
指针,例如&dir->d_subdirs
type
:包含list_head
的结构体类型(这里是struct dentry
)member
:list_head
字段在结构体中的名字(这里是d_subdirs
)offsetof(type, member)
:获取member
在type
中的偏移量,通过ptr
减去member
的偏移量,计算出结构体的起始地址
删除目录
- 在文件系统中,删除一个目录时,内核会使用深度优先算法递归地删除该目录下的所有文件和子目录
tmpfs
与 shm
联系
shm
introduction
shm
是 Linux 内核内部实现匿名内存共享的机制,主要为内核服务,不对用户直接可见shm
最初设计是为支持POSIX
共享内存(如shm_open
)和 System V 共享内存(如shmget
),共享内存需要一个临时的、内存驻留的文件系统来存储共享的内存页shm
为匿名页面提供统一的文件支持接口,使内核可以用文件操作函数(如readpage
或writepage
)管理这些页面,实现匿名共享内存(如通过mmap
创建的MAP_ANONYMOUS | MAP_SHARED
区域)和 System V 共享内存(shmget
)shm
对虚拟文件的描述都是使用shmem_inode_info
(shmem_inode_info
可以看作inode
的继承,在内存文件系统里面如果要创建一个文件,要先向系统申请一个inode
,然后才是将这个inode
传给shmem_inode_info
,有点类似 C++ 里面的当一个子类要实例化的时候需要先实例化父类)1 2 3 4 5 6 7 8 9 10 11 12
// https://elixir.bootlin.com/linux/v2.6.0/source/include/linux/shmem_fs.h#L10 struct shmem_inode_info { spinlock_t lock; unsigned long next_index; swp_entry_t i_direct[SHMEM_NR_DIRECT]; /* for the first blocks */ struct page *i_indirect; /* indirect blocks */ unsigned long alloced; /* data pages allocated to file */ unsigned long swapped; /* subtotal assigned to swap */ unsigned long flags; struct list_head list; struct inode vfs_inode; };
tmpfs
introduction
tmpfs
是面向用户的、通用的基于内存的文件系统,使用 RAM 作为存储媒介,用于提供临时存储和共享内存功能,能够通过挂载点提供更多功能tmpfs
的核心实现文件在内核的shm.c
,tmpfs
的许多功能,包括内存页的管理、inode
的创建和操作等,都在这个文件中实现
tmpfs
的核心实现源自于 shm
shm
在通常语境下确实是共享内存(shared memory)的缩写,但在 Linux 内核中,shmem
指的是一个更通用的机制,既用于共享内存,也为tmpfs
提供支持,这源于 Linux 内核设计中对内存管理的统一抽象shm
这个名字更多反映了其历史背景,由最初支持共享内存机制的主要目标,到后来逐渐扩展到支持tmpfs
- 随着
tmpfs
的引入,Linux 直接复用了shmem
的基础设施,因为它们的核心需求一致:一种无持久存储后端、基于内存的文件系统 - 因此
shm
这种命名并不是严格意义上的“共享内存”限定,而是一个历史遗留的名字 - 当使用
tmpfs
或共享内存时,内核实际上都在调用shmem
相关的功能,换句话说,tmpfs
和共享内存是同一个机制在不同场景下的两个应用实例 shmem
和tmpfs
核心需求一致:- 动态分配页(在需要时为文件或共享内存段分配内存页)
- 支持按需增长(
tmpfs
文件或共享内存段会随内容增长) - 内存页面可以被回收或写入交换分区(swapping)
shmem
是底层机制,最初服务于共享内存需求tmpfs
是基于shmem
的一个文件系统实现,扩展了其使用场景,支持临时文件存储tmpfs
的诞生得益于shmem
的存在,是共享内存技术的一次成功复用
shm
使用
System V 共享内存
1 2 3 4 5
// 使用 `shmget` 和 `shmat` 创建的共享内存段,通过 `shm` 提供底层支持 int shmid = shmget(IPC_PRIVATE, 1024, IPC_CREAT | 0666); char *data = shmat(shmid, NULL, 0); strcpy(data, "Hello, shm!"); printf("Data in shared memory: %s\n", data);
匿名共享内存
1 2 3 4 5 6 7 8 9 10
// 当进程调用 `mmap` 并指定 `MAP_ANONYMOUS | MAP_SHARED` 时,`shm` 会为这些匿名页面创建支持 size_t size = 4096; // 分配一个4KB的内存区域 void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); if (addr == MAP_FAILED) { perror("mmap failed"); return 1; } // 通过指针访问并修改匿名共享内存 int *data = (int *)addr;
使用
POSIX
文件接口与shm
结合1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
const char *shm_name = "/my_shm"; // 共享内存对象的名称 size_t size = 4096; // 共享内存的大小 // 创建或打开共享内存对象 // 使用 shm_open() 创建或打开共享内存对象后,返回的文件描述符可以通过 open() 进行访问 int shm_fd = shm_open(shm_name, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR); if (shm_fd == -1) { perror("shm_open"); return 1; } // 设置共享内存的大小 if (ftruncate(shm_fd, size) == -1) { perror("ftruncate"); return 1; } // 将共享内存映射到进程的地址空间 void *shm_ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0); if (shm_ptr == MAP_FAILED) { perror("mmap"); return 1; } // 使用共享内存 // 写入数据到共享内存 snprintf((char *)shm_ptr, size, "Hello from shared memory!"); // 解除映射 if (munmap(shm_ptr, size) == -1) { perror("munmap"); return 1; } // 关闭共享内存对象 close(shm_fd);
tmpfs
使用
tmpfs
默认挂载到/tmp
,为用户提供快速的、基于 RAM 的临时文件存储空间,读写速度快,它也可以手动挂载到其他地方1 2 3 4 5 6 7 8 9
# 在 /mnt/tmpfs 挂载一个 tmpfs 文件系统 sudo mount -t tmpfs -o size=128M tmpfs /mnt/tmpfs # 在 tmpfs 上创建文件 echo "Hello, tmpfs!" > /mnt/tmpfs/testfile cat /mnt/tmpfs/testfile # 卸载 tmpfs sudo umount /mnt/tmpfs
tmpfs
也可以使用多进程open
或者多进程mmap
到一个共同的tmpfs
文件达到进程间通信的效果1 2 3
int fd = open("example.txt", O_RDONLY); char *mapped = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0); close(fd);
观察真实机器上的 tmpfs
|
|
- 在上面的
df -h
输出中,列出了多个tmpfs
挂载点/run
:tmpfs
被挂载在/run
目录。它是一个动态的、短期的文件系统,用于存储系统运行时的数据(如进程 ID 文件、锁文件等)/dev/shm
:tmpfs
被挂载在/dev/shm
目录,它是共享内存的挂载点,供进程间通信使用/run/lock
:tmpfs
被挂载在/run/lock
目录,用于存放进程间锁文件/tmp
:tmpfs
被挂载在/tmp
目录,用于存放临时文件/run/user/1000
: 该tmpfs
是为用户 ID 为 1000 的用户(也就是用户hcy
)提供的临时文件系统,用于存储用户的运行时数据,如程序缓存、临时文件等
- 这些
tmpfs
挂载点的数据在机器重启之后都不会存在
观察容器里面的 tmpfs
- 在
Docker
中,容器的文件系统是基于UnionFS
(联合文件系统)的,通常采用的是aufs
、overlay
或overlay2
等文件系统,而UnionFS
并不直接使用tmpfs
,除非明确将某些目录挂载为tmpfs
- Docker 容器的文件系统是持久化的,在容器中文件存储在镜像层
read-only layer
和容器层read-write layer
上 - 默认情况下,当容器重启时,容器层会保持不变,即使写入到
/tmp
目录的文件,都会保存在容器层中,而不是丢失 - 只有在宿主机层面在容器创建时,通过
--tmpfs
参数来指定将/tmp
挂载为 tmpfs,才有可能让容器里面的/tmp
的表现和常规的一样1
docker run --tmpfs /tmp <image_id>
全局共享的零页(global zero page)
- Linux 内核中有一个全局共享的零页,所有字节都被初始化为零,这块内存页被全局所有进程共享,通常是只读的,多进程可以同时访问
- 当一个进程需要访问大量的全零数据(如未初始化的内存、扩展文件的空白部分),可以直接映射到全局零页,而无需实际分配和初始化物理内存,避免为每个进程单独分配一块全零的内存页
- 例如当文件通过
truncate()
扩展时,新增加的部分需要初始化为零。如果直接写零到磁盘或内存,会消耗资源 - 通过全局零页,文件系统可以将新增加的部分映射到这块零页,而不需要实际分配和初始化
- 又例如进程分配内存时,未使用的部分(例如通过
mmap
映射的匿名内存)通常被映射为零页,直到实际写入数据为止 - 由于零页是只读的,如果进程试图写入零页,会触发页错误
page fault
,内核会复制一块新的物理页供进程使用,这叫做“写时复制”Copy-on-Write, COW