Go并发编程里的数据竞争以及解决之道

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中的哪一个最先完成而不同。

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

outside_default.png
数据竞争--读操作先完成

outside_default.png
数据竞争--写操作先完成

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

检测数据竞争

我们上面代码是一个高度简化的数据竞争示例。在较大的应用程序中,仅靠自己检查代码很难检测到数据竞争。幸运的是,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后程序执行的时间线:

outside_default.png
使用WaitGroup后程序执行的时间线

使用通道阻塞

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

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

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

outside_default.png
使用通道解决数据竞争后程序的执行流程

使用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()
}

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

outside_default.png
先获取到写锁时程序的执行流程

outside_default.png
先获取读锁时程序的执行流程

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语句(已经提示了很多啦:),相当于把最近关于并发编程文章里的知识串一遍。

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

推荐阅读:

Go语言sync包的应用详解

资料下载

点击下方卡片关注公众号,发送特定关键字获取对应精品资料!

  • 回复「电子书」,获取入门、进阶 Go 语言必看书籍。

  • 回复「视频」,获取价值 5000 大洋的视频资料,内含实战项目(不外传)!

  • 回复「路线」,获取最新版 Go 知识图谱及学习、成长路线图。

  • 回复「面试题」,获取四哥精编的 Go 语言面试题,含解析。

  • 回复「后台」,获取后台开发必看 10 本书籍。

对了,看完文章,记得点击下方的卡片。关注我哦~ 👇👇👇

如果您的朋友也在学习 Go 语言,相信这篇文章对 TA 有帮助,欢迎转发分享给 TA,非常感谢!116236ee5ea035188e68454ae2514ac1.png

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
本书作者带你一步一步深入这些方法。你将理解 Go语言为何选定这些并发模型,这些模型又会带来什么问题,以及你如何组合利用这些模型中的原语去解决问题。学习那些让你在独立且自信的编写与实现任何规模并发系统时所需要用到的技巧和工具。 理解Go语言如何解决并发难以编写正确这一根本问题。 学习并发与并行的关键性区别。 深入到Go语言的内存同步原语。 利用这些模式中的原语编写可维护的并发代码。 将模式组合成为一系列的实践,使你能够编写大规模的分布式系统。 学习 goroutine 背后的复杂性,以及Go语言的运行时如何将所有东西连接在一起。 作者简介 · · · · · · Katherine Cox-Buday是一名计算机科学家,目前工作于 Simple online banking。她的业余爱好包括软件工程、创作、Go 语言(igo、baduk、weiquei) 以及音乐,这些都是她长期的追求,并且有着不同层面的贡献。 目录 · · · · · · 前言 1 第1章 并发概述 9 摩尔定律,Web Scale和我们所陷入的混乱 10 为什么并发很难? 12 竞争条件 13 原子性 15 内存访问同步 17 死锁、活锁和饥饿 20 确定并发安全 28 面对复杂性的简单性 31 第2章 对你的代码建模:通信顺序进程 33 并发与并行的区别 33 什么是CSP 37 如何帮助你 40 Go语言的并发哲学 43 第3章 Go语言并发组件 47 goroutine 47 sync包 58 WaitGroup 58 互斥锁和读写锁 60 cond 64 once 69 池 71 channel 76 select 语句 92 GOMAXPROCS控制 97 小结 98 第4章 Go语言的并发模式 99 约束 99 for-select循环103 防止goroutine泄漏 104 or-channel 109 错误处理112 pipeline 116 构建pipeline的最佳实践 120 一些便利的生成器 126 扇入,扇出 132 or-done-channel 137 tee-channel 139 桥接channel模式 140 队列排队143 context包 151 小结 168 第5章 大规模并发 169 异常传递169 超时和取消 178 心跳 184 复制请求197 速率限制199 治愈异常的goroutine 215 小结 222 第6章 goroutine和Go语言运行时 223 工作窃取223 窃取任务还是续体 231 向开发人员展示所有这些信息 240 尾声 240 附录A 241
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值