io.Copy是一个非常好用的函数,能够很方便地对数据进行拷贝。本文将会从定义、用法、底层源码逐一来讲解。并在文末通过项目见闻,来加深大家的io.Copy的理解与思考。
io.Copy
基本定义
io.Copy函数的作用是:将源(io.Reader)数据,读取到目标(io.Writer),并一直持续到数据读取完毕 或 出现错误,才会返回读取的字节数 (written int64)与错误信息(err)。
go
func Copy(dst io.Writer, src io.Reader) (written int64, err error)
其中dst 为目标写入器,用于接收源数据;src则是源读取器,用于提供数据。
基本用法
go
package main
import (
"fmt"
"io"
"os"
)
func main() {
// 打开源文件(只读)
src, err := os.Open("a.bin")
if err != nil {
fmt.Println(err)
return
}
defer src.Close() // 程序结束前关闭源文件句柄
// 创建/覆盖目标文件(只写)
dst, err := os.Create("b.bin")
if err != nil {
fmt.Println(err)
return
}
defer dst.Close() // 程序结束前关闭目标文件句柄
// 将 src 的内容拷贝到 dst,直到读完或出错
written, err := io.Copy(dst, src)
if err != nil {
fmt.Println(err)
return
}
// 可选:强制将数据刷到磁盘(更慢;只有强一致需要时才用)
if err := dst.Sync(); err != nil {
fmt.Println(err)
return
}
// 打印实际拷贝的字节数
fmt.Println("copied bytes:", written)
}
这个示例代码,是一个典型的 文件间互相拷贝(file->file)的案例。
把当前目录下的 a.bin文件 复制到 b.bin文件:先打开源文件、创建目标文件,再用 io.Copy 流式拷贝全部内容,最后可选 Sync 强制刷盘,并在终端上输出实际拷贝的字节数。
实现原理
1. 原理
在了解了 io.Copy 的基本定义和使用后,让我带大家对 io.Copy 的实现进行一下深度剖析。
io.Copy 的核心实现分为两步:快路径 与通用路径。
快路径:
io.Copy 在进入"Read(buf) → Write(buf)"通用循环前,会先尝试两条更快的路径:
- 如果
src(Reader)实现了io.WriterTo接口,就直接调用src.WriteTo(dst),让src用自己更高效的方式把数据写到dst。 - 否则如果
dst(Writer)实现了io.ReaderFrom接口,就调用dst.ReadFrom(src),让dst自己从src读取并写入。
这些快路径的意义是:把拷贝逻辑交给对应类型的所实现的接口,因为配套的接口通常更适合当前的场景,从而避免io.Copy自己分配的默认 32KB 临时缓冲区;在某些组合(如文件↔网络)下还可能触发更高效的系统级拷贝(甚至能达到零分配)。
其中
io.WriterTo和io.ReaderFrom是 Go 提供的用于优化拷贝的接口,某些 Reader/Writer 类型实现了这些接口以此加快复制速度
通用路径(慢路径)
当 src 和 dst 都不支持上述接口时,就会进入最常见的通用路径:
首先会创建一个临时缓冲区 (默认 32KB;若 src 是 LimitedReader 且剩余更小,会缩小缓冲区),
然后循环执行 src.Read(buf) 把数据读入 缓冲区,再用 dst.Write(buf[:n]) 写出 到具体目标内。
循环持续直到 读完 (也就是Read 返回 EOF)。
这期间如果读或写发生错误,io.Copy 会立刻中断并返回错误。
2. 底层源码:
注:源码截取自Go 1.24版本
go
package io
// Copy 将 src 的数据持续读取并写入 dst,直到读完(EOF)或发生错误。
// 返回:实际写入的字节数 written,以及拷贝过程中遇到的错误 err。
func Copy(dst Writer, src Reader) (written int64, err error) {
// Copy 只是 copyBuffer 的封装:不传 buf 时,由内部决定是否分配默认缓冲区
return copyBuffer(dst, src, nil)
}
// copyBuffer 是 Copy / CopyBuffer 的核心实现。
// buf 为 nil:内部会分配默认缓冲区;buf 非 nil:直接复用调用方传入的缓冲区。
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
// 快路径 1:如果 src 自己实现了 WriterTo,则交给 src.WriteTo(dst)(可能更快/少分配)
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
// 快路径 2:如果 dst 自己实现了 ReaderFrom,则交给 dst.ReadFrom(src)(可能更快/少分配)
if rf, ok := dst.(ReaderFrom); ok {
return rf.ReadFrom(src)
}
// 没有传入 buf:分配默认缓冲区(默认 32KB)
if buf == nil {
size := 32 * 1024
// 如果 src 是 LimitedReader,则根据剩余可读字节数缩小 buf(避免浪费内存)
if l, ok := src.(*LimitedReader); ok && int64(size) > l.N {
if l.N < 1 {
size = 1
} else {
size = int(l.N)
}
}
buf = make([]byte, size)
}
// 通用路径:循环 Read -> Write,直到 EOF 或错误
for {
// 从 src 读取数据到 buf
nr, er := src.Read(buf)
if nr > 0 {
// 将 buf 中读到的 nr 字节写入 dst
nw, ew := dst.Write(buf[:nr])
// 防御性检查:Write 返回值异常(写入负数或写得比读到的还多)视为非法写入
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
ew = errInvalidWrite
}
}
// 累加已写入字节数
written += int64(nw)
// 写入出错:直接结束
if ew != nil {
err = ew
break
}
// 短写:读到 nr,但只写了 nw(且无 ew),属于错误
if nr != nw {
err = ErrShortWrite
break
}
}
// Read 返回错误:EOF 表示正常结束;其他错误则返回该错误
if er != nil {
if er != EOF {
err = er
}
break
}
}
return written, err
}
项目应用
我最近再编写一套,对大图片进行分片上传 与断点续传 的接口。
当用到io.Copy的那一刻,我就想通过sync.Pool进行优化。但最终我选择了放弃。
sync.Pool 是Go 提供的临时对象池,可以复用对象以减少GC压力。
如果想深入了解 sync.Pool,可以点击这里(sync.Pool)
在此,我结合自己当时面临的两个抉择,来加深大家对io.Copy的理解。
为何我最初想要进行优化?
因为在io.Copy中的通用路径也就是慢路径中,
通常会make 一个临时缓冲区 (默认32KB的buf),
如下:
go
size := 32 * 1024
buf = make([]byte, size)
高并发上传图片的情况下,就会导致创建多个buf。也就会造成GC压力过高,而sync.Pool正是解决这个问题的有力工具。
为何我最终直接用 io.Copy(以及为何没上 sync.Pool)
io.Copy 并不只是 "固定的 Read(buf)→Write(buf)" 循环。它在进入通用循环前,会先尝试两条快路径:
- 如果
src实现了io.WriterTo,优先调用src.WriteTo(dst); - 否则如果
dst实现了io.ReaderFrom,调用dst.ReadFrom(src)。
这些快路径的目标是:让更 "懂底层" 的类型(例如文件、网络连接、内存 reader)接管拷贝逻辑,从而减少 io.Copy 自己的分配与搬运开销(不少场景甚至能避免分配 32KB 临时 buf)。
至于为何走了快路径,不需要sync.Pool了,可以看以下所示。
通用路径
当 src/dst 都不支持上述接口时,io.Copy 才会分配默认 32KB 缓冲区,并循环执行 Read(buf) → Write(buf):
src ──read──> 用户态 buf ──write──> dst
(内核在 read/write 时参与,但 Go 层需要这块 buf 做中转)
快路径
一般调用io.Copy的时候,会先判断是否能直接走快路径(当命中 WriterTo/ReaderFrom 时,拷贝逻辑交由具体类型实现),不能的话再走循环。
go
io.Copy(dst, src)
└─ copyBuffer(dst, src, buf=nil)
1) if src implements WriterTo → 走 src.WriteTo(dst) 【快路径1】
2) else if dst implements ReaderFrom → 走 dst.ReadFrom(src) 【快路径2】
3) else → 走通用循环(下面)
总结
io.Copy 用于把 src(io.Reader) 的数据持续写入 dst(io.Writer),直到读完(EOF)或出错,并返回写入字节数与错误。实现上会先尝试快路径:
若 src 实现了 io.WriterTo 则调用 src.WriteTo(dst),否则若 dst 实现了 io.ReaderFrom 则调用 dst.ReadFrom(src);
两者都不支持时才进入通用路径,内部默认分配约 32KB 的缓冲区循环 Read→Write 完成拷贝。
借鉴文章:
2、go标准文档,且源码截取自Go 1.24版本