我相信很多人都知道 data race, 但是仅仅停留在知道并发读写一个变量会导致内存可见性问题,最终出现业务异常,那么会出现哪些业务异常呢?我会列一些我们业务中发现的真实问题给大家讲解!
所以怎么避免呢?尽可能单侧加上 -race 测试!
例子1
在map使用写少(基本不写)读多的场景中,且map中元素不多,我们没必要新增一个锁来解决问题,通常来说可以用以下代码解决!很多同学第一时间就会觉得需要在 atomic.Value,确实如此,但是我们服务器没开-race所以无所谓了,不需要使用atomic.Value!
package test_map
import (
"sync"
"testing"
)
type Map struct {
kv map[string]interface{} // 8byte
}
func (m *Map) Get(key string) interface{} {
return m.kv[key]
}
func (m *Map) clone() map[string]interface{} {
res := make(map[string]interface{}, len(m.kv))
for k, v := range m.kv {
res[k] = v
}
return res
}
func (m *Map) Set(mm map[string]interface{}) {
clone := m.clone()
for k, v := range mm {
clone[k] = v
}
m.kv = clone // replace
}
func TestMapRW(t *testing.T) {
wg := sync.WaitGroup{}
wg.Add(11)
kv := Map{}
kv.Set(map[string]interface{}{"1": 1})
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
for j := 0; j < 100000; j++ {
kv.Get("1")
}
}()
}
for i := 0; i < 1; i++ {
go func() {
defer wg.Done()
for j := 0; j < 100000; j++ {
kv.Set(map[string]interface{}{"2": 2})
}
}()
}
wg.Wait()
}
但是会出现一个问题?此代码在 amd64 机器上没问题,但是在arm64 上有问题!
因此我给官方提了个issues github.com/golang/go/i…
给的答复是:
On a system with a total store order (TSO, all stores become visible in program order, i.e., amd64), the concurrent map read and map write
error will never occur because the reader will never see the hashWriting
bit.
On a system without TSO (arm64), I suspect something like this occurs:
- G1 in Set(), finishing up writing the last element to the new map,
clone
- G1 clears the
writing
flag:clone.flags &^= hashWriting
(clone.flags == 0
now) - G1 publishes the new map
m.kv = clone
. - G2 reads
m.kv
, receiving the new mapclone
- G2 starts accessing the map and reads
clone.flag == hashWriting
. - G2 triggers concurrent read/write map error.
What happens here is that without TSO, one CPU clears hashWriting
and then writes to m.kv
, but the other CPU observes the write to m.kv
but not the write to clone.flags
. So this explains why only arm64 sees the throw.
解释后的意思就是,在amd64的芯片上会保证 TSO (Total Store Order) 相对较强的内存一致性模型,他能保证你写入和读取的顺序性,我们可以用下面代码理解下官方给的答复
关于内存一致性问题可以参考文章: jamesbornholt.com/blog/memory… 和 research.swtch.com/plmm
package main
import (
"fmt"
"time"
)
type Data struct {
flag int
}
func main() {
var data = &Data{flag: 1}
go func() {
for {
if v := data.flag; v != 1 {
panic(fmt.Sprintf(`error: %v`, v))
}
}
}()
go func() {
for {
clone := &Data{flag: 2}
clone.flag = 1
data = clone
}
}()
time.Sleep(time.Second * 5)
}
这里我们可以用这个代码理解下,其中在arm64芯片上会报错: panic: error: 0
这里为啥会报错 panic: error: 0
原因是因为
- (1)g2: clone = &Data{}
- (2)g2: clone.flag = 2
- (3)g2: clone.flag=1
- (4)g2: data=clone (修改变量data的值[指针],比如从 0x140000ac008 到 0x140001ac0b8)
- (5)g1: data.flag
在没有强内存一致性下的arm芯片下,很可能出现 g1线程仅关注到了data的值从 0x140000ac008[origin data] 变成了 0x140001ac0b8[clone],但是并没有关注到 clone 的flag从0->2->1 ! 因此读取到了 v=0 !
那就说明 data ptr 的修改(0x140000ac008->0x140001ac0b8) 和 clone.flag(0->2->1) 修改不是顺序一致的!(TSO 完全存储顺序,同步的顺序有问题)
参考文章: mysteriouspreserve.com/blog/2023/0…
ARM 系统的概念模型是每个处理器从其自己的完整的内存副本中读取和写入,每个写入都独立地传播到其他处理器,允许在写入传播时重新排序
AMD 是 TSO(完全存储顺序),就是存储过程满足顺序一致性!
解决:如何确保当一个线程或协程修改了共享变量的值后,这个变化对其他线程或协程可见且顺序一致性!
- 这里包含一个变量的修改可见性
- 多个变量的可见性,比如两个变量(a1,b1初始化为0),然后a线程修改先修改了 a1=1,后修改了b1=1,那么b线程读取到了b1=1, 那么a1一定是1!
总结:关于上诉问题,我们可以用 atomic 、mutex 解决解决!
例子2
很多人喜欢把结构体清空了下次复用(gopool),但是对于有些场景,比如你回收的线程和使用的线程不在一起或者生命周期不对,就会导致以下问题(通常异步场景最好clone一份,比如gin.Context 异步场景 clone一份ctx)
package main
import (
"fmt"
"time"
)
func main() {
str := "123"
go func() {
for i := 1; i < 10000; i++ {
_ = fmt.Sprintf("fullPath: %s", str)
}
}()
for {
str = "456789"
time.Sleep(time.Nanosecond)
str = ""
time.Sleep(time.Nanosecond)
}
}
有概率报错(arm64/amd64 都会有)!
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x104f4f6b8]
goroutine 20 [running]:
fmt.(*buffer).writeString(...)
/Users/bytedance/software/go1.20/src/fmt/print.go:108
fmt.(*fmt).padString(0x14000045518?, {0x0, 0xf})
问题?
// Use simple []byte instead of bytes.Buffer to avoid large dependency.
type buffer []byte
func (b *buffer) writeString(s string) {
*b = append(*b, s...)
}
原因:
-
在写入 string(data,len) 和 读取 string(data,len) 他不是并发安全操作,没加锁或者原子操作替换,导致会出现 data=nullptr , len=xxx 就会出现此问题!
- g1: write string ("456789", 6)
- g2: read string len = 6
- g1: write string (nullptr, 0)
- g2: read string data = nullptr
-
string = "" 的时候 data 为 nullptr,导致程序挂掉,但是如果 data不为空,len 不匹配是不会程序报错的,仅仅只会程序不符合预期!
可以用下面代码复线
package main
import (
"fmt"
"reflect"
"unsafe"
)
type buffer []byte
func (b *buffer) writeString(s string) {
*b = append(*b, s...)
}
func modifyString(s string) string {
v := *(*reflect.StringHeader)(unsafe.Pointer(&s))
// v.Len = v.Len * 10 // 这里只会内存越界,只要不访问非法内存就没啥事
v.Data = 0 // 如果data为0,则 segmentation violation
return *(*string)(unsafe.Pointer(&v.Data))
}
func main() {
buff := buffer{}
v := modifyString("222")
buff.writeString(v)
fmt.Println(len(buff)) // 30
fmt.Println(string(buff))
}