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

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。ui

这就是为何它被称为数据竞争:getNumber返回的值根据操做1或操做2中的哪个最早完成而不一样。spa

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

数据竞争--读操做先完成


数据竞争--写操做先完成

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

检测数据竞争

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

使用它很是简单,只需在使用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语句(已经提示了不少啦:),至关于把最近关于并发编程文章里的知识串一遍。

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

相关文章
相关标签/搜索