avatar

朝花惜拾

Be a Real Engineer

  • 首页
  • 分类
  • 标签
  • 归档
  • 关于
Home TLPI:文件 I/O
文章

TLPI:文件 I/O

Posted 2024-09-5 Updated 2024-12- 8
By Ray Lyu
19~24 min read

1. 文件描述符原理

所有执行 I/O 操作的系统调用都以文件描述符(一个非负整数)来指代打开的文件,文件描述符用以表示所有类型的已打开文件,包括管道、FIFO、socket、终端设备和普通文件。

  • inode:系统级,不考虑硬链接时,它与文件数据一一对应

  • 打开文件句柄(open file handle):系统级,不同的 handle 可能指向同一个 inode(e.g. open 同一个文件)

  • 文件描述符:进程级,不同的描述符可能指向同一个 handle(e.g. dup、fcntl、fork)

2. 通用 I/O 模型

4 个基本的 I/O 系统调用为:open、read、write、close,它们可以对所有类型的文件执行 I/O 操作。这些系统调用实现一个简易的 copy 命令如下。

#include <sys/stat.h>
#include <fcntl.h>
#include "tlpi_hdr.h"

#ifndef BUF_SIZE        /* Allow "cc -D" to override definition */
#define BUF_SIZE 1024
#endif

int
main(int argc, char *argv[])
{
    int inputFd, outputFd, openFlags;
    mode_t filePerms;
    ssize_t numRead;
    char buf[BUF_SIZE];

    if (argc != 3 || strcmp(argv[1], "--help") == 0)
        usageErr("%s old-file new-file\n", argv[0]);

    /* Open input and output files */

    inputFd = open(argv[1], O_RDONLY); // 仅设置访问模式
    if (inputFd == -1)
        errExit("opening file %s", argv[1]);

    openFlags = O_CREAT | O_WRONLY | O_TRUNC;
    filePerms = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP |
        S_IROTH | S_IWOTH;      /* rw-rw-rw- */
    outputFd = open(argv[2], openFlags, filePerms); // 设置访问模式、访问权限
    if (outputFd == -1)
        errExit("opening file %s", argv[2]);

    /* Transfer data until we encounter end of input or an error */

    while ((numRead = read(inputFd, buf, BUF_SIZE)) > 0)
        if (write(outputFd, buf, numRead) != numRead)
            fatal("write() returned error or partial write occurred");
    if (numRead == -1)
        errExit("read");

    if (close(inputFd) == -1)
        errExit("close input");
    if (close(outputFd) == -1)
        errExit("close output");

    exit(EXIT_SUCCESS);
}

我对该命令程序进行实验,发现当指定终端/dev/pts/1为输入文件时,会出现终端敲击的字符无法被全部读入的情况,且一次只能读入 1 个字节(少数时候是 2 字节),哪些字符能被读入看起来是随机的。但如果代码中指定STDIN_FILENO为输入文件,然后从运行命令程序的终端输入字符,此时运行效果是符合预期的。暂时没搞清楚其中的原理,只先记录现象如下(读入字节数的提示是额外加上的),

2.1. 部分读和部分写

“部分读”是指一次 read 调用读取的字节数小于请求的字节数,它可能在以下情况发生:

  1. 读取到普通文件的末尾(EOF),此时会返回已经读取的字节数,并设置errno为 0(表示没有错误);

  2. 当终端为标准输入时,遇到换行符;

  3. 其它场景(后续遇到再总结)

“部分写”是指一次 write 调用写入的字节数小于请求的字节数,它可能在以下情况发生:

  1. 磁盘已满;

  2. 进程资源对文件大小的限制;

  3. 其它场景(后续遇到再总结)

3. 高级特性

3.1. 原子操作和竞争条件

TLPI [1] 中关于系统调用原子性的表述如下,我的疑问是:系统调用都是原子操作吗,有没有例外?

All system calls are executed atomically. By this, we mean that the kernel guarantees that all of the steps in a system call are completed as a single operation, without being interrupted by another process or thread.

“竞争条件”是指程序执行的结果会因为进程的调度顺序而变得无法预测,而原子操作通过规避竞争条件来保证结果的正确性。下面我们举一个例子。

考虑问题:两个进程如何写同一个文件而不发生冲突?

首先,从文件描述符原理可以知道,当两个进程各自调用 open 函数打开同一个文件时,内核“打开文件表”会创建两个打开文件句柄,且它们指向同一个实际的文件。由于文件偏移量 offset 是与 open file handle 关联的,所以在下图中 h1 和 h2 的 offset 相互独立。如果 process1 和 process2 一起运行,必然会出现写冲突(覆盖)。

我们通过下面的程序做个实验。两个进程各自打印 1000 个字符,当它们同时运行时最终只能打印出 1000 个字符,因为每个进程都是各写各的,每一轮打印都会出现写覆盖。

// ./non_exclusively_write tmp.txt &./non_exclusively_write tmp.txt 并发运行
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int
main(int argc, char *argv[])
{
    int fd = open(argv[1], O_RDWR);
    if (fd == -1) {
        printf("failed to open\n");
        return 1;
    }

    for (int j = 0; j < 1000; j++) {
        if (write(fd, "0", 1) != 1) {
            printf("write() failed");
            return 1;
        }
    }

    printf("%ld done\n", (long) getpid());
}

那如果每次写之前都更新一下 open file handle 的文件偏移量到末尾,是不是就不会出现覆盖了?答案是仍然无法避免。因为 lseek 和 write 两个动作并非一个原子操作。如果当某个进程执行完 lseek 之后,CPU 立刻切换到另一个进程,那么此时两个进程会在相同的偏移量上写入,导致写覆盖,也就是说存在竞争条件。对于下面的例子,如果两个进程同时运行,最终文件中字符的个数基本不会是 2000,而是少于 2000,且个数是不确定的,因为其中某些轮次的打印发生了写覆盖。

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int
main(int argc, char *argv[])
{
    int fd = open(argv[1], O_RDWR);
    if (fd == -1) {
        printf("failed to open\n");
        return 1;
    }

    for (int j = 0; j < 1000; j++) {
        if (lseek(fd, 0, SEEK_END) == -1) { // 手动调整 offset
            printf("lssek() failed");
            return 1;
        }
        if (write(fd, "0", 1) != 1) {
            printf("write() failed");
            return 1;
        }
    }

    printf("%ld done\n", (long) getpid());
}

那有没有办法让 lseek 和 write 操作成为一个原子操作呢?有办法。只需要在打开文件时添加 O_APPEND 标志,即可自动在 write 时将 offset 调整到文件末尾然后再写入(此时无需再手动调用 lseek),且保证这是一个原子操作。优化过后,对于下面的程序,每次两个进程并发运行都能够完整打印出 2000 个字符。

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int
main(int argc, char *argv[])
{
    int fd = open(argv[1], O_RDWR | O_APPEND); // 添加 O_APPEND 标记
    if (fd == -1) {
        printf("failed to open\n");
        return 1;
    }

    for (int j = 0; j < 1000; j++) {
        if (write(fd, "0", 1) != 1) {
            printf("write() failed");
            return 1;
        }
    }

    printf("%ld done\n", (long) getpid());
}

3.2. 复制文件描述符

考虑问题:如何实现下面的标准错误的重定向?

./myscript > results.log 2>&1

该 shell 语句的效果是把标准错误和标准输出的内容都打印到 results.log 文件中,那么通过打开 results.log 文件两次获得两个文件描述符,然后分别打印常规信息和错误信息,是否可行?显然不可行,因为这样会产生两个 open file handle,它们无法共享相同的文件偏移量,会导致写覆盖。此时,新的系统调用 dup 出场了。

dup/dup2/dup3 会复制一个已经打开的文件描述符,并返回一个新的描述符,二者指向同一个 open file handle,因而共享文件偏移量。

3.3. I/O 缓冲

read 和 write 系统调用并不会直接发起磁盘访问,而是仅在用户空间缓冲和内核空间缓冲之间复制数据。举个例子,write(fd, "abc", 3)在将数据从用户空间缓冲写到内核空间缓冲之后会立即返回,在后续的某个时刻内核会将数据落到磁盘。 同理,read 也是先从内核缓冲开始读取数据,直到其中的数据全部读完,内核才会将文件的下一段内容读入内核空间缓冲(简单理解是这样,实际会复杂一些)。道理很容易理解,因为磁盘访问很慢,内核的这一机制就是尽量减少对磁盘的访问次数。

上述是内核层面对 I/O 的优化,在用户层面,当需要传输的数据量不变时,通过增加单次传输的数据量(BUF_SIZE)尽可能减少系统调用也是一个常见的优化手段(相关的基准测试数据参 13.1节)。这是因为当系统调用次数很多时,系统调用本身上下文切换的开销是很客观的。C 语言函数库的 I/O 函数,如 fprintf()、fscanf()、fgets() 等,就是这么做的!

标准库和内核提供了相关的机制,让我们有办法控制用户空间缓冲、内核空间缓冲和磁盘间的数据传输行为,或者跳过其中的某一个环节,详细方法不在这里赘述。下图很好地总结了 I/O 缓冲原理和可能的干预手段。

3.4. 非阻塞 I/O

如果没有特殊声明,I/O 系统调用都会阻塞直到完成数据传输。这里所言的“阻塞”就是程序会停留在系统调用函数上,不会继续往下执行(不宜将其与 OS 调度中的线程休眠联系在一起,因为 I/O 系统调用完成前线程是否会休眠是不一定的。我们说“任务阻塞”可能比“进程/线程阻塞”要更好理解些)。如果数据传输无法马上完成(e.g. 管道、socket 空间不足无法写全部数据,类比 golang channel 就很容易理解),都会导致空等待。但有时我们不希望 I/O 操作产生空等待,此时可以通过 O_NONBLOCK 标识设置非阻塞 I/O 模式。管道、FIFO、socket、终端都支持该模式,不过管道和 socket 无法通过 open 系统调用来设置 O_NONBLOCK,只能使用 fcntl 调用。

4. 参考资料

  1. 《Linux/Unix 系统编程手册》(Ch4、5、13、63)

计算机系统基础
Linux 系统编程
License:  CC BY 4.0
Share

Further Reading

Nov 26, 2024

TLPI:文件系统

1. 基本原理 磁盘可以被划分成互不重叠的分区,每个分区可以容纳任何类型的信息,但通常只会包含以下其一。 文件系统(FS):存放常规文件 数据区域:可作为裸设备访问,一些 DBMS 会使用该技术 交换区域:用于内核的内存管理 下面我们介绍文件系统。Linux 支持种类繁多的文件系统,包括 Windo

Oct 22, 2024

TLPI:动态内存分配

堆内存 动态内存分配是一种很常见的行为,因为很多数据结构的大小只有当程序跑起来之后才能确定,此时我们一般从堆(heap)中申请(事实上也可以从栈上申请,但这里不谈)。堆是一段长度可变的连续虚拟内存,始于进程的“未初始化数据段末尾”,随着内存的分配和释放而增减。堆的末端称为“program break

Sep 5, 2024

TLPI:文件 I/O

1. 文件描述符原理 所有执行 I/O 操作的系统调用都以文件描述符(一个非负整数)来指代打开的文件,文件描述符用以表示所有类型的已打开文件,包括管道、FIFO、socket、终端设备和普通文件。 inode:系统级,不考虑硬链接时,它与文件数据一一对应 打开文件句柄(open file handl

OLDER

Go 语言基础

NEWER

TLPI:动态内存分配

Recently Updated

  • 6.824 Lab1: MapReduce
  • 服务架构演进小结
  • ChineseChess 程序:Minimax 算法与 Alpha-Beta 剪枝
  • 2024年终总结
  • 初识 RPC 与 REST

Trending Tags

算法 架构 分布式系统 Golang Linux 系统编程 Kubernetes 搜索

Contents

©2025 朝花惜拾. Some rights reserved.

Using the Halo theme Chirpy