It's been quite some time since I picked up Go, and I've built a flex-wrap projects with it (a simple blockchain, p2p file storage, and neartalk). With each project, I find myself liking Go more :D - I think the language really does everything right for what it's made for. It's simple and it's great (and quite fast). Yes, concurrency is quite nice, but when multiple goroutines access shared data simultaneously, things can go wrong fast, although no problem as we can solve this with mutexes.
In this blog, I want to share what I have learned about goroutines, channels, race conditions and mutexes.
So concurrency is about dealing with multiple things at once. In Go, we achieve this through goroutines - lightweight threads managed by the Go runtime.
go doSomething() // runs concurrently
Think of concurrency like a chef managing multiple dishes:
Single Goroutine: Multiple Goroutines:
Task A Task A Task B Task C
Task B | | |
Task C v v v
| [Running Concurrently]
v
[Sequential] Time Saved!
Concurrency is NOT the same as parallelism
Concurrency (1 CPU): Parallelism (2+ CPUs):
Task A Task B Task A Task B
| | | |
v | v v
| v [CPU 1] [CPU 2]
v | | |
| v v v
[Switching back and [Both running simultaneously!]
forth on single CPU]
Go's runtime scheduler can run goroutines concurrently on a single CPU core, or in parallel across multiple cores. You write concurrent code, and Go handles making it parallel when possible!
A race condition occurs when multiple goroutines access shared data simultaneously, and at least one of them is writing. The outcome depends on the unpredictable timing of their execution.
e.g., if one thread tries to increase an integer and another thread tries to read it, this will cause a race condition. On the other hand, if the variable is read-only, there won't be a race condition. Let's try to create a race condition. The easiest way to do it is by using multiple goroutines, and at least one of the goroutines must be writing to a shared variable.
Imagine two people trying to withdraw money from the same account at the exact same time:
var balance = 100
func withdraw(amount int) {
if balance >= amount {
// What if another goroutine runs here?
balance = balance - amount
}
}
// Two goroutines run simultaneously
go withdraw(60)
go withdraw(60)
What could go wrong?
Time Goroutine 1 Goroutine 2 Balance
---- ----------- ----------- -------
t1 Read balance=100 100
t2 Read balance=100 100
t3 Check: 100>=60 ✓ 100
t4 Check: 100>=60 ✓ 100
t5 balance = 100-60 40
t6 balance = 100-60 40
Result: Balance is 40, but we withdrew 120
(Race Condition):
┌─────────────┐ ┌─────────────┐
│ Goroutine 1 │ │ Goroutine 2 │
└──────┬──────┘ └──────┬──────┘
│ │
│ Read: balance=100 │
├───────────────────────┤
│ │ Read: balance=100
│ ├───────────────────
│ Write: balance=40 │
├───────────────────────┤
│ │ Write: balance=40 X
│ │
▼ ▼
[Data Corruption!]
A So a Mutex (mutual exclusion) is like a lock on a door. Only one goroutine can hold the lock at a time.
Let's see with an example:
import "sync"
var (
balance = 100
mu sync.Mutex // Our lock
)
func withdraw(amount int) {
mu.Lock() // Lock the door
defer mu.Unlock() // Unlock when done
if balance >= amount {
balance = balance - amount
}
}
With Mutex Protection:
┌─────────────┐ ┌─────────────┐
│ Goroutine 1 │ │ Goroutine 2 │
└──────┬──────┘ └──────┬──────┘
│ │
│ Lock() ✓ │
├───────────────────────┤
│ Read: balance=100 │
│ Write: balance=40 │ Lock() ⏳ [Waiting...]
│ Unlock() │
├───────────────────────┤
│ │ Lock() ✓
│ │ Read: balance=40
│ │ Check: 40>=60 ✗
│ │ Unlock()
▼ ▼
[Safe! Balance=40]
Sometimes many goroutines just want to read data, which is actually safe. RWMutex allows multiple readers OR one writer.
var (
balance = 100
mu sync.RWMutex
)
func getBalance() int {
mu.RLock() // Read lock (multiple allowed)
defer mu.RUnlock()
return balance
}
func deposit(amount int) {
mu.Lock() // Write lock (exclusive)
defer mu.Unlock()
balance += amount
}
RWMutex Behavior:
Multiple Readers (Allowed):
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Reader1 │ │ Reader2 │ │ Reader3 │
│ RLock() │ │ RLock() │ │ RLock() │
└────┬────┘ └────┬────┘ └────┬────┘
└────────┴────────┴────────┘
All read safely!
One Writer (Exclusive):
┌─────────┐ ┌─────────┐
│ Writer │ │ Reader │
│ Lock() │ │ RLock() │ ⏳ Blocked
└────┬────┘ └─────────┘
│ Exclusive access
│ No one else allowed!
While mutexes protect shared memory, channels let goroutines communicate by passing data.
ch := make(chan int)
// Sender goroutine
go func() {
ch <- 42 // Send value to channel
}()
// Receiver
value := <-ch // Receive value from channel
Unbuffered Channel (Synchronous):
Sender Channel Receiver
│ │ │
│ ch <- 42 │ │
├─────────────────────────>│ │
│ [BLOCKED] │ │
│ │ value := <-ch │
│ │<───────────────────────┤
│ [UNBLOCKED] │ │
│ │ value = 42 │
│ │ │
Buffered Channel (buffer=2):
Sender Channel Receiver
│ [ ][ ] │
│ ch <- 1 ↓ │
│ ch <- 2 [1][2] │
│ [No blocking yet!] │ │
│ ch <- 3 │ │
│ [BLOCKED - full] [1][2] │
│ │ value := <-ch │
│ │ (gets 1) │
│ [UNBLOCKED] [2][ ] │
│ (sent 3) [2][3]
We use Mutexes when: we need to protect shared data from concurrent access
The code below is an example of how to use a mutex to protect a counter. Check the increment function. If we remove the mutex, the counter will be corrupted (race condition)
package main
import (
"fmt"
"sync"
)
// Mutex Example: Protecting a counter
var (
counter int
mu sync.Mutex
)
func increment() {
mu.Lock()
counter++
mu.Unlock()
}
func main() {
// Multiple goroutines incrementing the counter
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
fmt.Println("Counter:", counter) // Will always be 1000 (without mutex)
}
Go has a built-in race detector! Use it during development:
go run -race main.go
go test -race ./...
The race detector will catch issues like:
WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
main.withdraw()
/path/to/main.go:15 +0x44
Previous read at 0x00c000018090 by goroutine 6:
main.withdraw()
/path/to/main.go:14 +0x3a
From what I have read and learned so far:
1. Always use defer with Unlock
mu.Lock()
defer mu.Unlock() // Ensures unlock even if panic occurs
2. Keep critical sections small
// Bad: Long critical section
mu.Lock()
doLotsOfWork()
mu.Unlock()
// Good: Minimal critical section
doLotsOfWork()
mu.Lock()
updateSharedState()
mu.Unlock()
3. Avoid nested locks (can cause deadlocks)
// Dangerous!
mu1.Lock()
mu2.Lock() // What if another goroutine locks in reverse order?
mu2.Unlock()
mu1.Unlock()
So I think that's about it. I wrote this one because I have been working with Go for quite some time, and wanted to write down my understanding of it. I am here to learn, if you feel something's wrong or missing, please let me know at askwhyharsh@gmail.com.