Go语言并发模型的2种编程方案

概述

我一直在找一种好的方法来解释 go 语言的并发模型

不要通过共享内存来通信,相反,应该通过通信来共享内存

但是没有发现一个好的解释来满足我下面的需求:

1.通过一个例子来说明最初的问题
2.提供一个共享内存的解决方案
3.提供一个通过通信的解决方案

这篇文章我就从这三个方面来做出解释。

读过这篇文章后你应该会了解通过通信来共享内存的模型,以及它和通过共享内存来通信的区别,你还将看到如何分别通过这两种模型来解决访问和修改共享资源的问题。

前提

设想一下我们要访问一个银行账号:

代码如下:

type Account interface {
  Withdraw(uint)
  Deposit(uint)
  Balance() int
}

type Bank struct {
  account Account
}

func NewBank(account Account) *Bank {
  return &Bank{account: account}
}

func (bank *Bank) Withdraw(amount uint, actor_name string) {
  fmt.Println("[-]", amount, actor_name)
  bank.account.Withdraw(amount)
}

func (bank *Bank) Deposit(amount uint, actor_name string) {
  fmt.Println("[+]", amount, actor_name)
  bank.account.Deposit(amount)
}

func (bank *Bank) Balance() int {
  return bank.account.Balance()
}

因为 Account 是一个接口,所以我们提供一个简单的实现:

代码如下:

type SimpleAccount struct{
  balance int
}

func NewSimpleAccount(balance int) *SimpleAccount {
  return &SimpleAccount{balance: balance}
}

func (acc *SimpleAccount) Deposit(amount uint) {
  acc.setBalance(acc.balance + int(amount))
}

func (acc *SimpleAccount) Withdraw(amount uint) {
  if acc.balance >= int(mount) {
    acc.setBalance(acc.balance - int(amount))
  } else {
    panic("杰克穷死")
  }
}

func (acc *SimpleAccount) Balance() int {
  return acc.balance
}

func (acc *SimpleAccount) setBalance(balance int) {
  acc.add_some_latency()  //增加一个延时函数,方便演示
  acc.balance = balance
}

func (acc *SimpleAccount) add_some_latency() {
  <-time.After(time.Duration(rand.Intn(100)) * time.Millisecond)
}

你可能注意到了 balance 没有被直接修改,而是被放到了 setBalance 方法里进行修改。这样设计是为了更好的描述问题。稍后我会做出解释。

把上面所有部分弄好以后我们就可以像下面这样使用它啦:

代码如下:

func main() {
  balance := 80
  b := NewBank(bank.NewSimpleAccount(balance))
 
  fmt.Println("初始化余额", b.Balance())
 
  b.Withdraw(30, "马伊琍")
 
  fmt.Println("-----------------")
  fmt.Println("剩余余额", b.Balance())
}

运行上面的代码会输出:

代码如下:

初始化余额 80
[-] 30 马伊琍
-----------------
剩余余额 50

没错!

不错在现实生活中,一个银行账号可以有很多个附属卡,不同的附属卡都可以对同一个账号进行存取钱,所以我们来修改一下代码:

代码如下:

func main() {
  balance := 80
  b := NewBank(bank.NewSimpleAccount(balance))
 
  fmt.Println("初始化余额", b.Balance())
 
  done := make(chan bool)
 
  go func() { b.Withdraw(30, "马伊琍"); done <- true }()
  go func() { b.Withdraw(10, "姚笛"); done <- true }()
 
  //等待 goroutine 执行完成
  <-done
  <-done
 
  fmt.Println("-----------------")
  fmt.Println("剩余余额", b.Balance())
}

这儿两个附属卡并发的从账号里取钱,来看看输出结果:

代码如下:

初始化余额 80
[-] 30 马伊琍
[-] 10 姚笛
-----------------
剩余余额 70

这下把文章高兴坏了:)

结果当然是错误的,剩余余额应该是40而不是70,那么让我们看看到底哪儿出问题了。

问题

当并发访问共享资源时,无效状态有很大可能会发生。

在我们的例子中,当两个附属卡同一时刻从同一个账号取钱后,我们最后得到银行账号(即共享资源)错误的剩余余额(即无效状态)。

我们来看一下执行时候的情况:

代码如下:

处理情况
             --------------
             _马伊琍_|_姚笛_
 1. 获取余额     80  |  80
 2. 取钱       -30  | -10
 3. 当前剩余     50  |  70
                ... | ...
 4. 设置余额     50  ?  70  //该先设置哪个好呢?
 5. 后设置的生效了
             --------------
 6. 剩余余额        70

上面 ... 的地方描述了我们 add_some_latency 实现的延时状况,现实世界经常发生延迟情况。所以最后的剩余余额就由最后设置余额的那个附属卡决定。

解决办法

我们通过两种方法来解决这个问题:

1.共享内存的解决方案
2.通过通信的解决方案

所有的解决方案都是简单的封装了一下 SimpleAccount 来实现保护机制。

共享内存的解决方案

又叫 “通过共享内存来通信”。

这种方案暗示了使用锁机制来预防同时访问和修改共享资源。锁告诉其它处理程序这个资源已经被一个处理程序占用了,因此别的处理程序需要排队直到当前处理程序处理完毕。

让我们来看看 LockingAccount 是怎么实现的:

代码如下:

type LockingAccount struct {
  lock    sync.Mutex
  account *SimpleAccount
}

//封装一下 SimpleAccount
func NewLockingAccount(balance int) *LockingAccount {
  return &LockingAccount{account: NewSimpleAccount(balance)}
}

func (acc *LockingAccount) Deposit(amount uint) {
  acc.lock.Lock()
  defer acc.lock.Unlock()
  acc.account.Deposit(amount)
}

func (acc *LockingAccount) Withdraw(amount uint) {
  acc.lock.Lock()
  defer acc.lock.Unlock()
  acc.account.Withdraw(amount)
}

func (acc *LockingAccount) Balance() int {
  acc.lock.Lock()
  defer acc.lock.Unlock()
  return acc.account.Balance()
}

直接明了!注意 lock sync.Lock,lock.Lock(),lock.Unlock()。

这样每次一个附属卡访问银行账号(即共享资源),这个附属卡会自动获得锁直到最后操作完毕。

我们的 LockingAccount 像下面这样使用:

代码如下:

func main() {
  balance := 80
  b := NewBank(bank.NewLockingAccount(balance))
 
  fmt.Println("初始化余额", b.Balance())
 
  done := make(chan bool)
 
  go func() { b.Withdraw(30, "马伊琍"); done <- true }()
  go func() { b.Withdraw(10, "姚笛"); done <- true }()
 
  //等待 goroutine 执行完成
  <-done
  <-done
 
  fmt.Println("-----------------")
  fmt.Println("剩余余额", b.Balance())
}

输出的结果是:

代码如下:

初始化余额 80
[-] 30 马伊琍
[-] 10 姚笛
-----------------
剩余余额 40

现在结果正确了!

在这个例子中第一个处理程序加锁后独享共享资源,其它处理程序只能等待它执行完成。

我们接着看一下执行时的情况,假设马伊琍先拿到了锁:

代码如下:

处理过程
                        ________________
                        _马伊琍_|__姚笛__
        加锁                   ><
        得到余额            80  |
        取钱               -30  |
        当前余额            50  |
                           ... |
        设置余额            50  |
        解除锁                 <>
                               |
        当前余额                50
                               |
        加锁                   ><
        得到余额                |  50
        取钱                    | -10
        当前余额                |  40
                               |  ...
        设置余额                |  40
        解除锁                  <>
                        ________________
        剩余余额                40

现在我们的处理程序在访问共享资源时相继的产生了正确的结果。

通过通信的解决方案

又叫 “通过通信来共享内存”。

现在账号被命名为 ConcurrentAccount,像下面这样来实现:

代码如下:

type ConcurrentAccount struct {
  account     *SimpleAccount
  deposits    chan uint
  withdrawals chan uint
  balances    chan chan int
}

func NewConcurrentAccount(amount int) *ConcurrentAccount{
  acc := &ConcurrentAccount{
    account :    &SimpleAccount{balance: amount},
    deposits:    make(chan uint),
    withdrawals: make(chan uint),
    balances:    make(chan chan int),
  }
  acc.listen()
 
  return acc
}

func (acc *ConcurrentAccount) Balance() int {
  ch := make(chan int)
  acc.balances <- ch
  return <-ch
}

func (acc *ConcurrentAccount) Deposit(amount uint) {
  acc.deposits <- amount
}

func (acc *ConcurrentAccount) Withdraw(amount uint) {
  acc.withdrawals <- amount
}

func (acc *ConcurrentAccount) listen() {
  go func() {
    for {
      select {
      case amnt := <-acc.deposits:
        acc.account.Deposit(amnt)
      case amnt := <-acc.withdrawals:
        acc.account.Withdraw(amnt)
      case ch := <-acc.balances:
        ch <- acc.account.Balance()
      }
    }
  }()
}

ConcurrentAccount 同样封装了 SimpleAccount ,然后增加了通信通道

调用代码和加锁版本的一样,这里就不写了,唯一不一样的就是初始化银行账号的时候:

代码如下:

b := NewBank(bank.NewConcurrentAccount(balance))

运行产生的结果和加锁版本一样:

代码如下:

初始化余额 80
[-] 30 马伊琍
[-] 10 姚笛
-----------------
剩余余额 40

让我们来深入了解一下细节。

通过通信来共享内存是如何工作的

一些基本注意点:

共享资源被封装在一个控制流程中。

结果就是资源成为了非共享状态。没有处理程序能够直接访问或者修改资源。你可以看到访问和修改资源的方法实际上并没有执行任何改变。

代码如下:

func (acc *ConcurrentAccount) Balance() int {
    ch := make(chan int)
    acc.balances <- ch
    balance := <-ch
    return balance
  }
  func (acc *ConcurrentAccount) Deposit(amount uint) {
    acc.deposits <- amount
  }

func (acc *ConcurrentAccount) Withdraw(amount uint) {
    acc.withdrawals <- amount
  }

访问和修改是通过消息和控制流程通信。

在控制流程中任何访问和修改的动作都是相继发生的。

当控制流程接收到访问或者修改的请求后会立即执行相关动作。让我们仔细看看这个流程:

代码如下:

func (acc *ConcurrentAccount) listen() {
    // 执行控制流程
    go func() {
      for {
        select {
        case amnt := <-acc.deposits:
          acc.account.Deposit(amnt)
        case amnt := <-acc.withdrawals:
          acc.account.Withdraw(amnt)
        case ch := <-acc.balances:
          ch <- acc.account.Balance()
        }
      }
    }()
  }

select不断地从各个通道中取出消息,每个通道都跟它们所要执行的操作相一致。

重要的一点是:在 select 声明内部的一切都是相继执行的(在同一个处理程序中排队执行)。一次只有一个事件(在通道中接受或者发送)发生,这样就保证了同步访问共享资源。

领会这个有一点绕。

让我们用例子来看看 Balance() 的执行情况:

代码如下:

一张附属卡的流程      |   控制流程
      ----------------------------------------------

1.     b.Balance()         |
 2.             ch -> [acc.balances]-> ch
 3.             <-ch        |  balance = acc.account.Balance()
 4.     return  balance <-[ch]<- balance
 5                          |

这两个流程都干了点什么呢?

附属卡的流程

1.调用 b.Balance()
2.新建通道 ch,将 ch 通道塞入通道 acc.balances 中与控制流程通信,这样控制流程也可以通过 ch 来返回余额
3.等待 <-ch 来取得要接受的余额
4.接受余额
5.继续

控制流程

1.空闲或者处理
2.通过 acc.balances 通道里面的 ch 通道来接受余额请求
3.取得真正的余额值
4.将余额值发送到 ch 通道
5.准备处理下一个请求

控制流程每次只处理一个 事件。这也就是为什么除了描述出来的这些以外,第2-4步没有别的操作执行。

总结

这篇博客描述了问题以及问题的解决办法,但那时没有深入去探究不同解决办法的优缺点。

其实这篇文章的例子更适合用 mutex,因为这样代码更加清晰。

最后,请毫无顾忌的指出我的错误!

(0)

相关推荐

  • Go语言操作mysql数据库简单例子

    Go语言操作数据库非常的简单, 他也有一个类似JDBC的东西"database/sql" 实现类是"github.com/go-sql-driver/mysql" 使用过JDBC的人应该一看就懂 对日期的处理比较晦涩,没有JAVA流畅: 复制代码 代码如下: package main import (     "database/sql"     _ "github.com/go-sql-driver/mysql"     &

  • Go语言中的方法、接口和嵌入类型详解

    概述 在 Go 语言中,如果一个结构体和一个嵌入字段同时实现了相同的接口会发生什么呢?我们猜一下,可能有两个问题: 1.编译器会因为我们同时有两个接口实现而报错吗? 2.如果编译器接受这样的定义,那么当接口调用时编译器要怎么确定该使用哪个实现? 在写了一些测试代码并认真深入的读了一下标准之后,我发现了一些有意思的东西,而且觉得很有必要分享出来,那么让我们先从 Go 语言中的方法开始说起. 方法 Go 语言中同时有函数和方法.一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的

  • Go语言并发技术详解

    有人把Go比作21世纪的C语言,第一是因为Go语言设计简单,第二,21世纪最重要的就是并行程序设计,而Go从语言层面就支持了并行. goroutine goroutine是Go并行设计的核心.goroutine说到底其实就是线程,但是它比线程更小,十几个goroutine可能体现在底层就是五六个线程,Go语言内部帮你实现了这些goroutine之间的内存共享.执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩.也正因为如此,可同时运行成千上万个并发任务.goro

  • Go语言的GOPATH与工作目录详解

    GOPATH设置 go 命令依赖一个重要的环境变量:$GOPATH1 (注:这个不是Go安装目录.下面以笔者的工作目录为说明,请替换自己机器上的工作目录.) 在类似 Unix 环境大概这样设置: 复制代码 代码如下: export GOPATH=/home/apple/mygo 为了方便,应该把新建以上文件夹,并且把以上一行加入到 .bashrc 或者 .zshrc 或者自己的 sh 的配置文件中. Windows 设置如下,新建一个环境变量名称叫做GOPATH: 复制代码 代码如下: GOPA

  • GO语言实现列出目录和遍历目录的方法

    本文实例讲述了GO语言实现列出目录和遍历目录的方法.分享给大家供大家参考.具体如下: GO语言获取目录列表用 ioutil.ReadDir(),遍历目录用 filepath.Walk(),使用方法课参考本文示例. 具体示例代码如下: 复制代码 代码如下: package main import (  "fmt"  "io/ioutil"  "os"  "path/filepath"  "strings" )

  • Go语言入门教程之基础语法快速入门

    Go语言是一个开源的,为创建简单的,快速的,可靠的软件而设计的语言. Go语言实(示)例教程,通过过实例加注释的方式来介绍Go语言的用法. Hello World 第一个程序会输出"hello world"消息.源代码如下: 复制代码 代码如下: package main import "fmt" func main() {     fmt.Println("hello world") } //通过go run来运行Go程序 $ go run h

  • GO语言求100以内的素数

    本文实例讲述了GO语言筛选法求100以内的素数.分享给大家供大家参考.具体实现方法如下: 思路:找出一个非素数就把它挖掉,最后剩下就是素数. 下面就来欣赏一下go简洁的代码吧 目前不支持GO的代码插入,使用xml的代替一下. 复制代码 代码如下: package main import (     "fmt"     "math" ) func main() {     var i, j, n int     var a [101]int     for i = 1

  • 创建第一个Go语言程序Hello,Go!

    建立一个用于编写Go程序的工作目录go-examples,其绝对路径为/home/go-examples.开始编写我们的第一个Go程序. 一.在go-examples下创建一个文件hello.go 复制代码 代码如下: //hello.go   package main import "fmt"//实现格式化的I/O    /*Printf someting*/  func main(){          fmt.Printf("Hello,GO!\n")   }

  • GO语言实现简单的目录复制功能

    本文实例讲述了GO语言实现简单的目录复制功能.分享给大家供大家参考.具体实现方法如下: 创建一个独立的 goroutine 遍历文件,主进程负责写入数据.程序会复制空目录,也可以设置只复制以 ".xx" 结尾的文件. 严格来说这不是复制文件,而是写入新文件.因为这个程序是创建新文件,然后写入复制数据的.我们一般的 copy 命令是不会修改文件的 ctime(change time) 状态的. 代码如下: 复制代码 代码如下: // 一个简单的目录复制程序:一个独立的 goroutine

  • Go语言实现的一个简单Web服务器

    Web是基于http协议的一个服务,Go语言里面提供了一个完善的net/http包,通过http包可以很方便的就搭建起来一个可以运行的Web服务.同时使用这个包能很简单地对Web的路由,静态文件,模版,cookie等数据进行设置和操作. http包建立Web服务器 复制代码 代码如下: package main import (     "fmt"     "net/http"     "strings"     "log"

  • GO语言异常处理机制panic和recover分析

    本文实例分析了GO语言异常处理机制panic和recover.分享给大家供大家参考.具体如下: Golang 有2个内置的函数 panic() 和 recover(),用以报告和捕获运行时发生的程序错误,与 error 不同,panic-recover 一般用在函数内部.一定要注意不要滥用 panic-recover,可能会导致性能问题,我一般只在未知输入和不可靠请求时使用. golang 的错误处理流程:当一个函数在执行过程中出现了异常或遇到 panic(),正常语句就会立即终止,然后执行 d

随机推荐