golang unsafe遇上字符串拼接优化导致的bug
最近料理老项目的时候被unsafe坑惨了,这里挑一个最不易察觉的错误记录一下。
这个问题几乎影响近几年来所有的go版本,为了方便讨论我就用最新版的1.24.3做例子了。
线上BUG
我们有一个收集集群信息的线上系统,这个系统有好几个数据源而且数据量比较大。众所周知Go语言总是会在一些关键性能点上拉跨,我们也遇到了,所以作为性能优化策略之一,我们在一部分数据处理逻辑里使用了unsafe以减少不必要的开销。
其中一个函数是利用unsafe把字符串转换成字符切片:
func getBytes(str string) (b []byte) {
sh := (*reflect.StringHeader)(unsafe.Pointer(&str))
bh := reflect.SliceHeader{
Data: sh.Data,
Len: sh.Len,
Cap: sh.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
}
尽管reflect.StringHeader和它的朋友reflect.SliceHeader都已经标记为废弃了,但因为1.0兼容性保证,这两个东西在1.24里依旧能用,而且还能正常工作。这个函数实际上把字符串底层的内存直接赋值给了slice,让slice和字符串共用这块内存来实现零复制零分配。
这个函数的风险在于如果这块共享的内存被修改了,这个修改会意外地被我们返回的slice看到。然而string在go里是不可变的,改变一个string的值只会重新分配一块内存存放新值,是不是我们多虑了?
事实上我们没有多虑,因为真的有这种意外会发生——线上系统出Bug了。
这个Bug其实很容易观察到,因为上线没多久我们就发现数据里出现了一些重复数据还有一些脏数据。当我们把版本回滚到做unsafe优化前,数据就彻底恢复了正常。
虽然问题现象很容易发现,但问题原因就很棘手了。因为如上面所说,字符串是不可变的,理论上从字符串里拿出来的切片应该也是不会被意外改变的,更何况我们用的字符串都是拼接出来的,也不存在共用字符串变量导致问题的可能。
然而排查了两天最终我们发现正是这个“拼接”导致的问题,在看了go编译器的源码之后我写了一段最小复现代码:
func main() {
buffers := make([][]byte, 0)
for i := range 5 {
s1 := "test: "
s2 := fmt.Sprintf("%02d", i)
s3 := s1 + s2
buffers = append(buffers, getBytes(s3))
}
for _, s := range buffers {
fmt.Println(string(s))
}
}
大部分会觉得这段代码会输出test: 01\ntest: 02\n......,然而这段代码会输出五个test: 04,如果不信可以自己在1.24环境下运行一次。当然1.22,1.23结果也是一样的。
golang在我们用+拼接字符串时都做了什么
表达式str1 + str2做了哪些操作,我相信会写go的人都能答出来:创建了一个新字符串然后把str1和str2的内容拼接进新字符串的内存空间里。
表达上也许会有些出入,但大部分书、教程、视频甚至是语言标准都是这么说的。
遗憾的是标准只能规定语言的行为,并不能规定实现这个行为使用的技术细节。而问题正是出现这个技术细节上。
如果要代入技术细节的话,上面对于字符串拼接的表述并不全对。因为go编译器为了优化性能做了一些额外的处理:将小字符串尽量分配在堆上。
具体是实现代码在runtime/string.go中:
// The constant is known to the compiler.
// There is no fundamental theory behind this number.
const tmpStringBufSize = 32
type tmpBuf [tmpStringBufSize]byte
// concatstrings implements a Go string concatenation x+y+z+...
// The operands are passed in the slice a.
// If buf != nil, the compiler has determined that the result does not
// escape the calling function, so the string data can be stored in buf
// if small enough.
func concatstrings(buf *tmpBuf, a []string) string {
idx := 0
l := 0
count := 0
for i, x := range a {
n := len(x)
if n == 0 {
continue
}
if l+n < l { // 检测长度是否有整数溢出
throw("string concatenation too long")
}
l += n
count++
idx = i
}
if count == 0 {
return ""
}
// If there is just one string and either it is not on the stack
// or our result does not escape the calling frame (buf != nil),
// then we can return that string directly.
if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
return a[idx]
}
s, b := rawstringtmp(buf, l)
for _, x := range a {
n := copy(b, x)
b = b[n:]
}
return s
}
这是负责实现字符串拼接的函数,它会先求出所有字符串拼接后的最终长度并顺手做了长度溢出检查,然后再调用rawstringtmp申请容纳新字符串的空间,最后把字符copy进内存里。所以rawstringtmp这个函数才是重头戏:
func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte) {
if buf != nil && l <= len(buf) {
b = buf[:l]
s = slicebytetostringtmp(&b[0], len(b))
} else {
s, b = rawstring(l)
}
return
}
// rawstring allocates storage for a new string. The returned
// string and byte slice both refer to the same storage.
// The storage is not zeroed. Callers should use
// b to set the string contents and then drop b.
func rawstring(size int) (s string, b []byte) {
p := mallocgc(uintptr(size), nil, false)
return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}
rawstringtmp会检测申请的长度是否超过参数buf的大小,如果没有就直接从buf里切片出一个新空间,如果超过了就直接走内存分配除一块新内存不再使用buf。
那么buf有多大?最上面两行告诉你了,[32]byte这么大,也就是32字节。
所以其实str1 + str2真正的拼接操作是下面这样的伪代码:
var tmpbuf1 [32]byte
runtime.concatstrings(&tmpbuf1, []string{str1, str2})
// 如果str1和str2加起来的长度≤32,那么就会利用tmpbuf1的内存生成新字符串
// 否则从heap里重新分配内存给新字符串用
因为目前go的编译器对大小符合限制要求的数组,一般总是分配在栈上不会逃逸,所以能复用tmpbuf1空间的字符串也是在栈上的,go就这样完成了小字符串尽量分配在栈上的优化。
这个优化有啥问题呢?一般没问题,因为go编译器总是会在[]byte(str3)这样的表达式中复制原字符串的内容,除非极少数编译器能100%确定转换出来的[]byte是只读的,因此完全不用担心栈上内存的生命周期以及tmpbuf1是否会被意外修改。
然而不巧的是,我的getBytes编译器并不会特殊处理,它会直接把tmpbuf1的内存拿出来,go当然不知道这块内存是从哪拿来的,而且我们还用了unsafe,这就进一步阻止了编译器的检测。分配在栈上的东西在函数调用结束之后生命周期就终止了,这就是为什么数据里会有垃圾值。
但这还没解释为什么会出现重复的值和最小复现代码中的现象。
其实也很简单,因为这个tmpbuf是编译器进行分配的,这个buf分配在了循环外面,所以每次循环拼接字符串都会改变buf的内容。伪代码如下:
for {
str1 := xxxx
str2 := xxx
str3 := str1 + str2
}
// 等价于
var tmpbuf [32]byte
for {
str1 := xxxx
str2 := xxx
str3 := runtime.concatstrings(&tmpbuf1, []string{str1, str2})
}
这么做无可厚非,因为buf设置在循环体外性能更好,而且前面说了在不用unsafe的时候编译器能正确处理buf的生命周期以及是否需要被复制。但我们的getBytes就出问题了,因为每次拼接出来的字符串大小都只要二十几字节,不到32,所以每次新字符串都使用tmpbuf的内存,而循环里创建的所以字符串和从字符串获得的slice都指向tmpbuf,这就是为什么会有重复数据的原因。
不巧的是或者说运气好的是我们的系统里两类问题全都遇上了。
修复bug
其中一种修复办法自然是让编译器把buf放到循环体里,然而这样容易破坏已有的代码,毕竟这个优化存在很久了,另外还有是否会导致大量栈空间被浪费的问题,如果你有耐心或者更好的做法可以去go的官方github上开提案并提交PR。
不过话说回来,官方以及明说了不会保证unsafe的兼容,也不保证unsafe的安全,因此出了问题官方并不负责。所以这个问题只能我们自己改业务代码。
改起来也简单,不用unsafe就行了,原先用unsafe是因为误解了字符串拼接一定会在heap上新申请内存,那样转成[]byte又得申请一遍内存还要复制数据,得不偿失。现在知道拼接会优先服用栈空间,想安全使用转换出来的[]byte还得分配一块堆内存并把数据复制进去。对比一下会发现内存分配的次数一样,只不过数据多copy了一次。数据复制是非常快的,只有内存分配才会是性能瓶颈,因此不用unsafe也不会有太大的问题。
修复后代码是这样的:
func main() {
buffers := make([][]byte, 0)
for i := range 5 {
s1 := "test: "
s2 := fmt.Sprintf("%02d", i)
s3 := s1 + s2
- buffers = append(buffers, getBytes(s3))
+ buffers = append(buffers, []byte(s3))
+ // getBytes(strings.Clone(s3)) 这样也行,效果类似,但代码更复杂还要引入额外依赖,不建议
}
for _, s := range buffers {
fmt.Println(string(s))
}
}
新代码更简洁,同时也不会有bug。修复代码上线后系统正常了,也没有明显的性能回退,问题圆满解决。
总结
这回要不是我有给go的runtime和编译器提交过修改大致对字符串拼接有个印象,恐怕很难定位到是什么问题成为悬案了。
这个问题在gccgo上也存在,现象也一模一样,至于tinygo之类的编译器没有进行尝试,但就算没问题也不代表应该这样用,上一个也说了这种unsafe优化其实效果不明显更不用说它不安全了。
另外还有个坏消息,1.25新版本的go不仅字符串拼接有优化,slice内容物总大小不大于32字节的时候也会有类似的优化,因此用unsafe获取slice内容的做法也不再安全了。
最一劳永逸的办法还是避免使用unsafe,俗话说常在河边走哪有不湿鞋,除非你精通go的编译器和runtime,否则还是别在项目代码里用unsafe了,我们也是踩了好几回坑之后终于下决心把能不用unsafe的地方都改用其他方法了,剩下的地方也在进行性能评估没有太大影响的话将会删除unsafe。
golang unsafe遇上字符串拼接优化导致的bug的更多相关文章
- JS字符串拼接优化
// 请把以下用于连接字符串的JavaScript代码修改为更高效的方式 var htmlString = ‘ < div class=”container” > ’ + ‘ < u ...
- 【javaScript】js出现allocation size overflow以及字符串拼接优化
字符串拼接长一点了,就出现了allocation size overflow异常! 先创建缓冲字符串数组,最后将数组转化为字符串 <script type="text/javascri ...
- 从字符串拼接看JS优化原则
来自知乎的问题:JavaScript 怎样高效拼接字符串? 请把以下用于连接字符串的JavaScript代码修改为更高效的方式: var htmlString ='< div class=”co ...
- [Golang]字符串拼接方式的性能分析
本文100%由本人(Haoxiang Ma)原创,如需转载请注明出处. 本文写于2019/02/16,基于Go 1.11.至于其他版本的Go SDK,如有出入请自行查阅其他资料. Overview 写 ...
- golang的字符串拼接
常用拼接方法 字符串拼接在日常开发中是很常见的需求,目前有两种普遍做法: 一种是直接用 += 来拼接 s1 := "Hello" s2 := "World" s ...
- golang中的字符串拼接
go语言中支持的字符串拼接的方法有很多种,这里就来罗列一下 常用的字符串拼接方法 1.最常用的方法肯定是 + 连接两个字符串.这与python类似,不过由于golang中的字符串是不可变的类型,因此用 ...
- Java中测试StringBuilder、StringBuffer、String在字符串拼接上的性能
应一个大量字符串拼接的任务 测试一下StringBuilder.StringBuffer.String在操作字符串拼接时候的性能 性能上理论是StringBuilder > StringBu ...
- Sql动态查询拼接字符串的优化
Sql动态查询拼接字符串的优化 最原始的 直接写:string sql="select * from TestTables where 1=1";... 这样的代码效率很低的,这样 ...
- Golang拼接字符串的5种方法及其效率_Chrispink-CSDN博客_golang 字符串拼接效率 https://blog.csdn.net/m0_37422289/article/details/103362740
Different ways to concatenate two strings in Golang - GeeksforGeeks https://www.geeksforgeeks.org/di ...
- 【SQL】小心字符串拼接导致长度爆表
请看代码: DECLARE @max VARCHAR(max) SET @max='aaa...' --这里有8000个a +'bb' --连接一个varchar常量或变量 SELECT LEN(@m ...
随机推荐
- Processing中获取表格数据( .tsv\.csv )的经验分享
在日常收集数据的需求中,会有很多场合用到表格数据类型,如.tsv和.csv,一来高效查看和编辑,二来数据条理清晰,导入数据结构方便.在Prcocessing中帮我预留好了loadTable().loa ...
- 面试题54. 二叉搜索树的第k大节点
地址:https://leetcode-cn.com/problems/er-cha-sou-suo-shu-de-di-kda-jie-dian-lcof/ <?php /** 面试题54. ...
- 什么是 IPv6,为什么我们还未普及?
在大多数情况下,已经没有人一再对互联网地址耗尽的可怕境况发出警告,因为,从互联网协议版本 4(IPv4)的世界到 IPv6 的迁移,虽然缓慢,但已经坚定地开始了,并且相关软件已经到位,以防止许多人预测 ...
- 【长知识】BIOS
设置最新UEFI BIOS 本章导读 BIOS是电脑启动和操作的基础,若电脑系统中没有BIOS,则所有硬件设备都不能正常使用.UEFI是目前最新的BIOS类型,以后会逐渐取代传统的BIOS.本章将认识 ...
- CoreOS 更新重启后, 所有容器服务全部停掉了
今天有几个服务出问题了,上去看了下,这台 CoreOS 下的所有容器服务竟然全部停掉了,好奇怪,启动容器时明明加了--detach参数了呀. 问题原因 想了想,会不是是 CoreOS 更新重启导致的, ...
- datasnap的监督功能【3】-TCP链接监督功能
1.对于使用TCP/IP链接的客户端应用程序,是具有状态的.一直等到客户端完成服务请求后释放配置的资源.如何掉线了,那么服务器就是傻傻地等着,可能导致资源耗尽. 如何在服务端选择一个链接切断关闭之: ...
- JMeter跨线程传参总结
- 格林威治时间(Tue Jan 01 00:00:00 CST 2019)转Date
Excel导入时后台接受日期格式数据为[格林威治时间](例:Tue Jan 01 00:00:00 CST 2019) 格林威治时间转Date package com.cn; import java. ...
- 刷题——关于struts框架,下面那些说法是正确的?
关于struts框架,下面那些说法是正确的? Struts中无法完成上传功能 Struts框架基于MVC模式 Struts框架容易引起流程复杂.结构不清晰等问题 Struts可以有效地降低项目的类文件 ...
- python,批量修改文件后缀名
比如,D盘test目录下有以下几个没有后缀的文件,需要修改为txt结尾 python代码如下 # python批量更换后缀名 import os import sys #需要修改后缀的文件目录 os. ...