可以同时对一个 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