golang-string 和 bytes 之间的 unsafe 转换

最近写一个 golang 的工具包时,涉及到反复在 string 和 []byte 之间来回转换。这给了我一个机会了解转换时底层发生的事情。

结论先行

  • string 和 []byte 互转都涉及底层数据复制;可以通过 unsafe 强制转换绕过复制提高性能。
  • string 类型的底层数组可能放在常量区(字面量初始化)也可能动态分配;无论哪一种,当以 string 类型出现时底层数据都是不可修改的(避免影响其他引用),string 的修改实际上是指向重新生成的底层数组。
  • 当以 []byte 类型出现时,可以修改具体某一个 byte 的值;不过如果是从指向常量区的 string 通过 unsafe 转换而来,尝试修改时会产生不可恢复的 runtime error。
  • 这也是为什么这个包叫 unsafe:绕过类型检查强行转换,绕过了底层数据复制,提高性能同时也失去了检查和复制的保护,需要调用方自行确认不会出错。
  • + 连接会复制内存,strings.Split() 直接在原串做切片…具体不同实现要以源码为准。
  • 即使 string 是动态分配的内容,也不建议修改对应的 []byte,可能会引起引用同一块内容的其他 string 的异常—— 除非你能确保没有别的地方引用它。
  • 实际调用中碰到了 对 A 串做 unsafe 转换,结果完全无关的 B 串出现切片时数组越界;A 串改为普通转换就好了。暂时没能找到原因,保险起见放弃使用 unsafe,改为用 json.RawMessage 多封装一层。本次研究权当学习了。

情景

具体来说,是一个缓存相关的封装包:把对象 json.Marshal() 之后得到的 []byte 放到缓存;或者反过来,取出 []byte 交给 json.Unmarshal() 。无论正反方向的调用,输入输出都是 []byte,本没有 string 的事。

偏偏这中间为了实现某些功能,需要往 marshal 之后的 json 字符串上追加(存的时候)或提取(读的时候)某些信息。追加还好办,可以把追加的内容一起变成 []byte 之后 append() ;但提取需要基于字符串的语义,不得不先转成 string,解析完再转回来。

我对这中间的开销产生了兴趣。

unsafe

写 demo 对比地址之后,可以确定每次转换,string 和 []byte 的地址都有变化。

但是这只能确定 string 和 slice ([]byte 的 slice) 的结构体发生了复制,结构体指向的底层 byte array 是否有发生复制无从得知。

通过查阅网上的讨论以及源码,进一步确认,底层的 array 也发生的复制。既然发生了深复制,那么一定是有额外开销的,只是多和少的差别。

通过 unsafe 包,可以强制直接转换来绕过复制的开销。实际上 strings.Builder 就是这样做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
package myunsafe
import (
"unsafe"
)
func BytesToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
func StringToBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(&s))
}

Benchmark

那究竟两种转换方式差别有多大呢?

跑一个 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
package myunsafe_test
import (
. "myunsafe"
"testing"
)
var (
strs = []string{
"a",
"abc",
"some words",
"loooooooooooooonger",
"Characters with 1234567890 +-*/ and !@#$%^&()=",
`a multi-line long text, here is line one.
line two.
line three.
some other texts:
1234567890-=!@#$%^&*()_+
abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ `,
}
)
func BenchmarkCast(b *testing.B) {
n := len(strs)
for i := 0; i < b.N; i++ {
for j := 0; j < n; j++ {
b := []byte(strs[j])
_ = string(b)
}
}
}
func BenchmarkUnsafeCast(b *testing.B) {
n := len(strs)
for i := 0; i < b.N; i++ {
for j := 0; j < n; j++ {
b := StringToBytes(strs[j])
_ = BytesToString(b)
}
}
}

测试结果对比如下

1
2
3
4
5
6
7
8
go test -test.bench=.* -benchmem -run=none
goos: windows
goarch: amd64
pkg: myunsafe
BenchmarkCast-8 5000000 281 ns/op 448 B/op 4 allocs/op
BenchmarkUnsafeCast-8 200000000 8.81 ns/op 0 B/op 0 allocs/op
PASS
ok myunsafe 4.996s

我用不同数据测过几次,上面只是其中一次。用较短的字符串测试时,大概是 20 倍的差距;当字符串越来越长,差距越来越明显。所以当反复转换较长的字符创时,可以考虑用 unsafe。

字面量常量

网友提到这个转换只能对动态生成的字符串用,但是我上面的测试用了字面量常量,却没有报错呢?

有什么办法可以让它报错呢?我想差别是在于读和写。上面虽然对字面量常量做了 unsafe 转换,但是转换之后得到的结果并没有尝试写入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import (
. "myunsafe"
"fmt"
)
func main() {
s := "a"
b := StringToBytes(s)
fmt.Println(b, string(b))
b[0] = 0x42 // ascii for B
fmt.Println(b, string(b))
}

果然报错了

1
2
3
4
[97] a
unexpected fault address 0x4bb9ac
fatal error: fault
[signal 0xc0000005 code=0x1 addr=0x4bb9ac pc=0x48b615]

改为动态生成的字符串看看。怎么生成?可以用 strings.Builder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main
import (
. "myunsafe"
"fmt"
"strings"
)
func main() {
sb := strings.Builder{}
sb.WriteString("a")
s := sb.String()
b := StringToBytes(s)
fmt.Println(b, string(b))
b[0] = 0x42 // ascii for B
fmt.Println(b, string(b))
}

这回就不报错了

1
2
[97] a
[66] B

其他情况

concat 运算符 +

如果一个字符串是通过连接符 + 得到的呢?

它的实现在 src/runtime/string.go 里,可以看到做了内存复制。所以无论连接前的字符串是动态分配的还是字符串常量,连接之后得到的结果都是动态分配的。

strings.Split()

类似的,如果字符串是分解得到的子串呢?

同样可以看源码,Split 实际上并没有生成字符串,而是在原串的基础上做了切片。所以修改是否会报错,视乎原串是常量还是动态分配的内存。

其他情况就不一一尝试了。总的来说,各种情况都有,需要看源码核实。不过确认是动态分配的串也只是避免 runtime error 造成程序中断而已;但即使不报错,也应该避免修改 unsafe 转换得到的 []byte,这会导致其他指向同一地址的 string 有意外的结果—— 除非你确定这是对底层 array 唯一的引用

参考资料


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
详情请点击查看协议具体内容。