与日俱进,在 Go 1.20 中这种高效转换的方式又变了

在 Go 1.19 的开发中, string.SliceHeaderstring.StringHeader经历了一个生死存亡的争斗,这两个类型一度被标记为弃用(deprecated),但是这两个类型经常用在 slice of byte 和 string 高效互转的场景中,如果被标记为弃用,但是目前还没有可替代的方法,所以这两个类型又把弃用标记去掉了,如无意外,它们也会在 Go 1.20 再次被标记为弃用。

byte slice 和 string 的转换优化

直接通过强转string(bytes)或者[]byte(str)会带来数据的复制,性能不佳,所以在追求极致性能场景,我们会采用『骇客』的方式,来实现这两种类型的转换,比如k8s采用下面的方式:

https://github.com/kubernetes/apiserver/blob/706a6d89cf35950281e095bb1eeed5e3211d6272/pkg/authentication/token/cache/cached_token_authenticator.go#L263-L271
1
2
3
4
5
6
7
8
9
// toBytes performs unholy acts to avoid allocations
func toBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(&s))
}
// toString performs unholy acts to avoid allocations
func toString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}

更多的采用下面的方式(rpcx也采用下面的方式):

1
2
3
4
5
6
7
8
9
func SliceByteToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
func StringToSliceByte(s string) []byte {
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptr{x[0], x[1], x[1]}
return *(*[]byte)(unsafe.Pointer(&h))
}

甚至,标准库也采用这种方式:

https://github.com/golang/go/blob/82f902ae8e2b7f7eff0cdb087e47e939cc296a62/src/strings/clone.go
1
2
3
4
5
6
7
8
func Clone(s string) string {
if len(s) == 0 {
return ""
}
b := make([]byte, len(s))
copy(b, s)
return *(*string)(unsafe.Pointer(&b))
}

因为 slice of byte 和 string 数据结构类似,所以我们可以可以使用这种『骇客』的方式强转。这两种类型的数据结构在reflect包中有定义:

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
}

SliceString多一个Cap字段,它们的数据通过一个数组存储,这两个结构的Data存储了指向这个数组的指针。

Go 1.20 的新的方式

很多项目中都使用上面的方式进行性能提升,但是这是通过unsafe实现的,有相当的风险,因为强转之后,slice可能会做一些变动,导致相关的数据被覆盖了或者被回收了,也经常会出现一些意想不到的问题,我在使用这种方式做RedisProxy的时候,也犯过类似的错误,我当时还以为是标准库出错了呢。

因此, Go官方准备在 1.20 中把这两个类型SliceHeaderStringHeader废弃掉,避免大家的误用。
废弃就废弃吧,但是也得提供相应的替代方法才行。这不,在 Go 1.12中,增加了几个方法StringStringDataSliceSliceData,用来做这种性能转换。

  • func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType: 返回一个Slice,它的底层数组自ptr开始,长度和容量都是len
  • func SliceData(slice []ArbitraryType) *ArbitraryType:返回一个指针,指向底层的数组
  • func String(ptr *byte, len IntegerType) string: 生成一个字符串,底层的数组开始自ptr, 长度是len
  • func StringData(str string) *byte: 返回字符串底层的数组

这四个方法看起来很原始很底层。

这个提交是由cuiweixie提交的。因为涉及到很基础很底层的实现,而且又是可能被广泛使用的方法,所以大家review起来特别的仔细,大家可以围观: go-review#427095

甚至,这个修改都惊动了蛰伏多月的Rob Pike大佬,他老人家询问为啥只有实现连注释文档都没有呢:#54858,当然原因是这个功能还在开发和review之中,不过可以看出Rob Pike很重视这个修改。

cuiweixie 甚至还修改了标准库里面一些写法,使用他提交的unsafe中的这四个方法。

性能测试

虽然cuiweixie的提交还没有被merge到主分支,还存在一些变数,但是我发现使用gotip能使用这几个方法了。 我理解的是gotip适合master分支保持一致的,难道不是么?

不管怎样,先写个benchmark:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
var L = 1024 * 1024
var str = strings.Repeat("a", L)
var s = bytes.Repeat([]byte{'a'}, L)
var str2 string
var s2 []byte
func BenchmarkString2Slice(b *testing.B) {
for i := 0; i < b.N; i++ {
bt := []byte(str)
if len(bt) != L {
b.Fatal()
}
}
}
func BenchmarkString2SliceReflect(b *testing.B) {
for i := 0; i < b.N; i++ {
bt := *(*[]byte)(unsafe.Pointer(&str))
if len(bt) != L {
b.Fatal()
}
}
}
func BenchmarkString2SliceUnsafe(b *testing.B) {
for i := 0; i < b.N; i++ {
bt := unsafe.Slice(unsafe.StringData(str), len(str))
if len(bt) != L {
b.Fatal()
}
}
}
func BenchmarkSlice2String(b *testing.B) {
for i := 0; i < b.N; i++ {
ss := string(s)
if len(ss) != L {
b.Fatal()
}
}
}
func BenchmarkSlice2StringReflect(b *testing.B) {
for i := 0; i < b.N; i++ {
ss := *(*string)(unsafe.Pointer(&s))
if len(ss) != L {
b.Fatal()
}
}
}
func BenchmarkSlice2StringUnsafe(b *testing.B) {
for i := 0; i < b.N; i++ {
ss := unsafe.String(unsafe.SliceData(s), len(str))
if len(ss) != L {
b.Fatal()
}
}
}

实际测试结果:

1
2
3
4
5
6
7
8
9
10
➜ strslice gotip test -benchmem -bench .
goos: darwin
goarch: arm64
pkg: github.com/smallnest/study/strslice
BenchmarkString2Slice-8 18826 63942 ns/op 1048579 B/op 1 allocs/op
BenchmarkString2SliceReflect-8 1000000000 0.6498 ns/op 0 B/op 0 allocs/op
BenchmarkString2SliceUnsafe-8 1000000000 0.8178 ns/op 0 B/op 0 allocs/op
BenchmarkSlice2String-8 18686 65864 ns/op 1048580 B/op 1 allocs/op
BenchmarkSlice2StringReflect-8 1000000000 0.6488 ns/op 0 B/op 0 allocs/op
BenchmarkSlice2StringUnsafe-8 1000000000 0.9744 ns/op 0 B/op 0 allocs/op

可以看到,不通过『骇客』的方式,两种类型强转耗时非常巨大,如果采用reflect的方式,性能提升大大改观。

如果采用最新的unsafe包的方式,性能也能大大提高,虽然耗时比reflect略有增加,可以忽略。