阅读 104

GO语言基础篇(二十五)- go中的数据竞态

这是我参与8月更文挑战的第 25 天,活动详情查看: 8月更文挑战

竞态

前言

在之前的文章中分享了通过goroutine和channel的配合实现并发(也就是CSP模型),本篇文章主要是分享通过传统的同步机制来实现并发

go并不是只能用CSP模型实现并发,它也有传统的同步机制,但是建议少用。前边分享的文章中有提到waitGroup(不清楚的点这里),它就是传统的同步机制的一种。传统的同步机制还有互斥量等,在go语言中其实有原子操作的库,叫atomic。在多个goroutine并发运行的时候,使用这个库下边的方法是并发安全的。但是本文为了演示,不会使用这里边的方法

image.png

下边就用互斥量来是实现在多个goroutine并发执行下安全的加法操作。下边通过简单的自增数据来演示,先不使用锁,看一下是否有什么问题

package main

import (
    "fmt"
    "time"
)

type atomicInt int

func (a *atomicInt) increment() {
    *a++
}

func (a *atomicInt) get() int {
    return int(*a)
}

func main() {
    var a atomicInt
    a.increment()
    go func() {
        a.increment()
    }()
    time.Sleep(time.Millisecond)
    fmt.Println(a)
}

输出:
2
复制代码

可以看到,正常的打印结果的。但是并不代表这没问题,在前边的文章中有说到go run -race命令,它可以看到数据访问冲突。现在用该命名执行上边的程序可以看到

image.png

可以看到数据冲突,冲突的地方是,main函数在读0x00c0000bc008这个地址的数据的时候,increment在往这个地址写。也就是说,一个goroutine在写的时候,另一个goroutine在读。现在通过锁来解决这个问题,具体实现如下:

package main

import (
    "fmt"
    "sync"
    "time"
)

type atomicInt struct {
    value int
    lock sync.Mutex
}

func (a *atomicInt) increment() {
    a.lock.Lock()
    defer a.lock.Unlock() //记得关闭锁,用defer才是最正确的写法
    a.value++
}

func (a *atomicInt) get() int {
    a.lock.Lock()
    defer a.lock.Unlock()
    return a.value
}

func main() {
    var a atomicInt
    a.increment()
    go func() {
        a.increment()
    }()
    time.Sleep(time.Millisecond)
    fmt.Println(a.get())
}
复制代码

再通过go run -race执行这段代码,就发现没数据冲突了。这个只是简单的演示一下锁在go中的使用,下边进行详细的介绍

竞态&数据竞态

在串行程序中(即一个程序只有一个goroutine),程序中各个步骤的执行顺序由程序逻辑来决定。比如,在一系列语句中,第一句在第二句之前执行,以此类推。当一个程序有两个或者多个goroutine时,每个goroutine内部的各个步骤也是顺序执行的,但我们无法知道 一个goroutine中的事件x和另外一个goroutine中的事件y的先后顺序。如果我们无法自信地说一个事件肯定先于另外一个事件,那么这两个事件就是并发的

考虑一个能在串行程序中正确工作的函数。如果这个函数在并发调用时仍然能正确工作,那么这个函数是并发安全(concurrency-safe)的,在这里并发调用是指,在没有额外同步机制的情况下,从两个或者多个goroutine同时调用这个函数。这个概念也可以推广到其他函数,比如方法或者作用于特定类型的一些操作。如果一个类型的所有可访问方法和操作都是并发安全时,则它可称为并发安全的类型

让一个程序并发安全并不需要其中的每一个具体类型都是并发安全的。实际上,并发安全的类型其实是特例而不是普遍存在的,所以仅在文档指出类型是安全的情况下,才可以并发地访问一个变量。对于绝大部分变量,如要回避并发访问,要么限制变量只存在于一个 goroutine内,要么维护一个更高层的互斥不变量。下边会解释这些概念

与之对应的是,导出的包级别(全局)函数通常可以认为是要保证并发安全的。因为包级别的变量无法限制在一个goroutine内,所以那些修改这些变量的函数就必须采用互斥机制

函数并发调用时不工作的原因有很多,包括死锁、活锁(livelock)、以及资源耗尽。下边主要分享其中一种情形—竞态

竞态是指在多个goroutine按某些交错顺序执行时程序无法给出正确的结果。竞态对于程序是致命的,因为它们可能会潜伏在程序中,出现频率也很低,有可能仅在高负载环境或者在使用特定的编译器、平台和架构时才出现。这些都让竞态很难再现和分析

数据竞态场景

下边还是使用经典的银行转账为示例来解释竞态

package bank

var balance int

func Deposit(amount int)  {
    balance = balance + amount
}

func Balance() int {
    return balance
}
复制代码

对于上边这样一个简单的程序,一眼就可以看出,任意串行地调用Deposit和Balance都可以得到正确的结果。即Balance会输出之前存入的总金额。如果这些函数的调用不是串行而是并行,Balance就不能保证输出正确结果了。考虑下边两个goroutine,它们代表对同一个共享账户的两笔交易

package main

import (
    "fmt"
    "go.language/ch9/bank"
)

func main() {
    //A
    go func() {
        bank.Deposit(200)//------A1
        fmt.Println("=", bank.Balance())//------A2
    }()
    //B
    go bank.Deposit(100)//------B
}
复制代码

A存入200元,然后查询她的余额,与此同时B存入了 100元。A1、A2两步与B是并发进行的我们无法预测实际的执行顺序。直觉来看,可能存在三种不同的顺序, 分别称为“A先”、“B先”和 A/B/A。下面的表格显示了每个步骤之后 balance变量的值。带引号的字符串代表输出的账户余额

image.png

在所有情况下,最终的账户余额都是300美元。唯一不同的是A看到的账户余额是否包含了B的交易(第三种情况)

但这种直觉是错的。这里还有第四种可能,B的存款在A的存款操作中间执行,晚于账户余额读取(balance+amount),但早于余额更新(balance =...),这会导致B存的钱消失了。这是因为A的存款操作A1实际上是串行的两个操作,读部分和写部分,我们暂且叫A1r和A1w。下面就是有问题的执行顺序:

  1. A1r,它读取到balance的值,也就是0
  2. B,向账户balance中存入了100,所以辞职balance=100(注意,此时A1r已经取过balance的值了,是0)
  3. A1w,执行balance+amount,结果是200,并将其赋值给了balance
  4. A2,所以A在读取的时候读到的是200

在A1r之后,表达式balance + amount求值结果为200,这个值在A1w步骤中用于写入, 完全没理会中间的存款操作。最终的余额为仅有200元,银行从B手上挣了100元

程序中的这种状况是竞态中的一种,称为数据竞态(data race)。数据竞态发生于两个goroutine并发读写同一个变量并且至少其中一个是写入时

当发生数据竞态的变量类型是大于一个机器字长的类型(比如接口、字符串或slice) 时,事情就更复杂了。下面的代码并发把x更新为两个不同长度的slice

var x []int
go func() {
    x = make([]int, 10)
}()
go func() {
    x = make([]int, 1000000)
}()

x[99999] = 1
复制代码

最后一个表达式中x的值是未定义的,它可能是nil、可能是一个长度为10的slice或者一个长度为1000000的slice。我们知道slice包含三个部分:指针、长度和容量如果指针来自于第一个make调用,而长度来自第二个make调用那么x会变成一个嵌合体,它名义上长度为1 000 000,但底层的数组只有10个元素。在这种情况下,尝试存储到第999 999个元素会伤及很遥远的一段内存,其恶果无法预测,问题也很难调试和定位。这种语义上的雷区称为未定义行为,C程序员对这个比较熟悉。幸运的是,相比之下Go语言很少有这种问题

并行程序是几个串行程序交错执行这个观念是一个错觉。在后边的内容中,你会发现,数据竞态可能由更奇怪的原因来引发。如何在程序中避免数据竞态?

如何避免数据竞态

我们知道:数据竞态发生于两个或多个goroutine并发读写同一个变量并且至少其中一个是写入时。从定义不难看出,有三种方法来避免数据竞态

  • 方法一不要修改变量。考虑下边这个map,它进行了延迟初始化,对于每个键,在第一次访问时才触发加载。如果icon的调用是串行的,那么程序能正常工作,但如果工con 的调用是并发的,在访问map时就存在数据竞态
var icons = make(map[string]image.Image)

func loadIcon(name string) image.Image {}

//不是并发安全的
func Icon(name string) image.Image {
    icon, ok := icons[name]
    if !ok {
        icon = loadIcon(name)
        icons[name] = icon
    }
}
复制代码

如果在创建其它goroutine之前,就用完整的数据来初始化map,并且不再修改。那么无论多少goroutine也可以安全的并发调用Icon函数,因为每个goroutine只读取这个map

var icons = map[string]image.Image{
    "spades.png": loadIcon("spades.png"),
    "hearts.png": loadIcon("hearts.png"),
    "diamonds.png": loadIcon("diamonds.png"),
    "clubs.png": loadIcon("clubs.png"),
}

//并发安全
func Icon(name string) image.Image {
    return icons[name]
}
复制代码

在上边的示例中,icons变量的赋值发生在包初始化时,也就是在程序的main函数开始运行之前。一旦初始化完成后,icons不再修改。那些从不修改的数据结构以及不可变数据结构本质上是并发安全的,也不需要做任何同步

  • 方法二:避免数据竞态的方法,是避免从多个goroutine访问同一个变量

由于其他goroutine无法直接访问相关变量,因此它们就必须使用通道来向受限 goroutine发送查询请求或者更新变量。这也是这句Go箴言的含义:“不要通过共享内存来通信,而应该通过通信来共享内存”(这个在前边的文章中有提到,可以点这里)。使用通道请求来代理一个受限变量的所有访问的 goroutine 称为该变量的监控goroutine ( monitor goroutine)

下边是对银行转账按理的重写,用一个叫teller的监控goroutine限制balance变量

var deposits = make(chan int) //发送存款额
var balances = make(chan int) //接收余额

func Deposit(amount int) {
    deposits <- amount
}

func Balance() int {
    return <-balances
}

func teller()  {
    var balance int // balance被限制在teller goroutine中
    for  {
        select {
        case amount := <-deposits:
            balance += amount
        case balances <- balance:
        }
    }
}

func init()  {
    go teller() //启动监控goroutine
}
复制代码

方法三:允许多个goroutine访问同一个变量,但在同一时间只有一个goroutine可以访问。这种方法称为互斥机制。会在下一篇文章分享

参考

《Go程序设计语言》—-艾伦 A. A. 多诺万

《Go语言学习笔记》—-雨痕

文章分类
后端
文章标签