2.1 同步 I/O (Sync I/O)
一次性读完整个 Linux IO 编程接口文档,再进行编程,这也太难了!我们不妨把需求简化到极致:仅仅读写一次文件,先不计较任何的并发和性能,写完就可以交差!
1 posix 标准接口
对于这种简单需求,posix 标准为我们提供了一些 api 接口。
| 函数名 | 原型 |
|---|---|
lseek |
off_t lseek(int fd, off_t offset, int whence); |
write |
ssize_t write(int fd, const void *buf, size_t count); |
read |
ssize_t read(int fd, void *buf, size_t count); |
pwrite |
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset); |
pread |
ssize_t pread(int fd, void *buf, size_t count, off_t offset); |
其中:
fd: 文件描述符buf: 数据缓冲区(void*类型)count: 操作字节数(size_t类型)offset: 偏移量(off_t类型)whence: 基准位置(SEEK_SET/SEEK_CUR/SEEK_END)
在 posix 世界中,所有的文件操作,都要打开一个这个文件,得到 int 类型文件描述符 fd。为了读写它,有一个偏移游标 offset。
我们要么先去 seek 到这个 offset,然后读写文件。这期间还要注意线程安全,seek+read/write 并不是原子的。
要么使用 pwrite/pread 在一次调用中原子性地指定 offset 完成读写。
| api | lseek |
write/read |
pwrite/pread |
|---|---|---|---|
| 用途 | 移动文件指针 | 基础读写操作 | 定位读写(不移动指针) |
| POSIX 标准 | POSIX.1-1988 | POSIX.1-1988 | POSIX.1-2001 (XSI 扩展) |
| 原子性 | ❌ 非原子 | ❌ 非原子 | ✅ 原子操作 |
| 线程安全 | ❌ 需额外保证 | ❌ 需保证 | ✅ 线程安全 |
| 文件指针影响 | ✅ 修改指针位置 | ✅ 读写后指针移动 | ❌ 不影响指针位置 |
| 典型使用场景 | 随机访问文件 | 顺序读写 | 多线程/多进程并发读写 |
2 Code Snippet
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main() {
int fd = open("testfile.txt", O_RDWR | O_CREAT, 0644);
if (fd == -1) {
perror("open failed");
exit(1);
}
// 使用write写入数据
const char *msg1 = "Hello, world!\n";
write(fd, msg1, strlen(msg1));
// 使用lseek移动指针并写入
lseek(fd, 100, SEEK_SET);
const char *msg2 = "At position 100\n";
write(fd, msg2, strlen(msg2));
// 使用pwrite在特定位置写入(不移动指针)
const char *msg3 = "Written with pwrite at 200\n";
pwrite(fd, msg3, strlen(msg3), 200);
// 读取文件内容
char buffer[256];
lseek(fd, 0, SEEK_SET); // 回到文件开头
ssize_t bytes_read;
while ((bytes_read = read(fd, buffer, sizeof(buffer))) > 0) {
write(STDOUT_FILENO, buffer, bytes_read);
}
// 使用pread从特定位置读取
printf("\nReading with pread from position 100:\n");
bytes_read = pread(fd, buffer, sizeof(buffer), 100);
write(STDOUT_FILENO, buffer, bytes_read);
close(fd);
return 0;
}运行一下
➜ snip git:(master) ✗ g++ -Wall -Wextra -g -o 01 ./01_sync_io.cpp
➜ snip git:(master) ✗ ./01
Hello, world!
At position 100
Written with pwrite at 200
Reading with pread from position 100:
At position 100
Written with pwrite at 2003 什么是“同步”? 为什么关注阻塞?
“同步”,指的是 Synchronous I/O,简写为 Sync I/O。是指调用的进程这期间会被阻塞 Block。我们不得不回顾一下线程模型。
强如 128 cores 的 Linux Server 其实是个大单片机!
图: 单线程程序被阻塞
我们启动的单线程程序在调用 write/read 时,会进入阻塞状态。这期间 CPU 虽然可以调度给其他程序,但我们进程傻傻地(也只能傻傻地)等待这个调用返回。
示例程序的规模完全不需要担心阻塞带来的性能问题。当我们单机需要处理几十万 iops 和 数十 GBps 的流量时,阻塞以及线程上下文切换,对我们系统设计、性能的影响是巨大的。
必须意识到,实际的产品中,我们整个程序除了执行 IO 操作,还需要处理用户请求 socket、执行相关的业务逻辑、编解码等。阻塞会导致该线程强行 “闲置”。为了达到性能要求,榨干现代存储硬件给我们提供的吞吐能力,整个工程必须合理地安排线程工作内容。
因此,使用线程池管理所有阻塞 IO 的模式应运而生。我们将在稍后探索 同步 IO 的线程池模式。
4 Stream IO
除了 posix read/write 外,还有一种不同角度考虑的 IO 方式,流式 I/O (Stream I/O)。流式 I/O 在低级 IO 接口上构建了一层缓冲区,可以攒一些输入输出后在进行刷盘,减少系统调用和读写次数。
C 语言提供的常用流式 IO 接口如下。
| 函数 | 头文件 | 描述 |
|---|---|---|
fopen() |
<stdio.h> |
打开文件并关联到 FILE* 流对象 |
fclose() |
<stdio.h> |
关闭流并刷新缓冲区 |
fread()/fwrite |
<stdio.h> |
二进制数据的缓冲读写 |
fgets()/fputs |
<stdio.h> |
文本行的缓冲读写 |
fprintf()/fscanf |
<stdio.h> |
格式化的缓冲读写 |
setbuf()/setvbuf |
<stdio.h> |
手动控制缓冲区策略 |
fflush() |
<stdio.h> |
强制刷新输出缓冲区 |
构建存储引擎时,开发者更常见希望自己针对需求自行构建刷盘、缓冲策略,因此较少见到使用 Stream IO 构建存储引擎。其多见于日志系统的存储和读取。因此本篇不会详细介绍。
注意:此处的 Stream I/O 不是指 kernel 提供的 buffer/cache 缓存。此处指的是编程语言为我们包装的带缓冲的 I/O 库。比如我们上面 C 语言的 stdio.h,Go 语言提供的 bufio 包。
5 确保数据到达硬盘
LWN 上一篇文章 Ensuring data reaches disk 1 向存储系统程序员强调了持久化的认知。
图: 数据读写的全链路 1
除了我们应用程序构建的缓存外,还可能经过 Stream IO 库提供的缓冲、Kernel 提供的 Page Cache、存储硬件上的易失/非易失性缓存,最终落在磁盘上。只有数据到达了非易失存储,才能认为数据安全保存。
一种方式是通过显式调用 sync 接口强制刷盘。另一种方式是打开文件时候指定 O_SYNC 或 O_DSYNC,在文件写入时候会被立即写入稳定存储。
值得注意的是,以上调用都是在应用程序角度尽可能执行落盘操作。例如 kernel 挂载磁盘时使用了 nobarrier,也无法保证磁盘控制器缓存的实际刷新。
性能方面,使用 O_SYNC,也代表每次写入都会写入 Kernel Page Cache 后强制落盘,性能预期会有比较大的下降。使用 fio 压测磁盘性能时,可以使用 --sync=1 观察 sync 写入性能。
例如:
fio --name=dsync_test --filename=fio_testfile --size=10G --rw=rw --rwmixread=50 --bs=4096 --ioengine=io_uring --iodepth=16 --direct=1 --sync=1 --numjobs=8 --runtime=60 --time_based --group_reporting