可以同时对一个 go string 进行读写操作吗?
May 30, 2020
写过 Go 代码的同学都知道,在程序内启动多个 goroutine 处理任务是很常见的事情, 启动一个 goroutine 要比启动一个线程简单的多。当多个 goroutine 同时处理同一份数据时, 我们应该在代码中加入同步机制,保证多个 goroutine 按照一定顺序来访问数据, 不然就会出现 data race。 最常见的例子如下,同时写操作 map 数据会导致程序 panic,即使操作的是不同 key:
// example 1
package main
func main() {
for {
c := make(chan bool)
m := make(map[string]string)
go func() {
m["1"] = "a" // First conflicting access.
c <- true
}()
m["2"] = "b" // Second conflicting access.
<-c
}
}
那么下面的代码也会 panic 吗?
// example 2
//
1 package main
2
3 import "sync"
4
5 func main() {
6 var wg sync.WaitGroup
7
8 for {
9 var s string
10 var r []byte
11
12 wg.Add(2)
13
14 // goroutine 1: update string s
15 go func() {
16 defer wg.Done()
17 s = "panic?"
18 }()
19
20 // goroutine 2: read string s
21 go func() {
22 defer wg.Done()
23 r = append(r, s...)
24 }()
25
26 wg.Wait()
27 }
28 }
答案稍后揭晓,现在先看下 Go Runtime 下是如何描述 string 数据: 一个 string 有两个 字段,str 用来存储长度为 len 的字符串。那么 string 的赋值会是原子操作吗?
// https://github.com/golang/go/tree/release-branch.go1.13/src/runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
// 为了防止编译器优化带来影响,需要在下面的代码里引入 print 和额外的 goroutine,
// 保证在汇编结果里就可以看到实际的字符串赋值语句了。
//
// cat -n main.go
1 package main
2
3 func main() {
4 var s string
5 go func() {
6 s = "I am string"
7 }()
8 print(s)
9 }
为了查看具体 string 赋值代码,这里需要使用 go tool compile -S ./main.go
来获
取汇编结果。在下面的输出结果中,s = "I am string"
赋值语句会被拆成两部分: 先
更新字符串的长度 len 字段, 再更新具体的字符串内容到 str 字段。
"".main.func1 STEXT size=89 args=0x8 locals=0x8
0x0000 00000 (./main.go:5) TEXT "".main.func1(SB), ABIInternal, $8-8
0x0000 00000 (./main.go:5) MOVQ (TLS), CX
0x0009 00009 (./main.go:5) CMPQ SP, 16(CX)
0x000d 00013 (./main.go:5) JLS 82
0x000f 00015 (./main.go:5) SUBQ $8, SP
0x0013 00019 (./main.go:5) MOVQ BP, (SP)
0x0017 00023 (./main.go:5) LEAQ (SP), BP
0x001b 00027 (./main.go:5) FUNCDATA $0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)
0x001b 00027 (./main.go:5) FUNCDATA $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x001b 00027 (./main.go:5) FUNCDATA $2, gclocals·39825eea4be6e41a70480a53a624f97b(SB)
0x001b 00027 (./main.go:6) PCDATA $0, $1
0x001b 00027 (./main.go:6) PCDATA $1, $1
0x001b 00027 (./main.go:6) MOVQ "".&s+16(SP), DI
先更新长度 0x0020 00032 (./main.go:6) MOVQ $11, 8(DI)
0x0028 00040 (./main.go:6) PCDATA $0, $-2
0x0028 00040 (./main.go:6) PCDATA $1, $-2
0x0028 00040 (./main.go:6) CMPL runtime.writeBarrier(SB), $0
0x002f 00047 (./main.go:6) JNE 68
再赋值内容 0x0031 00049 (./main.go:6) LEAQ go.string."I am string"(SB), AX
0x0038 00056 (./main.go:6) MOVQ AX, (DI)
0x003b 00059 (./main.go:7) MOVQ (SP), BP
0x003f 00063 (./main.go:7) ADDQ $8, SP
0x0043 00067 (./main.go:7) RET
0x0044 00068 (./main.go:6) LEAQ go.string."I am string"(SB), AX
0x004b 00075 (./main.go:6) CALL runtime.gcWriteBarrier(SB)
0x0050 00080 (./main.go:6) JMP 59
0x0052 00082 (./main.go:6) NOP
0x0052 00082 (./main.go:5) PCDATA $1, $-1
0x0052 00082 (./main.go:5) PCDATA $0, $-1
0x0052 00082 (./main.go:5) CALL runtime.morestack_noctxt(SB)
0x0057 00087 (./main.go:5) JMP 0
NOTE:
runtime.xxxBarrier
是 Go 编译器为垃圾回收生成的代码,可以忽略。
回到一开始的问题 example 2 代码片段,r = append(r, s...)
采用 memmove
方法从字符串 s 拷贝 len(s) 个字节到 r 里。由于 s = "panic?"
赋值和 append
读
操作是同时进行:假设 s.len 已经被更新成 6 ,但是 s.str 还是 nil 状态,这个时候
正好执行了 append 的操作,直接读取空指针必定会 panic。
// 其中一种可能的执行顺序
goruntine 1: set s.len = len("panic?") # 6 字节
goruntine 2: r = append(r, s...) # 将从 s.str 中拷贝 6 字节,但 s.str = nil
goroutine 1: set s.str = "panic?"
// part of example 2
//
(...)
14 // goroutine 1: update string s
15 go func() {
16 defer wg.Done()
17 s = "panic?"
18 }()
19
20 // goroutine 2: read string s
21 go func() {
22 defer wg.Done()
23 r = append(r, s...)
24 }()
(...)
除了 append 这种场景以外,字符串的比较同样需要 len 和 str 一致。如果在执行读操作 时,str 实际存储的数据长度比 len 短,程序就会 panic。所以避免 data race 最好方式 就是采用合适的同步机制,这来自 Go 团队给出的最佳实践:
Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.
from https://golang.org/ref/mem Advice section