zoukankan      html  css  js  c++  java
  • 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程序设计语言》内存同步这一节看一下。

      

  • 相关阅读:
    iaas,paas,saas理解
    July 06th. 2018, Week 27th. Friday
    July 05th. 2018, Week 27th. Thursday
    July 04th. 2018, Week 27th. Wednesday
    July 03rd. 2018, Week 27th. Tuesday
    July 02nd. 2018, Week 27th. Monday
    July 01st. 2018, Week 27th. Sunday
    June 30th. 2018, Week 26th. Saturday
    June 29th. 2018, Week 26th. Friday
    June 28th. 2018, Week 26th. Thursday
  • 原文地址:https://www.cnblogs.com/dogtwo0214/p/13700958.html
Copyright © 2011-2022 走看看