暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

可以同时对一个 go string 进行读写操作吗?

Loopback 2020-05-31
281

写过 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
      //
      package main


      import "sync"


      func main() {
      var wg sync.WaitGroup


      for {
      var s string
                      var r []byte
                      wg.Add(2)
      // goroutine 1: update string s
      go func() {
      defer wg.Done()
      s = "panic?"
                      }()
      // goroutine 2: read string s
      go func() {
      defer wg.Done()
      r = append(r, s...)
                      }()
      wg.Wait()
      }
      }

      答案稍后揭晓,现在先看下 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
            //
            (...)
            // goroutine 1: update string s
            go func() {
            defer wg.Done()
            s = "panic?"
                            }()
            // goroutine 2: read string s
            go func() {
            defer wg.Done()
            r = append(r, s...)
                            }()
            (...)

            除了 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


            文章转载自Loopback,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

            评论