Skip to content
Go
io — Reader & Writer 接口深入

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)
}

四条必须牢记的规则:

  1. 最多读 len(p) 字节,返回实际数 n。
  2. n 可以小于 len(p),即使后面还有数据。Read 不承诺填满缓冲区——“我请求 1024 字节,只回来 37 字节"是完全正常的(网络包到了多少读多少)。
  3. 错误时 n 可以大于 0。最后一批数据和 io.EOF 可以在同一次调用里一起返回。调用者必须先处理 p[:n],再看 err。
  4. 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 的承诺更硬:

  1. 必须写完整个 p,否则必须返回非 nil 错误(n < len(p) 且 err == nil 是实现 bug,调用方如 io.MultiWriter 会补上 short write 错误)。
  2. 写入后不得保留 p,调用者可以立即复用这个切片。
  3. 不得临时修改 p 的内容(文档原文:must not modify the slice data, even temporarily)——想边写边转换,先拷一份。
  4. *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 循环。它按优先级探测三条快捷路径:

  1. dst 实现了 io.ReaderFrom → 调 dst.ReadFrom(src);
  2. 否则 src 实现了 io.WriterTo → 调 src.WriteTo(dst);
  3. 否则才用 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。