对底层IO的深度总结
对底层IO的深度总结
前言
本文介绍 stdio
库函数与系统调用 read
/write
的核心行为,重点解析缓冲机制对 I/O 操作的影响。
一、read
系统调用
功能:原子地从内核缓冲区(内存)读取数据块到用户缓冲区(内存)。原子——多进程同时调用read也会保证每个进程读取到的数据块是完整的。
具体表格:
I/O类型 | 是否启用O_NONBLOCK | 数据/状态条件 | read 行为 |
---|---|---|---|
标准输入 | 否 | 输入未就绪(如终端无输入) | 阻塞,直到输入数据或EOF |
标准输入 | 是 | 输入未就绪 | 立即返回EAGAIN 错误 |
标准输入 | 无所谓 | 输入已就绪(用户键入回车 | 读取实际输入字节数(≤请求字节数) |
标准输入 | 无所谓 | 输入结束(如Ctrl+D) | 返回0(EOF) |
普通文件 | 无所谓 | 文件未结束 | 读取请求的字节数(除非文件剩余字节不足,返回实际剩余字节数) |
普通文件 | 无所谓 | 已到文件结尾 | 返回0(EOF) |
管道/FIFO | 否 | 管道中无数据,但写入端未关闭 | 阻塞,直到有数据写入 |
管道/FIFO | 否 | 管道中无数据,且写入端已关闭 | 返回0(EOF) |
管道/FIFO | 否 | 管道中有数据(≤请求字节数) | 读取实际可用字节数 |
管道/FIFO | 是 | 管道中无数据,但写入端未关闭 | 立即返回EAGAIN 错误 |
管道/FIFO | 是 | 管道中有数据(≤请求字节数) | 读取实际可用字节数 |
套接字 | 否 | 无数据到达(面向连接,如TCP) | 阻塞,直到数据到达或连接关闭 |
套接字 | 否 | 连接关闭(如TCP收到FIN) | 返回0(EOF) |
套接字 | 否 | 数据到达(≤请求字节数) | 读取实际到达字节数 |
套接字 | 是 | 无数据到达 | 立即返回EAGAIN 错误 |
套接字 | 是 | 数据到达(≤请求字节数) | 读取实际到达字节数 |
总结 read
何时阻塞:
- 1.未设置O_NONBLOCK,
- 2.对象是支持阻塞的设备(终端设备、套接字、管道等)而非普通文件,
- 3.此时内核缓冲区恰好没有数据。
阻塞需同时满足上述三种情况。可以理解为read
会等待可能提供数据的设备。其余情况,内核缓冲区一旦有数据块被write写入就一次性搬走并返回数据量,没读到就返回0(EOF/设备关闭)或-1(发生错误)。
二、write
系统调用
功能:原子地将用户缓冲区(内存)的数据块写入内核缓冲区(内存)。原子——多进程同时调用write也能保证每个进程写的数据块是独立的。
对象类型 | 是否启用 O_NONBLOCK | 行为描述 |
---|---|---|
标准输出 | 未启用 | 如果标准输出是终端设备,write 会阻塞,直到数据被读取或终端缓冲区满。 |
启用 | 如果标准输出是终端设备,write 会立即返回。如果终端缓冲区满,返回 -1 并设置 errno 为 EAGAIN 。 | |
普通文件 | 未启用 | write 会立即将数据写入文件,不会阻塞。 |
启用 | write 会立即将数据写入文件,不会阻塞。 | |
管道/FIFO | 未启用 | 如果写入字节数 n ≤ PIPE_BUF ,write 会原子地写入n字节;如果 n > PIPE_BUF ,write 不能保证原子性。 |
启用 | 如果写入字节数 n ≤ PIPE_BUF ,write 会原子地写入n字节;如果 n > PIPE_BUF ,返回 -1 并设置 errno 为 EAGAIN 。 | |
套接字 | 未启用 | 如果套接字缓冲区有足够空间,write 会立即写入;否则会阻塞,直到有足够空间。 |
启用 | 如果套接字缓冲区有足够空间,write 会立即写入;否则返回 -1 并设置 errno 为 EAGAIN 。 | |
读取端关闭 | 未启用 | 如果写入对象是管道、FIFO 或套接字,且读取端关闭,write 会返回 -1 并设置 errno 为 EPIPE ,同时可能会产生 SIGPIPE 信号。 |
启用 | 如果写入对象是管道、FIFO 或套接字,且读取端关闭,write 会返回 -1 并设置 errno 为 EPIPE ,同时可能会产生 SIGPIPE 信号。 |
关键点:
write
返回仅表示数据块已提交给内核,不保证已写入磁盘。- 阻塞模式下,若内核缓冲区满,
write
会阻塞;非阻塞模式直接返回EAGAIN
。
三、stdio
库的缓冲机制
1. 输入类函数(如 scanf
)
- 何时调用
read
:当用户缓冲区无足够数据时触发read
。 缓冲影响:
- 若有用户缓冲区:
read
填充库缓冲区,数据按需提取(如scanf
遇空格/换行停止)。 - 若无缓冲区(
_IONBF
):read
直接填充用户指定内存。
- 若有用户缓冲区:
示例对比:
// 例1:关闭缓冲区(_IONBF)
setvbuf(stdin, NULL, _IONBF, 0);
scanf("%s", string); // read直接填充string,"world"留在内核缓冲区
// 例2:启用缓冲区(默认)
scanf("%s", string); // read填充库缓冲区,"world"保留在库缓冲区中
2. 输出类函数(如 printf
)
何时调用
write
:由缓冲模式决定:缓冲模式 触发条件 无缓冲( _IONBF
)立即调用 write
行缓冲( _IOLBF
)遇到换行符或缓冲区满 全缓冲( _IOFBF
)缓冲区满或手动刷新( fflush
)
示例对比:
// 行缓冲(默认)
printf("I'm A.\n"); // 遇换行符立即调用write
write(STDOUT_FILENO, "I'm B.\n", 7); // 直接输出
// 输出顺序:A → B
// 全缓冲(_IOFBF)
printf("I'm A.\n"); // 缓冲区未满,不调用write
write(STDOUT_FILENO, "I'm B.\n", 7); // 直接输出
// 输出顺序:B → A(程序退出时刷新缓冲区)
四、close的机制
对于进程打开一个文件而言,有三个关联的数据结构:进程层面的文件描述符表,内核层面的打开文件表,文件系统层面的inode表。
进程层面的文件描述符表记录了fd与fd指向的内核的打开文件表的文件描述(句柄);
内核层面的打开文件表的每个表项(即文件句柄)记录了当前文件IO操作的偏移量,以及文件在文件系统inode表中的位置(下标);
文件系统层面的inode表记录了每个文件的元数据,以及记录锁(系统调用fcntl()
维护的POSIX锁)。
close(int fd)
的作用就是断开文件描述符fd与文件描述(内核维护的打开文件表的表项)的指向关系,并在进程层面的文件描述符表删除这一表项。而内核层面的打开文件表的表项只有没有任何进程的文件描述符指向它的时候才会被删除。
总结
read
/write
就是一个搬运者,可阻塞,也可立即返回。stdio
封装了read与write,为用户进程建立缓冲区:- 输入类函数:缓冲决定数据暂存位置(库缓冲区或用户内存)。
- 输出类函数:缓冲决定
write
的触发条件(换行符、缓冲区满或无缓冲)。
read与write在IO时写入与读取的数据量可以不同,此时内核缓冲区就像是水流一样,数据之间没有分隔,这被称为字节流。相比之下,数据包或消息就不能用read和write了。