昨天公司群中同事提到 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 包中定义了 SliceHeader
和 StringHeader
, 分别对应 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 包中的 SliceHeader
和 StringHeader
准备废弃了。让我们看看这种新的转换方式:
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.Slice
、unsafe.String
、unsafe.StringData
和 unsafe.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
...
./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是不是可变的,这样会避免意外的情况发生,否则不妨采用强制转换的方式,安全第一。