系统级 I/O

I/O 接口

I/O 是主存与外部设备之间复制数据的过程。常见 I/O 接口分为三层:

层次 代表接口 特点
Unix I/O open, read, write, close 内核提供,低层、通用、开销低
Robust I/O rio_readn, rio_writen, rio_readlineb 对 Unix I/O 的健壮封装,处理 short count
Standard I/O fopen, fread, fprintf, fgets C 标准库提供,带用户态缓冲

在 signal handler 或网络 socket 场景中,有时必须使用 Unix I/O 或 RIO。

Unix 文件抽象

Unix 文件是长度为 mm 的字节序列:

1
B0, B1, ..., Bk, ..., Bm-1

Unix 将 I/O 设备统一建模为文件,包括磁盘、终端、网络等。所有输入输出都可转化为对文件的读写。

文件类型

类型 含义
Regular file 普通文件,包含任意数据
Directory 目录文件,由文件名到文件的链接组成
Socket 用于跨网络与其他进程通信
Named pipe 命名管道
Symbolic link 符号链接
Character device 字符设备
Block device 块设备

普通文件从应用视角可分为:

  • text file:只包含 ASCII 或 Unicode 字符。
  • binary file:其他所有文件。

对内核而言,文本文件和二进制文件没有本质区别。Linux 文本文件由文本行组成,每行以换行符 \n 结束,其 ASCII 值为 0x0a

目录

目录是由 link 组成的文件,每个 link 将文件名映射到文件。每个目录至少包含:

  • .:指向目录自身。
  • ..:指向父目录。

相关命令:

1
2
3
mkdir dirname  # 创建目录
ls dirname # 列出目录下文件
rmdir dirname # 删除空目录

Linux 内核将所有文件组织为以根目录 / 为根的单一目录树。每个文件都是根目录的直接或间接后代。

路径名

每个进程都有当前工作目录。路径名用于定位目录树中的文件:

  • 绝对路径:从 / 开始,例如 /home/droh/hello.c
  • 相对路径:从当前工作目录开始,例如 ./hello.c../../home/droh/hello.c

cd 命令用于改变 shell 的当前工作目录。

文件操作

open

应用访问 I/O 设备前,需要请求内核打开对应文件。内核返回一个非负整数文件描述符(file descriptor, fd)。之后对该文件的操作都通过 fd 完成。

每个打开文件都有当前文件位置 kk,初始为 0。应用可以通过 seek 操作显式修改当前位置。

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

/* flags 指定打开方式;mode 只在创建新文件时指定权限。 */
int open(char *filename, int flags, mode_t mode);

返回值:

  • 成功:返回新文件描述符。
  • 失败:返回 -1

flags 必须包含一种访问模式;mode 指定新建文件权限:

基本 flag 含义 可选 flag 含义 权限位 mode 含义
O_RDONLY 只读 O_CREAT 文件不存在则创建 S_IRUSR, S_IWUSR, S_IXUSR owner 读、写、执行
O_WRONLY 只写 O_TRUNC 打开时截断文件 S_IRGRP, S_IWGRP, S_IXGRP group 读、写、执行
O_RDWR 读写 O_APPEND 写操作追加到文件末尾 S_IROTH, S_IWOTH, S_IXOTH others 读、写、执行

umask 会屏蔽部分权限:

1
2
3
4
5
6
7
8
/* 默认创建权限:用户、组、其他用户均可读写。 */
#define DEF_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH)
/* umask 会屏蔽组写和其他用户写权限。 */
#define DEF_UMASK (S_IWGRP | S_IWOTH)

umask(DEF_UMASK);
/* 创建或截断 foo.txt,并以只写方式打开。 */
fd = open("foo.txt", O_CREAT | O_TRUNC | O_WRONLY, DEF_MODE);

实际权限为 mode & ~umask,所以上例会去掉 group 和 others 的写权限。

close

1
2
3
4
#include <unistd.h>

/* 关闭文件描述符 fd,释放进程描述符表项。 */
int close(int fd);

close(fd) 关闭文件描述符。成功返回 0,失败返回 -1

关闭文件时:

  • 应用不再访问该 fd。
  • 内核释放打开文件时创建的相关结构。
  • 内核把该 fd 放回可用描述符池。
  • 下次打开文件会获得当前最小可用 fd。

进程终止时,内核会关闭所有打开文件并释放相关资源。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
int fd1, fd2;

/* 第一次 open 通常返回当前最小可用描述符。 */
fd1 = Open("foo.txt", O_RDONLY, 0);
/* close 后 fd1 对应的描述符编号可被后续 open 复用。 */
Close(fd1);
fd2 = Open("baz.txt", O_RDONLY, 0);
printf("fd2 = %d\n", fd2);
exit(0);
}

fd1 被关闭,则 fd2 可能复用相同的最小可用描述符。

readwrite

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

/* 最多读取 count 字节到 buf,返回实际读取字节数。 */
ssize_t read(int fd, void *buf, size_t count);
/* 最多从 buf 写出 count 字节,返回实际写出字节数。 */
ssize_t write(int fd, const void *buf, size_t count);

read 从当前文件位置 kk 开始,最多复制 count 字节到内存 buf,并将 kk 增加实际读取字节数。

返回值:

  • 成功:返回实际读取字节数。
  • EOF:返回 0
  • 出错:返回 -1

write 从内存 buf 复制 count 字节到文件当前写入位置。

返回值:

  • 成功:返回实际写入字节数。
  • 出错:返回 -1

文件末尾没有显式 EOF 字符。EOF 是读操作遇到文件末尾时返回 0 的条件。

标准描述符

进程启动时通常有三个打开描述符:

描述符 含义
0 STDIN_FILENO 标准输入
1 STDOUT_FILENO 标准输出
2 STDERR_FILENO 标准错误

复制标准输入到标准输出:

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

int main(void)
{
char c;

/* 每次从标准输入读 1 字节,直到 EOF。 */
while (Read(STDIN_FILENO, &c, 1) != 0)
/* 将该字节写到标准输出。 */
Write(STDOUT_FILENO, &c, 1);

exit(0);
}

Short Count:
short count 指 readwrite 实际传输字节数少于请求字节数。 常见原因:

  • 读时遇到 EOF。
  • 从终端读取文本行。
  • 读写网络 socket。
  • 被信号中断。

对磁盘文件,short count 不常见;对终端和网络,必须正确处理。

statfstat

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

// 按文件名找文件获取元数据并放入 buf
int stat(const char *filename, struct stat *buf);

// 按描述符找文件获取元数据并放入 buf
int fstat(int fd, struct stat *buf);

成功返回 0,失败返回 -1

struct stat 包含文件元数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct stat {
dev_t st_dev; /* device */
ino_t st_ino; /* inode */
mode_t st_mode; /* protection and file type */
nlink_t st_nlink; /* number of hard links */
uid_t st_uid; /* user ID of owner */
gid_t st_gid; /* group ID of owner */
dev_t st_rdev; /* device type if inode device */
off_t st_size; /* total size, in bytes */
unsigned long st_blksize; /* preferred I/O block size */
unsigned long st_blocks; /* allocated disk blocks */
time_t st_atime; /* last access time */
time_t st_mtime; /* last modification time */
time_t st_ctime; /* last change time */
};

判断文件类型和权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct stat statbuf;
char *type, *readok;

/* 读取 argv[1] 指定文件的元数据。 */
Stat(argv[1], &statbuf);

/* 根据 st_mode 中的文件类型位判断文件类型。 */
if (S_ISREG(statbuf.st_mode))
type = "regular";
else if (S_ISDIR(statbuf.st_mode))
type = "directory";
else
type = "other";

/* 检查用户读权限位。 */
if (statbuf.st_mode & S_IRUSR)
readok = "yes";
else
readok = "no";

常用宏:

  • S_ISREG(mode):是否普通文件。
  • S_ISDIR(mode):是否目录。
  • S_IRUSR:owner 是否可读。

v-node table 中保存了许多 stat 结构中的信息,例如 st_modest_size

读取目录

1
2
3
4
5
6
7
8
9
10
11
#include <sys/types.h>
#include <dirent.h>

// 按路径打开目录
DIR *opendir(const char *filename);

// 从目录流中读取下一条目录项。每调用一次,就读出目录里的一个条目
struct dirent *readdir(DIR *dirp);

// 关闭已经打开的目录流,释放相关资源
int closedir(DIR *dirp);
  • opendir 成功返回目录流指针,失败返回 NULL
  • readdir 成功返回下一条目录项指针;若无更多条目或出错,返回 NULL
  • closedir 成功返回 0,失败返回 -1

目录项结构:

1
2
3
4
struct dirent {
ino_t d_ino; // inode 编号
char d_name[256]; // 目录项名字
};

遍历目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main(int argc, char **argv)
{
DIR *streamp;
struct dirent *dep;

/* 打开目录流,后续 readdir 会逐项读取目录项。 */
streamp = Opendir(argv[1]);

errno = 0;
while ((dep = Readdir(streamp)) != NULL)
/* d_name 是当前目录项的名字。 */
printf("Found file: %s\n", dep->d_name);

/* readdir 返回 NULL 可能是到达目录末尾,也可能是出错,需要检查 errno。 */
if (errno != 0)
unix_error("readdir error");

Closedir(streamp);
exit(0);
}

内核文件数据结构

内核通过三类结构管理打开文件。

  • Descriptor Table

    每个进程有独立的 descriptor table。表项由 fd 索引,每个打开描述符指向 file table 中的一个 entry。

  • File Table

    file table 由所有进程共享。每个 entry 表示一个打开文件实例,包含:

    • 当前文件位置。
    • 引用计数 refcnt
    • 指向 v-node table entry 的指针。

    refcnt 变为 0,内核删除该 file table entry。

  • V-node Table

    v-node table 由所有进程共享。每个 entry 包含文件的大部分元数据,例如访问权限、大小、类型等。

区分:i_node 是磁盘上的元数据对象,v_node 是内核内存中的文件对象抽象i_node 是文件元数据,可以通过 inode number 获取磁盘上的 i_node 结构。Virtual File System 引入 v_node 对不同文件系统的文件做统一抽象:

1
2
3
4
5
6
7
8
9
10
11
12
struct v_node {
/* 文件系统对象类型。 */
int v_type;
/* 引用计数,表示有多少内核对象引用该 v-node。 */
int refcnt;

/* 指向底层 inode,保存具体文件元数据和磁盘位置。 */
struct i_node *i_node;

/* 文件系统相关操作函数表。 */
struct v_node_ops *ops;
};

v_node 中包含有 i_node 的指针,指向真实的 inode

结构关系:

1
process descriptor table -> open file table -> v-node table

文件共享

多次 open 同一文件

同一进程两次 open 同一文件,会得到两个不同 file table entry,因此文件位置独立。

1
2
3
4
5
6
7
8
9
int fd1, fd2;
char c;

/* 两次 open 得到两个独立的 open file table entry,文件偏移量互不影响。 */
fd1 = open("foobar.txt", O_RDONLY, 0);
fd2 = open("foobar.txt", O_RDONLY, 0);
read(fd1, &c, 1);
read(fd2, &c, 1);
printf("c = %c\n", c);

若文件内容为 foobar,两次读取都从各自位置 0 开始,因此最终打印 f

fork 后共享打开文件

父进程在 fork 前打开文件,子进程会继承父进程的 descriptor table 副本。父子进程的描述符指向同一个 file table entry,因此共享当前文件位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int fd;
char c;

/* fork 前打开文件,父子进程将共享同一个 open file table entry。 */
fd = open("foobar.txt", O_RDONLY, 0);
if (fork() == 0) {
/* 子进程读取 1 字节,同时推进共享文件偏移量。 */
read(fd, &c, 1);
exit(0);
}
wait(NULL);
/* 父进程继续从已被子进程推进的位置读取。 */
read(fd, &c, 1);
printf("c = %c\n", c);

若子进程先读取 f,共享文件位置变为 1,父进程随后读取 o

I/O 重定向

dup2

dup2 复制文件描述符表项:

1
2
3
4
#include <unistd.h>

/* 让 newfd 指向 oldfd 对应的 open file table entry。 */
int dup2(int oldfd, int newfd);

  • newfd 对应 descriptor table entry 替换为 oldfd 的 entry。
  • newfd 原来打开,先关闭它。
  • 成功返回非负描述符,失败返回 -1

shell 中的输出重定向:

1
ls > foo.txt  # 将 ls 的标准输出重定向到 foo.txt

可理解为:

1
2
3
4
5
6
/* 打开输出文件,并清空原有内容。 */
fd = open("foo.txt", O_WRONLY | O_CREAT | O_TRUNC, DEF_MODE);
/* 将标准输出重定向到 fd 指向的文件。 */
dup2(fd, STDOUT_FILENO);
/* execve 后新程序继承已经重定向的标准输出。 */
execve("ls", argv, environ);

因为子进程继承父进程的 fd table,shell 通常在子进程中设置重定向,再执行 execve

共享文件位置

1
2
3
4
5
6
7
8
9
10
11
int fdA, fdB;
char c;

/* fdA 与 fdB 初始指向两个独立 open file table entry。 */
fdA = open("foobar.txt", O_RDONLY, 0);
fdB = open("foobar.txt", O_RDONLY, 0);
read(fdB, &c, 1);
/* dup2 后 fdA 和 fdB 共享 fdB 原来的文件偏移量。 */
dup2(fdB, fdA);
read(fdA, &c, 1);
printf("c = %c\n", c);

dup2(fdB, fdA) 后,fdAfdB 指向同一个 file table entry,共享同一文件位置。若第一次从 fdB 读到 f,第二次从 fdA 读到 o

恢复 stdout

dupdup2 类似,但由内核选择新的 fd(通常是最小可用 fd),让它指向和原 fd 完全相同的打开文件对象。

1
2
/* 复制 oldfd,由内核选择最小可用描述符编号作为返回值。 */
int dup(int oldfd);

shell 保存并恢复标准输出:

1
2
3
4
5
6
7
8
9
/* 保存当前标准输出,便于命令执行后恢复。 */
save_stdout = dup(STDOUT_FILENO);
dup2(fd, STDOUT_FILENO);

/* run command */

/* 恢复标准输出并关闭临时副本。 */
dup2(save_stdout, STDOUT_FILENO);
close(save_stdout);

stdinstdoutstderrFILE * 类型。若需要获取对应 fd,可使用:

1
2
/* 从 stdio 流对象取得底层 Unix 文件描述符。 */
int fileno(FILE *stream);

例如 fileno(stdout) == 1

forkprintf

标准 I/O 有用户态缓冲。若在 fork 前用 printf 写入缓冲区但未刷新,缓冲区会被复制到子进程,父子进程后续都可能刷新同一内容,导致输出次数多于预期。

处理方法:

  • fork 前调用 fflush(stdout)
  • 在重定向前后明确刷新标准 I/O 缓冲。
  • 对需要精确控制的输出,使用 Unix I/O 的 write

Robust I/O

RIO 是 CSAPP 提供的一组 I/O 封装,目标是高效、健壮地处理 short count,适用于网络程序等场景。

RIO 分为两类:

类别 函数 用途
无缓冲 I/O rio_readn, rio_writen 读写二进制数据
带缓冲输入 rio_readlineb, rio_readnb 读取文本行或二进制数据

无缓冲 RIO

无缓冲 RIO 直接在用户内存和文件之间传输数据,不使用应用级内部缓冲。

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

/* 从 fd 精确读取 count 字节,除非 EOF 或出错。 */
ssize_t rio_readn(int fd, void *usrbuf, size_t count);
/* 向 fd 精确写出 count 字节,处理中断和短写。 */
ssize_t rio_writen(int fd, void *usrbuf, size_t count);

返回值:

  • rio_readn:实际读取字节数,EOF 时可小于 count,出错返回 -1
  • rio_writen:成功写入 count 字节,出错返回 -1

rio_readn 核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ssize_t rio_readn(int fd, void *buf, size_t count)
{
/* nleft 表示尚未读取的字节数,ptr 指向下一次写入位置。 */
size_t nleft = count;
ssize_t nread;
char *ptr = buf;

while (nleft > 0) {
if ((nread = read(fd, ptr, nleft)) < 0) {
/* 被信号中断时不算真正失败,继续重试。 */
if (errno == EINTR)
nread = 0;
else
return -1;
} else if (nread == 0) {
/* EOF:无法继续读满 count 字节。 */
break;
}
nleft -= nread;
/* 指针前移到缓冲区中尚未填充的位置。 */
ptr += nread;
}
return count - nleft;
}

rio_writen 核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ssize_t rio_writen(int fd, const void *buf, size_t count)
{
size_t nleft = count;
ssize_t nwritten;
const char *ptr = buf;

while (nleft > 0) {
if ((nwritten = write(fd, ptr, nleft)) <= 0) {
/* EINTR 表示写操作被信号中断,循环重试。 */
if (errno == EINTR)
nwritten = 0;
else
return -1;
}
nleft -= nwritten;
/* 跳过已经写出的字节,继续写剩余部分。 */
ptr += nwritten;
}
return count;
}

处理原则:

  • 若被信号中断并返回 EINTR,继续重试。
  • 若发生其他错误,返回 -1
  • 循环直到传输完请求字节数或遇到 EOF。

带缓冲 RIO

逐字符读取文本行时,若每次都调用 Unix read,系统调用开销较高。一次 read 系统调用可能需要超过 10,000 个时钟周期。

缓冲输入的思路:

  1. 使用 Unix read 一次读取一块数据到内部缓冲区。
  2. 用户函数从缓冲区逐字节取数据。
  3. 缓冲区空时再重新填充。

rio_t

1
2
3
4
5
6
7
8
#define RIO_BUFSIZE 8192

typedef struct {
int rio_fd; /* 绑定的文件描述符 */
int rio_cnt; /* 内部缓冲区中尚未读取的字节数 */
char *rio_bufptr; /* 下一次从内部缓冲区读取的位置 */
char rio_buf[RIO_BUFSIZE]; /* 内部缓冲区 */
} rio_t;

字段含义:

字段 含义
rio_fd 绑定的文件描述符
rio_cnt 内部缓冲区中未读字节数
rio_bufptr 指向下一个未读字节
rio_buf 内部缓冲区

初始化:

1
2
3
4
5
6
7
void rio_readinitb(rio_t *rp, int fd)
{
/* 初始化 rio_t,使其绑定 fd 且内部缓冲区为空。 */
rp->rio_fd = fd;
rp->rio_cnt = 0;
rp->rio_bufptr = rp->rio_buf;
}

rio_read

rio_read 是带缓冲 RIO 的内部函数。若内部缓冲为空,则调用 Unix read 填充缓冲;随后从内部缓冲复制数据到用户缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
int cnt;

while (rp->rio_cnt <= 0) {
/* 内部缓冲区为空时,从内核一次性读取较大块数据。 */
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
if (rp->rio_cnt < 0) {
if (errno != EINTR)
return -1;
} else if (rp->rio_cnt == 0) {
/* EOF。 */
return 0;
} else {
/* 重新从内部缓冲区起始位置消费数据。 */
rp->rio_bufptr = rp->rio_buf;
}
}

cnt = n;
if (rp->rio_cnt < n)
/* 若内部缓冲区剩余不足 n 字节,只返回现有数据。 */
cnt = rp->rio_cnt;
memcpy(usrbuf, rp->rio_bufptr, cnt);
rp->rio_bufptr += cnt;
rp->rio_cnt -= cnt;
return cnt;
}

rio_readnb

rio_readnb 从带缓冲 reader 中读取最多 n 个字节到用户缓冲区。

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

while (nleft > 0) {
/* rio_read 先消耗内部缓冲区,不足时再读内核。 */
if ((nread = rio_read(rp, bufp, nleft)) < 0) {
if (errno == EINTR)
nread = 0;
else
return -1;
} else if (nread == 0) {
break;
}
nleft -= nread;
/* 移动到用户缓冲区中未填充的位置。 */
bufp += nread;
}
return n - nleft;
}

rio_readlineb

rio_readlineb 按行读取文本,最多读取 maxlen-1 个字符,遇到换行或 EOF 停止,并以 '\0' 结尾。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen)
{
int n, rc;
char c, *bufp = usrbuf;

for (n = 1; n < maxlen; n++) {
/* 每次通过带缓冲 rio_read 取一个字符。 */
if ((rc = rio_read(rp, &c, 1)) == 1) {
*bufp++ = c;
/* 读到换行即形成一行。 */
if (c == '\n')
break;
} else if (rc == 0) {
if (n == 1)
/* 第一次读取就遇到 EOF。 */
return 0;
else
/* 已读到部分数据,遇到 EOF 后返回该部分。 */
break;
} else {
return -1;
}
}
/* 以字符串形式返回,保证以 \\0 结尾。 */
*bufp = 0;
return n;
}

RIO 示例

按行复制标准输入到标准输出:

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

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

/* 将标准输入绑定到 RIO 缓冲区。 */
Rio_readinitb(&rio, STDIN_FILENO);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
/* 每读到一行,就按实际字节数写到标准输出。 */
Rio_writen(STDOUT_FILENO, buf, n);
}

标准 I/O

C 标准库提供更高层的标准 I/O 函数,包含在 libc.so 中。

常见函数:

功能 函数
打开/关闭文件 fopen, fclose
读写字节 fread, fwrite
读写文本行 fgets, fputs
格式化读写 fscanf, fprintf
刷新缓冲 fflush
定位 fseek

标准 I/O 将打开文件建模为 stream。stream 是文件描述符和内存缓冲区的抽象,类似带缓冲 RIO。

C 程序启动时有三个打开 stream:

stream 对应 fd
stdin 0
stdout 1
stderr 2

标准 I/O 缓冲

标准 I/O 使用用户态缓冲区。以 stdout 为例,多个 printf 调用可先写入缓冲区,遇到换行或显式 fflush(stdout) 时再调用 write

1
2
3
4
5
6
7
8
printf("h");
printf("e");
printf("l");
printf("l");
printf("o");
printf("\n");
/* 强制刷新 stdio 缓冲区,确保数据立即进入底层文件描述符。 */
fflush(stdout);

缓冲减少系统调用次数,但也引入与 forkdup2、socket 相关的限制。

Unix I/O & RIO & Standard I/O

对比

Standard I/O 和 RIO 都建立在底层 Unix I/O 之上。

I/O 类型 优点 缺点 / 适用限制
Unix I/O 最通用;开销低;其他 I/O 包都基于它实现;提供文件元数据访问函数;async-signal-safe,可在 signal handler 中使用 short count 处理容易出错;高效按行读取需要手动实现缓冲
Standard I/O 缓冲减少 read / write 系统调用次数,提高效率;自动处理许多 short count 情况;接口适合磁盘文件和终端文件 不提供访问文件元数据的函数;不是 async-signal-safe,不适合 signal handler;不适合直接用于网络 socket
RIO 适合网络 socket 场景;处理 short count;提供按行读取;避免标准 I/O stream 在 socket 上的限制 主要面向网络健壮 I/O 场景,相比 Standard I/O 更偏底层,需要显式使用 RIO 接口

Socket 限制

标准 I/O 对同一个 stream 的读写顺序有约束:

  1. 输出函数后不能直接调用输入函数,除非中间调用 fflushfseekfsetposrewind
  2. 输入函数后不能直接调用输出函数,除非中间调用 fseekfsetposrewind,或输入遇到 EOF。

原因:

  • 标准 I/O 使用用户态缓冲。
  • 一个 stream 维护单一当前位置。
  • 缓冲区通常在读状态或写状态之一工作。
  • fflushfseekfsetposrewind 用于重置缓冲状态。

socket 是顺序流,不是随机访问文件,因此 fseek 在 socket 上会失败。

若必须在 socket 上使用标准 I/O,可使用 dual streams:

1
2
3
4
5
6
7
8
9
10
11
12
13
FILE *fpin, *fpout;
int copyfd;

/* 复制 socket 描述符,使读流和写流各自拥有独立 FILE 结构。 */
copyfd = dup(sockfd);
fpin = fdopen(sockfd, "r");
fpout = fdopen(copyfd, "w");

/* fprintf(fpout, ...); fflush(fpout); fgets(fpin, ...); */

/* 关闭两个 FILE 流会分别关闭各自底层描述符。 */
fclose(fpin);
fclose(fpout);
  • 读写使用不同 stream。
  • 写后仍需 fflush,否则可能导致通信双方互相等待。
  • 两个 stream 都必须关闭。

总体原则:使用满足需求的最高层 I/O 接口。

场景 推荐接口
磁盘文件或终端文件 Standard I/O
signal handler 内部 Unix I/O
需要访问文件元数据 Unix I/O
追求最低开销的少数场景 Unix I/O
网络 socket 读写 RIO
需要健壮处理 short count RIO
  • 普通文件处理优先使用标准 I/O。
  • socket 程序优先使用 RIO。
  • signal handler 中只使用 async-signal-safe 的 Unix I/O。
  • 不要在 socket 上直接混用标准 I/O 的读写 stream,除非明确处理缓冲限制。