目录
1 I/O 缓冲简介
1.1 什么是I/O 缓冲
出于速度和效率的考虑,系统 I/O 调用(即文件 I/O)和标准 C 语言库 I/O 函数(即标准 I/O 函数)在操作磁盘文件时会对数据进行缓冲。I/O缓冲指的是在数据传输过程中,用于临时存储数据的内存区域。它允许程序先将数据写入或从内存中的一个缓冲区读取,然后再与外部设备(如硬盘、网络或控制台)进行数据交换。
1.2 I/O 缓冲的目的
提高效率:减少实际的I/O操作次数,因为可以积累一定量的数据后再执行一次较大的传输,而不是每次只传输少量数据。
减少阻塞:在缓冲的帮助下,程序可以继续执行其他任务,而不必等待每次I/O操作完成。
数据整合:在输出时,可以将多次小的数据写入合并为一次较大的写入操作。
2 文件 I/O 的内核缓冲
read()和 write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。譬如调用 write()函数将 5 个字节数据从用户空间内存拷贝到内核空间的缓冲区中:
write(fd, "Hello", 5); //写入 5 个字节数据
调用 write()后仅仅只是将这 5 个字节数据拷贝到了内核空间的缓冲区中,拷贝完成之后函数就返回了,在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此可知,系调用 write()与磁盘操作并不是同步的, write()函数并不会等待数据真正写入到磁盘之后再返回。如果在此期间, 其它进程调用 read()函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。
与此同理,对于读文件而言亦是如此,内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,当调用 read()函数读取数据时, read()调用将从内核缓冲区中读取数据,直至把缓冲区中的数据读完,这时,内核会将文件的下一段内容读入到内核缓冲区中进行缓存。
文件 I/O 的内核缓冲区自然是越大越好, Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。
3 刷新文件 I/O 的内核缓冲区
3.1 什么是刷新文件 I/O 的内核缓冲区
刷新文件 I/O 的内核缓冲区:就是强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中。对于某些应用场景来说,可能是很有必要的,例如应用程序在进行某操作之前, 必须要确保前面步骤调用 write()写入到文件的数据已经真正写入到了磁盘中, 诸如一些数据库的日志进程。
以Ubuntu系统中的文件传输为例,当用户将文件复制到U盘后,拔除U盘前通常需要执行sync
命令。sync
命令的作用是确保所有文件I/O操作已经完成,内核缓冲区中的数据已被强制写入到U盘。如果省略此步骤,直接拔出U盘,可能会导致数据丢失或文件损坏,因为缓冲区中的数据可能尚未完全写入到U盘。
3.2 控制文件 I/O 内核缓冲的系统调用函数
Linux 中提供了一些系统调用可用于控制文件 I/O 内核缓冲,包括系统调用 sync()、 syncfs()、 fsync()以及 fdatasync()。
#include <unistd.h>
void sync(void);
int syncfs(int fd);
int fsync(int fd);
int fdatasync(int fd);
sync()
:将所有未写的或延迟写入的缓冲数据发送到磁盘。
- 用法:通常在系统范围内刷新所有的文件系统缓冲区。
syncfs()
:同步指定文件描述符的文件系统缓冲区到磁盘。
- 用法:
syncfs()
调用只影响特定的文件系统,而不是整个系统。
fsync()
:强制将指定文件描述符的所有未写入数据同步到磁盘。
- 用法:
fsync()
通常用于确保对某个特定文件的所有更改都已持久化。
fdatasync()
:类似于 fsync()
,但它只同步文件的数据部分,不包括元数据(如修改时间)。
- 用法:当只需要同步文件内容而不需要更新文件属性时,使用
fdatasync()
。
3.3 示例程序
下面是程序示例演示了如何使用 fsync()
和 fdatasync()
函数来确保文件数据被同步到磁盘:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main()
{
// 打开文件用于写入
int fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("Failed to open file");
return 1;
}
// 写入数据到文件
const char *data = "Important data to sync to disk";
if (write(fd, data, strlen(data)) == -1) {
perror("Failed to write to file");
close(fd);
return 1;
}
// 使用 fsync() 同步文件的所有数据和元数据到磁盘
if (fsync(fd) == -1) {
perror("fsync failed");
close(fd);
return 1;
}
printf("File and metadata synced to disk using fsync.\n");
// 再次写入数据到文件
if (write(fd, data, strlen(data)) == -1) {
perror("Failed to write to file");
close(fd);
return 1;
}
// 使用 fdatasync() 只同步文件的数据部分到磁盘
if (fdatasync(fd) == -1) {
perror("fdatasync failed");
close(fd);
return 1;
}
printf("Data synced to disk using fdatasync.\n");
// 关闭文件描述符
close(fd);
return 0;
}
程序首先打开(或创建)一个名为 example.txt
的文件用于写入,然后向其中写入一段数据。接着,使用 fsync()
函数确保这些数据和文件的元数据被同步到磁盘,以保证数据的持久性。之后,程序再次写入相同的数据,并使用 fdatasync()
函数仅同步数据部分到磁盘,而不包括元数据。最后,关闭文件描述符。程序运行结果如下:
4 控制文件 I/O 内核缓冲的标志
4.1 O_DSYNC
和 O_SYNC
标志简介
O_DSYNC
和 O_SYNC
是两个用于控制文件I/O操作同步性的标志,定义了文件操作的同步写入行为。可以与 open()
函数一起使用,以改变文件的默认写入行为。
O_SYNC
:
- 当使用
O_SYNC
标志打开文件时,所有对该文件的写操作都将被执行为同步写入。意味着每次写操作(write()
)完成后,系统都会确保数据被实际写入到磁盘,而不仅仅是内核缓冲区,但可能会降低性能,因为每次写操作都需要等待磁盘I/O完成。
O_DSYNC
:
O_DSYNC
标志用于打开文件,使得所有写操作都执行为同步的,就像O_SYNC
一样。但是,与O_SYNC
不同的是,O_DSYNC
只保证数据的同步性,而不保证文件元数据(如修改时间)的同步更新。意味着使用O_DSYNC
标志时,文件内容会及时写入磁盘,但文件属性可能不会立即更新。
4.2 示例程序
下面的示例演示如使用 O_SYNC
和 O_DSYNC
标志打开文件,并进行写操作。
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
const char *filename = "sync_example.txt";
const char *data = "Data that needs to be synced immediately";
// 使用 O_SYNC 标志打开文件,每次写操作后都会同步数据和元数据
int sync_fd = open(filename, O_WRONLY | O_CREAT | O_SYNC, 0644);
if (sync_fd == -1) {
perror("Failed to open file with O_SYNC");
return 1;
}
write(sync_fd, data, strlen(data)); // 写操作会立即同步到磁盘
close(sync_fd);
// 使用 O_DSYNC 标志打开文件,每次写操作后都会同步数据,但元数据可能不会
int dsync_fd = open(filename, O_WRONLY | O_CREAT | O_DSYNC, 0644);
if (dsync_fd == -1) {
perror("Failed to open file with O_DSYNC");
return 1;
}
write(dsync_fd, data, strlen(data)); // 数据会立即同步到磁盘,元数据可能不会
close(dsync_fd);
return 0;
}
- 使用
O_SYNC
标志打开文件sync_example.txt
进行写操作,这确保了每次写操作后,数据和文件的元数据都会立即同步到磁盘。 - 使用
O_DSYNC
标志再次打开同一个文件进行写操作,这确保了数据会立即同步,但文件的元数据(如修改时间)可能不会立即更新到磁盘。
5 直接 I/O:绕过内核缓冲
5.1 绕过内核缓冲含义
前面的内容提到,数据传输过程中会临时存储数据到内存缓冲区,为了保持数据同步,会利用相关函数和标志将数据立即更新到磁盘。那么,有没有方法省略中间的缓冲步骤,直接将数据传递到文件或磁盘设备?
从Linux内核2.4版本起,Linux系统就支持应用程序执行直接I/O操作,即绕过内核缓冲区,直接在用户空间和磁盘之间传输数据。
5.2 为什么不都使用直接 I/O
在有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写率, 那么在这种应用需要下,我们就需要保证 read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。
在特定情况下,如磁盘读写性能测试,直接I/O是必要的,以确保读写操作不经过内核缓冲,从而获得准确的测试结果。对于大多数应用程序而言,使用直接 I/O 可能会大大降低性能,这是因为为了提高 I/O 性能,内核针对文件 I/O 内核缓冲区做了不少的优化,譬如包括按顺序预读取、在成簇磁盘块上执行 I/O、允许访问同一文件的多个进程共享高速缓存的缓冲区。如果应用程序使用直接 I/O 方式, 将无法享受到这些优化措施所带来的性能上的提升,直接 I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等。
要实现直接I/O,可以在打开文件时通过open()
函数并带上O_DIRECT
标志来指定。例如:
int fd = open("file.dat", O_WRONLY | O_CREAT | O_DIRECT, 0644);
if (fd < 0) {
perror("open");
// 处理错误
}
// 使用write()等函数进行I/O操作
// ...
close(fd);
示例中,O_DIRECT
标志被添加到open()
函数的参数中,操作系统进行直接I/O操作。
5.3 直接 I/O 的对齐限制
因为直接 I/O 涉及到对磁盘设备的直接访问,所以在执行直接 I/O 时,必须要遵守以下三个对齐限制要求:
- 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
- 写文件时,文件的位置偏移量必须是块大小的整数倍;
- 写入到文件的数据大小必须是块大小的整数倍。
如果不满足以上任何一个要求,调用 write()均为以错误返回 Invalid argument。以上所说的块大小指的是磁盘设备的物理块大小(block size) ,常见的块大小包括 512 字节、 1024 字节、 2048 以及 4096 字节。
可以如下命令进行查看磁盘分区的块大小:
tune2fs -l /dev/sda | grep "Block size"
-l 后面指定了需要查看的磁盘分区,可以使用 df -h 命令查看 Ubuntu 系统的根文件系统所挂载的磁盘分区:
运行得到块大小:
6 stdio 缓冲
6.1 stdio 缓冲简介
标准 I/O(fopen、 fread、 fwrite、 fclose、 fseek 等)是 C 语言标准库函数, 而文件 I/O(open、 read、 write、close、 lseek 等)是系统调用,虽然标准 I/O 是在文件 I/O 基础上进行封装而实现, 但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实现维护了自己的缓冲区, 我们把这个缓冲区称为 stdio 缓冲区。
前面提到了文件 I/O 内核缓冲,这是由内核维护的缓冲区,而标准 I/O 所维护的 stdio 缓冲是用户空间的缓冲区,当应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。
6.2 标准 I/O 的 stdio 缓冲函数
C 语言提供了一些库函数可用于对标准 I/O 的 stdio 缓冲区进行相关的一些设置, 包括 setbuf()、setbuffer()以及 setvbuf()。
setvbuf()
函数:设置缓冲区。
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
stream
:指向FILE
结构的指针,表示要设置缓冲区的流。buf
:用户提供的缓冲区,或NULL
以使用stdio
的默认缓冲区。mode
:缓冲区模式,可以是_IOFBF
(全缓冲)、_IONBF
(无缓冲)或_IOLBF
(行缓冲)。size
:缓冲区的大小。- 返回值: 成功返回 0,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
无缓冲(_IONBF) | 在无缓冲模式下,每次写操作都会直接发送到输出设备,不经过任何内部缓冲区。 |
行缓冲(_IOLBF) | 行缓冲模式下,输出通常在遇到换行字符(\n )时被刷新到输出设备。 |
全缓冲(_IOFBF) | 全缓冲模式下,输出会被存储在缓冲区中,直到缓冲区满或者通过显式调用刷新缓冲区的函数(如fflush() )时,才会将数据发送到输出设备。 |
setbuf()
函数:函数允许指定一个自定义的缓冲区,如果提供了有效的 buf
指针,将使用这个缓冲区进行数据缓冲。
void setbuf(FILE *stream, char *buf);
stream
:指向FILE
结构的指针,表示要设置缓冲区的输出流(如stdout
或文件流)。buf
:指向字符数组的指针,用作流的缓冲区。如果buf
为NULL
,则流变为无缓冲。
setbuffer()
函数:setbuffer()函数类似于 setbuf(),但允许调用者指定 buf 缓冲区的大小
void setbuffer(FILE *stream, char *buf, size_t size);
stream
:指向FILE
结构的指针,表示要设置缓冲区的流。buf
:用户提供的缓冲区。size
:缓冲区的大小。
6.3 标准输出 printf()的行缓冲模式测试
我们先看看下面这个简单地示例代码,调用了 printf()函数,区别在于第二个 printf()没有输出换行符。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World 1\n");
printf("Hello World 2");
for ( ; ; )
sleep(1);
}
运行之后可以发现只有第一个 printf()打印的信息显示出来了,第二个并没有显示出来,这是为什么呢?
这就是 stdio 缓冲的问题,前面提到了标准输出默认采用的是行缓冲模式, printf()输出的字符串写入到了标准输出的 stdio 缓冲区中,只有输出换行符时(不考虑缓冲区填满的情况) 才会将这一行数据刷入到内核缓冲区。因为第一个 printf()包含了换行符,所以已经刷入了内核缓冲区,而第二个 printf 并没有包含换行符,所以第二个 printf 输出的"Hello World!"还缓存在 stdio 缓冲区中。
scanf()函数的行缓冲模式:格式化输入 scanf()函数通过键盘输入数据,只有在按下回车键(换行符键)时程序才会接着往下执行,因为标准输入默认也是采用了行缓冲模式。
6.4 将标准输出配置为无缓冲模式测试
修改上面的代码,使标准输出变成无缓冲模式,修改后代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
/* 将标准输出设置为无缓冲模式 */
if (setvbuf(stdout, NULL, _IONBF, 0)) {
perror("setvbuf error");
exit(0);
}
printf("Hello World 1\n");
printf("Hello World 2");
for ( ; ; )
sleep(1);
}
在使用 printf()之前,调用 setvbuf()函数将标准输出的 stdio 缓冲设置为无缓冲模式,可以发现该程序能够成功输出两个“Hello World!”。运行结果如下:
6.5 fflush()刷新 stdio 缓冲区
无论我们采取何种缓冲模式,在任何时候都可以使用库函数 fflush()来强制刷新stdio 缓冲区, 该函数会刷新指定文件的 stdio 输出缓冲区,此函数原型如下所示:
int fflush(FILE *stream);
参数: stream 指定需要进行强制刷新的文件,如果该参数设置为 NULL,则表示刷新所有的 stdio 缓冲区。
返回值:函数调用成功返回 0,否则将返回-1,并设置 errno 以指示错误原因。
进一步修改上面的代码,在第二个 printf 后面调用 fflush()函数,修改后代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World 1\n");
printf("Hello World 2");
fflush(stdout); //刷新标准输出 stdio 缓冲区
for ( ; ; )
sleep(1);
}
可以看到,打印了两次“Hello World”, 这就是 fflush()的作用了强制刷新 stdio 缓冲区。运行结果如下:
6.6 关闭与退出时刷新 stdio 缓冲区
当文件关闭时、程序退出时,也会自动刷新 stdio 缓冲区。修改上面的代码,在调用第二个 printf 函数后关闭标准输出,如下所示:
// 关闭文件时刷新 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World!\n");
printf("Hello World!");
fclose(stdout); //关闭标准输出
for ( ; ; )
sleep(1);
}
程序退出时也会自动刷新 stdio 缓冲区,修改上面的代码,去掉 for 死循环,让程序结束,修改完之后如下所示:
// 程序结束时刷新 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World!\n");
printf("Hello World!");
}