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

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后程序执行的时间线

使用通道阻塞

这个方法原则上与上一种方法类似,只是我们使用了通道而不是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语句(已经提示了很多啦:),相当于把最近关于并发编程文章里的知识串一遍。

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

本作品采用《CC 协议》,转载必须注明作者和本文链接
公众号:网管叨bi叨 | Golang、Laravel、Docker、K8s等学习经验分享
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
未填写
文章
113
粉丝
365
喜欢
484
收藏
314
排名:34
访问:20.4 万
私信
所有博文
社区赞助商