文件描述符

文件 I/O

处理文件 I/O 的基本系统调用:

  • open :请求生成到某文件的连接
  • close :请求关闭到某文件的连接
  • read :请求通过特定连接来读取文件的部分字节
  • write :请求通过特定连接来写入部分字节到文件

文件描述符

File Descriptor

open 系统调用返回的值称为文件描述符,本质上是内核维护的打开文件数组的一个索引。

int fd = open("/dev/sr0")

上面提到的数组即文件描述符表,文件描述符是该表的 索引。针对每一个 open 系统调用,即进程打开一个文件时,内核就会创建一个文件描述符,并将其与底层的文件对象关联起来,该文件可以是设备文件,也可以是其它文件。

文件描述符是内核为了高效管理被打开的文件所创建的索引,是一个非负整数(通常是小整数),用于 指代 被打开的文件,所有执行 I/O 操作 的系统调用都要通过文件描述符。

每个 Unix 进程(除了可能的守护进程)均应有三个标准的 POSIX 文件描述符,对应于三个标准流:

文件描述符 用途 POSIX 名称 标准 I/O 流
0 标准输入 STDIN_FILENO stdin
1 标准输出 STDOUT_FILENO stdout
2 标准错误 STDERR_FILENO stderr

程序刚刚启动的时候,0 是标准输入,1 是标准输出,2 是标准错误。如果此时去打开一个新的文件,它的文件描述符会是 3。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。

POSIX 标准要求每次打开文件时(含 socket)必须使用当前进程中可用的最小的文件描述符号码,因此,在网络通信过程中,稍不注意就有可能造成串话。

在 Linux 的系统调用中,大量的系统调用都是依赖于文件描述符。文件描述符为在该系列平台上进行设备相关的编程提供了一个统一的方法。

Stream

文件描述符是个抽象的句柄,通过它可以访问一个文件或任何其它的输入、输出资源,如网络套接字或管道。只要文件处于打开状态,就可以随时使用其文件描述符来对其进行读写操作。而此时的文件不是通常意义上的磁盘中的文件,而是代表一个流,可以对流进行读取的操作。

打开一个文件时,操作系统会创建一个到该文件的 ,将其连接到打开的文件上。描述符实际上就代表了这个流。类似的,操作系统创建了一些默认的流,但它们不是连接到文件,而是连接到终端。因此,你在终端输入时,会发送到标准输入流和操作系统中。当你在终端键入 ls 命令时,操作系统会将结果输出到标准输出流。而标准输出流是连接到显示终端的,于是你看到了输出结果。

文件描述符与 PCB

每个进程在 Linux 内核中都有一个 task_struct 结构体来维护进程相关的信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块(PCB,Process Control Block)。

task_struct 中有一个指针(files)指向 files_struct 结构体,称为文件描述符表,其中每个表项包含一个指向已打开的文件的指针,如下图。

image-center

用户程序不能直接访问内核中的文件描述符表,只能使用文件描述符表的索引(即 0、1、2、3 这些数字),这些索引即文件描述符,用 int 型变量保存。

当调用 open 打开一个文件或创建一个新文件时,内核分配一个文件描述符,并返回给用户程序,该文件描述符表项中的指针指向新打开的文件。当读写文件时,用户程序把文件描述符传给 read 或 write ,内核根据文件描述符找到相应的表项,再通过表项中的指针找到相应的文件。

文件描述的限制

文件描述符是系统的一个重要资源,虽然说系统内存有多少就可以打开多少的文件描述符,但是在实际实现过程中内核是会做相应的处理。

整个系统可以打开的最大文件数通常限制在系统内存的 10%(以 KB 计算),称之为系统级限制。可用 sysctl -a | grep fs.file-max 查看。

为了避免某一个进程消耗掉所有的文件资源,内核也会对单个进程可打开的最大文件数加以限制,称用户级限制,默认值为 1024,用 ulimit -n 查看。在 Web 服务器中,常通过更改该默认值来优化服务器。

文件描述符与打开的文件

每一个文件描述符与一个打开的文件相对应,不同的文件描述符可指向同一个文件。既它们之间的关系可以是 一对一,也可以是 多对一

同一个文件可以被 不同的进程 打开,也可以在 同一个进程 中被 多次 打开。

系统为每个进程维护了一个文件描述符表,该表的值都是从 0 开始的,所以在不同的进程中你会看到相同的文件描述符,但相同的文件描述符不一定指向同一个文件。

具体指向哪个文件,需要查看由内核维护的 3 个数据结构:

  1. 进程级的文件描述符表
  2. 系统级的打开文件描述符表
  3. 文件系统的 i-node 表

从在进程中打开一个文件,一直到获取文件内容,这期间要经历几级的间接寻址。从实施角度来看,经历某个级别通常意味着:在内核中翻译成为某种 数据结构,然后指向下一级。

进程级描述符表

The per-process Open File Descriptor Table

对于 每一个进程,内核都为其维护一个打开文件描述符的表。表中的每个条目都记录了单个文件描述符的相关信息,包括:

  1. 控制文件描述符操作的一组标签(目前,此类标志仅定义了一个,即 close-on-exec 标签)
  2. 对打开文件描述符的引用
数据结构

在 Linux 系统中,进程打开的文件是由 files_struct 结构来管理的,而该结构又是位于进程的 task_struct 结构中:

struct task_struct {
    ...
    /* open file information */
    struct files_struct *files;

每进程的文件描述符表(fdt)位于 files_strct 结构中:

struct files_struct {
    ...
    struct fdtable __rcu *fdt;

当进程要打开文件时,它会产生一个 open 调用,继而调用 sys_open

sys_open(filename, …)
    // 1)   从用户空间拷贝文件名
    getname(filename)
            strncpy_from_user()
    // 2)  获取第一个未用的描述符,会将其返回给进程
    int fd = get_unused_fd()
        struct files_struct *files = current->files
    // 3) 从文件系统获取文件
    struct file *f = file_open(filename)
        open_namei
            // lookup operation for filesystem
            dentry = cached_lookup or real_lookup
            // initializes file struct
            dentry_open
    // 4) 把文件系统返回的文件安装到进程的描述符表中
    fd_install
        current->files->fd[fd] = file

进程于是得到了返回的索引号,即描述符表指向该打开的文件的索引。

系统级描述符表

The system-wide table of open file descriptions,打开文件描述表

注意:该结构保存的是 file description,是比较详细的文件描述信息,而描述符 file descriptor 只是一个数字而已。

内核针对 所有打开的文件 会维护一个系统级的描述表,也称 打开文件表(open file table),并将表中各条目称为 打开文件句柄(open file handle),其中存储了与一个打开文件相关的全部信息:

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

Linux 系统中,系统级描述符表是由 struct file 数据结构实现的。

文件系统 i-node 表

每个文件系统都有一个 i-node 表,维护整个文件系统所有的文件,其中的信息包括:

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

三个数据结构的关系

下图展示了文件描述符、打开的文件句柄以及 i-node 之间的关系,图中,两个进程拥有诸多打开的文件描述符。

图中,进程 A 的描述符 1 和 20 是指向打开文件表中同一个句柄的,这种情况可能是由调用 dup()dup2()fcntl() 导致的,即 复制了描述符

而进程 A 的描述符 2 与进程 B 的描述符 2 同时指向句柄 73。这种情况可能是因为调用了 fork(),如 A 和 B 可能是父子进程关系,或者,一个进程通过域套接字传递了一个描述符给另一个进程

进程 A 的描述符 0 与进程 B 的描述符 3 虽然指向不同的句柄,但这些句柄是指向同一个 i-node 表条目 1976 的,即指向同一个文件。当 一个进程重复两次打开同一文件 时会导致该结果。

总结

  • 由于进程级文件描述符表的存在,不同的进程中会出现相同的文件描述符,它们可能指向同一个文件,也可能指向不同的文件
  • 如果两个不同的描述符指向同一个句柄,则这两个描述符共享相同的文件偏移量。因此,如果文件偏移量被其中的一个描述符修改了(read()write()lseek() 都可导致),则该修改对于另一个描述符也是可见的。无论这两个描述符是属于同一进程还是不同进程,该情况都适用。当文件状态被某个描述符修改时,结果也是一样的。
  • 描述符的标签(如 close-on-exec)对于进程和描述符是私有的,修改这些标签不会影响另一个描述符。

管道

管道是进程间通信的主要手段之一。

Linux 管道有两种:匿名管道命名管道

管道有一个特点,如果管道中没有数据,那么取管道数据的操作就会滞留,直到管道内进入数据,然后读出后才会终止这一操作;同理,写入管道的操作如果没有读取管道的操作,这一动作就会滞留。

管道的实现机制

管道是由 内核管理 的一个 缓冲区,相当于我们放入内存中的一个纸条。

管道的一端连接一个进程的输出。这个进程会向管道中放入信息。管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。

一个缓冲区不需要很大,一般为 4K,它被设计成为 环形的数据结构,以便管道可以被循环利用。当管道中没有信息时,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。

image-center

从原理上,管道利用 fork 机制建立,从而让两个进程可以连接到同一个管道上:

  • 最开始的时候,管道的两端都连接在同一个进程上
  • 复用 fork 复制进程时,会将这两个连接也复制到新进程上
  • 随后,每个进程关闭自己不需要的一个连接,剩下的连接就构成了管道

匿名管道

一个匿名管道实际上就是个 只存在于内存中的文件,对这个文件的操作要通过两个已经打开的文件进行,它们分别代表管道的两端。

匿名管道使用 | 作为操作符,两端是两个普通的、匿名的、打开的文件描述符:一个 只读端 和一个 只写端,这就让其它进程无法连接到该匿名管道。

管道两端的进程均将该管道 看做一个文件,一个进程负责往管道中写内容,而另一个从管道中读取。这种传输遵循 “先入先出”(FIFO)的规则。

cat file | less

为了执行以上命令,shell 会创建两个进程来分别执行 catless

image-center

两个进程都连接到了管道上,写入进程 cat 将其标准输出(描述符 1)连接到了管道的写入端,读取进程 less 将其标准输入(描述符 0)连接到了管道的读入端。实际上,两个进程并不知道管道的存在,它们只是从标准文件描述符中读取数据和写入数据。

匿名管道的特点

  • 半双工:数据只能向 一个方向 流动,需要双方通信时,需建立起两个管道
  • 管道没有名字:因此只能用于有 亲缘关系 的进程,如父子进程或兄弟进程之间
  • 单独构成一种 独立的文件系统:管道对于其两端的进程而言,就是一个文件,但不是普通的文件,不属于某种文件系统,而是单独构成一种文件系统,且只存在于内存中
  • 数据的读取和写入:一个进程向管道中写入的内容被另一端的进程读取。写入 的内容每次都添加在管道 缓冲区末尾,并且每次都是从缓冲区的 头部 读取数据
  • 向管道中写入数据时,linux 将不保证写入的原子性,管道缓冲区一有空闲区域,写进程就会试图向管道写入数据。如果读进程不读走管道缓冲区中的数据,那么写操作将一直 阻塞
  • 只有在管道的 读端存在 时,向管道中写入数据才有意义。否则,向管道中写入数据的进程将收到内核传来的 SIFPIPE 信号,应用程序可以处理该信号,也可以忽略(默认动作则是应用程序终止)。
  • 管道的 缓冲区是有限的:管道只存在于内存中,创建管道时,为缓冲区分配一个页面大小
  • 管道所传递的是 无格式字节流:这就要求管道双方必须事先约定好数据的格式,比如多少字节算作一个消息、命令或记录

命名管道

命名管道是为了解决匿名管道只能用于近亲进程之间通信的缺陷而设计的,也称 FIFO。

命名管道是建立在 实际的磁盘介质或文件系统上的有自己名字的文件,任何进程可以在任何时间通过文件名或路径名与该文件建立联系。即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO,不相关的进程也能交换数据

FIFO 严格遵循先进先出(first in first out),对管道及 FIFO 的读总是从 开始处 返回数据,对它们的写则把数据添加到 末尾。它们不支持诸如 lseek() 等文件定位操作。

命名管道的特点

从语义上来讲,FIFO 其实与匿名管道类似,其特点为:

  • 在文件系统中,FIFO 拥有 名称,并且是以 设备特殊文件 的形式存在的;
  • 任何进程 都可以通过 FIFO 共享 数据;
  • FIFO 两端必须同时有读与写的进程,否则 FIFO 的数据流通将会阻塞;
  • 匿名管道是由 shell 自动创建的,存在于内核中;而 FIFO 则是由程序创建的(如 mkfifo 命令),存在于 文件系统 中;
  • 匿名管道是单向的字节流,而 FIFO 则是 双向 的字节流;

匿名管道与命名管道的唯一区别就是它们 创建和打开的方式,一旦这些任务完成,对管道的 I/O 操作在语义上是相同的。

FIFO 文件

FIFO(First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。

为了实现命名管道,引入了一种新的文件类型 —— FIFO 文件(遵循先进先出的原则)。实现一个命名管道实际上就是实现一个 FIFO 文件。命名管道一旦建立,其读、写及关闭操作都与普通管道完全相同。虽然 FIFO 文件的 inode 节点在磁盘上,但是仅是一个节点而已,文件的数据 还是存在于 内存 缓冲页面中,和普通管道相同。

FIFO 实际上也 由内核管理,不与硬盘打交道。当进程之间通过 FIFO 进行数据交换时,内核 直接 在管道内交换数据,而不会写入文件系统。因此,FIFO 文件在文件系统中没有任何内容,文件系统的入口只是作为一个引用点,以便进程能够使用文件系统中的 文件名 来访问管道。

FIFO 可以被 多个进程 打开,进行读取或写入。但一个 FIFO 文件即使被多个进程打开,内核也同时只维护一个管道对象。而且,FIFO 必须同时在 两端都打开 才能开始传递数据。当只有一端打开时,FIFO 会暂时 阻塞,直到另一端也被打开。

另外,一个进程也可以用非阻塞模式打开 FIFO,在这种情况下,即使写入端没有打开,以 只读方式 也能够成功打开;但是,只有另一端打开时,才能以只写方式打开,否则会报 “找不到设备或地址” 的错误。

删除 FIFO 文件时,管道连接也随之 消失

FIFO 的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间可以建立连接。

在 Linux 中,无论处于阻塞还是非阻塞模式,都可以打开 FIFO 成功地读写。POSIX 没有定义该行为,因此可以利用这一点,在 没有读取端 时,打开 FIFO 进行 写入。如果一个进程要复用管道的两端与自己通讯,一定要谨防死锁。

FIFO 读写规则

关于文件的阻塞

所谓的阻塞,即内核在对文件操作 I/O 系统调用时,如果条件不满足(可能需要产生 I/O),则内核会将该进程 挂起

非阻塞则是发现条件不满足就会 立即返回

此外需要注意的是非阻塞并不是轮询,不然就和阻塞没多大区别了,它只是调用不成功就直接返回了,不会再去看啥时候会满足条件,而是由你自己去选择接下来该做什么。

read/write 系统调用 不会直接读写文件,只是去操作文件所对应的内存页(此时的页为虚拟内存):

对于 read,如果在页中找到了想要读写的数据,则直接从页中将数据 复制到用户缓存 即可;如果要读的页没有找到,只能从磁盘读出该页内容,缓存在内存中即可。所谓的读过程,其实文件系统所要做的只是 锁定页面,然后 构造一个读请求,并将请求发给底层的 I/O 子系统即可。linux 内核中默认 read 系统调用是阻塞的,write 调用是非阻塞的,因为写入时只是将用户态的数据写入缓存页面中即可返回。

从 FIFO 中读取数据

【 约定 】:一个进程想从 FIFO 中读取数据,如果 以阻塞模式打开 FIFO,称该进程的读操作为 “设置了阻塞标志 的” 读操作。

以下把为了写入而打开 FIFO 简称 写打开,为了读取数据而打开 FIFO 简称 读打开

  • 如果有进程写打开 FIFO,且当前 FIFO 内没有数据,则设置了阻塞标志的读操作将一直阻塞,没有设置阻塞标志的读操会返回 -1,errno 值为 EAGAIN,提醒以后再试。
  • 对于设置了阻塞标志的读操作,如果 FIFO 内没有数据,或 FIFO 内虽然有数据,但其它进程在读,会造成阻塞。只要 FIFO 中有新的数据写入,就会解阻塞。
  • 读打开的阻塞标志只对本进程第一个读操作起作用,如果本进程内有多个读操作序列,则在第一个读操作被唤醒并完成读操作后,其它将要执行的读操作将不再阻塞,即使在执行读操作时,FIFO 中没有数据也一样(此时,读操作返回 0)。
  • 如果没有进程写打开 FIFO,则设置了阻塞标志的读操作会阻塞。

注:如果 FIFO 中有数据,则设置了阻塞标志的读操作不会因为 FIFO 中的字节数小于请求读的字节数而阻塞,此时,读操作会返回 FIFO 中现有的数据量。

向 FIFO 中写入数据

【 约定 】:如果一个进程为了向 FIFO 中写入数据而以阻塞模式打开 FIFO,那么称该进程内的写操作为 设置了阻塞标志 的写操作。

对于设置了阻塞标志的写操作:

当要写入的数据量不大于 PIPE_BUF 时,linux 将保证写入的原子性。如果此时管道可用缓冲区不足以容纳要写入的字节数,则进入睡眠,直到当缓冲区中能够容纳要写入的字节数时,才开始进行一次性写操作。

当要写入的数据量大于 PIPE_BUF 时,linux 将不再保证写入的原子性。FIFO 缓冲区一有可用区域,写进程就会试图向管道写入数据,写操作在写完所有请求写的数据后返回。

对于没有设置阻塞标志的写操作:

当要写入的数据量不大于 PIPE_BUF 时,linux 将保证写入的原子性。如果当前 FIFO 空闲缓冲区能够容纳请求写入的字节数,写完后成功返回;如果当前 FIFO 空闲缓冲区不能够容纳请求写入的字节数,则返回 EAGAIN 错误,提醒以后再写;

当要写入的数据量大于 PIPE_BUF 时,linux 将不再保证写入的原子性。在写满所有 FIFO 空闲缓冲区后,写操作返回。

.