read 和 write:一次调用到底搬了多少字节

难度 ★★☆☆☆ · 前置:文件描述符:一个整数背后的三层跳转 | 一句话结论:read/write 的返回值是实际搬运的字节数,可以小于请求量,所以必须写循环;write 返回成功只代表进了页缓存,fsync 才代表落盘。

我刚开始写文件相关代码时,理所当然地以为 write 的返回值就是成功还是失败,写了个判断 if (write(fd, buf, 4096) < 0) 报错;,不报错就当它写完了,这套逻辑跑了大半年没出过问题,直到有一天服务重启,日志里有一段内容缺了一截,不是丢在末尾,是中间断了一截,前后的行都还在,偏偏中间那几百个字没了,那天我在电脑前坐了整个下午,把整段代码从头读到尾,怎么都想不通写入成功怎么会缺一块。

答案就在那个返回值里,它返回的不是成功或失败,是这次实际搬了多少字节。

这一篇想把这层讲透,顺便解释一个更隐蔽的事实,write 成功,数据也还不在磁盘上。

五百倍的差距

一组对比最能说明问题,两条命令都往 /tmp 写 100 万个字节,一条按字节走,一条按兆走。

1
2
time dd if=/dev/zero of=/tmp/a bs=1 count=1000000 2>&1 | tail -1
time dd if=/dev/zero of=/tmp/b bs=1M count=1 2>&1 | tail -1

我这边第一条大概 1.1 秒,第二条 0.002 秒,差五百倍,搬的字节数完全一样。

这不是 dd 本身的效率问题,它暴露的是 read/write 的真实代价,每次调用都要从用户态切进内核、再切回来,切一次几百纳秒,切一百万次就是半秒,再加上 dd 自己的循环开销,凑够了 1.1 秒,换成 bs=1M 两次调用搬完,就只切两次,所以一次搬多少这件事,本身就是性能设计的一部分。(我为了找那个能让它变快的参数,把 dd 的 man 从头看到尾,其实没有这个参数,快慢全在这两个数字上)

这也带出本篇几个反直觉的点,返回值不是布尔值、可以成功但只做了一半、以及写完了和写进磁盘了是两句话。

返回值有四种情况

read 和 write 是系统调用本身,不是库函数。

这一篇的 C 代码片段大多是一段函数,不是完整程序,要自己补上 #include <unistd.h>、#include <errno.h>、#include <fcntl.h> 和一个 main 才能编,编译用 gcc -O0 -o x x.c && ./x。

1
2
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

ssize_t 是有符号的 size_t,带符号就是为了失败时能返回 -1。

返回值有四种情况,这是整套 IO 的地基。

返回值 read 的含义 write 的含义
等于 count 读满了 写满了
大于 0 小于 count 实际读到这么多字节 实际写入这么多字节
0 文件末尾(EOF) 通常不会出现(写 0 字节直接返回 0)
-1 出错,看 errno 出错,看 errno

第二行是关键,你请求读 4096 字节,可能只回来 137 字节,请求写 4096 字节,可能只写进去 1000 字节,这两种叫短读和短写,都是正常返回,不是错误,而新手写 IO 最常踩的坑就是这里,read(fd, buf, 4096) 之后直接用 buf,后半截是垃圾数据,我开头丢的那段日志就是短写在作怪,它不报错,只是默默少写了一点。(我当时的第一反应是内核有 bug,因为返回值明明是正数,正数怎么会是没做完呢,后来才明白正数只说明做了一部分)

短读为什么会发生

内核没有义务给你凑满,它能给多少给多少,原因至少四种。

数据本身不够了,文件还剩 137 字节,你要 4096,只能给 137,这是一个管道或 socket,对面只写了这么多,或者写完了但还没关,终端(tty)那边你按了回车,一次给你一行,不可能等到 4096 字节,还有被信号打断了,返回已经读到的部分,或者干脆返回 -1 加 EINTR,写同理,管道满了、socket 发送缓冲满了、磁盘配额到顶了,write 都可能只写进去一部分,所以正确的写法永远是循环,而且要一直循环到真的凑满为止,中途任何一次偷懒都会在半年后的某个夜里变成一段缺失的日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 读满 count 字节,除非遇到 EOF 或错误 */
ssize_t read_full(int fd, void *buf, size_t count) {
size_t done = 0;
while (done < count) {
ssize_t n = read(fd, (char *)buf + done, count - done);
if (n < 0) {
if (errno == EINTR) continue; /* 被信号打断,重试 */
return -1;
}
if (n == 0) break; /* EOF */
done += n;
}
return (ssize_t)done;
}

这段代码有个细节容易忽略,(char *)buf + done 必须按字节偏移,不能按元素偏移,用 int * 做缓冲区时这里错过的人非常多,因为 buf + done 走过去的是 done 个 int,也就是 4 倍的字节数。

write 版本几乎一样,只是 n == 0 的处理不同,写 0 字节意味着对端关了,一般当错误处理。

EINTR 不算失败

EINTR 是 interrupted by signal,进程在等 read 时如果来了信号(SIGWINCH 窗口变了、SIGCHLD 子进程退出),内核会让 read 提前返回 -1,errno 设成 EINTR,这不算失败,重试就行,上面代码里那句 continue 就是干这个的。

有个细节值得知道,read 已经读到部分数据再被打断,Linux 的行为是返回已读到的字节数,不是返回 -1,所以短读和 EINTR 的循环写法可以合并。

那能不能躲掉 EINTR,看情况,而且判断依据不是内核版本,是信号处理函数怎么装的,装 handler 时如果带了 SA_RESTART 这个标志,被信号打断的 read 会被内核悄悄重启,用户态根本看不到 EINTR,不带这个标志(比如用 sigaction() 且没置它,或者 glibc 的 signal() 之外的裸装法),read 就返回 -1 并置 EINTR,而 epoll_wait、select、pause、sigsuspend 这几个是例外,SA_RESTART 对它们无效,永远返回 EINTR,所以循环里留一个 EINTR 分支永远是对的,信号那些细节到进程那一阶再展开。(哪几个调用会被重启、哪几个不会,我到现在也没背全,只记住一条,循环里留分支不会错)

偏移量只是一个整数

每个打开的文件,也就是上一篇那个 struct file,里面存着一个偏移量,read 读完自己往前走,write 写完也往前走,没人需要手动维护,想跳着读写就用 lseek。

1
off_t lseek(int fd, off_t offset, int whence);

whence 三个值。

whence 含义
SEEK_SET 从文件头算,offset 是绝对位置
SEEK_CUR 从当前位置算,offset 可正可负
SEEK_END 从文件尾算,offset 可正可负

两个常用技巧,一个求大小,一个回开头。

1
2
3
4
5
/* 求文件大小 */
off_t size = lseek(fd, 0, SEEK_END);

/* 回到开头 */
lseek(fd, 0, SEEK_SET);

有个反直觉的地方,lseek 不产生任何 IO,它只改那个整数,我当初以为求文件大小必须把文件读一遍,还专门写过一个读到最后再累加长度的函数,跑在几十兆的日志上慢得离谱,后来发现一行 lseek 就够了,而且它还有个副作用很少有人提,lseek 到比文件更大的位置再写一个字节,中间那段就自动变成空洞,这就是稀疏文件的来源,ls -l 会把这部分算进大小,du 不会,所以同一份文件在两个命令下面能差出几倍,这个差别在讲 stat 那篇还会再撞一次。

更值得记住的是上一篇讲过的,dup 出来的两个 fd 共享同一个偏移,fork 出来的子进程也共享,所以 fork 之后父子同时读同一个文件,两边会互相抢位置,要避免就各自 open 一次,还有个特殊情况,O_APPEND 打开的文件,lseek 基本没用,每次 write 内核都会重新定位到文件末尾,不管你 lseek 到哪,日志切割工具依赖的正是这个行为。

写完了,但没到磁盘

这一层最容易出事故。

1
write(fd, buf, 4096);   /* 返回 4096 */

这个成功的真实含义是,内核已经把这 4096 字节拷进了页缓存,什么时候真的落到盘上,内核另行决定,可能几毫秒后,可能几秒后,也可能因为断电永远不到,页缓存那一篇专门讲这块内存怎么进怎么出。

想确认真的落盘,得再调一次。

调用 保证什么
fsync(fd) 数据 + 元数据(mtime、大小)都刷到底
fdatasync(fd) 只保证数据,元数据该刷的还是要刷(比如文件变大)
sync() 刷整个系统所有脏页,重活
syncfs(fd) 刷这个 fd 所在的整个文件系统
open(..., O_SYNC) 每次 write 都等落盘,最慢
open(..., O_DSYNC) 每次 write 等数据落盘,元数据不等

fsync 就是数据库说的那个 fsync,PostgreSQL 的 synchronous_commit = on 就是在等它,那为什么很多程序不 fsync 也不丢数据,因为页缓存会在后台刷,默认 dirty_expire_centisecs = 3000,也就是 30 秒,超过这个时间还没被刷的脏页会被强制刷出去,所以断电最多丢 30 秒的数据,对日志无所谓,对转账就是灾难。

O_DIRECT 是什么

O_DIRECT 打开的 fd 会绕过页缓存,直接对磁盘做 IO,它解决的问题是用页缓存做大数据量传输时(比如数据库自己已经做了缓存),数据要在页缓存和用户缓冲之间多拷一次,白烧内存,代价很实在,缓冲区必须按块大小对齐(通常 512 或 4096),malloc 出来的地址常常不满足,读写的长度和偏移也要对齐,没对齐时行为是可能失败、可能悄悄退化,不同内核版本还不一样,绕过了预读,顺序读小数据反而更慢。

所以除非你在写数据库或者存储引擎,别碰它,dd 有个 direct 参数可以试,试之前先备份。(我照着某篇博客给一个日志程序加过 O_DIRECT,第一次跑直接 EINVAL,查了一下午才发现缓冲区是 malloc 出来的,地址没对齐,后来这段改动整个回滚了)

亲手制造一次短读

先亲手确认一次短读。

1
2
3
4
5
6
7
8
9
printf 'hello' > /tmp/tiny.txt
python3 -c "
import os
fd = os.open('/tmp/tiny.txt', os.O_RDONLY)
buf = os.read(fd, 4096)
print('读到的字节数:', len(buf))
print('内容:', buf)
os.close(fd)
"
1
2
读到的字节数: 5
内容: b'hello'

你要了 4096,拿到 5,那个 len(buf) 是唯一该信任的数字。

再看 dd 那个 500 倍差距,这次加上 conv=fsync。

1
2
time dd if=/dev/zero of=/tmp/c bs=1M count=10 conv=fsync 2>&1 | tail -1
time dd if=/dev/zero of=/tmp/d bs=1M count=10 2>&1 | tail -1

第二条明显更快,因为它把数据塞进页缓存就返回了,没等落盘,这两条的差距,就是 write 返回和数据到盘之间的距离。(这组数字是我自己机器上跑的,你那边可能差一点,倍数关系大致一样)

看看页缓存里攒了多少脏数据。

1
grep -E "^(Dirty|Writeback):" /proc/meminfo
1
2
Dirty:              4820 kB
Writeback: 0 kB

Dirty 是改了但还没刷的内存量,刚写完一个大文件立刻看,这个数会很大,等几十秒再看,它自己就下去了。

最后验证 lseek 不产生 IO。

1
2
3
4
5
6
7
python3 -c "
import os
fd = os.open('/tmp/tiny.txt', os.O_RDONLY)
print('跳到末尾:', os.lseek(fd, 0, os.SEEK_END))
print('跳回开头:', os.lseek(fd, 0, os.SEEK_SET))
os.close(fd)
"
1
2
跳到末尾: 5
跳回开头: 0

两次调用,磁盘一次都没动,因为那个偏移量只是 struct file 里的一个整数。

读满要自己循环

read 返回正数就是读满了?返回的是实际字节数,要读满必须循环,这一类 bug 在解析二进制协议时特别可怕,因为缓冲区后半截是上一次的残留数据,write 返回成功,数据就安全了?只是进了页缓存,要落盘必须 fsync,fsync 就是刷数据?它也刷元数据,只想刷数据、省掉一次元数据写,用 fdatasync,数据库的 WAL 通常用 fdatasync,因为文件大小早就定好了。

还有个更容易漏的,fsync(fd) 只保证这个文件的内容和属性落了盘,不保证它所在目录里的那条目录项落了盘,新建一个文件之后如果只 fsync 文件本身,掉电重启有可能文件在、名字没了,正确做法是把父目录用 O_DIRECTORY 打开再 fsync 一次,两条都要,这也是很多数据库自己的 WAL 写法和别人不一样的原因。

O_DIRECT 一定更快?绕过了页缓存也就绕过了预读和写合并,小数据的顺序读会明显变慢,它是特殊工具,不是通用优化。

lseek 到末尾再写会补零?会,这叫空洞文件,ls -l 显示的大小包含空洞,但 du 不算它,稀疏文件就是这么来的。

缓存和磁盘不是一回事

read/write 返回的是实际搬运的字节数,可以是 0、可以小于请求量、可以是 -1。

短读短写是常态,必须写循环,EINTR 不算失败,重试即可。

lseek 只改 struct file 里的偏移量,不产生 IO,dup 和 fork 会让多个 fd 共享这个偏移。

write 成功只代表进了页缓存,fsync/fdatasync 才代表落盘。

O_DIRECT 是给存储引擎准备的工具,不是加速开关。

(这五条我抄在一张便签上贴过工位,贴了小半年,后来搬工位就找不着了)

下一个问题是,一个文件除了内容,还有一堆属性,大小、属主、三个时间戳、inode 号、块数,stat 会把这十几个字段全吐出来,其中有一半,大部分人从没看过。