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!