Two programming schemes for the Go language concurrency model

  • 2020-05-10 18:20:58
  • OfStack

An overview of the

I've been looking for a good way to explain the go language's concurrency model:

Instead of communicating via Shared memory, share memory via communication

However, I failed to find a good explanation to meet my following requirements:

1. Illustrate the original problem with an example
2. Provide a Shared memory solution
3. Provide a solution through communication

In this article, I will explain from these three aspects.

After reading this article, you should understand the model of sharing memory through communication and how it differs from communicating through Shared memory. You will also see how these two models can be used to solve the problem of accessing and modifying Shared resources, respectively.

The premise

Imagine 1 that we want to access a bank account:


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

Since Account is an interface, we provide a simple implementation:


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(" Jack poor death ")
  }
} func (acc *SimpleAccount) Balance() int {
  return acc.balance
} func (acc *SimpleAccount) setBalance(balance int) {
  acc.add_some_latency()  // increase 1 Delay function, easy to demonstrate
  acc.balance = balance
} func (acc *SimpleAccount) add_some_latency() {
  <-time.After(time.Duration(rand.Intn(100)) * time.Millisecond)
}

You may have noticed that balance was not modified directly, but put into the setBalance method for modification. This is designed to better describe the problem. I'll explain later.

With all the top parts in place, we can use it like this:


func main() {
  balance := 80
  b := NewBank(bank.NewSimpleAccount(balance))
 
  fmt.Println(" Initialize balance ", b.Balance())
 
  b.Withdraw(30, " Ma � ")
 
  fmt.Println("-----------------")
  fmt.Println(" The remaining balance ", b.Balance())
}

Running the above code will output:


Initialize balance 80
[-] 30 Ma �
-----------------
The remaining balance 50

That's right!

Yes, in real life, a bank account can have many supplementary CARDS, different supplementary CARDS can access and withdraw money from the same account, so let's modify 1 code:


func main() {
  balance := 80
  b := NewBank(bank.NewSimpleAccount(balance))
 
  fmt.Println(" Initialize balance ", b.Balance())
 
  done := make(chan bool)
 
  go func() { b.Withdraw(30, " Ma � "); done <- true }()
  go func() { b.Withdraw(10, " yao "); done <- true }()
 
  // Waiting for the goroutine completes
  <-done
  <-done
 
  fmt.Println("-----------------")
  fmt.Println(" The remaining balance ", b.Balance())
}

Here are two supplementary CARDS that simultaneously withdraw money from the account, and see the output:


Initialize balance 80
[-] 30 Ma �
[-] 10 yao
-----------------
The remaining balance 70

This next article happy bad :)

The result is of course wrong, the remaining balance should be 40 instead of 70, so let's see what went wrong.

The problem

Invalid states are more likely to occur when Shared resources are accessed concurrently.

In our example, when two supplementary CARDS withdraw money from the same account at the same time, we end up with the bank account (that is, the Shared resource) with the wrong balance (that is, the invalid state).

Let's take a look at 1 at execution time:


     Processing conditions
             --------------
             _ Ma � _|_ yao _
 1. Get the balance      80  |  80
 2. To withdraw money        -30  | -10
 3. The current remaining      50  |  70
                ... | ...
 4. Set up the balance      50  ?  70  // Which should I set first?
 5. The post Settings are in effect
             --------------
 6. The remaining balance         70

The above... "Describes the latency of our add_some_latency implementation, which often occurs in the real world. So the last remaining balance is determined by the last supplementary card that sets the balance.

The solution

We solve this problem in two ways:

1. Shared memory solution
2. Solution via communication

All solutions simply encapsulate 1 SimpleAccount to implement the protection mechanism.

Shared memory solution

Also known as "communicating through Shared memory."

This approach implies the use of locking mechanisms to prevent simultaneous access to and modification of Shared resources. The lock tells other handlers that the resource is already occupied by one handler, so other handlers need to queue until the current handler has finished processing.

Let's take a look at how LockingAccount works:


type LockingAccount struct {
  lock    sync.Mutex
  account *SimpleAccount
} // encapsulation 1 Under the 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()
}

Straight forward! Note lock sync.Lock, lock.Lock (), lock.Unlock ().

In this way, one supplementary card at a time accesses the bank account (that is, the Shared resource), and the supplementary card will automatically acquire the lock until the operation is completed.

Our LockingAccount is used as follows:


func main() {
  balance := 80
  b := NewBank(bank.NewLockingAccount(balance))
 
  fmt.Println(" Initialize balance ", b.Balance())
 
  done := make(chan bool)
 
  go func() { b.Withdraw(30, " Ma � "); done <- true }()
  go func() { b.Withdraw(10, " yao "); done <- true }()
 
  // Waiting for the goroutine completes
  <-done
  <-done
 
  fmt.Println("-----------------")
  fmt.Println(" The remaining balance ", b.Balance())
}

The output result is:


Initialize balance 80
[-] 30 Ma �
[-] 10 yao
-----------------
The remaining balance 40

Now the result is correct!

In this example, the first handler locks the Shared resource and the other handlers wait for it to complete.

Let's take a look at what happens when we execute 1, and let's say majiweng gets the lock first:


The process
                        ________________
                        _ Ma � _|__ yao __
        lock                    ><
        Get the balance             80  |
        To withdraw money                -30  |
        The current balance             50  |
                           ... |
        Set up the balance             50  |
        Remove the lock                  <>
                               |
        The current balance                 50
                               |
        lock                    ><
        Get the balance                 |  50
        To withdraw money                     | -10
        The current balance                 |  40
                               |  ...
        Set up the balance                 |  40
        Remove the lock                   <>
                        ________________
        The remaining balance                 40

Now our handlers are successively producing the correct results when accessing the Shared resource.

Solution via communication

Also known as "sharing memory through communication."

The account is now called ConcurrentAccount and is implemented as follows:


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 also encapsulates SimpleAccount, and then adds communication channels

The only difference between the calling code and the locked version is the initialization of the bank account:


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

Run results and locked version 1 like:


Initialize balance 80
[-] 30 Ma �
[-] 10 yao
-----------------
The remaining balance 40

Let's dig into the details 1.

How does sharing memory through communication work

1. Some basic points to note:

Shared resources are encapsulated in a single control flow.

The result is that the resource becomes unshared. No handler can access or modify the resource directly. You can see that the methods for accessing and modifying the resource don't actually perform any changes.


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
  }

Access and modification are communicated through messages and control processes.

Any access and modification actions in the control flow occur sequentially.

When the control process receives a request for access or modification, the relevant action is performed immediately. Let's take a closer look at the process:


func (acc *ConcurrentAccount) listen() {
    // Executive control process
    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   constantly pulls messages from channels, each one corresponding to the operation they are about to perform.

One important point is that all 1 cuts are executed sequentially (queued in the same handler) within the select declaration. Only one event (received or sent in the channel) occurs at a time, thus ensuring synchronous access to the Shared resource.

To understand this is a little bit convoluted.

Let's look at the implementation of Balance() with an example:


 1 The process of a supplementary card       |   Control process
      ----------------------------------------------  1.     b.Balance()         |
 2.             ch -> [acc.balances]-> ch
 3.             <-ch        |  balance = acc.account.Balance()
 4.     return  balance <-[ch]<- balance
 5                          |

What do these two processes do?

Supplementary card process

1. Call b. Balance ()
2. Create a new channel ch and insert the ch channel into the channel acc.balances to communicate with the control process, so that the control process can also return the balance through ch
3. Wait for < -ch to get the balance to be accepted
4. Accept the balance
5. Continue

Control process

1. Idle or processing
2. Accept the balance request through the ch channel inside the acc.balances channel
3. Get the true balance value
4. Send the balance value to the ch channel
5. Prepare to process the next request

The control process processes only one event at a time. This is why there are no operations performed in steps 2-4 other than those described.

conclusion

The blog described the problem and the solutions to it, but didn't delve into the pros and cons of the different solutions at the time.

In fact, the example in this article is more suitable for mutex, because it makes the code clearer.

Finally, please feel free to point out my mistakes!


Related articles: