CSAPP第十章系统级IO

这里进入第三部分:程序间的交互和通信。我们学习计算机系统到现在,一直假设程序是独立运行的,只包含最小限度的输入和输出。然而,在现实世界里,应用程序利用操作系统提供的服务来与 I/O 设备及其他程序通信。

本书的这一部分将使你了解 Unix 操作系统提供的基本 I/O 服务,以及如何用这些服务来构造应用程序,例如 Web 客户端和服务器,它们是通过 Internet 彼此通信的。你将学习编写诸如 Web 服务器这样的可以同时为多个客户端提供服务的并发程序。编写并发应用程序还能使程序在现代多核处理器上执行得更快。当学完了这个部分,你将逐渐变成一个很牛的程序员,对计算机系统以及它们对程序的影响有很成熟的理解。

输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从 I/O 设备复制数据到主存,而输出操作是从主存复制数据到 I/O 设备。

所有语言的运行时系统都提供执行 I/O 的较高级别的工具。例如,ANSIC 提供标准 I/O 库,包含像 printf 和 scanf 这样执行带缓冲区的 I/O 函数。C++ 语言用它的重载操作符 <<(输入)和 >>(输出)提供了类似的功能。在 Linux 系统中,是通过使用由内核提供的系统级 Unix I/O 函数来实现这些较高级别的 I/O 函数的。大多数时候,高级别 I/O 函数工作良好,没有必要直接使用 Unix I/O。那么为什么还要麻烦地学习 Unix I/O 呢?

  • 了解 Unix I/O 将帮助你理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因此,我们经常遇到 I/O 和其他系统概念之间的循环依赖。例如,I/O 在进程的创建和执行中扮演着关键的角色。反过来,进程创建又在不同进程间的文件共享中扮演着关键角色。因此,要真正理解 I/O,你必须理解进程,反之亦然。在对存储器层次结构、链接和加载、进程以及虚拟内存的讨论中,我们已经接触了 I/O 的某些方面。既然你对这些概念有了比较好的理解,我们就能闭合这个循环,更加深入地研究 I/O。
  • 有时你除了使用 Unix I/O 以外别无选择。在某些重要的情况中,使用高级 I/O 函数不太可能,或者不太合适。例如,标准 I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。另外,I/O 库还存在一些问题,使得用它来进行网络编程非常冒险。

这一章介绍 Unix I/O 和标准 I/O 的一般概念,并且向你展示在 C 程序中如何可靠地使用它们。除了作为一般性的介绍之外,这一章还为我们随后学习网络编程和并发性奠定坚实的基础。

UNIX I/O

一个 Linux 文件就是一个 m 个字节的序列:

所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:

  • 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

  • Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。头文件 定义了常量 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO,它们可用来代替显式的描述符值。

  • 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作,显式地设置文件的当前位置为 k。

  • 读写文件。一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件,当k⩾m时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的 “EOF 符号”。

    类似地,写操作就是从内存复制 n > 0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。

  • 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

文件

每个 Linux 文件都有一个类型(type)来表明它在系统中的角色:

  • 普通文件(regular file)包含任意数据。应用程序常常要区分文本文件(text file)和二进制文件(binary file),文本文件是只含有 ASCII 或 Unicode 字符的普通文件;二进制文件是所有其他的文件。对内核而言,文本文件和二进制文件没有区别。

    Linux 文本文件包含了一个文本行(text line)序列,其中每一行都是一个字符序列,以一个新行符(“\n”)结束。新行符与 ASCII 的换行符(LF)是一样的,其数字值为 0x0a。

  • 目录(directory)是包含一组链接(link)的文件,其中每个链接都将一个文件名(filename)映射到一个文件,这个文件可能是另一个目录。每个目录至少含有两个条目:是到该目录自身的链接,以及是到目录层次结构(见下文)中父目录(parent directory)的链接。你可以用 mkdir 命令创建一个目录,用 Is 查看其内容,用 rmdir 删除该目录。

  • 套接字(socket)是用来与另一个进程进行跨网络通信的文件(11.4 节)。

其他文件类型包含命名通道(named pipe)、 符号链接(symbolic link),以及字符和块设备(character and block device),这些不在本书的讨论范畴。

Linux 内核将所有文件都组织成一个目录层次结构(directory hierarchy),由名为 /(斜杠)的根目录确定。系统中的每个文件都是根目录的直接或间接的后代。图 10-1 显示了 Linux 系统的目录层次结构的一部分。

10-01 linux目录层次的一部分

作为其上下文的一部分,每个进程都有一个当前工作目录(current working directory)来确定其在目录层次结构中的当前位置。你可以用 cd 命令来修改 shell 中的当前工作目录。

目录层次结构中的位置用路径名(pathname)来指定。路径名是一个字符串,包括一个可选斜杠,其后紧跟一系列的文件名,文件名之间用斜杠分隔。路径名有两种形式:

  • 绝对路径名(absolute pathname)以一个斜杠开始,表示从根节点开始的路径。例如,在图 10-1 中,hello.c 的绝对路径名为 /home/droh/hello.c
  • 相对路径名(relative pathname)以文件名开始,表示从当前工作目录开始的路径。例如,在图 10-1 中,如果 /home/droh 是当前工作目录,那么 hello.c 的相对路径名就是 ./hello.c。反之,如果 /home/bryant 是当前工作目录,那么相对路径名就是 ../home/droh/hello.c

打开和关闭文件

进程是通过调用 open 函数来打开一个已存在的文件或者创建一个新文件的:

1
2
3
4
5
6
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);
// 返回:若成功则为新文件描述符,若出错为 -1。

open 函数将 filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件:

  • O_RDONLY:只读。
  • O_WRONLY:只写。
  • O_RDWR:可读可写。

例如,下面的代码说明如何以读的方式打开一个已存在的文件:

fd = Open(“foo.txt”, O_RDONLY, 0);

flags 参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:

  • O_CREAT:如果文件不存在,就创建它的一个截断的(truncated)(空)文件。
  • O_TRUNC:如果文件已经存在,就截断它。
  • O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。

例如,下面的代码说明的是如何打开一个已存在文件,并在后面添加一些数据:

fd = Open("foo.txt", O_WRONLY|O_APPEND, 0);

mode 参数指定了新文件的访问权限位。这些位的符号名字如图 10-2 所示。

掩码 描述
S_IRUSR 使用者(拥有者)能够读这个文件
S_IWUSR 使用者(拥有者)能够写这个文件
S_IXUSR 使用者(拥有者)能够执行这个文件
S_IRGRP 拥有者所在组的成员能够读这个文件
S_IWGRP 拥有者所在组的成员能够写这个文件
S_IXGRP 拥有者所在组的成员能够执行这个文件
S_IROTH 其他人(任何人)能够读这个文件
S_IWOTH 其他人(任何人)能够写这个文件
S_IXOTH 其他人(任何人)能够执行这个文件

图 10-2 访问权限位。在 sys/stat.h 中定义

作为上下文的一部分,每个进程都有一个 umask,它是通过调用 umask 函数来设置的。当进程通过带某个 mode 参数的 open 函数调用来创建一个新文件时,文件的访问权限位被设置为 mode & ~umask。例如,假设我们给定下面的 mode 和 umask 默认值:

#define DEF_MODE S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH

#define DEF_UMASK S_IWGRP|S_IWOTH

接下来,下面的代码片段创建一个新文件,文件的拥有者有读写权限,而所有其他的用户都有读权限:

umask(DEF_UMASK);

fd = Open("foo.txt", O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);

最后,进程通过调用 close 函数关闭一个打开的文件。

1
2
3
4
\#include <unistd.h>

int close(int fd);
// 返回:若成功则为 0,若出错则为 -1。

关闭一个已关闭的描述符会出错。

读和写文件

应用程序是通过分别调用 read 和 write 函数来执行输入和输出的。

1
2
3
4
5
6
7
8
#include <unistd.h>

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

// 返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。
ssize_t write(int fd, const void *buf, size_t n);

// 返回:若成功则为写的字节数,若出错则为 -1。

read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值 -1 表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的是实际传送的字节数量。

write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。图 10-3 展示了一个程序使用 read 和 write 调用一次一个字节地从标准输入复制到标准输出。

1
2
3
4
5
6
7
8
9
#include "csapp.h"

int main(void)
{
char c;
while(Read(STDIN_FILENO, &c, 1) != 0)
Write(STDOUT_FILENO, &c, 1);
exit(0);
}

图 10-3 一次一个字节地从标准输入复制到标准输出

通过调用 lseek 函数,应用程序能够显示地修改当前文件的位置,这部分内容不在我们的讲述范围之内。

在某些情况下,read 和 write 传送的字节比应用程序要求的要少。这些不足值(short count)不表示有错误。出现这样情况的原因有:

  • 读时遇到 EOF。假设我们准备读一个文件,该文件从当前文件位置开始只含有 20 多个字节,而我们以 50 个字节的片进行读取。这样一来,下一个 read 返回的不足值为 20,此后的 read 将通过返回不足值 0 来发出 EOF 信号。
  • 从终端读文本行。如果打开文件是与终端相关联的(如键盘和显示器),那么每个 read 函数将一次传送一个文本行,返回的不足值等于文本行的大小。
  • 读和写网络套接字(socket)。如果打开的文件对应于网络套接字(11.4 节),那么内部缓冲约束和较长的网络延迟会引起 read 和 write 返回不足值。对 Linux 管道(pipe)调用 read 和 write 时,也有可能出现不足值,这种进程间通信机制不在我们讨论的范围之内。

实际上,除了 EOF,当你在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时,也不会遇到不足值。然而,如果你想创建健壮的(可靠的)诸如 Web 服务器这样的网络应用,就必须通过反复调用 read 和 write 处理不足值,直到所有需要的字节都传送完毕。

用RIO包健壮地读写

在这一小节里,我们会讲述一个 I/O 包,称为 RIO(Robust I/O,健壮的 I/O)包,它会自动为你处理上文中所述的不足值。在像网络程序这样容易出现不足值的应用中,RIO 包提供了方便、健壮和高效的 I/O。RIO 提供了两类不同的函数:

  • 无缓冲的输入输出函数。这些函数直接在内存和文件之间传送数据,没有应用级缓冲。它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用。
  • 带缓冲的输入函数。这些函数允许你高效地从文件中读取文本行和二进制数据,这些文件的内容缓存在应用级缓冲区内,类似于为 printf 这样的标准 I/O 函数提供的缓冲区。与【110】中讲述的带缓冲的 I/O 例程不同,带缓冲的 RIO 输入函数是线程安全的(12.7.1 节),它在同一个描述符上可以被交错地调用。例如,你可以从一个描述符中读一些文本行,然后读取一些二进制数据,接着再多读取一些文本行。

我们讲述 RIO 例程有两个原因。第一,在接下来的两章中,我们开发的网络应用中使用了它们;第二,通过学习这些例程的代码,你将从总体上对 Unix I/O 有更深入的了解。

RIO 的无缓冲的输入输出函数

通过调用 rio_readn 和 rio_writen 函数,应用程序可以在内存和文件之间直接传送数据。

1
2
3
4
5
6
7
#include "csapp.h"

ssize_t rio_readn(int fd, void *usrbuf, size_t n);

ssize_t rio_writen(int fd, void *usrbuf, size_t n);

// 返回:若成功则为传送的字节数,若 EOF 则为 0(只对 rio_readn 而言),若出错则为 -1。

rio_readn 函数从描述符 fd 的当前文件位置最多传送 n 个字节到内存位置 usrbuf。类似地,rio_writen 函数从位置 usrbuf 传送 n 个字节到描述符 fd。rio_read 函数在遇到 EOF 时只能返回一个不足值。rio_writen 函数决不会返回不足值。对同一个描述符,可以任意交错地调用 rio_readn 和 rio_writen。

图 10-4 显示了 rio_readn 和 rio_writen 的代码。注意,如果 rio_readn 和 rio_writen 函数被一个从应用信号处理程序的返回中断,那么每个函数都会手遍地重启 read 或 write。为了尽可能有较好的可移植性,我们允许被中断的系统调用,且在必要时重启它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ssize_t rio_readn(int fd, void *usrbuf, size_t n)
{
size_t nleft = n;
ssize_t nread;
char *bufp = usrbuf;

while (nleft > 0) {
if ((nread = read(fd, bufp, nleft)) < 0) {
if (errno == EINTR) /* Interrupted by sig handler return */
nread = 0; /* and call read() again */
else
return -1; /* errno set by read() */
}
else if (nread == 0)
break; /* EOF */
nleft -= nread;
bufp += nread;
}
return (n - nleft); /* Return >= 0 */
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t rio_writen(int fd, void *usrbuf, size_t n)
{
size_t nleft = n;
ssize_t nwritten;
char *bufp = usrbuf;

while (nleft > 0) {
if ((nwritten = write(fd, bufp, nleft)) <= 0) {
if (errno == EINTR) /* Interrupted by sig handler return */
nwritten = 0; /* and call write() again */
else
return -1; /* errno set by write() */
}
nleft -= nwritten;
bufp += nwritten;
}
return n;
}

RIO 的带缓冲的输入函数

假设我们要编写一个程序来计算文本文件中文本行的数量,该如何来实现呢?一种方法就是用 read 函数来一次一个字节地从文件传送到用户内存,检查每个字节来查找换行符。这个方法的缺点是效率不是很高,每读取文件中的一个字节都要求陷入内核。

一种更好的方法是调用一个包装函数(rio_readlineb),它从一个内部读缓冲区复制一个文本行,当缓冲区变空时,会自动地调用 read 重新填满缓冲区。对于既包含文本行也包含二进制数据的文件(例如 11.5.3 节中描述的 HTTP 响应),我们也提供了一个 rio_readn 带缓冲区的版本,叫做 rio_readnb,它从和 rio_readlineb 一样的读缓冲区中传送原始字节。

1
2
3
4
5
6
7
8
#include "csapp.h"
void rio_readinitb(rio_t *rp, int fd);
// 返回:无。
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);

ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);

// 返回:若成功则为读的字节数,若 EOF 则为 0,若出错则为 -1。 

每打开一个描述符,都会调用一次 rio_readinitb 函数。它将描述符 fd 和地址 rp 处的一个类型为 rio_t 的读缓冲区联系起来。

rio_readlineb 函数从文件叩读出下一个文本行(包括结尾的换行符),将它复制到内存位置 usrbuf,并且用 NULL(零)字符来结束这个文本行。rio_readlineb 函数最多读 maxlen-1 个字节,余下的一个字符留给结尾的 NULL 字符。超过 maxlen-1 字节的文本行被截断,并用一个 NULL 字符结束。

rio_readnb 函数从文件 rp 最多读 n 个字节到内存位置 usrbuf。对同一描述符,对 rio_readlineb 和 rio_readnb 的调用可以任意交叉进行。然而,对这些带缓冲的函数的调用却不应和无缓冲的 rio_readn 函数交叉使用。

在本书剩下的部分中将给出大量的 RIO 函数的示例。下图展示了如何使用 RIO 函数来一次一行地从标准输入复制一个文本文件到标准输出。

1
2
3
4
5
6
7
8
9
10
11
12
#include "csapp.h"

int main(int argc, char **argv)
{
int n;
rio_t rio;
char buf[MAXLINE];

Rio_readinitb(&rio, STDIN_FILENO);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
Rio_writen(STDOUT_FILENO, buf, n);
}

下图展示了一个读缓冲区的格式,以及初始化它的 rio_readinitb 函数的代码。rio_readinitb 函数创建了一个空的读缓冲区,并且将一个打开的文件描述符和这个缓冲区联系起来。

1
2
3
4
5
6
7
#define RIO_BUFSIZE 8192
typedef struct {
int rio_fd; /* Descriptor for this internal buf */
int rio_cnt; /* Unread bytes in internal buf */
char *rio_bufptr; /* Next unread byte in internal buf */
char rio_buf[RIO_BUFSIZE]; /* Internal buffer */
} rio_t;
1
2
3
4
5
6
void rio_readinitb(rio_t *rp, int fd)
{
rp->rio_fd = fd;
rp->rio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}

重定向

Linuxshell 提供了 I/O 重定向操作符,允许用户将磁盘文件和标准输入输出联系起来。例如,键入

linux> ls > foo.txt

使得 shell 加载和执行 Is 程序,将标准输出重定向到磁盘文件 foo.txto 就如我们将在 11.5 节中看到的那样,当一个 Web 服务器代表客户端运行 CGI 程序时,它就执行一种相似类型的重定向。那么 I/O 重定向是如何工作的呢?一种方式是使用 dup2 函数。

1
2
3
4
#include <unistd.h>

int dup2(int oldfd, int newfd);
// 返回:若成功则为非负的描述符,若出错则为 -1。

dup2 函数复制描述符表表项 oldfd 到描述符表表项 newfd,覆盖描述符表表项 newfd 以前的内容。如果 newfd 已经打开了,dup2 会在复制 oldfd 之前关闭 newfd。

假设在调用 dup2(4,1) 之前,我们的状态如图 10-12 所示,其中描述符 1(标准输出)对应于文件 A(比如一个终端),描述符 4 对应于文件 B(比如一个磁盘文件)。A 和 B 的引用计数都等于 1。图 10-15 显示了调用 dup2(4,1) 之后的情况。两个描述符现在都指向文件 B;文件 A 已经被关闭了,并且它的文件表和 v-node 表表项也已经被删除了;文件 B 的引用计数已经增加了。从此以后,任何写到标准输出的数据都被重定向到文件 B。

10-15 通过重定向标准输出之后的内核数据结构