io — Reader & Writer 接口深入
Go 的 I/O 生态建立在两个四行就能写完的接口上:io.Reader 和 io.Writer。标准库(bufio、compress/gzip、net/http、database/sql……)和第三方库都围着它们组织,所以读懂这两个接口的精确契约,等于读懂了半个标准库的组合方式。io 包本身还提供一组"包装器"(LimitReader、TeeReader、MultiWriter……),它们是安全防御和管道搭建的基本构件。
这一篇的重点:契约里那些反直觉的细节(n>0 同时 err!=nil、Write 不许改 p),以及 io.Copy 的短路机制——这是"为什么我的 Reader 实现一次都没被调用"这类困惑的答案。实测数据来自 go1.27.0 / Windows。
1. io.Reader —— 读取的契约
type Reader interface {
Read(p []byte) (n int, err error)
}四条必须牢记的规则:
- 最多读
len(p)字节,返回实际数n。 n可以小于len(p),即使后面还有数据。Read不承诺填满缓冲区——“我请求 1024 字节,只回来 37 字节"是完全正常的(网络包到了多少读多少)。- 错误时
n可以大于 0。最后一批数据和io.EOF可以在同一次调用里一起返回。调用者必须先处理p[:n],再看err。 io.EOF不是故障,是"输入结束"的信号;返回 EOF 之后的后续Read调用应当继续返回(0, io.EOF)。
// 正确读取循环的标准形状
buf := make([]byte, 1024)
for {
n, err := r.Read(buf)
if n > 0 {
process(buf[:n]) // 先处理数据 —— 顺序错了就是丢数据
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}文档还有一条对实现者的规则:不建议在 len(p) > 0 时返回 (0, nil)——那是"忙等"信号,io.Copy 之类的循环会把它当成异常(实测会进入重试逻辑,直到拿到数据或错误)。写自定义 Reader 时,宁可阻塞也别返回 (0, nil)。
2. io.Writer —— 写入的契约
type Writer interface {
Write(p []byte) (n int, err error)
}与 Reader 相反,Writer 的承诺更硬:
- 必须写完整个
p,否则必须返回非 nil 错误(n < len(p)且err == nil是实现 bug,调用方如io.MultiWriter会补上short write错误)。 - 写入后不得保留
p,调用者可以立即复用这个切片。 - 不得临时修改
p的内容(文档原文:must not modify the slice data, even temporarily)——想边写边转换,先拷一份。 *os.File的单次Write是原子的系统调用,但多个 goroutine 同时写同一个文件时字节会交错;要行级完整(日志),要么加锁,要么用bufio.Writer(它自己不是并发安全的,见bufio.md)。
3. 辅助接口一览
io 还定义了十几个"可选能力"接口。它们不会被自动调用,但标准库的工具会探测并利用——实现自定义类型时按需挑几个:
type Closer interface { Close() error }
type Seeker interface { Seek(offset int64, whence int) (int64, error) } // whence: io.SeekStart/Current/End
type ReaderAt interface { ReadAt(p []byte, off int64) (int, error) } // 不影响"当前偏移",可并发
type WriterAt interface { WriteAt(p []byte, off int64) (int, error) }
type ByteReader interface { ReadByte() (byte, error) }
type ByteScanner interface { ByteReader; UnreadByte() error }
type RuneReader interface { ReadRune() (r rune, size int, err error) }
type RuneScanner interface { RuneReader; UnreadRune() error }
type ByteWriter interface { WriteByte(c byte) error }
type StringWriter interface { WriteString(s string) (n int, err error) }设计意图:能力探测,而不是继承。bufio.NewReader 检测到源实现了 ByteScanner(比如 *os.File 不行、*bytes.Reader 行)就直接复用其 UnreadByte;io.Copy 检测 ReaderFrom/WriterTo(§4);fmt.Fprint 检测 io.StringWriter 决定要不要把 string 转成 []byte。一个组合规律:ReadSeeker = Reader + Seeker 这类小接口拼大接口,正是 Go 接口设计的样板——io.ReadWriteCloser、io.ReadSeekCloser 在 os、compress/gzip、archive/* 里到处都是。
ReaderAt 值得单独说一句:它带偏移量、不依赖也不移动内部游标,因此同一个 ReaderAt(如 *os.File、bytes.Reader)可以被多个 goroutine 并发读,这是并发预读/分块下载的正确工具(io.CopyN + SectionReader 就能拼出无共享状态的并发分段拷贝)。
4. io.Copy 的短路设计(实测)
io.Copy(dst, src) 不是老老实实的 Read → Write 循环。它按优先级探测三条快捷路径:
dst实现了io.ReaderFrom→ 调dst.ReadFrom(src);- 否则
src实现了io.WriterTo→ 调src.WriteTo(dst); - 否则才用 32KB 缓冲区的
Read → Write循环。
dst 的 ReadFrom 优先级高于 src 的 WriteTo,实测验证(构造计数器:src 每次都 Read 512 字节共 5 次后 EOF):
io.Copy(目标实现 ReadFrom) → src.Read 5 次, dst.Write 0 次, dst.ReadFrom 1 次
io.Copy(目标只有 Write) → src.Read 5 次, dst.Write 4 次 // 32KB 缓冲一次装下,4 次是源每次给 512 字节、最后一次 EOF 前 4 笔这就是设计深意所在:目标类型往往比源类型更知道自己想要什么。*bytes.Buffer.ReadFrom 可以按需扩容一次到位;*os.File 对 *os.File 的复制走 sendfile/CopyFileRange 系统调用零拷贝(Linux 上 io.Copy 文件到文件不经过用户态缓冲区,这是 cp 级别的效率);bufio.Writer.ReadFrom 会先看缓冲区剩余空间。你写自定义类型时,实现 ReadFrom/WriteTo 就是把"我处理这种组合的最优方式"告诉所有用 io.Copy 的人。
顺带一条:io.Copy 不检查 dst.Write 返回 n < len(p) 且 err == nil 的实现错误?检查——它会转成 io.ErrShortWrite(“short write”)。所以坏实现不会静默丢数据,但代价是一次错误。
5. io 包的实用函数
| 函数 | 作用 | 典型场景 |
|---|---|---|
io.ReadAll(r) |
读到 EOF 收成一个切片 | 小数据全量读取(大文件禁用,见 os-file.md) |
io.ReadFull(r, buf) |
读满 len(buf) 或报错 |
固定长度消息头 |
io.ReadAtLeast(r, buf, n) |
至少读 n 字节 | 长度可变但下限已知的协议 |
io.Copy(dst, src) / io.CopyN(dst, src, n) |
复制到 EOF / 复制 n 字节 | 文件传输、HTTP 代理 |
io.WriteString(w, s) |
写字符串(优先走 StringWriter) |
兼容只有 Writer 的接口 |
io.LimitReader(r, n) |
把读取量限制在 n 字节内 | 防御超大响应/请求体 |
io.TeeReader(r, w) |
读 r 时把同样字节写入 w | 边下载边算哈希 |
io.MultiReader(rs...) |
把多个 Reader 串成一个 | 头部+正文拼装 |
io.MultiWriter(ws...) |
一次写广播到多个 Writer | 同时写文件+stdout |
io.NopCloser(r) |
包一个空 Close |
把 bytes.Reader 塞给要 ReadCloser 的 API |
io.Discard |
永远成功、永远黑洞 | 丢弃不需要的响应体 |
io.Pipe() |
内存同步管道 | 生产者/消费者跨接口传流(§7) |
ReadFull/ReadAtLeast 的错误语义要单独记:一个字节都没读到且遇到 EOF → io.EOF;读到了一部分就断了 → io.ErrUnexpectedEOF。协议解析里这两种错误的处理通常不同(前者是正常结束,后者是坏包),== io.EOF 一把梭会把坏包当正常关闭。
防御性限读的三种写法(实测)
// 1) 限制总量:HTTP 响应体只收 1MB
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// 2) 截取一段:从第 5 字节起取 10 字节
sr := io.NewSectionReader(bytes.NewReader(data), 5, 10)
// 实测读出 "5678901234";sr.Size() = 10;没有 Len() 方法
// sr.Outer() 返回 (r, offset, n) —— 可用于检测两个 SectionReader 是否共享底层源
// 3) 跳过一段再读
io.CopyN(io.Discard, r, 4) // 廉价跳过,不分配LimitReader 返回的 *io.LimitedReader 耗尽后表现为 EOF 而不是报错,且它内部记录的 N 是剩余额度——多次 Read 递减。io.LimitReader 是一次性包装;同一个流要"分阶段限额”,就自己持有 *io.LimitedReader 改 N。
MultiWriter 的语义:任一分支报错就立刻返回该错误,实测两个分支中一个返回 errors.New("branch failed") 时 Write 错误原样透出,且各分支收到的字节数可能不一致(分支 A 已写完、分支 B 失败时,A 不会回滚)。用它做"双写"时要接受部分成功的状态;要求原子性的双写(比如同时落两份账本)不该用 io.MultiWriter 实现。
TeeReader 则是"读侧广播":数据只在你真的读时才流向副本,没人读就没有副本输出——用它算校验和时,如果中途 ReadAll 出错停止,副本哈希只覆盖了前半段,校验会"合理地失败"。
6. 常见 I/O 错误姿势(保留最伤人的两类)
// 错误 1:假设 Read 一次返回所有数据
buf := make([]byte, 1024)
n, _ := conn.Read(buf) // 可能只到了 37 个字节,剩下 987 个是零值
process(buf) // → 数据错乱
// 正确:定长协议用 ReadFull
if _, err := io.ReadFull(conn, buf); err != nil { ... }
// 错误 2:err != nil 就 break,丢掉了 n>0 的尾包
n, err := r.Read(buf)
if err != nil { return err } // ← 最后一块数据和 EOF 一起回来时,这块数据被扔了
process(buf[:n])
// 正确:先处理 p[:n],再判 err(§1 的标准循环)第三类错误值得单独点名:把 io.EOF 当错误往上抛。Read 循环、csv.Reader.Read、json.Decoder.Decode 都以 io.EOF 表示正常结束;把它包进 fmt.Errorf("...: %w", err) 返回给调用方,调用方的 errors.Is(err, io.EOF) 语义就乱了——“读取函数返回了 error"和"流正常结束"变成同一个信号。正确的 API 形状是函数把 EOF 消化掉(返回 false 的迭代器、或 io.ErrUnexpectedEOF 类真错误)。
7. io.Pipe —— 同步内存管道
r, w := io.Pipe()
go func() {
// 生产者:可以是任何接受 io.Writer 的函数
defer w.Close() // ← 必须关闭(或 CloseWithError),否则消费者永远等不到 EOF
fmt.Fprint(w, "hello from pipe")
}()
data, _ := io.ReadAll(r) // "hello from pipe"设计意图:把”只有 io.Writer 接口的库"(比如 image/png.Encode、json.Encoder)的产物接到"只有 io.Reader 接口的下游"(http.NewRequest 的 body、gzip.NewReader)上,全程不落地、不攒全量内存——服务端生成大 CSV/图表再流式返回,就靠它。
三条规则:
- 同步无缓冲:
Write会阻塞直到数据被Read消费掉,反过来也一样。所以生产端必须在另一个 goroutine,否则双方互相等待死锁。 - 错误传播是显式的:生产端失败用
w.CloseWithError(err),消费端的Read就会拿到同一个错误;用普通Close()则消费端拿到io.EOF(正常结束)。忘CloseWithError的管道故障表现为"下游以为数据完整",比崩溃更难查。 - 消费端提前退出(
io.ReadAll之外的场景只读了一半)时,Read一侧关闭后生产端的Write会拿到io.ErrClosedPipe,用这个错误去终止生产协程,防止泄漏。
http.Request.Body 用 Pipe 做"流式 JSON 生成器"是教科书用法;但生产协程里 panic 会同时打死管道两端,defer recover 加 CloseWithError 是生产环境的标配。
8. 陷阱汇总
| # | 现象 | 原因与对策 |
|---|---|---|
| 1 | 自定义 Reader 一次只给几字节,io.Copy 看起来"慢" |
正常;Read 不承诺填满,瓶颈在别处(§1 规则 2) |
| 2 | err != nil 先 break,尾包数据丢失 |
同一调用可以同时返回 n>0 和 EOF,先处理 p[:n](§6) |
| 3 | 把 EOF 当错误返回给调用方 | 流式 API 应消化 EOF(§6 第三类) |
| 4 | “我的 src 实现了 WriteTo 却没被调用” | dst 的 ReadFrom 优先(§4 实测优先级) |
| 5 | 以为 io.Copy 会做全量内存拷贝 |
默认 32KB 缓冲;文件到文件在 Linux 走 sendfile 零拷贝 |
| 6 | ReadFull 返回 EOF 被当成"数据不够" |
一个字节没读到才 EOF;读到一半断是 ErrUnexpectedEOF(§5) |
| 7 | LimitReader 后仍 OOM |
限的是"读走"的量;ReadAll 前忘了包就全量进内存(§5) |
| 8 | MultiWriter 某个分支失败,其它分支已写入 |
部分成功是语义之一(§5);需要原子性请另设计 |
| 9 | Pipe 死锁 | 生产端没放独立 goroutine,或没 Close(§7) |
| 10 | Pipe 消费者读到"完整"数据但其实是错包 | 生产端用 Close() 而不是 CloseWithError(err)(§7) |
| 11 | 用 SectionReader.Len() 编译不过 |
它只有 Size(),没有 Len()(实测 §5) |
9. 实战建议
写需要"流"的库函数时,参数收 io.Reader / io.Writer,返回值收 io.ReadCloser。 谁负责 Close 的边界要写进文档:接收 io.Reader 的函数不要调 Close(调用方的资源);返回 io.ReadCloser 的函数必须说明"调用方负责 Close"。http.Response.Body 的标准姿势(defer resp.Body.Close() + 需要丢弃时 io.Copy(io.Discard, resp.Body))就是这套规则的产物(见 ../03-网络编程/net-http.md)。
所有不受控输入一律先 LimitReader 再解析。 io.ReadAll(io.LimitReader(r, n)) 一行就把"响应体炸弹"变成可预期的截断。http.MaxBytesReader 在服务端做同样的事;两边都要有,因为任何一侧都可能被另一侧的异常实现坑到。
给自己的类型实现 ReadFrom/WriteTo 前先想清楚:你能比 32KB 缓冲循环做得更好吗? 答案是"能"的常见场景:批量写入并想合并内存分配(bytes.Buffer.ReadFrom)、知道协议结构可以整帧处理、或者能利用 OS 的 sendfile(os.File 已经做了)。做不到就别实现——少一个方法意味着 io.Copy 的通用路径反而更短。
用接口组合表达能力,别用大接口。 需要"随机读 + 关闭"就 io.ReadSeekCloser;只有顺序读需求就只收 io.Reader——后者能让你的函数接受更多来源(strings.Reader、Pipe、网络、嵌入文件),这正是 embed.FS + fs.FS 那套设计的同一个思想(见 fs-embed.md)。
相关阅读:bufio.md(缓冲与 ReadFrom 短路的具体表现)、os-file.md(os.File 同时实现 Reader/Writer/ReaderAt/Seeker/Closer 的全家桶)、fs-embed.md(fs.FS 如何复用这套小接口)、compress-archive.md(gzip/tar 的层层包装)、../07-附录/常见陷阱汇总.md。