Skip to content

Go 文件与流式 I/O:从 Reader/Writer 到安全的文件系统操作

面向有 Java 经验的开发者,基于 Go 1.26。

Go 的 I/O 体系建立在几个很小的接口上。文件、网络连接、压缩器、哈希器和内存缓冲区,只要实现 ReadWrite,就能接入同一套组合函数。这样的设计比庞大的流继承体系轻得多,代价是调用方必须真正理解短读、短写、EOF、缓冲、关闭以及文件系统边界。

这一章从 io.Readerio.Writer 的契约开始,顺着数据如何流动,继续讨论 osio/fspath/filepathbufio、原子替换、目录遍历、嵌入文件、大文件处理、并发和路径安全。重点不是背下所有函数,而是看清数据流与持久化边界该由谁负责。

目录

1. 先建立 I/O 心智模型

Go 把 I/O 看成字节流:

go
type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

实现者可以是:

  • *os.File
  • net.Conn
  • *bytes.Reader*strings.Reader
  • *bytes.Buffer
  • gzip 编解码器;
  • 哈希对象;
  • 自定义限速、计数、加密或观测包装器。

调用方依赖能力,而不是依赖“文件”这个具体来源:

go
func CopyReport(dst io.Writer, src io.Reader) error {
	_, err := io.Copy(dst, src)
	return err
}

于是,同一个业务函数既可以从文件读取,也可以在测试中从字符串读取;输出端既能接网络,也能换成内存缓冲区。

I/O API 的关键边界有四个:

  1. 数据边界:一次 Read 不等于一条消息;
  2. 资源边界:谁打开,通常谁负责关闭;
  3. 内存边界:是否把整个输入载入内存;
  4. 安全边界:路径是否可信,是否允许跟随符号链接。

2. Reader 的精确契约

2.1 Read 只承诺“最多 len(p)”

go
buf := make([]byte, 32*1024)
for {
	n, err := r.Read(buf)
	if n > 0 {
		if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
			return writeErr
		}
	}
	if err != nil {
		if errors.Is(err, io.EOF) {
			break
		}
		return err
	}
}

一次读取可能返回任意 0 < n <= len(p),不能假设填满缓冲区。更重要的是,合法 Reader 可以同时返回 n > 0err == io.EOF:必须先处理 n 个字节,再看错误,否则会丢最后一段数据。

调用 Readlen(p)==0 的语义较特殊,调用方不应靠零长读取判断 EOF。

2.2 (0, nil) 与无进展

Reader 在 len(p)>0 时一般不应返回 (0, nil),因为调用方可能空转。部分适配器会对连续无进展最终返回 io.ErrNoProgress。自定义 Reader 必须遵守契约,不能把“暂时没数据”随意表示成 (0,nil);网络连接有自己的阻塞和 deadline 机制。

2.3 EOF 不是所有场景的失败

io.EOF 表示输入正常结束。像 io.Copy 这类“读到结束即成功”的函数会把 EOF 转成 nil。固定长度协议中提前结束则是 io.ErrUnexpectedEOF

go
header := make([]byte, 16)
if _, err := io.ReadFull(r, header); err != nil {
	return fmt.Errorf("read header: %w", err)
}
  • io.ReadFull:必须填满缓冲区;
  • io.ReadAtLeast:至少读取指定字节;
  • io.CopyN:复制固定数量,提前结束会返回相应错误。

不要用 err.Error() == "EOF",可以直接与 io.EOF 比较;官方契约要求 Reader 返回 EOF 本身,不包装它。

3. Writer、短写与完整写入

Write(p) 返回已经接受的字节数。若 n < len(p),实现必须返回非 nil 错误;没有更具体错误时通常是 io.ErrShortWrite

自定义循环要处理短写:

go
func writeAll(w io.Writer, p []byte) error {
	for len(p) > 0 {
		n, err := w.Write(p)
		if n > 0 {
			p = p[n:]
		}
		if err != nil {
			return err
		}
		if n == 0 {
			return io.ErrShortWrite
		}
	}
	return nil
}

许多标准库 Writer 会完整接受或明确报错,但接口允许短写,通用适配器不能假设。

io.WriteString 会在 Writer 实现 io.StringWriter 时走优化路径,否则转成字节写入:

go
if _, err := io.WriteString(w, "status=ok\n"); err != nil {
	return err
}

4. io 包的组合工具

4.1 Copy、CopyN 与 CopyBuffer

go
written, err := io.Copy(dst, src)

io.Copy 读到 EOF 成功时返回 err == nil。若源实现 WriterTo,优先调用 src.WriteTo(dst);否则若目标实现 ReaderFrom,调用 dst.ReadFrom(src)。因此手工套一层缓冲不一定更快,甚至可能挡住 sendfile 等优化。

需要自己控制缓冲区复用:

go
buf := make([]byte, 128*1024)
_, err := io.CopyBuffer(dst, src, buf)

如果 buf 长度为零会 panic;源或目标有专用 fast path 时,提供的缓冲区可能不会被使用。

限定字节数:

go
_, err := io.CopyN(dst, src, 1<<20)

4.2 LimitReader

go
limited := io.LimitReader(r, 10<<20)
data, err := io.ReadAll(limited)

这最多读取 10 MiB,但无法区分输入恰好 10 MiB 还是更大。需要拒绝超限输入时,多允许一个字节:

go
const max = 10 << 20
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
	return err
}
if len(data) > max {
	return fmt.Errorf("input exceeds %d bytes", max)
}

4.3 MultiReader 与 MultiWriter

go
r := io.MultiReader(
	strings.NewReader(header),
	body,
	strings.NewReader(footer),
)

w := io.MultiWriter(file, hash)

MultiWriter 按顺序写每个目标,任一目标失败就停止;它不提供事务语义。文件写成功、网络写失败时,文件不会自动回滚。

4.4 TeeReader

go
hasher := sha256.New()
body := io.TeeReader(src, hasher)
if _, err := io.Copy(dst, body); err != nil {
	return err
}
sum := hasher.Sum(nil)

只有被下游实际读取的字节才会写入 tee 目标。若下游提前停止,哈希也只覆盖已读部分。

4.5 Pipe

io.Pipe 在内存中同步连接 Reader 和 Writer,没有内部缓冲;写入会阻塞到读取消费。适合把“只会写 Writer”的编码器接到“只会读 Reader”的上传 API:

go
pr, pw := io.Pipe()

go func() {
	err := encodeArchive(pw)
	_ = pw.CloseWithError(err)
}()

if err := upload(pr); err != nil {
	_ = pr.CloseWithError(err)
	return err
}
return pr.Close()

两端都要处理关闭和错误传播,否则容易泄漏 goroutine。

4.6 SectionReader

go
section := io.NewSectionReader(file, offset, length)

它把一个 ReaderAt 的指定区间包装成独立 Reader,并支持 ReadReadAtSeek。适合并发读取大文件不同区间,而不共享文件当前偏移。

5. 文件的打开、创建与关闭

5.1 常用入口

go
f, err := os.Open(path) // 只读

f, err := os.Create(path) // 写,存在则截断,权限参数相当于 0o666 再受 umask 影响

f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o640)

OpenFile 标志可组合:

  • O_RDONLYO_WRONLYO_RDWR:访问模式,三选一;
  • O_CREATE:不存在则创建;
  • O_EXCL:与 O_CREATE 一起要求必须新建;
  • O_TRUNC:打开时截断;
  • O_APPEND:每次写追加;
  • O_SYNC:请求同步 I/O,成本较高。

5.2 谁打开谁关闭

go
f, err := os.Open(path)
if err != nil {
	return fmt.Errorf("open %q: %w", path, err)
}
defer f.Close()

如果函数接收调用方提供的 io.Reader,通常不关闭,因为它不拥有资源。若确实接管所有权,必须在文档和命名中说明。

写文件要认真处理 SyncClose 错误:

go
if err := f.Sync(); err != nil {
	return fmt.Errorf("sync %q: %w", path, err)
}
if err := f.Close(); err != nil {
	return fmt.Errorf("close %q: %w", path, err)
}

只写入页缓存不等于耐久落盘。是否需要 Sync 取决于数据丢失模型;对缓存文件可能没必要,对交易日志可能必须。

5.3 错误类型

文件操作错误常包装在 *fs.PathError

go
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
	fmt.Println(pathErr.Op, pathErr.Path, pathErr.Err)
}

按语义判断:

go
errors.Is(err, fs.ErrNotExist)
errors.Is(err, fs.ErrExist)
errors.Is(err, fs.ErrPermission)
errors.Is(err, fs.ErrClosed)

旧的 os.IsNotExist 等辅助函数仍存在,但新代码通常优先 errors.Is

6. 偏移、Seek、ReadAt 与 WriteAt

6.1 共享当前偏移

File.ReadFile.Write 使用并改变当前文件偏移。Seek 修改它:

go
offset, err := f.Seek(0, io.SeekStart)

SeekStartSeekCurrentSeekEnd 分别以开头、当前位置、末尾为基准。

多个 goroutine 对同一个 *os.File 使用带当前偏移的操作,虽然 File 方法一般可并发调用,但每次获得哪段数据通常不是你想要的确定协议。并发按区间读写应使用显式偏移:

go
n, err := f.ReadAt(buf, offset)
n, err := f.WriteAt(buf, offset)

ReadAt 必须读取 len(p) 字节或返回错误;读到尾部时可能同时返回 n>0io.EOF

6.2 ReaderAt 的并发价值

ReaderAt 没有隐式游标,独立区间天然更容易并行。接口文档要求调用方可以并行调用 ReadAt;自定义实现必须相应保证。SectionReader 可以给每个任务提供独立逻辑视图。

6.3 append 模式

O_APPEND 打开时,写入发生在文件末尾。不要同时依赖 Seek 控制普通 Write 的位置。一次 Write 的追加原子性和最大范围受操作系统、文件系统和远程挂载影响,不能把多进程日志协议建立在未经验证的假设上。

7. bufio.Reader 与 bufio.Writer

7.1 Reader

go
br := bufio.NewReaderSize(r, 64*1024)
line, err := br.ReadString('\n')

ReadStringReadBytes 遇到分隔符前会持续读取;返回错误时仍可能带数据,必须先处理结果。

高性能解析可用:

go
part, err := br.ReadSlice('\n')

返回切片只在下一次读取前有效,并且行长超过内部缓冲时返回 bufio.ErrBufferFull。需要保留就复制。

Peek(n) 查看而不前移,Discard(n) 丢弃,UnreadByte/UnreadRune 有严格的调用顺序要求。

7.2 Writer

go
bw := bufio.NewWriterSize(w, 64*1024)
if _, err := bw.WriteString("hello\n"); err != nil {
	return err
}
if err := bw.Flush(); err != nil {
	return err
}

写入成功可能只表示数据进了用户态缓冲区。Flush 才把缓冲数据交给底层 Writer;文件耐久性还可能需要 File.Sync

典型关闭顺序:

  1. bufio.Writer 写完;
  2. Flush
  3. 文件 Sync(若需要耐久);
  4. 文件 Close

不要先关闭文件再 Flush。

7.3 Reset 与复用

bufio.Reader.ResetWriter.Reset 能复用已分配缓冲区。只有 profile 证明分配显著时才配合 sync.Pool;归还池后不得继续访问,也要防止超大缓冲长期滞留。

8. Scanner:按 token 读取

Scanner 适合逐行、逐词或自定义 token:

go
scanner := bufio.NewScanner(r)
for scanner.Scan() {
	line := scanner.Text()
	process(line)
}
if err := scanner.Err(); err != nil {
	return fmt.Errorf("scan input: %w", err)
}

默认 split 是 ScanLines,还有 ScanWordsScanBytesScanRunes

8.1 默认 token 上限

默认最大 token 大约 64 KiB。日志行、JSON 行、证书或生成文件可能轻易超过:

go
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)

最大值必须有业务上限,否则攻击者可用超长单行消耗内存。若 token 可能很大或需要精确控制分隔符,使用 bufio.Reader

8.2 Bytes 的生命周期

scanner.Bytes() 返回的切片可能在下一次 Scan 时被覆盖。需要异步处理或长期保存时复制:

go
token := bytes.Clone(scanner.Bytes())

Text() 返回字符串副本语义更方便,但可能产生分配。

8.3 自定义 SplitFunc

SplitFunc(data, atEOF) 返回:

  • advance:消耗多少输入;
  • token:本次 token;
  • err:错误。

必须保证进展,错误的 advance 会导致 Scanner 报错甚至 panic。bufio.ErrFinalToken 可正常提前结束并可携带最后一个 token。

9. 整文件 API 与流式处理的边界

小而有界的文件:

go
data, err := os.ReadFile(path)
err = os.WriteFile(path, data, 0o600)

它们简单可靠,但把全部数据放入内存。适合配置、模板、小测试夹具;不适合用户上传、归档、数据库导出和未知大小响应。

流式处理:

go
src, err := os.Open(srcPath)
if err != nil {
	return err
}
defer src.Close()

dst, err := os.Create(dstPath)
if err != nil {
	return err
}

_, copyErr := io.Copy(dst, src)
closeErr := dst.Close()
return errors.Join(copyErr, closeErr)

流式并不自动意味着有界:io.ReadAll、无限增长的 bytes.Buffer、没有上限的 Scanner 仍可耗尽内存。沿整个处理链检查每个缓冲和队列。

Go 1.26 的 io.ReadAll 减少了中间分配并返回紧凑切片,但它仍会读到 EOF,不能代替输入大小限制。

10. 文件权限、元数据和链接

10.1 FileMode 与 umask

go
err := os.WriteFile(path, data, 0o640)

权限字面量使用八进制。创建时实际权限还会受进程 umask 影响;若文件已存在,WriteFile 不会按传入模式重新设置其权限。需要强制权限时显式 Chmod,同时考虑平台支持和安全模型。

常见模式:

  • 0o600:仅所有者读写,适合秘密;
  • 0o640:所有者读写、组读;
  • 0o644:普通非敏感文件;
  • 0o700:仅所有者可访问的目录;
  • 0o755:公开可遍历目录或可执行文件。

Windows 的权限语义与 Unix 不同,不能假设九位权限完全生效。

10.2 Stat 与 Lstat

go
info, err := os.Stat(path)  // 跟随符号链接
info, err := os.Lstat(path) // 查看链接本身

FileInfo 提供名称、大小、模式、修改时间和平台相关 Sys()Mode().IsRegular() 比仅判断“不为目录”更安全,因为设备、管道、socket 也不是普通文件。

不要把先 StatOpen 当安全检查:两步之间目标可能被替换,这是 TOCTOU(check-to-use)竞争。

10.3 符号链接与硬链接

os.Readlink 读取链接目标,os.Symlink 创建符号链接,os.Link 创建硬链接。权限、支持情况和所需特权跨平台不同。处理不可信目录时,符号链接可能把看似安全的相对路径引向目录外,应该使用 os.Root 这类基于目录句柄的 API。

11. 原子写文件

直接 os.WriteFile(target, data, ...) 会截断目标。如果进程在写到一半时崩溃,读者可能看到残缺文件。常见做法是在目标同目录写临时文件,再重命名:

go
func AtomicWriteFile(path string, data []byte, perm fs.FileMode) (err error) {
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
	if err != nil {
		return fmt.Errorf("create temporary file: %w", err)
	}
	tmpName := tmp.Name()
	keep := false
	defer func() {
		if !keep {
			_ = os.Remove(tmpName)
		}
		if tmp != nil {
			err = errors.Join(err, tmp.Close())
		}
	}()

	if err := tmp.Chmod(perm); err != nil {
		return fmt.Errorf("chmod temporary file: %w", err)
	}
	if _, err := tmp.Write(data); err != nil {
		return fmt.Errorf("write temporary file: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		return fmt.Errorf("sync temporary file: %w", err)
	}
	if err := tmp.Close(); err != nil {
		tmp = nil
		return fmt.Errorf("close temporary file: %w", err)
	}
	tmp = nil

	if err := os.Rename(tmpName, path); err != nil {
		return fmt.Errorf("replace %q: %w", path, err)
	}
	keep = true
	return nil
}

这里有几个关键点:

  • 临时文件必须在目标同一文件系统,通常就是同一目录,否则 Rename 可能不是原子操作或直接失败;
  • 先完整写入、Flush(若有缓冲)、Sync、Close,再 Rename;
  • Rename 的替换语义和正在打开文件的行为跨平台、文件系统不同;
  • 若需要“崩溃后目录项也持久”,Unix 上通常还要打开父目录并 Sync,但这不是所有平台都支持的统一保证;
  • 目标原有所有权、ACL、扩展属性不会自动继承到新 inode;
  • 原子替换保证读者看到旧版或新版,不等于所有硬件故障下都绝不丢失。

高可靠配置或数据库文件应结合目标平台文档、故障注入和成熟存储方案,不要只凭一个 Rename 声称事务性。

12. 临时文件和临时目录

go
f, err := os.CreateTemp("", "report-*.json")
if err != nil {
	return err
}
name := f.Name()
defer os.Remove(name)
defer f.Close()

空目录参数使用系统临时目录。模式中的最后一个 * 会被随机字符串替换。不要自己用时间戳拼接文件名,容易碰撞和产生安全问题。

临时目录:

go
dir, err := os.MkdirTemp("", "extract-*")
if err != nil {
	return err
}
defer os.RemoveAll(dir)

测试中优先 t.TempDir(),测试结束自动清理。

需要把临时文件交给外部进程时,先完成写入并明确 Close;Windows 往往不允许像 Unix 那样随意删除仍打开的文件。秘密临时文件仍要考虑权限、生命周期、日志泄露和磁盘加密。

13. 目录读取、遍历与 Glob

13.1 ReadDir

go
entries, err := os.ReadDir(dir)
if err != nil {
	return err
}
for _, entry := range entries {
	fmt.Println(entry.Name(), entry.IsDir())
}

os.ReadDir 返回按文件名排序的条目,方便但会一次性加载整个目录。超大目录可打开目录后分批调用 File.ReadDir(n)

go
for {
	entries, err := dirFile.ReadDir(256)
	for _, entry := range entries {
		process(entry)
	}
	if errors.Is(err, io.EOF) {
		break
	}
	if err != nil {
		return err
	}
}

DirEntry.Info() 可能触发额外系统调用,也可能在条目读取后失败。

13.2 WalkDir

go
err := filepath.WalkDir(root, func(
	path string,
	entry fs.DirEntry,
	walkErr error,
) error {
	if walkErr != nil {
		return walkErr
	}
	if entry.IsDir() && entry.Name() == ".git" {
		return fs.SkipDir
	}
	if !entry.Type().IsRegular() {
		return nil
	}
	return process(path)
})

WalkDir 比旧 Walk 少做不必要的 Stat。回调必须先处理 walkErr,此时 entry 可能不可用。返回 fs.SkipDir 跳过目录,fs.SkipAll 停止全部遍历。

遍历顺序为词法顺序,需要先读完整目录以排序。海量目录若不需要稳定顺序,可自行基于 ReadDir(n) 设计。

默认不会沿目录符号链接递归,但处理条目时仍应明确链接策略,避免越界或循环。

13.3 Glob

go
matches, err := filepath.Glob(filepath.Join(root, "*.json"))

没有匹配项时返回空切片和 nil,不是错误;错误主要来自非法模式。Glob 不是安全访问控制,也不递归支持所有 shell 语法。

14. io/fs:抽象只读文件系统

最小接口:

go
type FS interface {
	Open(name string) (File, error)
}

路径使用正斜线 /,必须满足 fs.ValidPath

  • 是 UTF-8;
  • 非空;
  • 不以 / 开头;
  • 不含 ... 或空路径段;
  • 根目录写成 "."

面向 fs.FS 写代码:

go
func LoadTemplate(fsys fs.FS, name string) ([]byte, error) {
	data, err := fs.ReadFile(fsys, name)
	if err != nil {
		return nil, fmt.Errorf("read template %q: %w", name, err)
	}
	return data, nil
}

调用方可传:

  • os.DirFS(root)
  • embed.FS
  • fstest.MapFS
  • zip 等自定义文件系统。

可选接口如 ReadFileFSReadDirFSStatFS 允许实现优化操作;顶层 fs.ReadFile 会优先使用这些能力,否则回退到基本接口。

14.1 Sub

go
assets, err := fs.Sub(fsys, "web/assets")

它给子目录创建新视图,调用方用相对根路径访问。fs.Sub(os.DirFS(...)) 不提供对不可信符号链接的安全隔离;DirFS 的根是路径字符串,不是 traversal-resistant 沙箱。

14.2 fstest.MapFS

go
files := fstest.MapFS{
	"config/app.json": &fstest.MapFile{
		Data: []byte(`{"debug":true}`),
	},
}

它适合单元测试,无需创建真实临时目录。测试权限、符号链接、Rename 或 fsync 等 OS 行为时,仍应使用 t.TempDir() 和真实文件系统。

15. embed:把静态资源编进二进制

go
import "embed"

//go:embed templates/*.html static
var assets embed.FS

指令必须紧邻包级变量,可嵌入 string[]byteembed.FS

go
//go:embed version.txt
var version string

规则和边界:

  • 模式相对于当前包目录;
  • 默认不匹配以 ._ 开头的文件,all: 前缀可改变;
  • 不能包含 .. 或越出模块边界;
  • 内容在构建时固定,运行时只读;
  • 大资源会增加二进制和部署传输体积;
  • 秘密不能因为“放进二进制”就变安全,仍可能被提取。

fs.Subhttp.FileServerFStemplate.ParseFS 组合,可以让开发、测试和生产共享同一 fs.FS API。

16. path 与 filepath 的区别

path 永远处理斜线分隔的逻辑路径,适合:

  • URL 路径;
  • io/fs 路径;
  • tar/zip 内部路径;
  • 与操作系统无关的资源名。

path/filepath 使用当前操作系统规则,适合本地文件系统路径:

go
local := filepath.Join(base, "config", "app.json")
urlPath := path.Join("/api", "v1", "users")

不要用 filepath.Join 构造 URL;Windows 上会产生反斜线。也不要用 path.Join 构造本地绝对路径并假设 Windows 能正确处理卷名。

常用函数:

  • Clean:纯词法清理;
  • Abs:转绝对表示,但不保证唯一;
  • Rel:计算相对路径;
  • BaseDirExt
  • SplitSplitList
  • ToSlashFromSlash
  • EvalSymlinks:解析符号链接,但单独的“解析后检查再打开”仍可能有 TOCTOU。

17. 路径穿越、符号链接与 os.Root

17.1 Join 不是安全校验

用户传入 ../../etc/passwd 时:

go
path := filepath.Join(uploadDir, userName)

结果可能逃出 uploadDirClean 也只是词法规范化,不会建立安全边界。

若攻击者不能修改底层文件系统,先检查本地相对路径:

go
if !filepath.IsLocal(name) {
	return errors.New("path is not local")
}

对于来自 io/fs、URL 或归档的斜线路径,可用 filepath.Localize 转成本地表示。它只接受有效 fs.ValidPath,并返回当前系统的本地路径。

17.2 os.Root

攻击者可能创建符号链接时,“先检查、后打开”存在竞态。Go 1.24 引入 os.Root

go
root, err := os.OpenRoot(uploadDir)
if err != nil {
	return err
}
defer root.Close()

f, err := root.Open(name)

Root 的操作接受相对于根的名称,拒绝通过 .. 或符号链接逃出根。Go 1.25 又增加了 ReadFileRenameMkdirAllReadlink 等方法,Go 1.26 可用更完整的目录内操作。

一次打开:

go
f, err := os.OpenInRoot(uploadDir, name)

os.Root 适合上传、解压、插件目录、缓存目录等安全敏感操作。它不是权限系统:进程本身的 OS 权限仍然适用,也要限制文件大小、数量和类型。

17.3 安全解压

解压归档时逐项:

  1. 验证归档路径为合法相对路径;
  2. 限制条目数量、单文件大小和总解压大小;
  3. 拒绝或严格处理符号链接、硬链接和特殊文件;
  4. 使用 os.Root 在目标根内创建;
  5. 不信任归档权限位;
  6. 防止重复名称、大小溢出和压缩炸弹。

只检查字符串是否包含 ".." 不够,因为平台分隔符、绝对路径、Windows 保留名和链接都可能绕过。

18. 大文件与有界资源

18.1 保持 O(1) 或有界内存

go
func HashFile(path string) ([sha256.Size]byte, error) {
	var result [sha256.Size]byte

	f, err := os.Open(path)
	if err != nil {
		return result, err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return result, err
	}
	copy(result[:], h.Sum(nil))
	return result, nil
}

不需要 ReadAll。流式链可以同时解压、解码、校验和写出。

18.2 限制所有维度

仅限制字节数还不够,还要考虑:

  • 文件数量;
  • 目录深度;
  • 单行/token 长度;
  • 压缩前后比例;
  • 并发打开文件数;
  • 处理总时间和取消;
  • 输出增长;
  • 临时磁盘空间。

文件 I/O 本身不接受 context。需要取消时在读取循环中检查 ctx.Done(),或包装 Reader:

go
type ContextReader struct {
	Context context.Context
	Reader  io.Reader
}

func (r ContextReader) Read(p []byte) (int, error) {
	select {
	case <-r.Context.Done():
		return 0, context.Cause(r.Context)
	default:
		return r.Reader.Read(p)
	}
}

如果底层 Read 已经阻塞,这个包装器不能中断它。网络连接用 deadline/关闭连接;普通磁盘文件通常很快,但特殊设备和网络文件系统需要单独设计。

18.3 并行不是越多越好

并行读取同一旋转磁盘可能因寻道更慢;SSD、网络存储和页缓存表现不同。用有界 worker 数,结合吞吐、尾延迟、打开文件数和下游能力测量。

19. 并发安全与文件所有权

*os.File 的方法可由多个 goroutine 并发调用,但“不会数据竞争”不等于业务顺序正确:

  • 多个 Read 共享当前偏移,谁读到哪段不确定;
  • 多个 Write 的逻辑记录可能交错;
  • 一个 goroutine Close,另一个仍在读写会收到错误;
  • bufio.Readerbufio.WriterScanner 通常不保证并发安全;
  • 多进程之间需要 OS 级协议,sync.Mutex 无效。

常见所有权模式:

  1. 一个 goroutine 独占文件,其他任务通过 channel 发写请求;
  2. 按固定区间使用 ReadAt/WriteAt
  3. 每个 goroutine 独立打开只读文件;
  4. 写新版本后原子替换,读者各自打开快照;
  5. 应用级日志使用专用 logger,避免自行拼接并发写协议。

若使用文件锁,要明确它是建议锁还是强制锁、进程崩溃后的行为、网络文件系统支持和跨平台 API。标准库没有一个覆盖所有平台的通用文件锁抽象。

20. 内存映射的适用边界

Go 标准库没有跨平台 mmap 高层 API。可以通过平台系统调用或外部包使用,但代价包括:

  • 文件截断、设备错误可能以信号或致命故障表现,而不是普通 error;
  • 映射切片的生命周期必须短于底层映射;
  • 解除映射后任何引用都危险;
  • 并发写和文件替换需要额外协议;
  • 地址空间占用不等于实际驻留内存;
  • 刷盘、页缓存和一致性语义平台相关;
  • Windows 与 Unix API 差异明显。

mmap 适合随机访问超大只读索引、特定数据库存储等经过测量和封装的场景。顺序扫描通常先试 io.Copy、大缓冲和 ReadAt,它们更可移植、错误模型更清晰。

21. 跨平台注意事项

21.1 分隔符和卷名

Windows 有盘符、UNC 路径、保留设备名和不同的根路径语义。使用 filepath,不要手工拼 /\,不要只用 IsAbs 作为不可信路径校验。

21.2 大小写与 Unicode

文件系统可能大小写敏感或不敏感,Unicode 规范化规则也可能不同。不要把“不同字符串路径”自动当成不同安全主体。需要唯一性时,在目标文件系统上验证。

21.3 Rename、删除和打开文件

Unix 常允许删除或替换仍被打开的文件,已有文件描述符继续指向旧 inode;Windows 共享模式可能阻止操作。热更新和测试不能依赖单一平台观察。

21.4 权限与特殊文件

Unix mode、uid/gid、ACL、扩展属性、Windows DACL 不是同一模型。FileMode 提供可移植子集,安全敏感部署仍需平台验证。

21.5 换行

文本协议通常明确用 \n,不要自动按平台改写;bufio.ScanLines 会移除行尾 \n,并会移除其前的可选 \r。二进制内容必须原样处理。

22. 性能、观测与测试

22.1 先定位瓶颈

关注:

  • 每秒字节和文件数;
  • 系统调用次数;
  • 分配和 GC;
  • page cache 命中;
  • Sync 与远程存储延迟;
  • 打开文件描述符数量;
  • 队列长度和背压;
  • 错误分类:空间不足、权限、超时、短写。

基准小心页缓存:第二次读可能全在内存中。微基准不能替代真实磁盘、文件系统和并发负载测试。

22.2 不要盲目重复缓冲

bufio.NewReader(file) 外再套多层缓冲可能没有收益。io.Copy 已会利用 WriterTo/ReaderFrom。用基准确认缓冲大小,默认值往往足够。

22.3 故障注入

自定义 Reader/Writer 能稳定测试边界:

go
type failAfterWriter struct {
	remaining int
}

func (w *failAfterWriter) Write(p []byte) (int, error) {
	if w.remaining <= 0 {
		return 0, errors.New("injected write failure")
	}
	if len(p) > w.remaining {
		p = p[:w.remaining]
	}
	w.remaining -= len(p)
	return len(p), nil
}

应测试:

  • (n>0, io.EOF) 的 Reader;
  • 短读、短写;
  • Flush、Sync、Close 失败;
  • 超长 token;
  • 路径穿越和符号链接;
  • 磁盘空间不足;
  • 中途取消;
  • 原子写在 Rename 前后的故障;
  • Windows 和 Unix 的 CI。

测试真实文件用 t.TempDir(),抽象读取逻辑用 fstest.MapFS

23. 与 Java I/O 的对照

GoJava 近似概念重要差异
io.ReaderInputStream一个小接口,Read 可同时返回数据和错误
io.WriterOutputStream必须尊重短写契约
bufio.Reader/WriterBufferedInputStream/OutputStreamFlush 同样是独立失败点
io.CopytransferTo会按 WriterTo/ReaderFrom 能力选择快路径
fs.FS自定义 VFS/FileSystem使用 / 分隔的有效路径、默认只读能力
os.FileFileChannel/流同时实现多个小接口;共享偏移要谨慎
ReadAt/SectionReaderpositional read / sliced channel显式偏移适合并发区间读取
embed.FSclasspath resources编译时嵌入并实现 fs.FS
defer Closetry-with-resourcesdefer 属于函数作用域,关闭错误需自行合并

Go 没有 checked IOException,但 I/O 函数几乎都显式返回 error。不要因为编译器不强制 catch 就忽略 FlushClose 和遍历回调错误。

24. 常见误区

误区 1:一次 Read 就是一条完整消息

Reader 是字节流,短读是正常行为。消息边界要由协议长度、分隔符或解码器定义。

误区 2:err 非 nil 时 n 一定为 0

可能同时返回最后一段数据和 EOF/其他错误,先处理 n

误区 3:io.Copy 需要再套一个 bufio 才快

它可能已有专用快路径,额外包装反而遮蔽优化。

误区 4:bufio.Writer.Write 成功等于数据落盘

还要 Flush;耐久性需要进一步考虑 Sync 和目录项。

误区 5:Scanner 能读任意长的一行

默认 token 上限约 64 KiB,需配置有界最大值或改用 Reader。

误区 6:Clean 或 Join 能阻止路径穿越

它们只是词法处理。用 IsLocal/Localize,高威胁场景用 os.Root

检查和使用之间仍可能被替换,存在 TOCTOU。

误区 8:Rename 就是完整事务

同文件系统内通常能提供名称切换原子性,但耐久性、覆盖语义、元数据和平台行为仍需处理。

误区 9:File 可并发调用,所以共享 Read 偏移有确定结果

方法安全不代表操作顺序满足业务。区间读取用 ReadAt

误区 10:ReadFile 在 Go 1.26 更快,所以适合大文件

优化减少分配,不改变“读入全部内容”的内存模型。

误区 11:DirFS 是安全沙箱

它是便捷的 fs.FS 视图,面对可控符号链接时不能替代 os.Root

误区 12:文件创建权限就是最终权限

会受 umask、已有文件、ACL 和平台模型影响。

25. 速查表

需求API
小型有界文件os.ReadFile / os.WriteFile
流式复制到 EOFio.Copy
固定长度复制io.CopyN
限制读取量io.LimitReader,拒绝超限时读 max+1
必须填满缓冲io.ReadFull
按行/词读取bufio.Scanner,设置合理 Buffer
超长行或精细解析bufio.Reader
批量小写入bufio.Writer + Flush
并发按区间读ReadAt / SectionReader
打开带标志文件os.OpenFile
临时资源os.CreateTemp / os.MkdirTemp / t.TempDir
遍历本地目录filepath.WalkDir
面向抽象文件系统fs.FSfs.ReadFilefs.WalkDir
编译静态资源embed.FS
本地路径path/filepath
URL、归档、fs 路径path
检查不可信相对路径filepath.IsLocal / Localize
防链接穿越的目录操作os.Root / os.OpenInRoot
崩溃友好的替换同目录临时文件 + Flush/Sync/Close + Rename

资源处理检查清单:

  • 输入大小是否有上限?
  • n>0err!=nil 是否都处理?
  • 谁负责 Close?
  • Flush、Sync、Close 错误是否重要?
  • 路径是否来自不可信输入?
  • 是否可能跟随符号链接逃逸?
  • 是否共享文件偏移或缓冲器?
  • Windows 与 Unix 行为是否都验证?

可运行示例

这组示例只使用临时目录或内存流,不依赖固定路径,也不会在仓库里留下文件。程序退出前会关闭并清理资源。

示例一:有边界的流式复制

先从流式复制开始:从输入中完整读取固定协议头,再只复制限定长度的正文。这样既不会把短读误判成协议结束,也不必一次性把未知大小的数据读入内存。

go
package main

import (
	"bytes"
	"fmt"
	"io"
	"strings"
)

func main() {
	// Reader 可以来自文件、网络或压缩流;算法只依赖 io.Reader,
	// 因此本例用内存字符串就能稳定演示流式读取。
	src := strings.NewReader("header:abcdef")
	var dst bytes.Buffer

	// 先消费固定长度的头部。ReadFull 要么填满缓冲区,要么返回错误,
	// 比一次 Read 更适合解析协议头,因为 Read 允许短读。
	header := make([]byte, len("header:"))
	if _, err := io.ReadFull(src, header); err != nil {
		panic(err)
	}

	// LimitReader 给剩余数据设置硬上限,防止不受信任的输入无限增长。
	// io.Copy 使用固定大小缓冲区搬运数据,不会把整个输入一次性读入内存。
	n, err := io.Copy(&dst, io.LimitReader(src, 4))
	if err != nil {
		panic(err)
	}

	fmt.Printf("头部=%q\n", header)
	fmt.Printf("复制=%d 字节, 内容=%q\n", n, dst.String())
}

运行:

bash
go run ./examples/ch16/stream-copy

预期输出:

text
头部="header:"
复制=4 字节, 内容="abcd"

拆解:

  • io.ReadFull 明确要求填满协议头;普通 Read 即使没有错误也可能短读。
  • io.LimitReader 给不可信输入建立硬边界,io.Copy 则按流搬运,不要求数据全部驻留内存。
  • 限制读取和“超限即报错”不是一回事。本例会截断到 4 字节;上传接口若要拒绝超限,应读取 max+1 字节并检查结果。
  • bytes.Buffer 只是本例的可观察目标,替换为 os.File 或网络连接时算法不用改变。

修改实验:把限制从 4 改为 10,观察 io.Copy 在 EOF 正常结束;再把输入缩短到不足协议头,检查 io.ReadFull 返回的错误。

示例二:隔离的临时工作区

接着把文件操作放进隔离的临时工作区。程序会创建、读取并检查临时文件,即使中途失败或正常结束,也不应留下垃圾文件。

go
package main

import (
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	// 创建独立临时目录,避免示例污染当前工作目录,也避免并发执行时互相覆盖。
	// 测试代码里对应的首选方案是 t.TempDir(),它会由 testing 自动清理。
	dir, err := os.MkdirTemp("", "go-study-*")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(dir)

	path := filepath.Join(dir, "note.txt")
	if err := os.WriteFile(path, []byte("临时数据"), 0o600); err != nil {
		panic(err)
	}

	data, err := os.ReadFile(path)
	if err != nil {
		panic(err)
	}

	info, err := os.Stat(path)
	if err != nil {
		panic(err)
	}

	// 不输出随机临时路径,只输出稳定、可验证的属性。
	fmt.Printf("内容=%q\n", data)
	fmt.Printf("大小=%d, 权限=%s\n", info.Size(), info.Mode().Perm())
}

运行:

bash
go run ./examples/ch16/temp-workspace

预期输出:

text
内容="临时数据"
大小=12, 权限=-rw-------

拆解:

  • os.MkdirTemp 生成不可预测的独立目录,避免多个进程争用同一个文件名。
  • defer os.RemoveAll(dir) 在函数退出时清理整个工作区;测试代码应优先用 t.TempDir(),由测试框架自动回收。
  • filepath.Join 使用当前操作系统的路径规则,不要手工拼接 /
  • 0o600 只允许当前用户读写。权限仍会受平台、ACL 和文件系统语义影响,跨平台程序不能假定 Unix 权限位始终存在。

修改实验:把 WriteFile 改成 OpenFile 并添加 O_EXCL,验证目标已存在时会失败;然后写一个使用 t.TempDir() 的单元测试。

示例三:临时文件加 Rename 的原子替换

最后处理配置更新。直接覆盖目标文件,进程一旦在写入期间崩溃,磁盘上可能只剩半截内容。这里先把新内容完整写入同目录临时文件,再切换文件名。

go
package main

import (
	"fmt"
	"os"
	"path/filepath"
)

// WriteFileAtomic 先在目标文件所在目录写临时文件,再用 Rename 替换目标。
// 同目录非常关键:跨文件系统 rename 可能失败,也无法提供期望的原子替换语义。
func WriteFileAtomic(path string, data []byte, perm os.FileMode) (err error) {
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
	if err != nil {
		return fmt.Errorf("创建临时文件: %w", err)
	}
	tmpName := tmp.Name()
	defer func() {
		// Rename 成功后文件已不存在;失败时这里负责清理半成品。
		_ = os.Remove(tmpName)
	}()

	if err := tmp.Chmod(perm); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("设置权限: %w", err)
	}
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("写入临时文件: %w", err)
	}
	// Sync 把文件内容提交给操作系统;对掉电一致性要求极高的系统,
	// Rename 后还应打开并同步目录,且需要确认目标文件系统的保证。
	if err := tmp.Sync(); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("同步临时文件: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("关闭临时文件: %w", err)
	}
	if err := os.Rename(tmpName, path); err != nil {
		return fmt.Errorf("替换目标文件: %w", err)
	}
	return nil
}

func main() {
	dir, err := os.MkdirTemp("", "atomic-write-*")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(dir)

	path := filepath.Join(dir, "config.txt")
	if err := os.WriteFile(path, []byte("version=1\n"), 0o600); err != nil {
		panic(err)
	}
	if err := WriteFileAtomic(path, []byte("version=2\n"), 0o600); err != nil {
		panic(err)
	}

	data, err := os.ReadFile(path)
	if err != nil {
		panic(err)
	}
	fmt.Printf("最终内容=%q\n", data)
}

运行:

bash
go run ./examples/ch16/atomic-write

预期输出:

text
最终内容="version=2\n"

拆解:

  • 临时文件必须建在目标目录,才能避免跨文件系统移动,并获得文件系统提供的原子 rename 语义。
  • 写入后按 Sync → Close → Rename 排序;每一步错误都带上下文返回,失败时延迟清理临时文件。
  • Rename 解决的是读者看到旧文件或新文件的问题,不自动等于掉电后绝对耐久。严格场景还需同步父目录并验证具体文件系统。
  • Windows 对覆盖已存在文件、杀毒软件占用等行为可能与 Unix 不同,发布前应在目标平台测试。

修改实验:让写入阶段主动返回错误,确认原文件仍是 version=1;再为成功与失败路径各写一个使用 t.TempDir() 的测试。

26. 练习

  1. 实现一个能正确处理短读和 (n>0, io.EOF) 的复制函数,并用自定义 Reader 测试。
  2. 写一个最大 8 MiB 的上传保存器,限制单文件、总字节数和处理时间,不使用 ReadAll
  3. 实现 JSON Lines 读取器:允许最大 2 MiB 单行,报告行号,并说明为何选择 Scanner 或 Reader。
  4. 完善本章 AtomicWriteFile:增加父目录 Sync 的 Unix 实现和跨平台降级测试。
  5. 使用 os.Root 写一个安全解压器,拒绝路径穿越、符号链接、设备文件和压缩炸弹。
  6. 写一个 fs.FS 驱动的模板加载器,分别用 embed.FSos.DirFSfstest.MapFS 测试。
  7. 将一个大文件分成固定区间,使用 ReadAt 并发计算每段哈希,再比较顺序读取性能。
  8. 构造 Flush 成功但 Close 失败、Write 成功但 Sync 失败的 Writer/File 替身,验证错误没有丢失。
  9. 在 Linux 与 Windows CI 上测试原子替换已打开目标文件,记录差异并调整协议。
  10. 对同一复制任务比较 io.CopyCopyBuffer、额外 bufio 包装,使用基准和系统调用追踪解释结果。

27. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。