Dwi Wahyudi
Senior Software Engineer (Ruby, Golang, Java)
In this post, I will try to utilize Golang sync.Mutex
to synchronize access to a shared resource.
Overview
Previously I tried to write some concurrent code in this post:
Golang Concurrency With Goroutine and Channel.
Concurrency is a great tool, having many threads (Goroutines) to process jobs at the same time, it can help us to complete many concurrent jobs with speed, but without proper handling, it can hurt ourselves, especially when dealing with shared access to a resource, race conditions may occur. A Race condition will cause result of concurrent operations to be wrong/innacurate/corrupt. This is a bug.
A race condition will occur when a thread (Goroutine) try to write to a resource (like int
or string
or etc), while another thread running concurrently/simultaneously read it and process it.
Similar jobs run by Goroutines, like number counter, will have wrong numbers.
1, 2, 3, 4, 4, 5,…
Thread 1 (Goroutine 1) | Thread 2 (Goroutine 2) | Value |
---|---|---|
3 | ||
read | 3 | |
read | 3 | |
+1 | 3 | |
+1 | 3 | |
write | 4 | |
write | 4 |
Similar problem will occur in real world transactions data processing. Let say we have a bank account, Goroutine 1 is a credit operation (a customer is doing cash deposit), while Goroutine 2 is another credit operation (let say customer’s parent is doing cash deposit to the same account).
Goroutine 1 | Goroutine 2 | Value |
---|---|---|
10000 | ||
read | 10000 | |
read | 10000 | |
+4000 | 10000 | |
+4000 | 10000 | |
write | 14000 | |
write | 14000 |
Oh no, that customer is losing 4000. It should be 18000 not 14000. This is just for example, usually we need to lock the value from database (MySQL, PostgreSQL, Redis, etc), if we use one.
In order to handle this problem, we need concurrency control, we need to lock/synchronize/mutex access to such value. The read and write operations performed must be isolated. Only 1 Goroutine can perform such operation at at time, other Goroutine(s) need to wait.
No matter how many threads/Goroutines are there, only and only 1 thread/Goroutine can have access to that resource at a time.
In distributed network like internet, we can have huge number of customers/clients using our web application. It is really common for a web application to handle concurrent requests. Golang net/http package utilizes Goroutines means we can create a web server that can serve multiple web requests at once. New Goroutine will be spawned for a new web request.
Lock/Synchronize/Mutex is A Bottleneck
We must be aware that lock/synchronize/mutex is bottleneck. We must make really sure that the process is fast so that it won’t lock other Goroutines.
Let say there are 1000 concurrent Goroutines want to update an account’s balance. We must do it one by one. This is a tradeoff we have to make, or we should go with another approach like eventual consistency, but that’s another topic, and plenty of business logic must be done in ACID fashion.
We should be able to differentiate which tasks can be parallized, and which tasks must be synchronized.
Race Condition
Before we see the example of race condition, let’s write some code needed for the example. Here is Account
struct which will save account balance information, together with sleep
function, that is needed to simulate long process.
We will create 2 accounts, initialize some balances, and do transfer between them.
type Account struct {
name string
balance float64
}
// Simulating long process.
func sleep(duration int) {
time.Sleep(time.Duration(duration) * time.Millisecond)
}
Here is the transfer code, there is sender
which sends money (amount
) to receiver
, there is also transactionNo
for identifying the transaction number.
We also pass wg
waitgroup to wait for each of this transfer (we will need it so that we can print to console the amount for each account).
If sender’s balance is not enough for this transaction to happen, we will create a new error. This error will be channeled. We cannot let an account to have negative balance.
Otherwise, we perform the transaction.
As we can see here, this function is quite common. Any Goroutine can run this function immediately, in concurrent to each other.
func transferNotSync(transactionNo string,
sender *Account,
receiver *Account,
amount float64,
wg *sync.WaitGroup,
c chan error) {
defer wg.Done()
senderBalanceToBe := sender.balance - amount
sleep(500)
if senderBalanceToBe < 0 {
c <- errors.New(transactionNo + " balance is not enough")
} else {
sender.balance = senderBalanceToBe
receiver.balance += amount
c <- nil
}
}
If more than 1 Goroutine run that function at the same time, with similar sender and or receiver, there is a chance that senderBalanceToBe
will not be the correct value, because another Goroutine already update sender.balance
.
Here is the demo for the transfer function.
func demoNotSync() {
budi := Account{name: "Budi", balance: 100_000}
maman := Account{name: "Maman", balance: 230_000}
c := make(chan error)
var wg sync.WaitGroup
wg.Add(1)
go transferNotSync("T01", &maman, &budi, 220_000, &wg, c)
wg.Add(1)
go transferNotSync("T02", &maman, &budi, 15_000, &wg, c)
wg.Add(1)
go transferNotSync("T03", &maman, &budi, 18_000, &wg, c)
go func() {
wg.Wait()
close(c)
}()
for err := range c {
fmt.Println(err)
}
fmt.Println(budi)
fmt.Println(maman)
}
When we run the demoNotSync
, the result will be different each time we run the code, this is expected because we have no idea which Goroutine will complete first, but the problem is because each Goroutine is racing each other, this can cause bug in senderBalanceToBe
.
- T01, Maman initially has 230k, then he send 220k to Budi, at this point his balance should be 10k.
- T02 or T03, Maman tries to send Budi 15k or 18k, our app should reject the transaction, because that will cause his balance to be negative.
- The race condition happens because T02 is still reading
senderBalanceToBe := sender.balance - amount
from the old value (230k), T01 update the value to 10k, but later T02 with the old value update it again with 215k (the old 230k - 15k).
Golang Mutex
Golang provides us with sync.Mutex
in order to synchronize access to parts of codes, so only 1 Goroutine can run that part of the code at a time. It is a good practice to have mutex close to the data. If we place the mutex in the function, only 1 transfer can happen at a time, so we should put the mutex into the account, so that only 1 Goroutine can have access to it at a time.
type AccountWithLock struct {
mutex sync.Mutex
name string
balance float64
}
With this struct in place, the transfer function should incorporate this struct as well, plus, we must lock the sender
and receiver
, and unlock on defer
(after transaction is done).
func transferSync(transactionNo string,
sender *AccountWithLock,
receiver *AccountWithLock,
amount float64,
wg *sync.WaitGroup,
c chan error) {
sender.mutex.Lock()
defer sender.mutex.Unlock()
receiver.mutex.Lock()
defer receiver.mutex.Unlock()
defer wg.Done()
senderBalanceToBe := sender.balance - amount
sleep(500)
if senderBalanceToBe < 0 {
c <- errors.New(transactionNo + " balance is not enough")
} else {
sender.balance = senderBalanceToBe
receiver.balance += amount
c <- nil
}
}
Here is the demonstration code:
func demoSync() {
budi := AccountWithLock{name: "Budi", balance: 100_000}
maman := AccountWithLock{name: "Maman", balance: 230_000}
c := make(chan error)
var wg sync.WaitGroup
wg.Add(1)
go transferSync("T01", &maman, &budi, 220_000, &wg, c)
wg.Add(1)
go transferSync("T02", &maman, &budi, 15_000, &wg, c)
wg.Add(1)
go transferSync("T03", &maman, &budi, 18_000, &wg, c)
go func() {
wg.Wait()
close(c)
}()
for err := range c {
fmt.Println(err)
}
fmt.Println(budi)
fmt.Println(maman)
}
With mutex in place, the code can still run concurrently, but only 1 Goroutine have access to an account during transaction until the mutex is unlocked. No matter which Goroutine complete first, it shouldn’t cause race condition. The expectation is that data is still correct, and our app returns new error correctly.
On the first run, T01 run the transfer first, T02 and T03 waits. When T01 is completed, Maman’s balance is already 10k, making T02 and T03 to return errors (balance is not enough).
On the second run, T02 and T03 are completed first (either T02 first or T03 first). They can be completed because Maman’s balance is enough for both transactions/transfers. Maman’s balance is only 197k (which is not enought for sending 220k in T01) after T02 and T03, then T01 run the transaction/transfer, causing T01 to return the error.
Here is the repository for the demonstration code: https://github.com/dwahyudi/golang-sync