读linux programming interface chapter 5

读linux programming interface chapter 5

系统调用以原子方式执行。意味着内核保证操作中所有步骤在不被其他进程或线程中断的情况下完成

原子性对某些操作的完成至关重要。它可帮助我们避免竞态条件(race condition)--指两个进程(或线程)在共享资源上操作时产生的结果以意外的方式依赖于这些进程获得CPU访问权限的相对顺序

O_EXCLO_CREAT一起使用会导致open()在文件已存在时返回错误。这为进程确保其为文件的创建者提供了一种方式。检查文件先前是否存在以及创建文件是以原子方式执行的

O_EXCL 是使用文件系统的 open() 函数时可指定的标志。作用是确保文件在被打开或创建时的唯一性。与 O_CREAT 一起使用时:

  • 如指定文件已存在,open() 函数会因为 O_EXCL 而失败,返回错误。可防止覆盖一个已存在文件
  • 如指定文件不存在,open() 函数根据 O_CREAT 创建文件

通常用于需要确保文件是新创建的场景,如在创建临时文件或在多线程/多进程环境中确保文件名唯一性时。这可避免因多个进程或线程试图同时创建同一文件导致的数据竞争问题

fd = open(argv[1], O_WRONLY);   /* Open 1: check if file exists */
if (fd != -1) {                     /* Open succeeded */
    printf("[PID %ld] File \"%s\" already exists\n",
            (long) getpid(), argv[1]);
    close(fd);
} else {
    if (errno != ENOENT) {          /* Failed for unexpected reason */
        errExit("open");
    } else {
        /* WINDOW FOR FAILURE */
        fd = open(argv[1], O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR);
        if (fd == -1)
            errExit("open");
 
        printf("[PID %ld] Created file \"%s\" exclusively\n",
                (long) getpid(), argv[1]);          /* MAY NOT BE TRUE! */
    }
}

假设进程第一次调用open()时,文件不存在,但第二次open()的时候,其他进程已经创建了该文件。这可能发生在内核调度器决定进程的时间片已用尽并将控制权交给另一个进程时,或者在多处理器系统上两个进程同时运行时。在这种情况下,进程A会错误地认为它创建了文件,因为无论文件是否存在,第二个open()都会成功。

通过使用单个 open() 调用并指定 O_CREAT 和 O_EXCL 标志,可以防止这种情况,因为它保证了检查和创建步骤作为单个原子(即不可中断)操作进行。

对原子性的需求的第二个例子是当我们有多个进程向同一个文件(例如,一个全局日志文件)追加数据时。为此,我们可能会考虑在每个写入者中使用如下代码:

if (lseek(fd, 0, SEEK_END) == -1)
    errExit("lseek");
if (write(fd, buf, len) != len)
    fatal("Partial/failed write");

然而,这段代码存在与前一个例子相同的缺陷。如果第一个执行代码的进程在 lseek() 和 write() 调用之间被第二个执行相同操作的进程中断,那么两个进程都会在写入之前将它们的文件偏移设置到相同的位置,并且当第一个进程重新调度时,它将覆盖第二个进程已经写入的数据。同样,这是一个竞态条件,因为结果取决于两个进程的调度顺序。

避免这个问题需要寻找文件末尾下一个字节的操作和写入操作原子地发生。这就是以 O_APPEND 标志打开文件所保证的。


fcntl() 系统调用用于在一个已打开的文件描述符上执行一系列控制操作。

#include <fcntl.h>
 
int fcntl(int fd, int cmd, ...);

fcntl() 的一个用途是检索或修改已打开文件的访问模式和打开文件状态标志。(这些是在调用 open() 时由 flags 参数设置的值。)要检索这些设置,我们将 cmd 指定为 F_GETFL:

int flags, accessMode;
 
flags = fcntl(fd, F_GETFL);          /* 第三个参数不是必需的 */
if (flags == -1)
    errExit("fcntl");

在上述代码之后,我们可以测试文件是否已打开以进行同步写入:

if (flags & O_SYNC)
    printf("writes are synchronized\n");

SUSv3 要求只有在 open() 或稍后的 fcntl() F_SETFL 调用中指定的状态标志才应设置在打开的文件上。然而,Linux 在这方面有所偏离:如果应用程序使用第 5.10 节中描述的一种技术编译,用于打开大文件,那么通过 F_GETFL 检索的标志中总是会设置 O_LARGEFILE。

检查文件的访问模式略微复杂,因为 O_RDONLY (0)、O_WRONLY (1) 和 O_RDWR (2) 常量不对应于打开文件状态标志中的单个位。因此,要进行此检查,我们需要用常量 O_ACCMODE 屏蔽标志值,然后测试与其中一个常量的等同性:

accessMode = flags & O_ACCMODE;
if (accessMode == O_WRONLY || accessMode == O_RDWR)
    printf("file is writable\n");

我们可以使用 fcntl() 的 F_SETFL 命令来修改一些打开的文件状态标志。可以修改的标志有 O_APPEND、O_NONBLOCK、O_NOATIME、O_ASYNC 和 O_DIRECT。尝试修改其他标志会被忽略。(一些其他的 UNIX 实现允许 fcntl() 修改其他标志,例如 O_SYNC。)

在以下情况下,使用 fcntl() 修改打开的文件状态标志特别有用:

  • 调用程序没有打开该文件,因此无法控制在 open() 调用中使用的标志(例如,文件可能是在程序启动前打开的三个标准描述符之一)。
  • 文件描述符是从除 open() 以外的系统调用中获取的。这类系统调用的示例包括 pipe(),它创建一个管道并返回两个文件描述符,分别指向管道的两端,以及 socket(),它创建一个套接字并返回一个指向该套接字的文件描述符。

要修改打开文件的状态标志,我们使用 fcntl() 获取现有标志的副本,然后修改我们希望更改的位,最后再次调用 fcntl() 来更新标志。因此,要启用 O_APPEND 标志,我们会编写以下内容:

int flags;
 
flags = fcntl(fd, F_GETFL);
if (flags == -1)
    errExit("fcntl");
flags |= O_APPEND;
if (fcntl(fd, F_SETFL, flags) == -1)
    errExit("fcntl");

检查内核维护的三个数据结构:

  • 每个进程的文件描述符表;
  • 系统范围内的打开文件描述表;以及
  • 文件系统的i-node表。

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

  • 控制文件描述符操作的一组标志(只有一个这样的标志,即在执行时关闭的标志)
  • 对打开文件描述的引用。

内核维护一个系统范围内的所有打开文件描述的表。(这个表有时被称为打开文件表,其条目有时被称为打开文件句柄。)打开的文件描述存储了与打开文件相关的所有信息,包括:

  • 当前文件偏移量(通过read()和write()更新,或使用lseek()显式修改);
  • 打开文件时指定的状态标志(即open()的flags参数);
  • 文件访问模式(只读、只写或读写,如open()中所指定);
  • 与信号驱动I/O相关的设置(第63.3节);
  • 对这个文件的i-node对象的引用。

每个文件系统都有一个i-node表,用于文件系统中所有文件的i-node。i-node结构和文件系统一般在第14章中更详细地讨论。目前,我们注意到每个文件的i-node包括以下信息:

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

在这里,我们忽略了磁盘上i-node和内存中i-node表示之间的区别。磁盘上的i-node记录文件的持久属性,如其类型、权限和时间戳。当访问文件时,会创建i-node的内存副本,这个版本的i-node记录了指向i-node的打开文件描述的计数以及从中复制i-node的设备的主要和次要ID。内存中的i-node还记录了与打开文件时相关的各种短暂属性,如文件锁。

进程A的描述符2和进程B的描述符2指向一个打开的文件描述(73)。这种情况可能发生在调用fork()之后(即,进程A是进程B的父进程,反之亦然),或者一个进程使用UNIX域套接字(第61.13.3节)将一个打开的描述符传递给另一个进程。 最后,我们看到进程A的描述符0和进程B的描述符3指向不同的打开文件描述,但这些描述指向同一个i-node表条目(1976)——换句话说,指向同一个文件。这是因为每个进程独立地为同一个文件调用了open()。如果单个进程两次打开同一个文件,也可能发生类似的情况

两个不同的文件描述符,如果它们指向同一个打开的文件描述,则共享一个文件偏移值。因此,如果通过一个文件描述符更改了文件偏移量(作为对read()、write()或lseek()的调用的结果),则这种更改通过另一个文件描述符可见。这适用于两个文件描述符属于同一个进程时,也适用于它们属于不同进程时。

  • 使用fcntl()的F_GETFL和F_SETFL操作检索和更改打开文件状态标志(例如,O_APPEND、O_NONBLOCK和O_ASYNC)时,类似的作用域规则适用。
  • 相比之下,文件描述符标志(即,在执行时关闭的标志)是进程和文件描述符私有的。修改这些标志不会影响同一进程中的其他文件描述符或不同进程中的文件描述符。

(因为 shell 从左到右评估 I/O 方向)将标准输出和标准错误都发送到文件 results.log:

$ ./myscript > results.log 2>&1

shell 通过使文件描述符 2 成为文件描述符 1 的副本来重定向标准错误,使其指向同一个打开的文件描述(就像图 5-2 中进程 A 的描述符 1 和 20 指向同一个打开的文件描述一样)。可以使用 dup() 和 dup2() 系统调用实现这种效果。

注意,shell 仅仅打开两次 results.log 文件是不够的:一次在描述符 1,一次在描述符 2。其中一个原因是两个文件描述符不会共享文件偏移指针,因此可能会相互覆盖输出。另一个原因是文件可能不是磁盘文件。

dup() 调用接受 oldfd,一个打开的文件描述符,并返回一个指向相同打开文件描述的新描述符。新描述符保证是最低未使用的文件描述符。

#include <unistd.h>int dup(int oldfd);

确保我们总是得到我们想要的文件描述符,我们可以使用 dup2()。

#include <unistd.h>int dup2(int oldfd, int newfd);

dup2() 系统调用使用 oldfd 中给定的文件描述符的副本,并使用 newfd 中提供的描述符号。如果 newfd 中指定的文件描述符已经打开,dup2() 首先将其关闭。(在这种关闭过程中发生的任何错误都会被默默地忽略。)关闭和重用 newfd 是原子执行的,这避免了在信号处理程序或分配文件描述符的并行线程中,在这两个步骤之间重用 newfd 的可能性。

如果 oldfd 不是有效的文件描述符,那么 dup2() 会以错误 EBADF 失败,并且不会关闭 newfd。如果 oldfd 是有效的文件描述符,并且 oldfd 和 newfd 有相同的值,那么 dup2() 什么也不做——newfd 不会被关闭,dup2() 返回 newfd 作为其函数结果

另一个为复制文件描述符提供一些额外灵活性的接口是 fcntl() 的 F_DUPFD 操作:

newfd = fcntl(oldfd, F_DUPFD, startfd);

此调用通过使用大于或等于 startfd 的最低未使用的文件描述符来复制 oldfd。这在我们希望保证新描述符(newfd)落在某个特定值范围内时很有用。dup() 和 dup2() 的调用总是可以重编码为 close() 和 fcntl() 的调用,尽管前者调用更为简洁。

重复的文件描述符在它们共享的打开文件描述中共享相同的文件偏移值和状态标志。然而,新的文件描述符有自己的文件描述符标志集,其关闭时执行的标志(FD_CLOEXEC)总是关闭的。我们接下来描述的接口允许明确控制新文件描述符的关闭时执行标志。

dup3() 系统调用执行与 dup2() 相同的任务,但增加了一个额外的参数 flags,这是一个位掩码,用于修改系统调用的行为。

#define _GNU_SOURCE
#include <unistd.h>
 
int dup3(int oldfd, int newfd, int flags);

dup3() 支持一个标志,O_CLOEXEC,它使内核为新文件描述符启用关闭时执行标志(FD_CLOEXEC)。这个标志的用途与 open() 的 O_CLOEXEC 标志在第4.3.1节中描述的原因相同。

Linux 还支持一个额外的 fcntl() 操作用于复制文件描述符:F_DUPFD_CLOEXEC。这个标志与 F_DUPFD 相同,但额外为新文件描述符设置了关闭时执行标志(FD_CLOEXEC)。再次强调,这个操作的用途与 open() 的 O_CLOEXEC 标志相同

在指定偏移量处进行的文件 I/O:pread() 和 pwrite()

pread() 和 pwrite() 系统调用的操作方式与 read() 和 write() 相似,不同之处在于文件 I/O 是在由 offset 指定的位置执行的,而不是在当前文件偏移处。这些调用不会改变文件偏移。

#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);