专栏首页网管叨bi叨Go并发编程里的数据竞争以及解决之道

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

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

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


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

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

检测数据竞争

我们上面代码是一个高度简化的数据竞争示例。在较大的应用程序中,仅靠自己检查代码很难检测到数据竞争。幸运的是,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语句(已经提示了很多啦:),相当于把最近关于并发编程文章里的知识串一遍。

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

推荐阅读:

Go语言sync包的应用详解

本文分享自微信公众号 - 网管叨bi叨(kevin_tech),作者:KevinYan11

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-05-27

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 《Go语言程序设计》读书笔记(七)基于共享变量的并发

    上面的例子里Unlock会在return语句读取完balance的值之后执行,所以Balance函数是并发安全的。

    KevinYan
  • 上周并发题的解题思路以及介绍Go语言调度器

    今天的文章我首先说一下上篇文章里的思考题的解决思路,我会给出完整可运行的代码。之后通过观察程序的运行结果里的现象简单介绍Go语言的调度器是如何对goroutin...

    KevinYan
  • Go Web编程--应用数据库

    今天我们继续接着前几篇关于 GoWeb编程的文章往下延伸。在 Web应用程序中几乎每个应用场景都需要存储和检索数据库中的数据。当你处理动态内容,为用户提供表单以...

    KevinYan
  • Golang sync.Mutex 与 sync.RWMutex

    Golang中sync包实现了两种锁,Mutex(互斥锁)和RWMutex(读写锁),其中RWMutex是基于Mutex实现的。

    Dabelv
  • Python学习 Day 11 错误处理 try 调用堆栈 记录错误 抛出错误

    在程序运行的过程中,如果发生了错误,可以事先约定返回一个错误代码,这样,就可以知道是否有错,以及出错的原因。在操作系统提供的调用中,返回错误码非常常见。比如打开...

    统计学家
  • win10 uwp 存放网络图片到本地 下载图片保存图片从本地打开所有代码Nuget安装

    有时候我们的网络很垃圾,我的的UWP要在第一次打开网络图片,就把图片存放到本地,下次可以从本地打开。 有时候用户使用的是流量网络,不能每次都联网下载。 我们不得...

    林德熙
  • 只含有1、2、3的数组排序

    不要举 00 11 22 、 22 11 00 、 11 00 22 这类特点明显不够随机的用例。

    陈黎栋
  • spark2.1.0的配置与源码分析

    Spark作为一款优秀的计算框架,也配备了各种各样的系统配置参数(例如:spark.master,spark.app.name,spark.driv...

    加米谷大数据
  • 中文文本纠错算法实现

    文本纠错又称为拼写错误或者拼写检查,由于纯文本往往来源于手打或者OCR识别,很可能存在一些错误,因此此技术也是一大关键的文本预处理过程,一般存在两大纠错类型。

    机器学习AI算法工程
  • Python基础之公共方法

    python3中取消了cmp比较运算符,但我们可以直接通过比较运算符<>进行比较; 数字可以比较,字符串可以比较,元组,列表可以比较大小,但字典不能比较大小

    py3study

扫码关注云+社区

领取腾讯云代金券