共享变量实现并发

* 本页面主要介绍Go语言共享变量实现并发的相关内容。

Go语言基于CSP模型实现并发的两大武器 协程goroutine 通道channel 相信你已经有了一个非常详细的了解和认知。今天起,我们就详细探究一下并发机制。尤其是在多goroutine之间的共享变量,并发问题的分析手段,以及解决这些问题的基本模式。在深入探讨之前,我们先学习一个概念:竟态!

一、何为竞态?

竞态(有时也叫竞争条件)是指在多个 goroutine 按某些交错顺序执行时程序无法给出正确的结果

这种恶劣的“状态”平时还很难出现,可能就潜伏在你程序的某个角落里:它可能偶尔蹦出来,或者高并发大负载的时候偶发,又或是在某一个特定的编译器、平台甚至是架构里才会出现。使得这种问题非常难以分析和诊断。

这里给你透露一个小技巧——竞争检查器(the race detector):在go build、go run或者go test的时候通过 -race 参数开启竞态检测,有利于发现程序中的问题点。 另外,https://staticcheck.io 这个站点也是一个非常好的代码检测应用。平时可以使用它来检查程序中可能存在的问题。

数据竞争是最常见的竞争条件之一:数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生

既然竞态问题这么烦人,那么我们该怎么尽量避免程序中出现数据竞争这些问题呢?

二、如何避免竞态?

根据数据竞争的定义,我们可以发现,并发访问写操作,是问题产生的必要条件。所以,规避数据竞争的方法自然也要从这两方面入手:

  • 第一种方法是不要去写变量(并发读取是不会产生任何问题的)。
  • 第二种方法是避免从多个goroutine访问变量,也就是保证变量是在一个“线性”环境中访问的(无论是绑定到单个goroutine中,还是多个goroutine形成的类似管道的环境)。
  • 第三种方法是允许很多goroutine去访问变量,但是在同一个时刻最多只有一个 goroutine在访问。这种方式被称为“互斥”
三、实现“互斥”的方法:互斥锁(sync.Mutex)

互斥锁模式应用非常广泛,所以 sync 包有一个单独的 Mutex 类型来支持这种模式。它的 Lock 方法用来获取令牌 (token,此过程也称为上锁),Unlock 方法用于释放令牌:

  package main

  import "sync"

  var (
    mu sync.Mutex  // 保护 balance
    balance int
  )

  func Deposit(amount int)  {
    mu.Lock()
    balance += amount
    mu.Unlock()
  }

  func Balance() int {
    mu.Lock()
    b := balance
    mu.Unlock()
    return b
  }

在 Lock 和 Unlock 之间的代码,可以自由地读取和修改共享变量,这一部分称为临界区,在锁的持有人调用 Unlock 之前,其他的 goroutine 不能获取锁。上面的bank程序例证了一种通用的并发模式:

  • 一系列的导出函数封装了一个或多个变量
  • 访问这些变量唯一的方式就是通过这些函数
  • 每一个函数在一开始就获取互斥锁并在最后释放锁,从而保证共享变量不会被并发访问

这种函数、互斥锁和变量的编排叫作监控monitor。上面的临界区里只有一行代码,比较简单。再看看下面的示例程序:

  // 注意: 不是原子操作
  func Withdraw(amount int) bool {
    Deposit(-amount)
    if Balance() < 0 {
      Deposit(amount)
      return false // 余额不足
    }
    return true
  }

这个函数的问题在于不是原子操作:它包含三个串行的操作,每个操作都申请并释放了互斥锁,但对于整个序列没有上锁。所以,我们需要用锁把这三个串行的操作框在临界区里。但如果还用mu.Lock()的话,因为是互斥锁,如果Deposit再次调用的话,会导致死锁,所以要么再造一把锁,要么就把Deposit拆成两部分:一个不导出的函数deposit,这样我们用不导出的deposit来实现Withdraw:

  var (
    mu sync.Mutex  // 保护 balance
    balance int
  )

  func Withdraw(amount int) bool {
    mu.Lock()
    defer mu.Unlock()
    deposit(-amount)
    if balance < 0 {
      deposit(amount)
      return false
    }
    return true
  }

  func Deposit(amount int)  {
    mu.Lock()
    defer mu.Unlock()
    deposit(amount)
  }

  func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
  }

  // 这个函数要求已经获取互斥锁
  func deposit(amount int)  {
    balance += amount
  }
四、读写互斥锁(sync.RWMutex)

因为 Balance 函数只须读取变量的状态,所以多个 Balance 请求其实可以安全地并发运行,只要 Deposit 和 Withdraw 请求没有同时运行即可。在这种情况下,我们需要一种特殊类型的锁,它允许只读操作可以并发执行,但写操作需要获得完全读享的访问权限。这种锁称为多读单写锁,Go 语言中的 sync.RWMutex 可以提供这种功能:

  var (
    mu sync.RWMutex
    balance int
  )

  func Balance() int {
    mu.RLock()  // 读锁(共享锁)
    defer mu.RUnlock()
    return balance
  }

Balance 函数现在可以调用 RLock 和 RUlock 方法来分别获取和释放一个读锁(也称为共享锁)。Deposit 函数无需修改,它通过调用 mu.Lock 和 mu.Unlock 来分别获取一个写锁(它称为互斥锁)

五、延迟初始化(sync.Once)

如果初始化成本比较大的话,那么将初始化延迟到需要的时候再去做就是一个比较好的选择。如果在程序启动的时候就去做这类初始化的话,会增加程序的启动时间,并且因为执行的时候可能也并不需要这些变量,所以实际上有一些浪费。

从概念上来讲,Once 包含一个布尔变量和一个互斥量,布尔变量记录初始化是否已经完成,互斥量则保护这个布尔变量和客户端的数据结构。Once 的唯一方法 Do 以初始化函数作为它的参数。

  var loadIconsOnce sync.Once
  var icons map[string]image.Image

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

  // 并发安全
  func Icon(name string) image.Image {
    loadIconsOnce.Do(loadIcons)
    return icons[name]
  }

每次调用 Do(loadIcons) 时会先锁定互斥量并检查里边的布尔变量。在第一次调用时,这个布尔变量为假,Do 会调用 loadIcons 然后把变量设置为真。后续的调用相当于空操作,只是通过互斥量的同步来保证 loadIcons 对内存产生的效果(在这里是icons变量)对所有的 goroutine 可见。

好了,到此,基于共享变量的并发相关的内容都总结完了,你都掌握了吗?


* 本页内容参考以下数据源:

  • 《Go程序设计语言》
  • https://blog.csdn.net/wymyimeng/article/details/95937842

凯冰科技 · 代码改变世界,技术改变生活
Next Page→