四种字符串和bytes互相转换方式的性能比较

昨天公司群中同事提到 Go 1.22 中 string 和 bytes 的互转不需要再用 unsafe 那个包了,直接转就可以。我翻看了 Go 1.22 的 release notes 没找到相应的介绍,但是大家提到了 kubernetes 的 issue 中有这个说法:

As of go 1.22, for string to bytes conversion, we can replace the usage of unsafe.Slice(unsafe.StringData(s), len(s)) with type casting []bytes(str), without the worry of losing performance.

As of go 1.22, string to bytes conversion []bytes(str) is faster than using the unsafe package. Both methods have 0 memory allocation now.

自 Go 1.22 起,对于 string 到 bytes 的转换,我们可以用类型转换 []bytes(str) 来替换 unsafe.Slice(unsafe.StringData(s), len(s)) 的用法,而不用担心性能损失。
自 Go 1.22 起,string 到 bytes 的转换 []bytes(str) 比使用 unsafe 包更快。现在两种方法都不会有内存分配。

这个说法让我很好奇,但是我还是想验证一下这个说法。

注意,这个说法只谈到了 string 到 bytes 的转换,并没有提到 bytes 到 string 的转换,这篇文章也会关注这两者的互转。

首先,让我们看看几种 string 和 bytes 的转换方式,然后我们再写 benchmark 比较它们之间的性能。

一、强转

字符串和 bytes 之间可以强制转换,编译器会内部处理。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
func toRawBytes(s string) []byte {
if len(s) == 0 {
return nil
}
return []byte(s)
}
func toRawString(b []byte) string {
if len(b) == 0 {
return ""
}
return string(b)
}

这里我们做了一点点优化,处理空 string或者 bytes 的情况。

二、传统 unsafe 方式

reflect 包中定义了 SliceHeaderStringHeader, 分别对应 slice 和 string 的数据结构

1
2
3
4
5
6
7
8
9
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
type StringHeader struct {
Data uintptr
Len int
}

我们按照这种数据结构,可以实现 string 和 bytes 的互转。我们暂且把它叫做 reflect 方式吧,虽然下面的代码没有用到 reflect 包,但是实际我们是按照 reflect 包中的这两个数据结构进行转换的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func toReflectBytes(s string) []byte {
if len(s) == 0 {
return nil
}
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptr{x[0], x[1], x[1]}
return *(*[]byte)(unsafe.Pointer(&h))
}
func toReflectString(b []byte) string {
if len(b) == 0 {
return ""
}
return *(*string)(unsafe.Pointer(&b))
}

三、新型 unsafe 方式

我在两年前的文章与日俱进,在 Go 1.20 中这种高效转换的方式又变了介绍了新的 unsafe 方式,reflect 包中的 SliceHeaderStringHeader 准备废弃了。让我们看看这种新的转换方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
func toBytes(s string) []byte {
if len(s) == 0 {
return nil
}
return unsafe.Slice(unsafe.StringData(s), len(s))
}
func toString(b []byte) string {
if len(b) == 0 {
return ""
}
return unsafe.String(unsafe.SliceData(b), len(b))
}

利用 unsafe.Sliceunsafe.Stringunsafe.StringDataunsafe.SliceData 完成 Slice 和 String 的转换以及底层数据的指针的获取。

四、kubernetes 的实现

在 k8s 中,使用的是下面方式的优化的转换:

1
2
3
4
5
6
7
func toK8sBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(&s))
}
func toK8sString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}

可以看到,相对于传统 unsafe 方式,k8s 的实现更简洁,并没有为toBytes临时构造3元素的数组,而是直接将 string 和 bytes 的指针进行转换。

string不是只包含两个字段么?slice不是包含三个字段么?toK8sBytes返回的[]byte的cap是怎么确定的呢? 最后我们再分析这个问题,现在先把这几个实现的性能搞清楚。

性能比较

我们分别对这几种实现进行 benchmark,看看它们之间的性能差异。
使用一个简单的字符串和它对应的bytes, 分别进行 string 到 bytes 、 bytes 到 string 的转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var s = "hello, world"
var bts = []byte("hello, world")
func BenchmarkStringToBytes(b *testing.B) {
var fns = map[string]func(string) []byte{
"强制转换": toRawBytes,
"传统转换": toReflectBytes,
"新型转换": toBytes,
"k8s转换": toK8sBytes,
}
for name, fn := range fns {
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
bts = fn(s)
}
})
}
}
func BenchmarkBytesToString(b *testing.B) {
var fns = map[string]func([]byte) string{
"强制转换": toRawString,
"传统转换": toReflectString,
"新型转换": toString,
"k8s转换": toK8sString,
}
for name, fn := range fns {
b.Run(name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
s = fn(bts)
}
})
}
}

在Mac mini M2上运行,go1.22.6 darwin/arm64,结果如下:

1
2
3
4
5
6
7
8
goos: darwin
goarch: arm64
pkg: github.com/smallnest/study/str2bytes
BenchmarkStringToBytes/强制转换-8 78813638 14.73 ns/op 16 B/op 1 allocs/op
BenchmarkStringToBytes/传统转换-8 599346962 2.010 ns/op 0 B/op 0 allocs/op
BenchmarkStringToBytes/新型转换-8 624976126 1.929 ns/op 0 B/op 0 allocs/op
BenchmarkStringToBytes/k8s转换-8 887370499 1.211 ns/op 0 B/op 0 allocs/op

string 转 bytes性能最好的是k8s方案, 新型转换和传统转换性能差不多,新型方案略好,强制转换性能最差。

1
2
3
4
5
BenchmarkBytesToString/强制转换-8 92011309 12.68 ns/op 16 B/op 1 allocs/op
BenchmarkBytesToString/传统转换-8 815922964 1.471 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToString/新型转换-8 624965414 1.922 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToString/k8s转换-8 1000000000 1.194 ns/op 0 B/op 0 allocs/op

而对于 bytes 转 string,k8s方案性能最好,传统转换次之,新型转换性能再次之,强制转换性能非常不好。

在Linux amd64上运行,go1.22.0 linux/amd64,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
goos: linux
goarch: amd64
pkg: test
cpu: Intel(R) Xeon(R) Platinum
BenchmarkStringToBytes/强制转换-2 30606319 42.02 ns/op 16 B/op 1 allocs/op
BenchmarkStringToBytes/传统转换-2 315913948 3.779 ns/op 0 B/op 0 allocs/op
BenchmarkStringToBytes/新型转换-2 411972518 2.753 ns/op 0 B/op 0 allocs/op
BenchmarkStringToBytes/k8s转换-2 449640819 2.770 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToString/强制转换-2 38716465 29.18 ns/op 16 B/op 1 allocs/op
BenchmarkBytesToString/传统转换-2 458832459 2.593 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToString/新型转换-2 439537762 2.762 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToString/k8s转换-2 478885546 2.375 ns/op 0 B/op 0 allocs/op

整体上看,k8s方案、传统转换、新型转换性能都挺好,强制转换性能最差。k8s在bytes转string上性能最好。

性能分析

等等,kubernates的讨论中,不是说Go1.22中string到bytes的转换可以直接用[]byte(str)了么?为什么这里的性能测试中,强制转换为什么性能那么差呢?

同时你也可以看到,强制转换每个op都会有一次内存分配:1 allocs/op,这严重影响了它的性能。

如果我们编写两个benchmark测试函数, 如下:

1
2
3
4
5
6
7
8
9
10
11
func BenchmarkStringToBytesRaw(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = toRawBytes(s)
}
}
func BenchmarkBytesToStringRaw(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = toRawString(bts)
}
}

执行:

1
2
3
4
5
goos: darwin
goarch: arm64
pkg: github.com/smallnest/study/str2bytes
BenchmarkStringToBytesRaw-8 1000000000 0.2921 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToStringRaw-8 506502222 2.363 ns/op 0 B/op 0 allocs/op

你会发现一个令人诧异的事情,强制转换的性能非常好,没有额外的内存分配(零拷贝),设置字符串转换为bytes好太多。

这是咋回事呢?

当然聪明的你就会想到这个肯定是编译器做了优化,通过内联,把toRawBytes的函数调用展开了,这个好处是发现s

1
2
3
4
5
6
7
8
# go test -gcflags="-m=2" -bench Raw -benchmem
...
./convert_test.go:48:6: can inline toRawBytes with cost 10 as: func(string) []byte { if len(s) == 0 { return nil }; return ([]byte)(s) }
./convert_test.go:55:6: can inline toRawString with cost 10 as: func([]byte) string { if len(b) == 0 { return "" }; return string(b) }
...
./convert_test.go:101:17: ([]byte)(s) does not escape
./convert_test.go:101:17: zero-copy string->[]byte conversion
...

通过-gcflags="-m=2", 我们可以观察内联和逃逸分析的结果,可以看到编译器优化了强制转换的函数,将string转换为bytes的操作优化为零拷贝。

而上一节我们的benchmark中,bts = toRawBytes(s)这个操作,会导致([]byte)(s)逃逸到堆上,这样就会有一次内存分配,并且性能底下。

所以你现在情况了,Go1.22确实对强制转换做了优化,但是这个优化是通过编译器的内联和逃逸分析来实现的,并不是所有的场景都能够优化到零拷贝。

谁能在编写代码的时候注意到这个优化呢,甚至准确的判断能否避免逃逸?所以可能在现阶段,我们还是会通过其他三种方式进行优化。

貌似Go 1.23会进一步优化,参考这个CL: cmd/compile: restore zero-copy string->[]byte optimization

k8s实现的问题

一开始,我们留了一个问题:toK8sBytes返回的[]byte的cap是多少?

1
2
3
func toK8sBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(&s))
}

len是明确的,字段对应字符串的len字段,但是cap是多少呢?字符串可是没有cap字段的。

我们可以通过下面的代码来验证:

1
2
3
4
5
6
7
func Test_toK8sBytes(t *testing.T) {
a := *(*[3]int64)(unsafe.Pointer(&s))
fmt.Printf("%d, %d, %d\n", a[0], a[1], a[2])
b := *(*[]byte)(unsafe.Pointer(&s))
fmt.Printf("%d, %d, %d\n", unsafe.SliceData(b), len(b), cap(b))
}

首先我们强制获取三个字段,第一个字段应该是字符串底层数据的指针。第二个字段是字符串的长度,第三个字段是什么呢?
同样我进行强制转换成slice of byte, 然后打印slice的底层数据指针,长度和容量。

输出结果如下(每次运行可能会得到不同的结果):

1
2
4375580047, 12, 4375914624
4375580047, 12, 4375914624

可以看到两者的结果是一致的,第一个值就是底层数据指针,第二个值是长度12,第三个啥也不是,就取得的内存中的值,随机的,并不是容量12。

所以通过这种方式转换的slice,其容量是不确定的,这个是一个问题,可能会导致一些问题,比如slice的append操作。

1、如果得到的slice的容量那么大,我们是不是尽情的append数据呢?

1
2
3
4
b := *(*[]byte)(unsafe.Pointer(&s))
fmt.Printf("%d, %d, %d\n", unsafe.SliceData(b), len(b), cap(b))
b = append(b, '!')

运行上面的测试会导致panic:

1
2
3
unexpected fault address 0x105020dfb
fatal error: fault
[signal SIGBUS: bus error code=0x1 addr=0x105020dfb pc=0x10501ee98]

2、如果修改返回的bytes, 共享底层数据的原始string是不是也会发生变化?

1
2
3
b := *(*[]byte)(unsafe.Pointer(&s))
fmt.Printf("%d, %d, %d\n", unsafe.SliceData(b), len(b), cap(b))
b[0] = 'H'

运行上面的测试,会导致string的值s发生变化吗? 答案是不会,运行这段代码依然会导致panic"

1
2
3
unexpected fault address 0x104f1cdcf
fatal error: fault
[signal SIGBUS: bus error code=0x1 addr=0x104f1cdcf pc=0x104f1ae74]

3、如果修改原始的bytes, 返回的string是不是也会发生变化?
我们知道,字符串是不可变的,所以这个问题的答案是?
测试代码如下:

1
2
3
4
c := *(*string)(unsafe.Pointer(&bts))
fmt.Printf("%s\n", c)
bts[0] = 'H'
fmt.Printf("%s\n", c)

原始的bytes bts发生变化,返回的string c会发生变化吗?上面的代码打印出修改前后同一个字符串的值:

1
2
hello, world
Hello, world

哈,字符串也变成了"可变"的了。

总结

Go 1.22中,string和bytes的互转在部分场景(未逃逸的情况)下做了优化,实现了零拷贝,性能优秀,但是并不是所有的场景都能优化到零拷贝,所以我们、可以再等等,再等几个版本优化完全后再替换传统的互转方式。

在字符串和bytes互转的情况下,我们要确定bytes是不是可变的,这样会避免意外的情况发生,否则不妨采用强制转换的方式,安全第一。