昨天公司群中同事提到 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 theunsafepackage. 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 | func toRawBytes(s string) []byte { |
这里我们做了一点点优化,处理空 string或者 bytes 的情况。
二、传统 unsafe 方式
reflect 包中定义了 SliceHeader 和 StringHeader, 分别对应 slice 和 string 的数据结构
1 | type SliceHeader struct { |
我们按照这种数据结构,可以实现 string 和 bytes 的互转。我们暂且把它叫做 reflect 方式吧,虽然下面的代码没有用到 reflect 包,但是实际我们是按照 reflect 包中的这两个数据结构进行转换的:
1 | func toReflectBytes(s string) []byte { |
三、新型 unsafe 方式
我在两年前的文章与日俱进,在 Go 1.20 中这种高效转换的方式又变了介绍了新的 unsafe 方式,reflect 包中的 SliceHeader 和 StringHeader 准备废弃了。让我们看看这种新的转换方式:
1 | func toBytes(s string) []byte { |
利用 unsafe.Slice 、unsafe.String、unsafe.StringData 和 unsafe.SliceData 完成 Slice 和 String 的转换以及底层数据的指针的获取。
四、kubernetes 的实现
在 k8s 中,使用的是下面方式的优化的转换:
1 | func toK8sBytes(s string) []byte { |
可以看到,相对于传统 unsafe 方式,k8s 的实现更简洁,并没有为toBytes临时构造3元素的数组,而是直接将 string 和 bytes 的指针进行转换。
string不是只包含两个字段么?slice不是包含三个字段么?toK8sBytes返回的[]byte的cap是怎么确定的呢? 最后我们再分析这个问题,现在先把这几个实现的性能搞清楚。
性能比较
我们分别对这几种实现进行 benchmark,看看它们之间的性能差异。
使用一个简单的字符串和它对应的bytes, 分别进行 string 到 bytes 、 bytes 到 string 的转换。
1 | var s = "hello, world" |
在Mac mini M2上运行,go1.22.6 darwin/arm64,结果如下:
1 | goos: darwin |
string 转 bytes性能最好的是k8s方案, 新型转换和传统转换性能差不多,新型方案略好,强制转换性能最差。
1 |
|
而对于 bytes 转 string,k8s方案性能最好,传统转换次之,新型转换性能再次之,强制转换性能非常不好。
在Linux amd64上运行,go1.22.0 linux/amd64,结果如下:
1 | goos: linux |
整体上看,k8s方案、传统转换、新型转换性能都挺好,强制转换性能最差。k8s在bytes转string上性能最好。
性能分析
等等,kubernates的讨论中,不是说Go1.22中string到bytes的转换可以直接用[]byte(str)了么?为什么这里的性能测试中,强制转换为什么性能那么差呢?
同时你也可以看到,强制转换每个op都会有一次内存分配:1 allocs/op,这严重影响了它的性能。
如果我们编写两个benchmark测试函数, 如下:
1 | func BenchmarkStringToBytesRaw(b *testing.B) { |
执行:
1 | goos: darwin |
你会发现一个令人诧异的事情,强制转换的性能非常好,没有额外的内存分配(零拷贝),设置字符串转换为bytes好太多。
这是咋回事呢?
当然聪明的你就会想到这个肯定是编译器做了优化,通过内联,把toRawBytes的函数调用展开了,这个好处是发现s
1 | go test -gcflags="-m=2" -bench Raw -benchmem |
通过-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 | func toK8sBytes(s string) []byte { |
len是明确的,字段对应字符串的len字段,但是cap是多少呢?字符串可是没有cap字段的。
我们可以通过下面的代码来验证:
1 | func Test_toK8sBytes(t *testing.T) { |
首先我们强制获取三个字段,第一个字段应该是字符串底层数据的指针。第二个字段是字符串的长度,第三个字段是什么呢?
同样我进行强制转换成slice of byte, 然后打印slice的底层数据指针,长度和容量。
输出结果如下(每次运行可能会得到不同的结果):
1 | 4375580047, 12, 4375914624 |
可以看到两者的结果是一致的,第一个值就是底层数据指针,第二个值是长度12,第三个啥也不是,就取得的内存中的值,随机的,并不是容量12。
所以通过这种方式转换的slice,其容量是不确定的,这个是一个问题,可能会导致一些问题,比如slice的append操作。
1、如果得到的slice的容量那么大,我们是不是尽情的append数据呢?
1 | b := *(*[]byte)(unsafe.Pointer(&s)) |
运行上面的测试会导致panic:
1 | unexpected fault address 0x105020dfb |
2、如果修改返回的bytes, 共享底层数据的原始string是不是也会发生变化?
1 | b := *(*[]byte)(unsafe.Pointer(&s)) |
运行上面的测试,会导致string的值s发生变化吗? 答案是不会,运行这段代码依然会导致panic"
1 | unexpected fault address 0x104f1cdcf |
3、如果修改原始的bytes, 返回的string是不是也会发生变化?
我们知道,字符串是不可变的,所以这个问题的答案是?
测试代码如下:
1 | c := *(*string)(unsafe.Pointer(&bts)) |
原始的bytes bts发生变化,返回的string c会发生变化吗?上面的代码打印出修改前后同一个字符串的值:
1 | hello, world |
哈,字符串也变成了"可变"的了。
总结
Go 1.22中,string和bytes的互转在部分场景(未逃逸的情况)下做了优化,实现了零拷贝,性能优秀,但是并不是所有的场景都能优化到零拷贝,所以我们、可以再等等,再等几个版本优化完全后再替换传统的互转方式。
在字符串和bytes互转的情况下,我们要确定bytes是不是可变的,这样会避免意外的情况发生,否则不妨采用强制转换的方式,安全第一。
