乐趣区

并发编程的数据竞争问题以及解决之道

Go语言以容易进行并发编程而闻名,但是如果稍不注意,并发程序可能导致的数据竞争问题(data race)就会经常出现在你编写的并发程序的待解决 Bug 列表中 – 如果你不幸在代码中遇到这种错误,这将是最难调试的错误之一。

今天这篇文章里我们首先来看一个导致数据竞争的示例程序,使用 go 命令行工具检测程序的竞争情况。然后我们将介绍一些在不改变程序核心逻辑的情况下如何绕过并解决并发情况下的数据竞争问题的方法。最后我们会分析用什么方法解决数据竞争更合理以及留给大家的一个思考题。

本周这篇文章的主旨概要如下:

  • 并发程序的数据竞争问题。
  • 使用 go 命令行工具检测程序的竞争情况。
  • 解决数据竞争的常用方案。
  • 如何选择解决数据竞争的方案。
  • 一道测试自己并发编程掌握程度的思考题。

数据竞争

要解释什么是数据竞争我们先来看一段程序:

package main

import "fmt"

func main() {fmt.Println(getNumber())
}

func getNumber() int {
    var i int
    go func() {i = 5}()

    return i
}

上面这段程序 getNumber 函数中开启了一个单独的 goroutine 设置变量 i 的值,同时在不知道开启的 goroutine 是否已经执行完成的情况下返回了i。所以现在正在发生两个操作:

  • 变量 i 的值正在被设置成 5。
  • 函数 getNumber 返回了变量 i 的值。

现在,根据这两个操作中哪一个先完成,最后程序打印出来的值将是 0 或 5。

这就是为什么它被称为数据竞争:getNumber返回的值根据操作 1 或操作 2 中的哪一个最先完成而不同。

下面的两张图描述了返回值的两种可能的情况对应的时间线:


你可以想象一下,每次调用代码时,代码表现出来的行为都不一样有多可怕。这就是为什么数据竞争会带来如此巨大的问题。

检测数据竞争

我们上面代码是一个高度简化的数据竞争示例。在较大的应用程序中,仅靠自己检查代码很难检测到数据竞争。幸运的是,Go(从 V1.1 开始)有一个内置的数据竞争检测器,我们可以使用它来确定应用程序里潜在的数据竞争条件。

使用它非常简单,只需在使用 Go 命令行工具时添加 -race 标志。例如,让我们尝试使用 -race 标志来运行我们刚刚编写的程序:

go run -race main.go

执行后将输出:

0
==================
WARNING: DATA RACE
Write at 0x00c00001a0a8 by goroutine 6:
  main.getNumber.func1()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:12 +0x38

Previous read at 0x00c00001a0a8 by main goroutine:
  main.getNumber()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:15 +0x88
  main.main()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:6 +0x33

Goroutine 6 (running) created at:
  main.getNumber()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:11 +0x7a
  main.main()
      /QSC/go/src/example.com/http_demo/utils/vlog/main.go:6 +0x33
==================
Found 1 data race(s)
exit status 66

第一个 0 是打印结果(因此我们现在知道是操作 2 首先完成)。接下来的几行给出了在代码中检测到的数据竞争的信息。我们可以看到关于数据竞争的信息分为三个部分:

  • 第一部分告诉我们,在 getNumber 函数里创建的 goroutine 中尝试写入(这是我们将值 5 赋给 i 的位置)
  • 下一部分告诉我们,在主 goroutine 里有一个在同时进行的读操作。
  • 第三部分描述了导致数据竞争的 goroutine 是在哪里被创建的。

除了 go run 命令外,go buildgo test 命令也支持使用 -race 标志。这个会使编译器创建的应用程序能够记录所有运行期间对共享变量访问,并且会记录下每一个读或者写共享变量的 goroutine 的身份信息。

竞争检查器会报告所有的已经发生的数据竞争。然而,它只能检测到运行时的竞争条件,并不能证明之后不会发生数据竞争。由于需要额外的记录,因此构建时加了竞争检测的程序跑起来会慢一些,且需要更大的内存,即使是这样,这些代价对于很多生产环境的工作来说还是可以接受的。对于一些偶发的竞争条件来说,使用附带竞争检查器的应用程序可以节省很多花在 Debug 上的时间。

解决数据竞争的方案

Go提供了很多解决它的选择。所有这些解决方案的思路都是确保在我们写入变量时阻止对该变量的访问。一般常用的解决数据竞争的方案有:使用 WaitGroup 锁,使用通道阻塞以及使用 Mutex 锁,下面我们一个个来看他们的用法并比较一下这几种方案的不同点。

使用 WaitGroup

解决数据竞争的最直接方法是(如果需求允许的情况下)阻止读取访问,直到写入操作完成:

func getNumber() int {
    var i int
    // 初始化一个 WaitGroup
    var wg sync.WaitGroup
    // Add(1) 通知程序有一个需要等待完成的任务
    wg.Add(1)
    go func() {
        i = 5
        // 调用 wg.Done 表示正在等待的程序已经执行完成了
        wg.Done()}()
    // wg.Wait 会阻塞当前程序直到等待的程序都执行完成为止
    wg.Wait()
    return i
}

下面是使用 WaitGroup 后程序执行的时间线:

使用通道阻塞

这个方法原则上与上一种方法类似,只是我们使用了通道而不是WaitGroup

func getNumber() int {
    var i int
  // 创建一个通道,在等待的任务完成时会向通道发送一个空结构体
    done := make(chan struct{})
    go func() {
        i = 5
        // 执行完成后向通道发送一个空结构体
        done <- struct{}{}
    }()
  // 从通道接收值将会阻塞程序,直到有值发送给 done 通道为止
    <-done
    return i
}

下图是使用通道阻塞解决数据竞争后程序的执行流程:

使用 Mutex

到目前为止,使用的解决方案只有在确定写入操作完成后再读取 i 的值时才适用。现在让我们考虑一个更通常的情况,程序读取和写入的顺序并不是固定的,我们只要求它们不能同时发生就行。这种情况下我们应该考虑使用 Mutex 互斥锁。

// 首先,创建一个结构体包含我们想用互斥锁保护的值和一个 mutex 实例
type SafeNumber struct {
    val int
    m   sync.Mutex
}

func (i *SafeNumber) Get() int {、i.m.Lock()                       
    defer i.m.Unlock()                    
    return i.val
}

func (i *SafeNumber) Set(val int) {i.m.Lock()
    defer i.m.Unlock()
    i.val = val
}

func getNumber() int {
    // 创建一个 sageNumber 实例
    i := &SafeNumber{}
  // 使用 Set 和 Get 代替常规赋值和读取操作。// 我们现在可以确保只有在写入完成时才能读取,反之亦然
    go func() {i.Set(5)
    }()
    return i.Get()}

下面两个图片对应于程序先获取到写锁和先获取到读锁两种可能的情况下程序的执行流程:


Mutex vs Channel

上面我们使用互斥锁和通道两种方法解决了并发程序的数据竞争问题。那么我们该在什么情况下使用互斥锁,什么情况下又该使用通道呢?答案就在你试图解决的问题中。如果你试图解决的问题更适合互斥锁,那么就继续使用互斥锁。。如果问题似乎更适合渠道,则使用它。

大多数 Go 新手都试图使用通道来解决所有并发问题,因为这是 Go 语言的一个很酷的特性。这是不对的。语言为我们提供了使用 MutexChannel的选项,选择两者都没有错。

通常,当 goroutine 需要相互通信时使用通道,当确保同一时间只有一个 goroutine 能访问代码的关键部分时使用互斥锁。在我们上面解决的问题中,我更倾向于使用互斥锁,因为这个问题不需要 goroutine 之间的任何通信。只需要确保同一时间只有一个 goroutine 拥有共享变量的使用权,互斥锁本来就是为解决这种问题而生的,所以使用互斥锁是更自然的一种选择。

一道用 Channel 解决的思考题

上面讲数据竞争问题举的例子里因为多个 goroutine 之间不需要通信,所以使用 Mutex 互斥锁的方案更合理些。那么针对使用 Channel 的并发编程场景我们就先留一道思考题给大家,题目如下:

假设有一个超长的切片,切片的元素类型为 int,切片中的元素为乱序排列。限时 5 秒,使用多个 goroutine 查找切片中是否存在给定值,在找到目标值或者超时后立刻结束所有 goroutine 的执行。

比如切片为:[23, 32, 78, 43, 76, 65, 345, 762, …… 915, 86],查找的目标值为 345,如果切片中存在目标值程序输出:”Found it!” 并且立即取消仍在执行查找任务的goroutine。如果在超时时间为找到目标值程序输出:”Timeout! Not Found”,同时立即取消仍在执行查找任务的goroutine

不用顾忌题目里切片的元素重不重复,也不需要对切片元素进行排序。解决这个问题肯定会用到 context、计时器、通道以及select 语句(已经提示了很多啦:),相当于把最近关于并发编程文章里的知识串一遍。

看文章的朋友们尽量都想想应该怎么解,在留言里说出你们的解题思路,最好可以私信我你写的代码的截图。我会在下周的文章里给出这个题目我的解决方法。这个题没有标准答案,只要能解出来并且思路值得借鉴我都会一起公布到下周的文章里。

退出移动版