Golang 中避免 []bytesstring 转换开销的正确方法#

备注

本文写于 2024 年 5 月,基于 go version go1.22.3 linux/amd64 展开讨论。 一些事实(尤其是编译优化的条件)会随着时间发生改变。

本文涉及的所有测试脚本与代码可在 此处 获取。

背景和现状#

在 Golang 中,绝大部分的 type conversion 的运行时开销较小,但 []bytestring 是一个 特例:由于 string 类型不可变(immutable),而 []byte 类型可变(mutable), 两者转换时会导致内存的拷贝 [1]

  • string[]byte 会导致运行时的 runtime.stringtoslicebyte 开销

  • []bytestring 会导致运行时的 runtime.slicebytetostring 开销

不少开发人员对此类转换缺乏足够的敏感,导致编码中这样的开销随处可见。 另一方面这也是 Go 语言本身的缺陷:无法对变量的可变性进行约束,无法做更精准的 逃逸分析,导致 runtime 对此类转换的开销优化相当有限。

unsafe is not safe#

而当开销逐渐累计为性能瓶颈时,开发人员又会开始寻求快速优化的途径,且往往会 尝试简单粗暴的操作:通过 unsafe 包强制将 string 指向 []byte 的底层数据, 或反之,以避免内存的拷贝。

unsafe 绝非理想的解决方案,首先它打破了类型上 immutable 和 mutable 的约定, 可能导致程序的逻辑错误,在广泛使用并发的 Go 里就更容易出问题了;其次,使用 unsafe 时稍有不当就会导致非预期的内存 BUG,即使是有经验的开发者也很难 不踩坑:

  • modern-go/reflect2#12: string[]byte 的过程中,将构造好的 reflect.SliceHeader 转回 []byte 之前,GC 可能将 SliceHeader.Data 指向的 数据回收

  • DataDog/zstd#91: 将栈上分配的 []byte 强转为 string 后,栈扩张导致 string 底层数据 失效

不完美的解决方案们#

Go Team 在编译器和标准库层面提供了一些方案,但总的来说不够好:

  • 指标不治本:不够通用,不能覆盖所有场景

  • 手段不一致:有些是编译器优化,有些是标准库 API

这些方案在不同的 Go 版本中被陆续加入,文档也散落在不同的地方,在我印象里似乎没有 人好好地把他们收集起来捋一捋。比起在网上的哪个旮旯里随便找个博客抄一段易燃易爆炸 的 unsafe 代码,我更希望使用官方推荐的解决方案,这对项目的稳定性和可维护性都 是大有裨益的。

编译优化#

在以下的情况,编译器已经能直接省略 []bytestring 的开销了。如果你的应用对性能比较敏感, 你需要了解如何编写代码才能让它享受到编译器的优化,必要时可借助编译器的日志和 benchmark 做进一步的确认。

Go1.22: zero-copy string[]byte conversions#

Go1.22 默认启用了一项叫 zerocopy 的优化 [2],如果一个由 string 转化 而来的 []byte 没有逃逸到堆上(什么是逃逸?),且在它所有代码路径上都是 只读的(没有进行任何修改操作),那么转换的开销可以被省略。如下代码:

 7func BenchmarkZeroCopy(b *testing.B) {
 8	s := "202405"
 9	for i := 0; i < b.N; i++ {
10		_ = []byte(s)
11	}
12}

我们通过分别设置 -gcflags='-d zerocopy=1' [3]-gcflags='-d zerocopy=0' 来开启和关闭优化,在此基础上进行测试:

结果如下:

// zerocopy=1
goos: linux
goarch: amd64
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkZeroCopy-8   	1000000000	         0.2157 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	command-line-arguments	0.241s
// zerocopy=0
goos: linux
goarch: amd64
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkZeroCopy-8   	418946652	         3.056 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	command-line-arguments	1.573s

在测试的同时,我们还可以用 -gcflags '-m' [3] 输出编译器的优化日志, 通过对比可以发现,第 10 行的 string[]byte 转换在 zerocopy=1 的情况下被正确识别了:

--- /home/runner/work/silverrainz.github.io/silverrainz.github.io/_assets/bsconv/zerocopy0.log
+++ /home/runner/work/silverrainz.github.io/silverrainz.github.io/_assets/bsconv/zerocopy1.log
@@ -2,6 +2,7 @@
 ./zerocopy_test.go:7:6: can inline BenchmarkZeroCopy
 ./zerocopy_test.go:7:24: b does not escape
 ./zerocopy_test.go:10:14: ([]byte)(s) does not escape
+./zerocopy_test.go:10:14: zero-copy string->[]byte conversion
 # command-line-arguments.test
 _testmain.go:37:6: can inline init.0
 _testmain.go:45:24: inlining call to testing.MainStart

备注

这个优化并不支持另一个方向 []bytestring 的 zero copy,@randall77 说 难以实现 []byte 在 conversions 之后是只读的 这样的检查,并且多个 []byte 可以指向同一份 数据,难以追踪。我其实没有完全理解,因为将优化的条件再收紧一些, 感觉是可以做的?

  • 要求 []byte 完全地只读,而非在 conversions 之后只读

  • []byte 没有逃逸的话,完全跟踪所有指向底层存储的 slice 也非难事?

这两点都已经在当前的 zerocopy 优化里实现了,好奇为什么不这么做。如果有熟悉 编译器的朋友知道原因,希望不吝赐教。

我纠结它为什么不实现的原因是,编译器在下面两种特殊情况下已经实现了 []bytestring, 但只允许转换出来的 string 用于特定的内置操作(map查找和字符串运算),不能赋值给 变量也不能传递给其他函数,非常受限。

Go1.5: bytes comparison#

[]bytes 本质上是 slice 类型,无法直接判等,可以通过标准库 bytes.Equal 实现。 Go1.5 引入了一个优化 [4],将 []byte 临时转化为 string 用于判等时,不会产生额外的内存 分配。例如:

12	b.Run("Optimized", func(b *testing.B) {
13		for i := 0; i < b.N; i++ {
14			_ = string(bs1) == string(bs2)
15		}
16	})

但如果你用一个变量储存转换出来的 string,哪怕逻辑上完全等价,优化会失效:

17	b.Run("NotOptimized", func(b *testing.B) {
18		for i := 0; i < b.N; i++ {
19			s1 := string(bs1)
20			s2 := string(bs2)
21			_ = s1 == s2
22		}
23	})

其他的字符串操作,例如比较大小 >< 或者连接 + 也同样支持该优化:

24	b.Run("Optimized2", func(b *testing.B) {
25		for i := 0; i < b.N; i++ {
26			_ = string(bs1) >= string(bs2)
27		}
28	})

Benchmark 结果如下:

goos: linux
goarch: amd64
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkEqual/Optimized-8         	1000000000	         0.2547 ns/op	       0 B/op	       0 allocs/op
BenchmarkEqual/NotOptimized-8      	26003533	        46.33 ns/op	     160 B/op	       2 allocs/op
BenchmarkEqual/Optimized2-8        	382775796	         3.174 ns/op	       0 B/op	       0 allocs/op
BenchmarkEqual/bytes.Equal-8       	1000000000	         0.2310 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	command-line-arguments	3.328s

其中 bytes.Equal 的结果看起来也是优化过的,这是因为在 Go1.13 以后 bytes.Equal 就是简单地用 == 实现了 [5]

// Equal reports whether a and b
// are the same length and contain the same bytes.
// A nil argument is equivalent to an empty slice.
func Equal(a, b []byte) bool {
        // Neither cmd/compile nor gccgo allocates for these string conversions.
        return string(a) == string(b)
}

Go1.3: map lookup#

Go1.3 引入了一个优化 [6],当将 []byte 临时转化为 string 并用于查询 map 时, 可以免去转换:

13	b.Run("Optimized", func(b *testing.B) {
14		for i := 0; i < b.N; i++ {
15			_ = m[string(k)]
16		}
17	})

和上面类似,如果你用一个变量储存转换出来的 string,优化会失效:

18	b.Run("NotOptimized", func(b *testing.B) {
19		for i := 0; i < b.N; i++ {
20			sk := string(k)
21			_ = m[sk]
22		}

Benchmark 结果如下:

goos: linux
goarch: amd64
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkMapLookup/Optimized-8         	222915559	         5.483 ns/op	       0 B/op	       0 allocs/op
BenchmarkMapLookup/NotOptimized-8      	49391640	        33.78 ns/op	      48 B/op	       1 allocs/op
PASS
ok  	command-line-arguments	3.465s

标准库使用姿势#

Go1.18 之前不支持泛型,因此标准库中有关 string/[]byte 的接口如果需要另一种类型的支持, 基本就要加一个新接口。

当然,即使在已经有了泛型的 2024 年,标准库还是没有统一的方式来操作它们。 具体可以看这里的讨论:

stringsbytes#

两个库提供了基本相同的功能,但分别针对 string[]byte 类型, 合理挑选函数可以避免转换:

  var s = []byte("hello world")
- strings.HasPrefix(string(s), "hello")
+ bytes.HasPrefix(s, []byte("hello"))

io.Writerio.StringWriter#

io.Writer 写入数据时,如果 writer 还同时实现了 io.StringWriter, 可以根据数据的类型选择调用 Write 方法或者 WriteString 方法以避免转换:

  var s = "hello world"
- w.Write([]byte(s))
+ w.(io.StringWriter).WriteString(s)

标准库的 io.WriteString 就帮用户自动实现了这个逻辑:

// WriteString writes the contents of the string s to w, which accepts a slice of bytes.
// If w implements [StringWriter], [StringWriter.WriteString] is invoked directly.
// Otherwise, [Writer.Write] is called exactly once.
func WriteString(w Writer, s string) (n int, err error) {
        if sw, ok := w.(StringWriter); ok {
                return sw.WriteString(s)
        }
        return w.Write([]byte(s))
}

于是刚刚的代码就可以简化为:

  var s = "hello world"
  var w io.Writer
- w.Write([]byte(s))
+ io.WriteString(w, s)

提示

比较遗憾的是,要求每个 io.Writer 都实现 io.StringWriter 是不现实的, 很多情况下,我们持有一个 string,要喂给 io.Writer 时还是只能乖乖地转换。

io.Writer.Write 的入参在约定上是只读的:

// Writer is the interface that wraps the basic Write method.
//
// Write writes len(p) bytes from p to the underlying data stream.
// It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// Write must return a non-nil error if it returns n < len(p).
// Write must not modify the slice data, even temporarily.
//
// Implementations must not retain p.
type Writer interface {
        Write(p []byte) (n int, err error)
}

我们可以畅想一下,编译器其实可以考虑直接省略该转换,但约定毕竟只是约定, 没有强制力,如何识别修改 []byte 的边角 case 是个问题。社区对此有一些讨论:

strconv.AppendXXX#

strconv.FormatXXX 系列函数将其他的基本类型(intfloat64 等)转化为 string。某些情况下我们需要 []byte 形式的结果,可以使用 strconv.AppendXXX

  var data []byte
- data = []byte(strconv.FormatInt(1234, 10))
+ data = strconv.AppendInt(nil, 1234, 10)

Go1.20: strconv.ParseXXX#

很遗憾,ParseXXX 系列函数至今没有等价的 []byte 版本实现,在 strconv: add equivalents of Parsexxx() with []byte arguments 有过讨论但最终没有下文。 不过鉴于这类函数的合法入参总是比较短(不超过 math.Max{Uint,Float64}), 在栈上多复制一份也没什么大不了。

但有一个问题:当 parse 发生错误时,为了方便调试,错误信息会包含输入参数,例如执行 strconv.ParseInt("a") 会返回错误 strconv.ParseInt: parsing "a": invalid syntax。 在 Go1.20 前 [7],这会导致从而导致参数逃逸到堆上。

以下代码:

 8func BenchmarkStrconv(b *testing.B) {
 9	s := []byte{50, 48, 50, 52, 48, 53}
10	for i := 0; i < b.N; i++ {
11		_, _ = strconv.ParseInt(string(s), 10, 0)
12	}
13}

在 Go1.22 和 Go1.19 分别运行可以明显看到区别:

// go1.22
goos: linux
goarch: amd64
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkStrconv-8   	86147509	        12.70 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	command-line-arguments	1.111s
// go1.19
goos: linux
goarch: amd64
cpu: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz
BenchmarkStrconv-8   	51321450	        21.73 ns/op	       8 B/op	       1 allocs/op
PASS
ok  	command-line-arguments	1.142s

对比两个版本的编译器的优化日志,临时变量 string(s) 在 1.22 是不会发生逃逸的:

--- /home/runner/work/silverrainz.github.io/silverrainz.github.io/_assets/bsconv/strconv119.log
+++ /home/runner/work/silverrainz.github.io/silverrainz.github.io/_assets/bsconv/strconv122.log
@@ -1,7 +1,7 @@
 # command-line-arguments [command-line-arguments.test]
 ./strconv_test.go:8:23: b does not escape
 ./strconv_test.go:9:13: []byte{...} does not escape
-./strconv_test.go:11:33: string(s) escapes to heap
+./strconv_test.go:11:34: string(s) does not escape
 # command-line-arguments.test
 _testmain.go:37:6: can inline init.0
 _testmain.go:45:24: inlining call to testing.MainStart

Go1.19: fmt.Append{,f,ln}#

fmt.Sprint{,f,ln} 的返回值是 string,Go1.19 引入 [8]fmt.Append{,f,ln} 可以 直接格式化地输出 []byte

  var data []byte
- data = []byte(fmt.Sprintf("hello %s", "alice"))
+ data = fmt.Appendf(nil, ("hello %s", "alice"))

脚注#

评论

如果你有任何意见,请在此评论。 如果你留下了电子邮箱,我可能会通过 回复你。