当前位置: 首页 > 编程笔记 >

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

王弘和
2023-03-14
本文向大家介绍Go语言并发模型的2种编程方案,包括了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,因为这样代码更加清晰。

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

 类似资料:
  • 本文向大家介绍详解go语言的并发,包括了详解go语言的并发的使用技巧和注意事项,需要的朋友参考一下 1、启动go语言的协程 2、runtime.Goexit()方法。立即终止当前的协程 3、runtime.GOMAXPROCS()表示go使用几个cpu执行代码 4、管道定义和创建管道 5、管道的缓冲 6、关闭管道和接受关闭管道的信号 7、只读管道和只写管道和生产者和消费者模型 8、Timer定时器

  • 通过上一节《 Go语言goroutine》的学习,关键字 go 的引入使得在Go语言中并发编程变得简单而优雅,但我们同时也应该意识到并发编程的原生复杂性,并时刻对并发中容易出现的问题保持警惕。 事实上,不管是什么平台,什么编程语言,不管在哪,并发都是一个大话题。并发编程的难度在于协调,而协调就要通过交流,从这个角度看来,并发单元间的通信是最大的问题。 在工程上,有两种最常见的并发通信模型:共享数据

  • 主要内容:Goroutine 介绍,channel有人把Go语言比作 21 世纪的C语言,第一是因为Go语言设计简单,第二则是因为 21 世纪最重要的就是并发程序设计,而 Go 从语言层面就支持并发。同时实现了自动垃圾回收机制。 Go语言的并发机制运用起来非常简便,在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。 下面来介绍几个概念: 进程/线程 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个

  • 我用Go语言创建了一个示例gRPC客户端和服务器(使用原型)。我理解Go语言中的并发模型。但是,我正在尝试理解服务器中接受来自同一客户端(客户端上的多个goroutines)/多个客户端的并行请求的并发模型。 更具体地说: 当一个新的gRPC呼叫到来时,服务器是否创建了一个新的goroutine

  • 问题内容: 我听到来自Google的关于Go编程语言的嗡嗡声。维基百科是这样描述的:“ Go旨在通过简化动态语言的编程来提供静态类型的编译语言的效率”。我当时在想,由于它是android制造商提供的一种语言,因此他们应该对创建android应用程序有一些支持。有没有可以从Go创建android应用程序的工具?使用“ SOMEDAY” android可能基于GO的假设来学习Go语言是否值得。 问题答

  • 本书涵盖 CGO、Go 汇编语言、RPC 实现、Web 框架实现、分布式系统等高阶主题,针对Go语言有一定经验想深入了解Go语言各种高级用法的开发人员。