Go Reader 同时返回 n 和 err:最后一段数据为什么会被丢掉
Read返回了三个字节,同时返回io.EOF,消费者却一字节都没有写出。错误常藏在这两行顺序里:先判断err并退出,后面才处理n。
Go的io.Reader允许一次返回有效数据与错误。错误描述这次读取遇到的终止状态,不会让已经交给调用方的n个字节失效。最后一段数据恰好与EOF一起到达,是合法实现,不应要求所有Reader先单独返回数据、下一次才报告结束。
用一个故意同时返回数据和错误的 Reader
下面的程序使用Go 1.24.1,不连接网络。保存为main.go,在独立模块里执行go run .。
package main
import (
"bytes"
"errors"
"fmt"
"io"
)
type finalChunk struct { done bool; terminal error }
func (r *finalChunk) Read(p []byte) (int, error) {
if len(p) == 0 { return 0, nil }
if r.done { return 0, r.terminal }
// The controlled example requires a buffer able to hold its whole chunk.
if len(p) < 3 { return 0, io.ErrShortBuffer }
r.done = true
return copy(p, "abc"), r.terminal
}
func wrong(r io.Reader) string {
var out bytes.Buffer
buffer := make([]byte, 16)
for {
n, err := r.Read(buffer)
if err != nil { return out.String() }
out.Write(buffer[:n])
}
}
func consume(r io.Reader) (string, error) {
var out bytes.Buffer
buffer := make([]byte, 16)
idle := 0
for {
n, err := r.Read(buffer)
if n > 0 {
out.Write(buffer[:n])
idle = 0
}
if err != nil {
if err == io.EOF { return out.String(), nil }
return out.String(), err
}
if n == 0 {
idle++
if idle >= 100 { return out.String(), io.ErrNoProgress }
}
}
}
type idleReader struct{}
func (idleReader) Read([]byte) (int, error) { return 0, nil }
func main() {
fmt.Printf("wrong: %q\n", wrong(&finalChunk{terminal: io.EOF}))
good, err := consume(&finalChunk{terminal: io.EOF})
fmt.Printf("EOF: %q error=%v\n", good, err)
if good != "abc" || err != nil { panic("lost final bytes") }
broken := errors.New("checksum mismatch")
partial, err := consume(&finalChunk{terminal: broken})
fmt.Printf("failure: %q recognized=%t\n", partial, errors.Is(err, broken))
if partial != "abc" || !errors.Is(err, broken) { panic("lost data or error") }
_, err = consume(idleReader{})
fmt.Println("no progress:", errors.Is(err, io.ErrNoProgress))
var copied bytes.Buffer
_, err = io.Copy(&copied, &finalChunk{terminal: io.EOF})
fmt.Printf("io.Copy: %q error=%v\n", copied.String(), err)
}
错误循环得到空串,正确循环保留abc;自定义校验错误也与abc一起交给上层。io.Copy则验证标准消费工具能够保留EOF前的字节,正常结束时不把EOF作为失败返回。
这里的finalChunk只用于明确重现合同,要求至少三个字节的缓冲区,不是推荐给任意长度Reader的通用实现。实际Reader应尽量按调用方缓冲区提供部分数据,并在后续调用继续;消费方仍然不能依赖特定分块方式。
保留字节,不等于认可整份业务内容
n大于0时先处理数据,是Reader层的消费规则;遇到校验错误时是否能采用这些数据,是业务层的完整性规则。比如流末尾校验失败,已经收到的字节可能都必须丢弃。
程序让consume返回部分内容和错误,调用方据此决定保留诊断、拒绝整个对象或继续其他任务。若函数内部一遇到错误就丢掉全部信息,调试可能困难;若忽略错误直接返回字符串,又可能把损坏内容当成功结果。
下载文件时通常可以先写临时目标,只有整个读取及校验成功后才发布最终文件。这样仍然遵守先处理n,却不会让半份结果暴露为完整文件。Reader的迭代顺序和文件发布协议应分别设计。
EOF 与意外截断还要看上层格式
对普通字节流,EOF可以是正常结束;对声明固定长度的消息,只读到其中一部分就EOF,则属于不完整数据。io.ReadFull会围绕固定长度表达相应语义,调用方不必手工重造每种边界。
不能因为底层返回EOF,就把一个缺少结束标记的JSON或压缩流当成完整对象。格式解析器还有自己的完成条件。反过来,拿到一份完整格式后是否允许尾随数据,也应由协议规定。
同样,某次Read只返回很少字节,并不能说明输入已结束。网络、管道、文件包装器都可能分段返回,缓冲区没有装满不是终止信号。把n小于len(buffer)写成退出条件,会制造另一种截断错误。
零字节且无错误,不能当作永久成功
Reader合同不鼓励在非空缓冲区上反复返回0和nil,但消费方仍需知道这种结果不代表EOF。示例允许短暂无进展,并在连续一百次后返回ErrNoProgress,防止受控实验无限空转。
这一百次是演示策略,不是通用时间预算。一个Read调用自己就可能阻塞很久,计数上限不会让它按毫秒退出。真实网络应使用底层支持的截止时间或取消机制,不能用空读次数替代I/O超时。
也不建议在每次零读后随意sleep固定时长,假装解决了所有慢源问题。应先确认Reader为什么返回无进展,是否由错误包装器制造,是否违反自身约定。通用复制场景优先使用已有标准工具,再针对实际源增加必要约束。
处理buffer[:n]以后,不要把这一切片直接存到长期集合。下一次Read会复用缓冲区,旧切片内容可能被覆盖。示例写入bytes.Buffer会复制需要的字节;改成异步发送或保存切片时,要明确复制与所有权。
示例把EOF用直接相等判断,遵守Reader对正常终止返回io.EOF本身的约定。一个自定义Reader若把EOF包装进别的错误,实际上已经改变这项接口约定;不能只要求所有消费者不断放宽识别规则,掩盖实现不一致。ErrUnexpectedEOF也不应被悄悄视为普通完成,它明确表达某份预期内容没有读全。
这里使用bytes.Buffer保存全部结果,方便用字符串断言字节没有丢失,不代表大文件应整份留在内存。真实导出可以写到有限临时文件或受控Writer,并把写入错误纳入返回合同。读取成功的n个字节若只写出去一部分,同样不能宣布整段成功;需要处理Writer的短写与错误,或交给io.Copy等工具。
当读取与写入在同一轮都发现错误,接口还需要决定哪个原因优先、是否保留两者。不要为了一份漂亮日志忽略写入失败,也不要让源端校验错误被后续清理错误完全覆盖。最小程序用不会失败的内存Buffer把这个变量固定住,扩展到真实目标时要把它重新放回设计中。
读取字节也不等于一次拿到完整UTF-8字符。若下一层按文本处理,还应使用连续解码,不能因为本次n大于零就单独把每块当成完整字符串记录。
最小回归至少保留这三条:数据与EOF同次返回,数据与业务错误同次返回,连续零读。它们分别围住字节保留、失败传播与无进展退出,避免循环“看起来很标准”却只在某个具体Reader上碰巧正确。