[TOC]

fs/file.c 文件句柄管理(File Handle Management) 管理已打开文件的核心数据结构

历史与背景

这项技术是为了解决什么特定问题而诞生的?

fs/file.c 及其管理的数据结构是为了解决在多进程操作系统中如何清晰、高效地管理“已打开文件”这一核心概念而诞生的。它解决了以下几个基本问题:

  • 状态的区分:需要区分“磁盘上的文件”和“被打开的文件”。磁盘上的文件有其固有的属性(大小、权限等),而被打开的文件则有其动态的状态,最典型的就是当前的读写位置(offset)。多个进程可以同时打开同一个文件,但每个进程都应该有自己独立的读写位置。
  • 抽象与统一:操作系统需要一个统一的接口来处理所有类型的I/O操作。无论是读写磁盘文件、管道、套接字还是硬件设备,用户空间程序都应该能使用相同的系统调用(read, write, close)。fs/file.c 提供的框架是实现这种统一接口(即VFS - 虚拟文件系统)的关键部分。
  • 资源共享与隔离:需要一种机制来管理文件句柄在进程间的关系。例如,一个进程如何复制一个文件句柄(dup),以及父进程打开的文件如何被子进程继承(fork)。
  • 生命周期管理:需要精确地追踪一个打开的文件被多少个地方引用,以确保在没有任何进程再需要它时,能够安全地释放其占用的内核资源。

它的发展经历了哪些重要的里程碑或版本迭代?

fs/file.c 所体现的设计思想——即区分文件描述符、打开文件表和inode表——是Unix系统设计的基石之一,Linux从诞生之初就继承了这一经典模型。其发展主要体现在:

  • 从静态到动态:早期的Unix系统中,文件表等是静态大小的数组,限制了系统能打开的文件总数。Linux从一开始就使用了动态分配的结构,提供了更好的可伸伸缩性。
  • 通用化(”一切皆文件”):随着内核的发展,struct file 这个概念被极大地扩展了。它不再仅仅代表磁盘文件,而是成为了内核中所有可进行I/O操作对象的句柄。eventfd, signalfd, timerfd, epoll 等机制的实现,都是通过创建一个匿名的、不对应任何磁盘文件的inode,并为其关联一个struct file来将它们接入标准的文件描述符体系,使得用户空间可以用select/poll/epoll来统一监控它们。
  • 性能和并发优化:随着多核处理器的普及,对全局文件表的访问成为了性能瓶颈。内核开发者通过引入更精细的锁机制、读写锁乃至RCU(Read-Copy-Update)等技术,来优化对文件描述符表和全局文件对象的并发访问,提高了系统的整体吞吐量。

目前该技术的社区活跃度和主流应用情况如何?

fs/file.c 是Linux内核中最核心、最稳定的部分之一。它是所有I/O操作的基础,因此其健壮性至关重要。社区活动主要集中在细微的性能优化、bug修复和安全加固上,其核心架构已经非常成熟和稳定。系统上运行的每一个进程,执行的每一次I/O,都离不开fs/file.c所提供的管理框架。

核心原理与设计

它的核心工作原理是什么?

fs/file.c 的核心是围绕着三个关键数据结构的交互来工作的,它们分别是:

  1. 文件描述符 (File Descriptor, fd):这是一个每个进程独立的、非负的小整数。它仅仅是一个索引,指向该进程的文件描述符表中的一个条目。
  2. 文件描述符表 (struct files_struct):这也是一个每个进程独有的结构(在task_struct中由files指针指向)。它本质上是一个指针数组,将文件描述符(fd)这个整数映射到一个struct file对象的地址。
  3. 打开文件描述 (struct file):这是由fs/file.c直接管理的最核心的结构。它代表了一个系统全局唯一的、被打开的文件实例。当多个进程打开同一个磁盘文件时,它们会得到各自不同的struct file对象。此结构包含了:
    • f_pos:最重要的成员之一,记录了当前文件的读写偏移量。
    • f_op:一个指向struct file_operations的函数指针表。这个表由底层的文件系统(如ext4)或设备驱动提供,包含了对该文件实际进行read, write, llseek等操作的函数地址。这是VFS实现多态性的关键。
    • f_path:包含了dentryvfsmount,将这个打开的文件对象与VFS中的目录项和挂载点关联起来,并最终指向文件的inode
    • f_flags:记录了文件被打开时的标志,如O_RDONLY, O_APPEND, O_NONBLOCK等。
    • f_count:一个引用计数器,记录有多少个文件描述符指向这个struct file对象。

工作流程示例 (open -> read -> close)

  • open():当进程调用open("foo.txt", ...)时,内核VFS首先找到foo.txt对应的inode。接着,内核分配一个新的struct file对象,用该inode和文件系统提供的file_operations来初始化它,并将f_pos设为0。然后,内核在该进程的files_struct中找到一个空闲的槽位(例如,fd=3),将该槽位指向新创建的struct file对象,最后将整数3返回给用户空间。
  • read():进程调用read(3, buf, size)。内核通过当前进程的files_struct,发现fd 3指向了之前创建的struct file对象。内核随即调用file->f_op->read(file, buf, size, &file->f_pos)。底层的读函数执行实际的I/O操作,并根据读取的字节数更新file->f_pos
  • close():进程调用close(3)。内核找到fd 3指向的struct file对象,将其引用计数f_count减1。同时,将进程files_struct中的第3个槽位清空。如果f_count减到0,说明系统里没有任何地方再引用这个打开文件了,内核就会释放这个struct file对象。

它的主要优势体现在哪些方面?

  • 抽象与多态:通过file_operations函数指针,为所有类型的I/O提供了一个统一的视图,完美践行了“一切皆文件”的哲学。
  • 状态隔离:清晰地将共享的磁盘文件元数据(inode)与每个打开实例的私有状态(struct file中的f_pos)分离开来。
  • 灵活的共享机制:该模型优雅地支持了dup()(两个fd指向同一个struct file,共享f_pos)和fork()(父子进程的fd指向同一个struct file)等复杂的共享场景。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 资源限制:一个进程能打开的文件描述符数量是有限的(受RLIMIT_NOFILE限制),整个系统能打开的文件对象总数也是有限的(受/proc/sys/fs/file-max限制)。设计不佳的程序可能会耗尽这些资源。
  • 性能开销:每次read/write都需要通过fd进行间接查找,存在一定的系统调用开销。像io_uringmmap这样的现代I/O技术,就是为了在某些场景下绕过这种逐次调用的模型,以获得更高的性能。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

它是所有标准POSIX I/O的唯一解决方案,因此无所谓“首选”。所有需要打开、读取、写入、关闭文件或类似对象的场景都会使用它。

  • 标准I/O重定向 (ls > log.txt):Shell通过fork()创建子进程,在子进程中先close(1)关闭标准输出,然后open("log.txt", ...)open会使用当前最小的可用fd,也就是1。这样,fd 1就从指向终端设备切换为指向log.txtstruct file。随后exec("ls"),新程序ls继承了这个文件描述符表,当它向fd 1写入时,数据就自然进入了文件。
  • 管道 (cmd1 | cmd2):Shell调用pipe(),得到两个fd,分别指向管道的读端和写端这两个struct file对象。然后通过forkdup2等操作,巧妙地将一个子进程的标准输出连接到管道的写端,另一个子进程的标准输入连接到管道的读端。
  • 网络编程socket(), accept()等调用返回的都是文件描述符,它们背后也都是struct file对象,只是其file_operations指向的是TCP/IP协议栈提供的函数。

是否有不推荐使用该技术的场景?为什么?

对于常规I/O,没有理由不使用它。但在追求极致性能的特定场景下,可能会选择其他辅助技术:

  • 高性能文件I/O:对于需要频繁读写大文件的数据库或存储应用,使用mmap()将文件直接映射到内存,然后像访问数组一样访问数据,可以避免read/write的系统调用开销和内核/用户空间的数据拷贝。
  • 高并发网络I/O:对于需要管理数万并发连接的服务器,虽然底层仍然是fd,但上层会使用epoll或更新的io_uring来避免为每个fd都进行轮询,并实现异步I/O,从而大大降低CPU开销。

对比分析

请将其 与 其他相似技术 进行详细对比。

fs/file.c管理的核心是struct file。理解它的最好方式是将其与内核中另外两个紧密相关但功能完全不同的数据结构进行对比:struct inodestruct dentry

特性 struct file (打开文件描述) struct inode (索引节点) struct dentry (目录项)
代表什么 一个 打开的文件实例 文件本身 的元数据和磁盘位置。 一个路径中的 名字
生命周期 open()创建,当所有引用它的fd都close()后销毁。 只要文件存在于磁盘上,它就存在。在内存中作为缓存。 存在于目录项缓存(dcache)中,用于加速路径查找。
唯一性 不唯一。同一个磁盘文件可以被打开多次,每次都会创建一个新的struct file 唯一。一个磁盘文件只有一个inode 不唯一。同一个inode可以有多个名字(硬链接),即对应多个dentry
关键信息 当前读写位置f_pos,打开标志f_flags,操作函数表f_op 文件大小、权限、所有者、时间戳、指向数据块的指针。 文件名、指向父dentry的指针、指向inode的指针。
管理文件 fs/file.c fs/inode.c 和具体文件系统 fs/dcache.c
核心作用 管理I/O会话状态 描述文件的静态属性 建立文件名和文件本身之间的联系

sane_fdtable_size 计算文件描述符表(fdtable)大小

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
/*
* 请注意,合理的文件描述符表大小必须始终是 BITS_PER_LONG 的倍数,
* 因为我们有按此大小设置的位图。
*
* punch_hole 是可选的 - 当 close_range() 被要求取消共享并关闭时,
* 我们不需要复制该范围内的描述符,因此如果最后一个当前打开的描述符
* 落入该范围,则较小的克隆描述符表可能就足够了。
*/
static unsigned int sane_fdtable_size(struct fdtable *fdt, struct fd_range *punch_hole)
{
/* 查找文件描述符表中最后一个打开的文件描述符
* find_last_bit() 函数返回位图中最后一个设置为 1 的位的索引。
*/
unsigned int last = find_last_bit(fdt->open_fds, fdt->max_fds);

/* 如果没有找到打开的文件描述符(即所有文件描述符都关闭),返回默认大小 NR_OPEN_DEFAULT */
if (last == fdt->max_fds)
return NR_OPEN_DEFAULT;
/* 如果最后一个打开的文件描述符位于 punch_hole 范围内,重新查找范围外的最后一个打开的文件描述符。
如果范围外没有打开的文件描述符,返回默认大小 NR_OPEN_DEFAULT */
if (punch_hole && punch_hole->to >= last && punch_hole->from <= last) {
last = find_last_bit(fdt->open_fds, punch_hole->from);
if (last == punch_hole->from)
return NR_OPEN_DEFAULT;
}
/* 对齐文件描述符表大小 */
return ALIGN(last + 1, BITS_PER_LONG);
}

alloc_fdtable 动态分配文件描述符表

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/*
* 请注意,fdtable 位图分配必须是 BITS_PER_LONG 的倍数。这不仅是因为我们在某些地方以
* 'unsigned long' 的块形式处理这些内容,更是因为 Linux 内核位图的定义方式:它们不是
* “字节数组中的位”,而是非常明确地“unsigned long 数组中的位”。
*/
static struct fdtable *alloc_fdtable(unsigned int slots_wanted)
{
struct fdtable *fdt;
unsigned int nr;
void *data;

/*
* 确定我们实际上希望在此 fdtable 中支持多少个文件描述符(fd)。
* 分配步骤以 fdarray 的大小为关键,因为它的增长速度远远快于其他动态数据。
* 我们尝试将 fdarray 调整为适合页面的块大小:从 1024B 开始,并以 2 的幂次增长。
* 由于我们仅在 slots_wanted > BITS_PER_LONG 的情况下调用(嵌入在 files->fdtab 中的实例
* 已经提供了 BITS_PER_LONG 个槽位),上述内容可以归结为:
* 1. 使用足够大的最小 2 的幂次来满足所需的槽位数量。
* 2. 在 32 位系统上跳过 64 和 128——我们希望的最小容量是 256 个槽位(即 1Kb 的 fd 数组)。
* 3. 在 64 位系统上不跳过任何内容,1Kb 的 fd 数组意味着 128 个槽位,
* 并且我们永远不会被要求提供 64 或更少的槽位。
*/
if (IS_ENABLED(CONFIG_32BIT) && slots_wanted < 256)
nr = 256;
else
nr = roundup_pow_of_two(slots_wanted);
/*
* 请注意,如果在 expand_files() 中的检查和此处之间,sysctl_nr_open 被设置得更低,
* 这可能会导致 nr *低于*我们传递的值。
*
* 我们确保 nr 保持为 BITS_PER_LONG 的倍数——否则下面的位图处理会变得非常麻烦,轻描淡写地说……
*/
/* 计算的槽位数量超过系统允许的最大值 */
if (unlikely(nr > sysctl_nr_open)) {
nr = round_down(sysctl_nr_open, BITS_PER_LONG);
/* 如果限制后的槽位数量不足以满足 slots_wanted */
if (nr < slots_wanted)
return ERR_PTR(-EMFILE);
}

fdt = kmalloc(sizeof(struct fdtable), GFP_KERNEL_ACCOUNT);
if (!fdt)
goto out;
fdt->max_fds = nr;
/* 分配文件描述符数组,用于存储文件指针 */
data = kvmalloc_array(nr, sizeof(struct file *), GFP_KERNEL_ACCOUNT);
if (!data)
goto out_fdt;
fdt->fd = data;

/* 分配位图,用于管理打开的文件描述符、执行时关闭的文件描述符等 */
data = kvmalloc(max_t(size_t,
2 * nr / BITS_PER_BYTE + BITBIT_SIZE(nr), L1_CACHE_BYTES),
GFP_KERNEL_ACCOUNT);
if (!data)
goto out_arr;
fdt->open_fds = data;
data += nr / BITS_PER_BYTE;
fdt->close_on_exec = data;
data += nr / BITS_PER_BYTE;
fdt->full_fds_bits = data;

return fdt;

out_arr:
kvfree(fdt->fd);
out_fdt:
kfree(fdt);
out:
return ERR_PTR(-ENOMEM);
}

dup_fd 复制文件描述符表(fdtable),为新进程或线程创建一个独立的文件描述符结构(files_struct)

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/*
* 分配一个新的描述符表并从传入的实例中复制内容。
* 成功时返回指向克隆表的指针,失败时返回 ERR_PTR()。
* 关于 'punch_hole',请参阅 sane_fdtable_size()。
*/
struct files_struct *dup_fd(struct files_struct *oldf, struct fd_range *punch_hole)
{
struct files_struct *newf;
struct file **old_fds, **new_fds;
unsigned int open_files, i;
struct fdtable *old_fdt, *new_fdt;

newf = kmem_cache_alloc(files_cachep, GFP_KERNEL);
if (!newf)
return ERR_PTR(-ENOMEM);

atomic_set(&newf->count, 1);

spin_lock_init(&newf->file_lock);
/* 设置为 false,表示当前没有文件描述符表的调整操作正在进行 */
newf->resize_in_progress = false;
init_waitqueue_head(&newf->resize_wait);
/* 设置为 0,表示下一个可用的文件描述符索引 */
newf->next_fd = 0;
new_fdt = &newf->fdtab;
/* 设置文件描述符表的最大文件描述符数量 */
new_fdt->max_fds = NR_OPEN_DEFAULT;
/* 初始化关闭文件描述符的位图,表示哪些文件描述符在执行新程序时需要关闭 */
new_fdt->close_on_exec = newf->close_on_exec_init;
/* 初始化打开文件描述符的位图,表示当前打开的文件描述符 */
new_fdt->open_fds = newf->open_fds_init;
/* 初始化满文件描述符位图,用于快速检查文件描述符表是否已满 */
new_fdt->full_fds_bits = newf->full_fds_bits_init;
new_fdt->fd = &newf->fd_array[0];

spin_lock(&oldf->file_lock);
/* 获取父进程的文件描述符表 */
old_fdt = files_fdtable(oldf);
/* 计算父进程的打开文件数量(open_files),可能会根据 punch_hole 参数调整 */
open_files = sane_fdtable_size(old_fdt, punch_hole);

/*
* 检查是否需要分配更大的 fd 数组和 fd 集。
* 如果父进程的文件描述符数量超过默认大小,分配一个更大的文件描述符表
*/
while (unlikely(open_files > new_fdt->max_fds)) {
spin_unlock(&oldf->file_lock);

if (new_fdt != &newf->fdtab)
__free_fdtable(new_fdt);

new_fdt = alloc_fdtable(open_files);
if (IS_ERR(new_fdt)) {
kmem_cache_free(files_cachep, newf);
return ERR_CAST(new_fdt);
}

/*
* 重新获取 oldf 锁和指向其 fd 表的指针
* 谁知道它可能会有一个新的更大的 FD 表。我们需要
* 最新指针。
*/
spin_lock(&oldf->file_lock);
old_fdt = files_fdtable(oldf);
open_files = sane_fdtable_size(old_fdt, punch_hole);
}

/* 复制父进程的文件描述符位图到新表中 */
copy_fd_bitmaps(new_fdt, old_fdt, open_files / BITS_PER_LONG);

old_fds = old_fdt->fd;
new_fds = new_fdt->fd;

/*
* 尽管持有 ->file_lock,我们可能会与其他线程使用此 files_struct 的文件描述符分配竞争。
*
* alloc_fd() 可能已经占用了一个槽位,而 fd_install() 尚未填充它。
* 注意后者是无锁操作,因此在我们遍历下面的数组时,文件可能会出现。
*
* 同时我们知道不会有文件消失,因为所有其他操作都会加锁。
*
* 与其试图安抚与自身竞争的用户空间,我们选择在看到文件时引用它,
* 否则将文件描述符槽位标记为未使用。
*/
for (i = open_files; i != 0; i--) {
struct file *f = rcu_dereference_raw(*old_fds++);
if (f) {
get_file(f);
} else {
__clear_open_fd(open_files - i, new_fdt);
}
rcu_assign_pointer(*new_fds++, f);
}
spin_unlock(&oldf->file_lock);

/* 清除新表中未使用的文件描述符槽位 */
memset(new_fds, 0, (new_fdt->max_fds - open_files) * sizeof(struct file *));

rcu_assign_pointer(newf->fdt, new_fdt);

return newf;
}