点击上方蓝色“飞雪无情”关注我的公众号,设个星标,第一时间看文章
前段时间,使用Golang写了个解压tar文件的小工具,速度比linux自带的tar要快很多(4、5倍差距),而且同样支持稀疏文件,具体关于稀疏文件的解释,可以参考我这篇文章 Golang 和稀疏文件。
前方高能,稍微有点枯燥,但能学到东西:
tar标准包不支持稀疏文件,自己如何支持
如何调用私有方法
如何提升解压性能
golang tar解压
首先呢,我通过一个简单的tar文件解压的例子,逐步的分析它的实现,看golang的tar包为什么不支持稀疏文件。
以解压当前文件夹下的 demo.tar
为例,把它解压到当前目录,Golang实现代码如下所示:
func main() {
unTarDir := "." //解压到当前目录
tarFile, err := os.Open("demo.tar")
if err != nil {
log.Fatalln(err)
}
tr := tar.NewReader(tarFile)
for {
hdr, err := tr.Next()
if err == io.EOF {
break // End of archive
}
if err != nil {
log.Fatalln(err)
}
if hdr.Typeflag == tar.TypeDir {
// 创建解压目录
} else if hdr.Typeflag == tar.TypeReg || hdr.Typeflag == tar.TypeGNUSparse {
tarFile := path.Join(unTarDir, hdr.Name)
file, err := os.OpenFile(tarFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
log.Fatalln(err)
}
err = file.Truncate(hdr.Size)
if err != nil {
log.Fatalln(err)
}
_, err = io.Copy(file, tr)
if err != nil {
log.Fatalln(err)
}
}
}
}
以上代码是把常规文件和稀疏文件同样处理的,也就是使用 io.Copy
函数,把内容写到文件中。
func Copy(dst Writer, src Reader) (written int64, err error) {
return copyBuffer(dst, src, nil)
}
Copy
函数的实现规则如下:
如果
dst
实现了io.WriterTo
接口,那么直接使用dst.WriterTo
方法即可如果第1点不满足,那么再判断
src
是否实现了io.ReadFrom
接口,如果是的话,那么直接使用它最后再不满足,就调用
src
的Read
方法,然后用dst
的Write
方法写入内容
tar Reader源码分析
具体到 tar.Reader
来讲,因为它没有实现 io.WriterTo
接口,所以最终调用的是它的 Read
方法。
// If the current file is sparse, then the regions marked as a hole
// are read back as NUL-bytes.
func (tr *Reader) Read(b []byte) (int, error) {
if tr.err != nil {
return 0, tr.err
}
n, err := tr.curr.Read(b)
if err != nil && err != io.EOF {
tr.err = err
}
return n, err
}
从注释看,golang的tar包是支持稀疏文件的,它会把孔洞的区域采用值为0的字节填充,说明go tar包考虑到稀疏文件的场景。
func (sr *sparseFileReader) Read(b []byte) (n int, err error) {
// 省略无关代码
for endPos > sr.pos && err == nil {
var nf int // Bytes read in fragment
holeStart, holeEnd := sr.sp[0].Offset, sr.sp[0].endOffset()
if sr.pos < holeStart { // In a data fragment
bf := b[:min(int64(len(b)), holeStart-sr.pos)]
nf, err = tryReadFull(sr.fr, bf)
} else { // In a hole fragment
bf := b[:min(int64(len(b)), holeEnd-sr.pos)]
nf, err = tryReadFull(zeroReader{}, bf)
}
b = b[nf:]
sr.pos += int64(nf)
if sr.pos >= holeEnd && len(sr.sp) > 1 {
sr.sp = sr.sp[1:] // Ensure last fragment always remains
}
}
// 省略无关代码
}
留意以上源代码中的 zeroReader
,这就是是一个字节为0的填充器,他会生成一个全都是0的字节切片。可以看到 Read
方法是通过给文件写0值的数据,实现稀疏文件的。
tar包不支持稀疏文件
很不幸,我实际测试的结果,无法支持稀疏文件特性,磁盘占用还是和文件大小一样。
最开始我以为是我MacOS文件系统问题,我自己的是APFS系统,也是支持稀疏文件的。
所以我又尝试了Ext4、XFS系统,这种采用写入字节0的方式都不支持稀疏特性。
而且这种方式还有个弊端,就是0的数据也要写入到文件,写文件就要牵涉到磁盘IO,会造成性能浪费。现在看下使用填充0的方法的耗时:
➜ time go run cmd/main.go
go run cmd/main.go 1.13s user 7.19s system 54% cpu 15.317 total
以上是使用的一个1.43GB的tar文件的测试数据,耗时15秒左右。
现在陷入了僵局,但是我在前一篇稀疏文件的文章 Golang 和稀疏文件 中实际测试,自己的电脑是支持稀疏文件的,唯一的不同,是当时采用的 File.Seek
方法,tar解压采用填空字节0的方法。
刚刚也提到,性能不好就是因为每次都要写入0,如果我们知道这部分数据是0,使用 File.Seek
跳过,不就可以减少IO了吗?说不定可以同样解决稀疏文件问题。
私有方法writeTo
想法不错,这的确是可行的。我们继续顺着这个思路思考,如果要使用 File.Seek
方法跳过为0的数据,那么 file
变量起主导作用,要想办法,让 Seek
被调用。
_, err = io.Copy(file, tr)
原来我们是这么把数据写到 file
中的, Copy
方法我们已经分析过,它会优先判断 dst
是否实现 io.WriterTo
接口,如果是的话,会优先使用它把数据写入文件。
// If the reader has a WriteTo method, use it to do the copy.
// Avoids an allocation and a copy.
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
但是很遗憾, tar.Reader
没有实现io.WriterTo
接口,所以 io.Copy
这条路是走不通了。
但是这个给了我一个思路,我去翻了下tar.Reader
的源代码,发现了一个私有方法 writeTo
,从其注释说明来看,也是支持稀疏文件的,并且是使用 Seek
的方式跳过孔洞数据。具体实现是通过内部结构体 sparseFileReader
的 WriteTo
方法,详见下面代码的注释。
// If the current file is sparse and w is an io.WriteSeeker,
// then writeTo uses Seek to skip past holes defined in Header.SparseHoles,
// assuming that skipped regions are filled with NULs.
func (tr *Reader) writeTo(w io.Writer) (int64, error) {
if tr.err != nil {
return 0, tr.err
}
n, err := tr.curr.WriteTo(w)
if err != nil {
tr.err = err
}
return n, err
}
func (sr *sparseFileReader) WriteTo(w io.Writer) (n int64, err error) {
// 关键,先判断是否实现了io.WriteSeeker,实现了才有Seek方法
ws, ok := w.(io.WriteSeeker)
if ok {
if _, err := ws.Seek(0, io.SeekCurrent); err != nil {
ok = false // Not all io.Seeker can really seek
}
}
// 如果不支持Seek,又回到了Copy方法
if !ok {
return io.Copy(w, struct{ io.Reader }{sr})
}
var writeLastByte bool
pos0 := sr.pos
for sr.logicalRemaining() > 0 && !writeLastByte && err == nil {
var nf int64 // Size of fragment
holeStart, holeEnd := sr.sp[0].Offset, sr.sp[0].endOffset()
if sr.pos < holeStart { // In a data fragment
nf = holeStart - sr.pos
nf, err = io.CopyN(ws, sr.fr, nf)
} else { // In a hole fragment
nf = holeEnd - sr.pos
if sr.physicalRemaining() == 0 {
writeLastByte = true
nf--
}
// 关键,使用Seek方法跳过
_, err = ws.Seek(nf, io.SeekCurrent)
}
sr.pos += nf
if sr.pos >= holeEnd && len(sr.sp) > 1 {
sr.sp = sr.sp[1:] // Ensure last fragment always remains
}
}
}
是不是很惊喜,这就是我们想要的方法,但是它是私有的,怎么用它呢?
go:linkname出场
要调用私有方法,第一思路是不是反射?但是在Golang中,有更好的调用方法,它就是go:linkname 编译指令,这也是golang隐藏的黑科技,在自己的标准库中也有使用。
要想使用私有方法, 首先在自己的源代码文件中,定义一个同样签名的函数,没有函数体!!!
func writeTo(tr *tar.Reader, w io.Writer) (int64, error)
因为我们定义的是函数,所以第一个参数是 tar.Reader
本身,通过这种定义方法,表明是 tar.Reader
的方法。
然后就是go:linkname的使用了,在我们定义的函数上面加上这个指令的说明
//go:linkname writeTo archive/tar.(*Reader).writeTo
func writeTo(tr *tar.Reader, w io.Writer) (int64, error)
这个指后面分两部分,用空格分开:
第一部分
writeTo
表示我们当前源文件中writeTo
函数第二部门
archive/tar.(*Reader).writeTo
表示tar.Reader
的私有方法writeTo
注意第二部门,要写全import path
,导入的全路径,然后Reader
这个结构体,最后是writeTo
私有方法,缺一不可,不能写错。
因为这种方法是不标准的,所以我们还要导入 unsafe
包,告诉编译器我们已经知道它是不安全的了。
import (
"archive/tar"
_ "unsafe"
)
好了,准备工作都已经完成,现在使用它了。把我们示例代码中的 io.Copy
换成 writeTo
函数即可。
tarFile := path.Join(unTarDir, hdr.Name)
file, err := os.OpenFile(tarFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
log.Fatalln(err)
}
err = file.Truncate(hdr.Size)
if err != nil {
log.Fatalln(err)
}
_, err = writeTo(tr, file)
if err != nil {
log.Fatalln(err)
}
现在我们测试下,看看性能提升了多少:
➜ time go run cmd/main.go
go run cmd/main.go 0.55s user 3.65s system 86% cpu 4.844 total
耗时从原来的15秒,降低到5秒,这性能提升,刚刚的!!!
Seek
方法的提升,对于孔洞越多的稀疏文件,效果越好,因为都被跳过了。
关键,划重点来了,我们终于实现了稀疏文件的解压(APFS、XFS、Ext4都支持),通过du命令查看,磁盘占用已经远远小于文件实际大小了。
## 磁盘占用大小1.3G
$ du -h RAM
1.3G spare_file
## 文件实际大小12G
$ ls -lh RAM
12G spare_file
重构下代码
好了,现在我们重构下代码,区分出常规文件、稀疏文件,然后不同处理。原来的常规文件,还是通过 io.Copy
函数实现。
func writeFile(root string, hdr *tar.Header, tr *tar.Reader, sparseFile bool) {
tarFile := path.Join(root, hdr.Name)
file, err := os.OpenFile(tarFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
log.Fatalln(err)
}
err = file.Truncate(hdr.Size)
if err != nil {
log.Fatalln(err)
}
if sparseFile {
_, err = writeTo(tr, file)
} else {
_, err = io.Copy(file, tr)
}
if err != nil {
log.Fatalln(err)
}
}
抽取一个公共的函数 writeFile
,稀疏文件使用 writeTo
函数,常规文件使用 io.Copy
函数,然后在解压时使用他们。
else if hdr.Typeflag == tar.TypeReg {
writeFile(unTarDir, hdr, tr, false)
} else if hdr.Typeflag == tar.TypeGNUSparse {
writeFile(unTarDir, hdr, tr, true)
}
小结
至此,整个实战全部结束了,从这次过程来看,要想完成这次实战,首先不能放弃,不能在刚使用tar包的时候,发现不支持稀疏文件就放弃,放弃了就没有剩下的精彩的。
其次对源码、稀疏文件原理的深入理解,只有这样,我们才能找到更优的方法,进而实现最优的性能。
本文为原创文章,转载注明出处,欢迎扫码关注公众号
flysnow_org
或者网站 https://www.flysnow.org/ ,第一时间看后续精彩文章。觉得好的话,请猛击文章右下角「在看」,感谢支持。
扫码关注
分享、点赞、在看就是最大的支持