Go语言实战稀疏文件解压,4倍提升,多个技巧!!

点击上方蓝色“飞雪无情”关注我的公众号,设个星标,第一时间看文章

前段时间,使用Golang写了个解压tar文件的小工具,速度比linux自带的tar要快很多(4、5倍差距),而且同样支持稀疏文件,具体关于稀疏文件的解释,可以参考我这篇文章 Golang 和稀疏文件

前方高能,稍微有点枯燥,但能学到东西:

  1. tar标准包不支持稀疏文件,自己如何支持

  2. 如何调用私有方法

  3. 如何提升解压性能

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 函数的实现规则如下:

  1. 如果 dst 实现了 io.WriterTo 接口,那么直接使用 dst.WriterTo 方法即可

  2. 如果第1点不满足,那么再判断 src 是否实现了 io.ReadFrom 接口,如果是的话,那么直接使用它

  3. 最后再不满足,就调用 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)

这个指后面分两部分,用空格分开:

  1. 第一部分 writeTo 表示我们当前源文件中 writeTo 函数

  2. 第二部门 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/ ,第一时间看后续精彩文章。觉得好的话,请猛击文章右下角「在看」,感谢支持。

b13c3da862ca0f7bc9f1baf12d1d89e5.png

扫码关注

分享、点赞、在看就是最大的支持

猜你喜欢

转载自blog.csdn.net/flysnow_org/article/details/126615709