Go -- 并发编程


  主语言转成Go了,记录一些Go的学习笔记与心得,可能有点凌乱。内容来自于《Go程序设计语言》,这本书强烈推荐。

      (Go中并发编程是使用的Go独有的goroutine,不能完全等同于线程,但这不是这篇的重点,下面不做区分了)

  在串行程序中,程序中各个步骤的执行顺序由程序逻辑决定。比如,在一系列语句中,第一句在第二句之前执行,以此类推。当一个程序中有多个goroutine时,每个goroutine内部的各个步骤也是按顺序执行的,但我们不能确定一个goroutine中的事件x与另一个goroutine中的事件y的先后顺序。如果我们无法自信地说一个事件肯定先于另外一个事件,那么这两个事件就是并发的。(嗯,换了个角度理解并发,这个定义也确实有道理.

  关于并发编程会产生的问题,想必诸位都很清楚。诸如不同的线程操作相同的数据,造成的数据丢失,不一致,更新失效等等。在Go中关于并发产生的问题,重点可以讨论一下“竞态”----在多个goroutine按某些交错顺序执行时程序无法给出正确的结果。竞态对于程序是致命的,因为它们可能会潜伏在程序中,出现频率很低,很可能仅在高负载环境或者在使用特定的编译器,平台和架构时才出现。这些都使得竞态很难再现和分析。

  数据竞态发生于两个goroutine并发读写同一个变量并且至少其中一个是写入时。从定义出发,我们有几种方法可以规避数据竞态。

  第一种方法--不要修改变量(有点幽默,但也有效。每个线程都不会去写数据,自然也不会发生数据竞态的问题

  第二种方法--避免竞态的方法是避免从多个goroutine访问同一个变量.即我们只允许唯一的一个goroutine访问共享的资源,无论有多少个goroutine在做别的操作,当他们需要更改访问共享资源时都要使用同一个goroutine来实现,而共享的资源也被限制在了这个唯一的goroutine内,自然也就不会产生数据竞态的问题。这也是Go这门语言的思想之一 ---- 不要通过共享内存来通信,要通过通信来共享内存.Go中可以用chan来实现这种方式.(关于Chan可以看看笔者前面的博客哟

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

   这个简单的关于银行的例子,可以看出我们把余额balance限制在了teller内部,无论是更新余额还是读取当前余额,都只能通过teller来实现,因此避免了竞态的问题.

  这种方式还可以拓展,即使一个变量无法在整个生命周期受限于当个goroutine,加以限制仍然可以是解决并发访问的好方法。比如一个常见的场景,可以通过借助通道来把共享变量的地址从上一步传到下一步,从而在流水线上的多个goroutine之间共享该变量。在流水线中的每一步,在把变量地址传给下一步后就不再访问该变量了,这样所有对于这个变量的访问都是串行的。这中方式有时也被称为“串行受限”. 代码示例如下

type Cake struct {state string}

func baker(cooked chan <- *Cake) {
    for {
        cake := new(Cake)
        cake.state = "cooked"
        cooked <- cake // baker不再访问cake变量
    }
}

func icer(iced chan<- *Cake, cooked <-chan *Cake) {
    for cake := range cooked {
        cake.state = "iced"
        iced <- cake // icer不再访问cake变量
    }
}

    第三种避免数据竞态的办法是允许多个goroutine访问同一个变量,但在同一时间内只有一个goroutine可以访问。这种方法称为互斥机制。通俗的说,这也就是我们常在别的地方使用的“锁”。

  Go中的互斥锁是由 sync.Mutex提供的。它提供了两个方法Lock用于上锁,Unlock用于解锁。一个goroutine在每次访问共享变量之前,它都必须先调用互斥量的Lock方法来获取一个互斥锁,如果其他的goroutine已经取走了互斥锁,那么操作会一直阻塞到其他goroutine调用Unlock之后。互斥变量保护共享变量。按照惯例,被互斥变量保护的变量声明应当紧接在互斥变量的声明之后。如果实际情况不是如此,请确认已加了注释来说明此事.(深有同感,这确实是一个好的编程习惯)

  加锁与解锁应当成对的出现,特别是当一个方法有不同的分支,请确保每个分支结束时都释放了锁。(这点对于Go来说是特别的,一方面,Go语言的思想倡导尽快返回,一旦有错误就尽快返回,尽快的recover, 这就导致了一个方法中可能会有多个分支都返回。另一方面,由于defer方法,使我们不必在每个返回分支末尾都添上解锁或释放资源等操作,只要统一在defer中处理即可。)针对于互斥锁,结合我们前面的银行的例子的那部分的代码,我们来看一个有意思的问题。

//注意,这里不是原子操作
func withdraw(amount int) bool {
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false
    }
    return  true
}

   逻辑很简单,我们尝试提现。如果提现后余额小于0,则恢复余额,并返回false,否则返回true. 当我们给Deposit与Balance的内部都加上锁,来保证互斥访问的时候,会有一个有意思的问题.首先要说明的是,这个方法是针对它本身的逻辑----能否提现成功,总是可以正确的返回的。但副作用时,在进行超额提现时,在Deposit与Balance之间,余额是会降低到0以下的。换成实际一点的情况就是,你和你媳妇的共享的银行卡里有10w,你尝试买一辆法拉利时,导致了你媳妇买一杯咖啡付款失败了,并且失败原因是--余额不足。这种情况的根源是,Deposit与Balance两个方法内的锁是割裂开的,并不是一个原子操作,也就是说,给了别的goroutine的可乘之机。虽然最终余额方面的数据总是对的,但过程中也会发送诸如此类的错误。那如果我们用这样的实现呢:

//注意,这里是错误的实现
func withdraw(amount int) bool {
    mu.Lock()
    defer mu.Unlock()
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false
    }
    return  true
}

   即尝试给withdraw本身加锁。当然实际上,这是行不通的。由于Deposit内部也在加锁,这样的写法最终会导致死锁。一种改良方式是,分别实现包内可访问的deposit方法(在调用处外部提供锁,自己本身无锁),以及包外可以访问的Deposit(自己本身提供了互斥锁), 这样,在诸如提现这种需要同时使用更新余额/查余额的地方,我们就可以使用deposit来处理,并在提现方法本身提供锁来保证原子性。

  当然,Go也支持读写锁 sync.RWMutex. 关于读写锁就不多bb了,但有一点要注意,只有在大部分goroutine都在获取读锁,并且锁竞争很激烈时,RWMutex才有优势,因为RWMutex需要更加复杂的内部记录工作,所以在竞争不激烈时它比普通的互斥锁要慢。

  另外,书中提到由于现代计算机本身的多核机制以及Go中协程的实现,导致在一些无锁的情况下(且两个goroutine在不同的CPU上执行,每个CPU都有自己的缓存),可能导致goroutine拿不到最新的值。不过这种方式一来比较极端,二来可以通过简单且成熟的模式来避免。----在可能的情况下,把变量限制在单个goroutine内,对于其他的变量,采用互斥锁。 对于这部分感兴趣的同学,可以去搜一下Go的内存同步,或者直接找《Go程序设计语言》内存同步这一节看一下。