Golang 中避免 []bytes
和 string
转换开销的正确方法#
备注
本文写于 2024 年 5 月,基于 go version go1.22.3 linux/amd64
展开讨论。
一些事实(尤其是编译优化的条件)会随着时间发生改变。
本文涉及的所有测试脚本与代码可在 此处 获取。
背景和现状#
在 Golang 中,绝大部分的 type conversion 的运行时开销较小,但 []byte
⇆ string
是一个
特例:由于 string
类型不可变(immutable),而 []byte
类型可变(mutable),
两者转换时会导致内存的拷贝 [1]:
string
→[]byte
会导致运行时的runtime.stringtoslicebyte
开销[]byte
→string
会导致运行时的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 代码,我更希望使用官方推荐的解决方案,这对项目的稳定性和可维护性都 是大有裨益的。
编译优化#
在以下的情况,编译器已经能直接省略 []byte
⇆ string
的开销了。如果你的应用对性能比较敏感,
你需要了解如何编写代码才能让它享受到编译器的优化,必要时可借助编译器的日志和
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
备注
这个优化并不支持另一个方向 []byte
→ string
的 zero copy,@randall77 说 难以实现
[]byte
在 conversions 之后是只读的 这样的检查,并且多个 []byte
可以指向同一份
数据,难以追踪。我其实没有完全理解,因为将优化的条件再收紧一些,
感觉是可以做的?
要求
[]byte
完全地只读,而非在 conversions 之后只读[]byte
没有逃逸的话,完全跟踪所有指向底层存储的 slice 也非难事?
这两点都已经在当前的 zerocopy 优化里实现了,好奇为什么不这么做。如果有熟悉 编译器的朋友知道原因,希望不吝赐教。
我纠结它为什么不实现的原因是,编译器在下面两种特殊情况下已经实现了 []byte
→ string
,
但只允许转换出来的 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 年,标准库还是没有统一的方式来操作它们。 具体可以看这里的讨论:
proposal: byteseq: add a generic byte string manipulation package · Issue #48643 · golang/go
proposal: spec: byte view: type that can represent a []byte or string · Issue #5376 · golang/go
strings
和 bytes
#
两个库提供了基本相同的功能,但分别针对 string
和 []byte
类型,
合理挑选函数可以避免转换:
var s = []byte("hello world")
- strings.HasPrefix(string(s), "hello")
+ bytes.HasPrefix(s, []byte("hello"))
io.Writer
和 io.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
系列函数将其他的基本类型(int
、float64
等)转化为
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"))
脚注#
如果你有任何意见,请在此评论。 如果你留下了电子邮箱,我可能会通过 回复你。