数据竞争与竞态条件

数据竞争

定义:①多个线程对于同一个变量、②同时地、③进行读/写操作的现象并且④至少有一个线程进行写操作。(也就是说,如果所有线程都是只进行读操作,那么将不构成数据争用)

后果:如果发生了数据争用,读取该变量时得到的值将变得不可知,使得该多线程程序的运行结果将完全不可预测,可能直接崩溃。

如何防止:对于有可能被多个线程同时访问的变量使用排他访问控制,具体方法包括使用mutex(互斥量)和monitor(监视器),或者使用atomic变量。

例如,对一个非同步变量进行多个并发读取就可以了:

1
2
3
4
5
6
7
8
const a = 3

func main() {
    go func() {
        fmt.Printf("Thread B: %d\n", a)
    }
    fmt.Printf("Thread A: %d\n", a)
}

即使打印顺序因执行而异,但由于两个线程都仅从数据读取,因此没有数据竞争。

如果现在我们可以a可变地访问其中一个线程,则将引入数据竞争:

1
2
3
4
5
6
7
func main() {
    a := 3
    go func() {
        a = 10
    }
    fmt.Printf("Thread A: %d\n", a)
}

我们可以通过引入互斥量来同步访问权限来解决此问题a:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    a := 3
    var m sync.Mutex
    go func() {
        m.Lock()
        a = 10
        m.Unlock()
    }
    m.Lock()
    fmt.Printf("Thread A: %d\n", a)
    m.Unlock()
}

两个线程正在同时访问a,并且其中一个正在写入,但是由于访问是同步的,因此这不再是数据竞争。

竞态条件

相对于数据争用(data race),竞态条件(race condition)指的是更加高层次的更加复杂的现象,一般需要在设计并行程序时进行细致入微的分析,才能确定。(也就是隐藏得更深)

定义:受各线程上代码执行的顺序和时机的影响,程序的运行结果产生(预料之外)的变化。

后果:如果存在竞态条件(race condition),多次运行程序对于同一个输入将会有不同的结果,但结果并非完全不可预测,它将由输入数据和各线程的执行顺序共同决定。

如何预防:竞态条件产生的原因很多是对于同一个资源的一系列连续操作并不是原子性的,也就是说有可能在执行的中途被其他线程抢占,同时这个“其他线程”刚好也要访问这个资源。解决方法通常是:将这一系列操作作为一个critical section(临界区)。

从理论上讲,任何来自并发的可观察到的非确定性都可以视为 竞争条件,但实际上构成竞争条件的因素取决于我们希望程序尊重的属性。

让我们以以下程序为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
    go func() {
        for {
            fmt.Println("Thread B")
        }
    }
    for {
        fmt.Println("Thread A")
    }
}

我们将看到两种消息的某种随机交织:

1
2
3
4
5
6
7
Thread A
Thread A
Thread B
Thread A
Thread B
Thread B
...

如果我们希望程序尊重印刷的确切顺序,那么这可以认为是一种竞争条件。我们可以使用某种形式的同步来强制执行该打印顺序。

在实践中,即使执行不是确定性的,我们也不会将其视为竞争条件,因为这不是我们关心的属性。

总而言之,竞争条件是由于程序的并行执行而导致我们的程序应具有的某些特性的违反。

竞态检查

data race 是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。这种类型的代码可以创建您见过的最疯狂和最随机的 bug。通常需要大量的日志记录和运气才能找到这些类型的bug。

早在6月份的Go 1.1中,Go 工具引入了一个 race detector。竞争检测器是在构建过程中内置到程序中的代码。然后,一旦你的程序运行,它就能够检测并报告它发现的任何竞争条件。它非常酷,并且在识别罪魁祸首的代码方面做了令人难以置信的工作。

1
2
go build -race
go test -race

-race对程序负载较高,线上环境不建议使用.

工具似乎检测到代码的争用条件。如果您查看race condition 报告下面,您可以看到程序的输出: 全局计数器变量的值为4。

试图通过 i++ 方式来解决原子赋值的问题,但是我们通过查看底层汇编:

实际上有三行汇编代码在执行以增加计数器。这三行汇编代码看起来很像原始的Go代码。在这三行汇编代码之后可能有一个上下文切换。尽管程序现在正在运行,但从技术上讲,这个bug 仍然存在。我们的 Go 代码看起来像是在安全地访问资源,而实际上底层的程序集代码根本就不安全。

竞态检测器只能在运行代码实际触发竞态条件时才检测竞态条件,这意味着在实际的工作负载下运行启用竞态的二进制文件非常重要。但是,启用竞态的二进制文件可以使用十倍于 CPU 和内存,因此始终启用竞态检测器是不切实际的。解决此难题的一种方法是在启用了竞态检测器的情况下运行一些测试。负载测试和集成测试是不错的选择,因为它们倾向于使用代码的并发部分。使用生产工作负载的另一种方法是在运行中的服务器池中部署一个启用了竞争的实例.当然开启了该选项也未必能检测出潜在的数据竞争,当你的程序跑到数据竞争的片段它就会检测出来,你的程序可能有很多模块,当程序没有执行到数据竞争的地方那直到整个程序执行结束它也检测不出来。

示例

这里有竞态探测器检测到实际问题的两个示例.

示例 1: Timer.Reset

第一个示例是由竞态检测器发现的实际错误的简化版本。它使用计时器在 0 到 1 秒之间的随机持续时间后打印消息。如此重复五秒钟。它使用 time.AfterFunc 函数创建一个 Timer 作为第一个消息,然后使用 Reset 方法来调度下一条消息,每次重新使用 Timer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    start := time.Now()
    var t *time.Timer
    t = time.AfterFunc(randomDuration(), func() {
        fmt.Println(time.Now().Sub(start))
        t.Reset(randomDuration())
    })
    time.Sleep(5* time.Second)
}
func randomDuration() time.Duration {
    return time.Duration(rand.Int63n(1e9))
}

这看起来像是合理的代码,但是在某些情况下,它以令人惊讶的方式失败:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x8 pc=0x41e38a]

goroutine 4 [running]:
time.stopTimer(0x8, 0x12fe6b35d9472d96)
    src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
    src/pkg/time/sleep.go:81 +0x42
main.func·001()
    race.go:14 +0xe3
created by time.goFunc
    src/pkg/time/sleep.go:122 +0x48

这里发生了什么?在启用了竞态检测器的情况下运行程序更具启发性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
==================
WARNING: DATA RACE
Read by goroutine 5:
  main.func·001()
     race.go:14 +0x169

Previous write by goroutine 1:
  main.main()
      race.go:15 +0x174

Goroutine 5 (running) created at
  time.goFunc()
      src/pkg/time/sleep.go:122 +0x56
  timerproc()
     src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
==================

竞态检测器显示了问题:来自不同 goroutine 的变量 t 的异步读取和写入。如果初始计时器持续时间非常短,则计时器函数可能会在主 goroutine 将值分配给 t 之前触发,因此对 t.Reset 的调用将被设置为一个空的 t.

要修正竞态条件,我们将代码更改为仅从主 goroutine 中读取和写入变量 t:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
    start := time.Now()
    reset := make(chan bool)
    var t *time.Timer
    t = time.AfterFunc(randomDuration(), func() {
        fmt.Println(time.Now().Sub(start))
        reset <- true
    })
    for time.Since(start) < 5*time.Second {
        <-reset
        t.Reset(randomDuration())
    }
}

这里的主协程完全负责设置和重置 Timer t, 并且新的重置通道传达了以线程安全的方式重置计时器的需求.

一种简单但效率较低的方法是 避免重复使用计时器.

示例 2: ioutil.Discard

第二个示例更加微妙.

The ioutil package’s Discard object implements io.Writer, but discards all the data written to it. Think of it like /dev/null: a place to send data that you need to read but don’t want to store. It is commonly used with io.Copy to drain a reader, like this:

ioutil 包的 Discard 对象实现了 io.Writer, 但会丢弃所有写入其中的数据。可以将其视为 /dev/null: 发送需要读取但不想存储的数据的地方。它通常与 io.Copy 一起使用以消耗读取器,例如:

1
io.Copy(ioutil.Discard, reader)

回顾 2011 年 7 月,Go 团队注意到以这种方式使用 Discard 效率低下: Copy 函数每次调用时都会分配一个内部 32 kB 的缓冲区,但与 Discard 一起使用时缓冲区是不必要的,因为我们只是丢弃读取的数据。我们认为这种对 Copy 和 Discard 的惯用用法应该不会那么昂贵.

解决方法很简单。如果给定的 Writer 实现了 ReadFrom 方法,则 Copy 的调用如下:

1
io.Copy(writer, reader)

被委派给这个可能更有效的调用:

1
writer.ReadFrom(reader)

我们为 Discard 的基础类型 添加了 ReadFrom 方法 , 该基础类型具有内部缓冲区,该缓冲区在所有用户之间共享。我们知道从理论上讲这是一个竞争条件,但是由于所有对缓冲区的写操作都应该被丢弃,所以我们认为这并不重要.

实施竞态检测器后,它立即被标记为恶意代码 (golang.org/issue/3970). 再次,我们认为代码可能有问题,但是决定竞争条件不是 “真实的”. 为了避免在我们的版本中出现 “误报”, 我们实现了一个 非安全版本 , 该功能仅在运行竞态检测器时才启用.

但是几个月后,Brad 遇到了一个 令人沮丧和奇怪的错误. 经过几天的调试,他将其范围缩小到了由 ioutil.Discard 引起的实际竞争情况.

这是 io/ioutil 中的已知代码,其中 Discard 是一个 devNull, 它在所有用户之间共享一个缓冲区.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var blackHole [4096]byte // 共享缓冲区

func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
    readSize := 0
    for {
        readSize, err = r.Read(blackHole[:])
        n += int64(readSize)
        if err != nil {
            if err == io.EOF {
                return n, nil
            }
            return
        }
    }
}

Brad 的程序包括一个 trackDigestReader 类型,该类型包装一个 io.Reader 并记录其读取内容的哈希摘要.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type trackDigestReader struct {
    r io.Reader
    h hash.Hash
}

func (t trackDigestReader) Read(p []byte) (n int, err error) {
    n, err = t.r.Read(p)
    t.h.Write(p[:n])
    return
}

例如,可以在读取文件时将其用于计算文件的 SHA-1 哈希:

1
2
3
tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))

在某些情况下,将无可写数据 - 但仍需要对文件进行哈希处理 - 因此将使用 Discard:

1
io.Copy(ioutil.Discard, tdr)

但是在这种情况下,blackHole 缓冲区不仅仅是一个黑洞;在从源 io.Reader 读取数据并将其写入 hash.Hash 的之间存储数据的合法位置。通过同时使用多个 goroutines 散列文件 (每个文件共享相同的 blackHole 缓冲区), 竞争条件通过破坏读取和散列之间的数据来表现出来。没有发生错误或惊慌,但哈希是错误的。脑壳疼!

1
2
3
4
5
6
7
8
func (t trackDigestReader) Read(p []byte) (n int, err error) {
    // 缓冲区 p 是一个黑洞
    n, err = t.r.Read(p)
    // p 可能再这里被另一个协程破坏了,
    // 在上面读取和下面写入之间
    t.h.Write(p[:n])
    return
}

通过为 ioutil.Discard 的每次使用分配唯一的缓冲区,消除了共享缓冲区上的竞态条件,最终修复了该错误 (golang.org/cl/7011047).

不存在安全的data race

这看起来是一个串行化无限调用loop0和loop1的过程,但因为go关键字,我们不知道什么时候会产生调度,这个过程中会创建很多goroutine,先后执行顺序不知道.所以会出现race.

但因为是指针赋值,我们第一感觉是 single machine word 应该是原子赋值,为啥 -race 会乱报。我们执行这个代码看看会发生什么。

Type 指向实现了接口的 struct,Data 指向了实际的值。Data 作为通过 interface 中任何方法调用的接收方传递。

对于语句 var maker IceCreamMaker=ben,编译器将生成执行以下操作的代码。

当 loop1() 执行 maker=jerry 语句时,必须更新接口值的两个字段。

表示写入单个 machine word 将是原子的,但 interface 内部是是两个 machine word 的值。另一个goroutine 可能在更改接口值时观察到它的内容。

在这个例子中,Ben 和 Jerry 内存结构布局是相同的,因此它们在某种意义上是兼容的。想象一下,如果他们有不同的内存布局会发生什么混乱?

因为赋值过程不是原子性的,所以在赋值过程中因为两个变量内存布局不同,会panic.

如果是一个普通的指针、map、slice 可以安全的更新吗?

没有安全的 data race(safe data race)。您的程序要么没有 data race,要么其操作未定义,不要假定任何无同步原语的写入操作是原子性的.

  • 原子性
  • 可见行

机器字

知道了 race condition 的定义后,我们先来看一段代码考考大家,以下代码是否正确。

代码 A

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main
import "fmt"
type myType struct {
	A int
}
func main() {
	c := make(chan bool)
	x := new(myType)
	go func() {
		x = new(myType) // write to x
		c <- true
	}()
	_ = *x // read from x
	<-c
	fmt.Println("end")
}

这段代码主要做了两件事情,在第 10 行的一个 goroutine 中写入了一个指针到 x,在第 13 行的另一个 goroutine 中读取了该指针中的数据。这是一段抽象出的代码,在真实项目的这段代码对应的是一个 goroutine 的行为,这个 goroutine 会定时地发起 http 请求更新缓存中的数据,然后另一个 goroutine 会不停地读取这个缓存。

那么这段代码到底正确与否呢?答案是:即是错误的又是正确的。下面我们来慢慢分析。

Data Race

首先我们来说说为什么这段代码是错误的。 代码 A 所犯的错误叫 data race , data race 是 race condition 中的一种。我们来看下 go 官方对 data race 的定义。

A data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write.

简单来说 data race 就是在两个线程同时访问一块内存并且其中至少有一个写的操作,而上述的代码 A 就是 data race 的标准错误示例。

为什么代码 A 又是正确的?

首先在解释代码 A 正确之前我们需要先知道一个概念 pointer size,也就是指针占用的内存大小。

Typically, a pointer is the same size as your system’s architecture, 32 bits on a 32 bit system and 64 bits on a 64 bit system. If the argument is a scalar type (bool, int, float, etc), it’s going to be less than or equal to the size of a pointer. If the argument is a compound type, such as a struct with multiple fields, it’s likely the pointer is smaller.

通常情况下指针的大小是小于等于系统的 machine word 的,比如 32 位的系统指针 size 是小于等于 32 bit,64位的系统指针 size 是小于等于 64 bit。由这一点我们可以知道代码 A 中的指针大小也是小于等于一个 machine word 的。下面是一段简单的代码,可以亲自实践一下查看 pointer 具体的大小。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 输出 4
// 运行 GOARCH="386"  go run test.go
// 输出 8
// 运行 GOARCH="amd64"  go run test.go
package main
import (
	"fmt"
	"unsafe"
)
func main() {
	var a *int
	fmt.Println(unsafe.Sizeof(a))
}

代码 A 是正确的第二点原因是 CPU 寄存器的大小一定大于一个 machine word 。也就是说数据复制的最小单元大于一个 machine word。所以在复制指针的时候不可能出现复制到一半的中间状态,这也就解释了为什么代码 A 一定是正确的。

为什么代码 A 是正确的却不应该使用呢?

第一点原因是代码 A 出现了 data race,而 data race 的行为对编译器来说是 undefined 的。完全有可能谋个版本的编译器做了特殊的优化,从而导致这部分代码会出错。

第二点原因是代码 A 的用法依赖了硬件的实现,而硬件的实现对于 go 来说是不可控的,也就是 “uncontrollable events”。

第三点原因是人的因素。写下代码 A 的人可能是个资深的程序员十分了解了上述的原理以及风险。但是后续维护的程序员不一定也掌握了这些知识,他们可能会依样画葫芦,针对其他数据结构也都不加同步控制的进行并发读写,从而造成一些可怕的结果。

第四点原因是使用同步控制能够提升代码的可读性,当你的代码在某个地方加了并发控制,比如锁以后,其他程序员立刻就会警觉起来,从而更加注意减少犯错的风险。

第五点原因是 go 有一个 race condition 的检测工具 go run -race xxx.go ,也就是当加上 -race 选项,可以辅助检测可能存在的 race condition。虽然代码 A 是“无害”的,但是这个工具可以立即检测出存在 data race。如果大家长期忽略这个检查,等真正出现 date race 时就有可能会被忽略了,从而造成危险。

正确的做法

所以在并发编程中正确的做法是一定要使用同步控制,比如互斥锁、channel、以及 sync/atomic。个人我很喜欢 atomic 这个包,它的性能是三者最好的。

自增不是原子操作

让我们写一个非常的简单的包含竞态条件内置竞态检测代码的程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
    "fmt"
    "sync"
)

var Wait sync.WaitGroup
var Counter int = 0

func main() {

    for routine := 1; routine <= 2; routine++ {

        Wait.Add(1)
        go Routine(routine)
    }

    Wait.Wait()
    fmt.Printf("Final Counter: %d\n", Counter)
}

func Routine(id int) {

    for count := 0; count < 2; count++ {

        value := Counter
        value++
        Counter = value
    }

    Wait.Done()
}

这个程序看起来没有问题。它创建了两个协程,每一个协程都会增加全局变量 Counter 两次。当他们都运行结束后,程序显示全局变量 Counter 的值。当我运行这个程序的时候,他会显示正确答案 4。所以这个程序工作正常,但真的吗?

让我们通过 Go 竞态检测运行这个代码,看看它会发现什么? 在代码所在的目录打开终端,以 -race 参数编译代码。

1
go build -race

然后程序输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
==================
WARNING: DATA RACE
Read by goroutine 5:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:29 +0x44
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

Previous write by goroutine 4:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:33 +0x65
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

Goroutine 5 (running) created at:
  main.main()
      /Users/bill/Spaces/Test/src/test/main.go:17 +0x66
  runtime.main()
      /usr/local/go/src/pkg/runtime/proc.c:182 +0x91

Goroutine 4 (finished) created at:
  main.main()
      /Users/bill/Spaces/Test/src/test/main.go:17 +0x66
  runtime.main()
      /usr/local/go/src/pkg/runtime/proc.c:182 +0x91

==================
Final Counter: 4
Found 1 data race(s)

看起来,工具在代码中检测到竞争条件。如果你查看上面的竞争条件报告,你会看到针对程序的输出。全局变量 Counter 的值是 4。这就是这类的 bug 的难点所在,代码大部分情况是工作正常的,但错误的情况会随机产生。竞争检测告诉我们隐藏在代码中的糟糕问题。

警告报告告诉我们问题发生的准确位置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Read by goroutine 5:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:29 +0x44
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

        value := Counter

Previous write by goroutine 4:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:33 +0x65
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

        Counter = value

Goroutine 5 (running) created at:
  main.main()
      /Users/bill/Spaces/Test/src/test/main.go:17 +0x66
  runtime.main()
      /usr/local/go/src/pkg/runtime/proc.c:182 +0x91

        go Routine(routine)

你能发现竞争检测器指出两行读和写全局变量 Counter 的代码。同时也指出生成协程的代码。

让我们对代码进行简单修改,让竞争情况更容易暴露出来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
    "fmt"
    "sync"
    "time"
)

var Wait sync.WaitGroup
var Counter int = 0

func main() {

    for routine := 1; routine <= 2; routine++ {

        Wait.Add(1)
        go Routine(routine)
    }

    Wait.Wait()
    fmt.Printf("Final Counter: %d\n", Counter)
}

func Routine(id int) {

    for count := 0; count < 2; count++ {

        value := Counter
        time.Sleep(1 * time.Nanosecond)
        value++
        Counter = value
    }

    Wait.Done()
}

我在循环中增加了一个纳秒的暂停。这个暂停正好位于协程读取全局变量 Couter 存储到本地副本之后。让我们运行这个程序看看在这种修改之后,全局变量 Counter 的值是什么?

1
Final Counter: 2

循环中的暂停导致程序的失败。Counter 变量的值不再是 4 而是 2。发生了什么? 让我们深挖代码看看为什么这个纳秒的暂停会导致这个 Bug。

在没有暂停的情况下,代码运行如下图:

没有暂停的情况下,第一个协程被生成,并且完成执行,紧接着第二个协程才开始运行。这就是为什么程序看起来像正确运行的原因,因为它在我的电脑上运行速度非常快,以至于代码自行排队运行。

让我们看看在有暂停的情况下,代码如何运行:

上图已经展示了所有必要的信息,因此我就没有把他全部画出来。这个暂停导致运行的两个协程之间进行了一次上下文切换。这次我们有一个完全不同的情况。让我们看看图中展示的代码:

1
2
3
4
5
6
7
value := Counter

time.Sleep(1 * time.Nanosecond)

value++

Counter = value

在每一次循环的迭代过程中,全局变量 Counter 的值都被暂存到本地变量 value,本地的副本自增后,最终写回全局变量 Counter。如果这三行代码在没有中断的情况下,没有立即运行,那么程序就会出现问题。上面的图片展示了全局变量 Counter 的读取和上下文切换是如何导致问题的。

在这幅图中,在被协程 1 增加的变量被写回全局变量 Counter 之前,协程 2 被唤醒并读取全局变量 Counter。实质上,这两个协程对全局Counter变量执行完全相同的读写操作,因此最终的结果才是 2。

为了解决这个问题,你也许认为我们只需要将增加全局变量 Counter 的三行代码改写减少到一行即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
    "fmt"
    "sync"
    "time"
)

var Wait sync.WaitGroup
var Counter int = 0

func main() {

    for routine := 1; routine <= 2; routine++ {

        Wait.Add(1)
        go Routine(routine)
    }

    Wait.Wait()
    fmt.Printf("Final Counter: %d\n", Counter)
}

func Routine(id int) {

    for count := 0; count < 2; count++ {

        Counter = Counter + 1
        time.Sleep(1 * time.Nanosecond)
    }

    Wait.Done()
}

当我们运行这个版本的代码的时候,我们会再次得到正确的结果:

1
Final Counter: 4

如果我们启动竞争检测来运行该代码,上面出现的问题应该会消失:

1
go build -race

并且输出为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
==================
WARNING: DATA RACE
Write by goroutine 5:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:30 +0x44
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

Previous write by goroutine 4:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:30 +0x44
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

Goroutine 5 (running) created at:
  main.main()
      /Users/bill/Spaces/Test/src/test/main.go:18 +0x66
  runtime.main()
      /usr/local/go/src/pkg/runtime/proc.c:182 +0x91

Goroutine 4 (running) created at:
  main.main()
      /Users/bill/Spaces/Test/src/test/main.go:18 +0x66
  runtime.main()
      /usr/local/go/src/pkg/runtime/proc.c:182 +0x91

==================
Final Counter: 4
Found 1 data race(s)

然而,在这三十行代码的程序中,我们仍然检测到一个竞争条件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Write by goroutine 5:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:30 +0x44
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

        Counter = Counter + 1

Previous write by goroutine 4:
  main.Routine()
      /Users/bill/Spaces/Test/src/test/main.go:30 +0x44
  gosched0()
      /usr/local/go/src/pkg/runtime/proc.c:1218 +0x9f

        Counter = Counter + 1

Goroutine 5 (running) created at:
  main.main()
      /Users/bill/Spaces/Test/src/test/main.go:18 +0x66
  runtime.main()
      /usr/local/go/src/pkg/runtime/proc.c:182 +0x91

        go Routine(routine)

使用一行代码进行增加操作的程序正确地运行了。但为什么代码仍然有一个竞态条件? 不要被我们用于递增 Counter 变量的一行Go代码所欺骗。让我们看看这一行代码生成的汇编代码:

1
2
3
0064 (./main.go:30) MOVQ Counter+0(SB),BX ; Copy the value of Counter to BX
0065 (./main.go:30) INCQ ,BX              ; Increment the value of BX
0066 (./main.go:30) MOVQ BX,Counter+0(SB) ; Move the new value to Counter

实际上是执行这三行汇编代码增加 counter 变量。他们十分诡异地看起来像最初的 Go 代码。上下文切换可能发生在这三行汇编的中的任意一行后面。尽管这个程序正常工作了,但严格来说,Bug 仍然存在。

尽管我使用的例子非常简单,它还是体现发现这种 Bug 的复杂性。任何一行由 Go 编译器产生的汇编代码都有可能因为下文切换而停止运行。我们的 Go 代码也许看起来能够安全地访问资源,实际上底层汇编代码可能漏洞百出。

为了解决这类问题,我们需要确保读写全局变量 Counter 总是在任何其他协程访问该变量之前完成。管道(channle)能够帮助我们有序地访问资源。这一次,我会使用一个互斥锁(Mutex):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
    "fmt"
    "sync"
    "time"
)

var Wait sync.WaitGroup
var Counter int = 0
var Lock sync.Mutex

func main() {

    for routine := 1; routine <= 2; routine++ {

        Wait.Add(1)
        go Routine(routine)
    }

    Wait.Wait()
    fmt.Printf("Final Counter: %d\n", Counter)
}

func Routine(id int) {

    for count := 0; count < 2; count++ {

        Lock.Lock()

        value := Counter
        time.Sleep(1 * time.Nanosecond)
        value++
        Counter = value

        Lock.Unlock()
    }

    Wait.Done()
}

以竞态检测的模式,编译程序,查看运行结果:

1
2
go build -race
./test
1
Final Counter: 4

这一次,我们得到了正确的结果,并且没有发现任何竞态条件。这个程序是没有问题的。互斥锁保护了在 Lock 和 Unlock 之间的代码,确保了一次只有一个协程执行该段代码。

单例模式

下面这个demo就是一个常见的 懒汉式单例模式,依靠go的共享变量不需要担心可见性的问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

var config map[string]string

func main() {
	count, _ := strconv.Atoi(os.Args[1])
	for x := 0; x < count; x++ {
		go getConfig()
	}
	<-time.After(time.Second)
}
func getConfig() map[string]string {
	if config == nil {
    fmt.Println("init config")
		config = map[string]string{}
		return config
	}
	return config
}

执行go run -race demo.go 100

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sgcx015@172-15-68-151:~/go/code/com.anthony.http % go run -race cmd/once_demo.go 100
init config // load
==================
WARNING: DATA RACE
init config //load
Write at 0x0000012109c0 by goroutine 7: // g7在22行写
  main.getConfig()
      /Users/sgcx015/go/code/com.anthony.http/cmd/once_demo.go:22 +0xd2

Previous read at 0x0000012109c0 by goroutine 8: //g8在20行读race  main.getConfig()
      /Users/sgcx015/go/code/com.anthony.http/cmd/once_demo.go:20 +0x3e

Goroutine 7 (running) created at:// 这些无效信息
  main.main()
      /Users/sgcx015/go/code/com.anthony.http/cmd/once_demo.go:15 +0xae

Goroutine 8 (running) created at:
  main.main()
      /Users/sgcx015/go/code/com.anthony.http/cmd/once_demo.go:15 +0xae
==================
Found 1 data race(s)
exit status 66

发现出现读写竞争了,那么对于我们这种写法来说,确实存在多个线程同时去load,所以加载了两次。那么我们的业务场景是无关紧要的,因为配置加载几次无所谓。

​这里总结一下,race触发的条件不是同时写,而是读写同时发生,这个问题很严重,严重在哪呢,其实看一下tomic.Value就知道了,计算机64位的,8个字节,对于32位的机器,回去读两次。可能会出现一种情况是 a入32位字节,此时b读取了32位。然后a继续写入32位,此时发生的问题,就是读写不一致。所以atomic解决了这个问题。

那么咱们也需要解决问题是让他加载一次。

简单点,就是加个锁。然后双重检测一下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func getConfig() map[string]string {
	if config == nil {
		lock.Lock()
		defer lock.Unlock()
		if config != nil {
			return config
		}
		config = map[string]string{}
		fmt.Println("init config")
		return config
	}
	return config
}

还是出现了竞争读写的问题,必然的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sgcx015@172-15-68-151:~/go/code/com.anthony.http % go run -race cmd/once_demo.go 100
init config //加载一次
==================
WARNING: DATA RACE
Read at 0x0000012109c0 by goroutine 8:
  main.getConfig()
      /Users/sgcx015/go/code/com.anthony.http/cmd/once_demo.go:24 +0x5b

Previous write at 0x0000012109c0 by goroutine 7:
  main.getConfig()
      /Users/sgcx015/go/code/com.anthony.http/cmd/once_demo.go:30 +0xeb
==================
Found 1 data race(s)

如何解决竞争呢,用atomic类。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import (
	"fmt"
	"os"
	"strconv"
	"sync/atomic"
	"time"
)

var config atomic.Value

func main() {
	count, _ := strconv.Atoi(os.Args[1])
	for x := 0; x < count; x++ {
		go getConfig()

	}
	<-time.After(time.Second * 2)
}
func getConfig() map[string]string {
	if config.Load() == nil {
		fmt.Println("init config")
		config.Store(map[string]string{})
		return config.Load().(map[string]string)
	}
	return config.Load().(map[string]string)
}

执行: 发现确实没有竞争,原因很简单,就是atomic原子操作。然后load了两次

1
2
3
~/go/code/com.anthony.http % go run -race cmd/demo.go 1000
init config
init config

结论

竞态检测器是检查并发程序正确性的强大工具。它不会发出误报,因此请认真对待其警告。但这仅与您的测试一样好。您必须确保它们充分利用代码的并发属性,以便竞态检测器能够执行其工作.

参考

Go 并发编程——Race Condition 在 Go 中发现竞态条件 (Race Conditions) Go -race是啥? atomic解决了啥