理解Linux的file descriptor(文件描述符)

​ 我们知道在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。在操作这些所谓的文件的时候,我们每操作一次就找一次名字,这会耗费大量的时间和效率。所以Linux中规定每一个文件对应一个索引,这样要操作文件的时候,我们直接找到索引就可以对其进行操作了。

文件描述符(file descriptor)就是内核为了高效管理这些已经被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符来实现。同时还规定系统刚刚启动的时候,0是标准输入,1是标准输出,2是标准错误。这意味着如果此时去打开一个新的文件,它的文件描述符会是3,再打开一个文件文件描述符就是4……

Linux内核对所有打开的文件有一个文件描述符表格,里面存储了每个文件描述符作为索引与一个打开文件相对应的关系,简单理解就是下图这样一个数组,文件描述符(索引)就是文件描述符表这个数组的下标,数组的内容就是指向一个个打开的文件的指针。

文件描述符指向了由系统内核维护的一个file table中的某个条目(entry)。这个解释可能过于抽象,不过在正式详细介绍fd之前,有必要先了解用户程序和系统内核之间的工作过程。

注: 本文描述的所有场景仅限于类unix系统环境,在windows中这玩意叫file handle(臭名昭著的翻译: 句柄)。

User space & Kernel space

现代操作系统会把内存划分为2个区域,分别为Use space(用户空间) 和 Kernel space(内核空间)。用户的程序在User space执行,系统内核在Kernel space中执行。

用户的程序没有权限直接访问硬件资源,但系统内核可以。比如读写本地文件需要访问磁盘,创建socket需要网卡等。因此用户程序想要读写文件,必须要向内核发起读写请求,这个过程叫system call。

内核收到用户程序system call时,负责访问硬件,并把结果返回给程序。

FileInputStream fis = new FileInputStream("/tmp/test.txt");
byte[] buf = new byte[256];
fis.read(buf);

上面代码的流程如下图所示

File Descriptor

上面简单介绍了User space和Kernel space,这对于理解fd有很大的帮助。fd会存在,就是因为用户程序无法直接访问硬件,因此当程序向内核发起system call打开一个文件时,在用户进程中必须有一个东西标识着打开的文件,这个东西就是fd。

file tables

​ 一个 Linux 进程启动后,会在内核空间中创建一个 PCB 控制块,PCB 内部有一个文件描述符表(File descriptor table),记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。进程级的描述符表的每一条记录了单个进程所使用的文件描述符的相关信息,进程之间相互独立,一个进程使用了文件描述符3,另一个进程也可以用3。除了进程级的文件描述符表,系统还需要维护另外两张表:打开文件表、i-node 表。这两张表存储了每个打开文件的打开文件句柄(open file handle)。一个打开文件句柄存储了与一个打开文件相关的全部信息。

和fd相关的一共有3张表,分别是file descriptor、file table、inode table,如下图所示。

  • file descriptors

    file descriptors table由用户进程所有,每个进程都有一个这样的表,这里记录了进程打开的文件所代表的fd,fd的值映射到file table中的条目(entry)。

    另外,每个进程都会预留3个默认的fd: stdin、stdout、stderr;它们的值分别是0、1,2。

    Integer valueNamesymbolic constantfile stream
    0Standard inputSTDIN_FILENOstdin
    1Standard outputSTDOUT_FILENOstdout
    2Standard errorSTDERR_FILENOstderr
  • file table

    file table是全局唯一的表,由系统内核维护。这个表记录了所有进程打开的文件的状态(是否可读、可写等状态),同时它也映射到inode table中的entry。如下:

    • 当前文件偏移量(调用read()和write()时更新,或使用lseek()直接修改)
    • 打开文件时的标识(open()的flags参数)
    • 文件访问模式(如调用open()时所设置的只读模式、只写模式或读写模式)
    • 与信号驱动相关的设置
    • 对该文件i-node对象的引用,即i-node 表指针
  • inode table

    inode table同样是全局唯一的,它指向了真正的文件地址(磁盘中的位置),每个entry全局唯一。内容如下:

    • 文件类型(例如:常规文件、套接字或FIFO)和访问权限
    • 一个指针,指向该文件所持有的锁列表
    • 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳

文件描述符、打开的文件句柄以及i-node之间的关系如下图:

  • 在进程 A 中,文件描述符 1 和 20 都指向了同一个打开文件表项,标号为 23(指向了打开文件表中下标为 23 的数组元素),这可能是通过调用 dup()、dup2()、fcntl() 或者对同一个文件多次调用了 open() 函数形成的。

  • 进程 A 的文件描述符 2 和进程 B 的文件描述符 2 都指向了同一个文件,这可能是在调用 fork() 后出现的(即进程 A、B 是父子进程关系),或者是不同的进程独自去调用 open() 函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。

  • 进程 A 的描述符 0 和进程 B 的描述符 3 分别指向不同的打开文件表项,但这些表项均指向 i-node 表的同一个条目(标号为 1976);换言之,它们指向了同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了 open() 调用。同一个进程两次打开同一个文件,也会发生类似情况。

这就说明:同一个进程的不同文件描述符可以指向同一个文件;不同进程可以拥有相同的文件描述符;不同进程的同一个文件描述符可以指向不同的文件(一般也是这样,除了 0、1、2 这三个特殊的文件);不同进程的不同文件描述符也可以指向同一个文件。

流程

当程序向内核发起system call open(),内核将会

  1. 允许程序请求
  2. 创建一个entry插入到file table,并返回file descriptor
  3. 程序把fd插入到fds中。

当程序再次发起read()system call时,需要把相关的fd传给内核,内核定位到具体的文件(fd –> file table –> inode table)向磁盘发起读取请求,再把读取到的数据返回给程序处理。

下面是read这个函数的定义,第一个参数fd即file descriptor。

ssize_t read(int fd, void *buf, size_t count);

同样的,writesystem call函数如下

 ssize_t write(int fd, const void *buf, size_t nbytes);

从上面的结果来看,fd就是file table的一个索引,指向了file table中的entry。

查看进程的file descriptors

linux系统可以通过/proc/pid/fd文件夹查看进程的fd,比如我的redis进程id为96104,执行以下命令查看

ls -l /proc/96104/fd

参考

https://wiyi.org/linux-file-descriptor.html

https://blog.csdn.net/yushuaigee/article/details/107883964