存储基础 — 文件描述符 fd 究竟是什么?
存储基础 — 文件描述符 fd 究竟是什么?
文件描述符(File Descriptor,简称fd)是操作系统中用于标识打开文件的关键概念。本文将深入探讨fd的本质、其在Linux内核中的实现原理,以及与之相关的进程管理、文件操作等核心机制。
前情概要
通过上一篇《Go存储基础 — 文件IO的姿势》,我们了解到有两种文件读写方式:一种是直接使用系统调用,操作的对象是一个整数fd;另一种是使用Go标准库封装的IO操作,操作对象是Go封装的file
结构体,但其内部还是基于整数fd进行操作。因此,一切IO操作的根本都是通过fd来实现的。那么,这个fd究竟是什么呢?让我们深入剖析。
fd是什么?
fd是File Descriptor的缩写,中文名叫做文件描述符。文件描述符是一个非负整数,本质上是一个索引值(这句话非常重要)。
什么时候拿到的fd?
当打开一个文件时,内核会向进程返回一个文件描述符(通过open
系统调用得到)。后续对这个文件的读写操作(如read
、write
),只需要使用这个文件描述符来标识该文件,将其作为参数传入即可。
fd的值范围是什么?
在POSIX语义中,0、1、2这三个fd值已经被赋予特殊含义,分别对应标准输入(STDIN_FILENO)、标准输出(STDOUT_FILENO)和标准错误(STDERR_FILENO)。
文件描述符的范围是0~OPEN_MAX-1。早期的UNIX系统中,这个范围很小,但现在的主流系统中,这个值几乎不受限制,只受到系统硬件配置和系统管理员配置的约束。
你可以通过ulimit
命令查看当前系统的配置:
➜ ulimit -n
4864
如上,我系统上进程默认最多可以打开4864个文件。
窥探Linux内核
要理解fd究竟是什么,必须深入Linux内核。
task_struct
我们知道进程的抽象是基于struct task_struct
结构体的,这是Linux中最复杂的结构体之一,包含了许多成员字段。我们今天重点关注其中的files
字段:
struct task_struct {
// ...
/* Open file information: */
struct files_struct *files;
// ...
}
files
这个字段是今天的主角之一,它是一个指针,指向一个struct files_struct
结构体。这个结构体用于管理该进程打开的所有文件。
files_struct
通过进程结构体引出了struct files_struct
这个结构体。这个结构体用于管理某进程打开的所有文件,其结构如下:
/*
* Open file table structure
*/
struct files_struct {
// 读相关字段
atomic_t count;
bool resize_in_progress;
wait_queue_head_t resize_wait;
// 打开的文件管理结构
struct fdtable __rcu *fdt;
struct fdtable fdtab;
// 写相关字段
unsigned int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
unsigned long full_fds_bits_init[1];
struct file * fd_array[NR_OPEN_DEFAULT];
};
files_struct
本质上是通过数组来管理所有打开的文件的。这里有两种数组:
struct file * fd_array[NR_OPEN_DEFAULT]
是一个静态数组,在64位系统上大小为64。struct fdtable
是一个动态数组,其边界由max_fds
字段描述。
这种静态+动态的方式是性能和资源的权衡:大部分进程只会打开少量文件,所以静态数组足够;如果超过静态数组的阈值,则动态扩展。
fdtable
简单介绍下fdtable
结构体,这个结构体用于管理fd,其简化结构如下:
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; /* current fd array */
};
注意到fdtable.fd
是一个二级指针,指向一个数组,数组元素类型为struct file *
。max_fds
指明数组边界。
file
现在我们知道了fd本质是数组索引,数组元素是struct file
结构体的指针。这个结构体用于表征进程打开的文件,其简化结构如下:
struct file {
// ...
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
// ...
}
这个结构体非常重要,它标识一个进程打开的文件。其中:
f_path
标识文件名f_inode
指向VFS的inodef_pos
表示当前文件偏移
inode
struct file
结构体中有一个inode
指针,这引出了VFS(虚拟文件系统)的概念。VFS抽象出了一层统一的inode概念,屏蔽了具体文件系统的差异。
完整架构图如下:
VFS的inode结构如下:
struct inode {
// 文件相关的基本信息(权限,模式,uid,gid等)
umode_t i_mode;
unsigned short i_opflags;
kuid_t i_uid;
kgid_t i_gid;
unsigned int i_flags;
// 回调函数
const struct inode_operations *i_op;
struct super_block *i_sb;
struct address_space *i_mapping;
// 文件大小,atime,ctime,mtime等
loff_t i_size;
struct timespec64 i_atime;
struct timespec64 i_mtime;
struct timespec64 i_ctime;
// 回调函数
const struct file_operations *i_fop;
struct address_space i_data;
// 指向后端具体文件系统的特殊数据
void *i_private; /* fs or device private pointer */
};
VFS和后端具体文件系统的纽带是i_private
字段,用于传递一些具体文件系统使用的数据结构。
思考问题
files_struct
结构体只会属于一个进程,那么struct file
这个结构体呢,是只会属于某一个进程?还是可能被多个进程共享?
划重点:struct file
是属于系统级别的结构,换句话说是可以共享与多个不同的进程。
- 什么时候会出现多个进程的fd指向同一个file结构体?
比如fork
的时候,父进程打开了文件,后面fork
出一个子进程。这种情况就会出现共享file的场景。
- 在同一个进程中,多个fd可能指向同一个file结构吗?
可以。dup
函数就是做这个的。
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
思考实验
现在我们已经彻底了解fd这个所谓的非负整数代表的深层含义了,我们可以准备一些IO的思考举一反三。
文件读写(IO)的时候会发生什么?
- 在完成write操作后,在文件file中的当前文件偏移量会增加所写入的字节数,如果这导致当前文件偏移量超处了当前文件长度,则会把inode的当前长度设置为当前文件偏移量(也就是文件变长)
- O_APPEND标志打开一个文件,则相应的标识会被设置到文件file状态的标识中,每次对这种具有追加写标识的文件执行write操作的时候,file的当前文件偏移量首先会被设置成inode结构体中的文件长度,这就使得每次写入的数据都追加到文件的当前尾端处(该操作对用户态提供原子语义);
- 若一个文件seek定位到文件当前的尾端,则file中的当前文件偏移量设置成inode的当前文件长度;
- seek函数值修改file中的当前文件偏移量,不进行任何I/O操作;
- 每个进程对有它自己的file,其中包含了当前文件偏移,当多个进程写同一个文件的时候,由于一个文件IO最终只会是落到全局的一个inode上,这种并发场景则可能产生用户不可预期的结果;
总结
回到初心,理解fd的概念有什么用?
一切IO的行为到系统层面都是以fd的形式进行。无论是C/C++、Go、Python、JAVA都是一样,任何语言都是一样,这才是最本源的东西,理解了fd关联的一系列结构,你才能对IO游刃有余。
简要的总结:
- 从姿势上来讲,用户open文件得到一个非负数句柄fd,之后针对该文件的IO操作都是基于这个fd;
- 文件描述符fd本质上来讲就是数组索引,fd等于5,那对应数组的第5个元素而已,该数组是进程打开的所有文件的数组,数组元素类型为struct file;
- 结构体task_struct对应一个抽象的进程,files_struct是这个进程管理该进程打开的文件数组管理器。fd则对应了这个数组的编号,每一个打开的文件用file结构体表示,内含当前偏移等信息;
- file结构体可以为进程间共享,属于系统级资源,同一个文件可能对应多个file结构体,file内部有个inode指针,指向文件系统的inode;
- inode是文件系统级别的概念,只由文件系统管理维护,不因进程改变(file是进程出发创建的,进程open同一个文件会导致多个file,指向同一个inode);
回顾一眼架构图:
内核把最复杂的活干了,只暴露给您最简单的一个非负整数fd。所以,绝大部分场景会用fd就行,倒不用想太多。当然如果能再深入看一眼知其所以然是最好不过。本文分享是基础准备篇,希望能给你带来不一样的IO视角。