IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    smallnest发表于 2024-08-13 14:25:43
    love 0

    昨天公司群中同事提到 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
    # 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是不是可变的,这样会避免意外的情况发生,否则不妨采用强制转换的方式,安全第一。



沪ICP备19023445号-2号
友情链接