Session 13¶
Topic 2: The Go language¶
- tour of Go
- tour of Go (continued)
- Go’s programming syntax
- many workers with Goroutines
- shared memory between workers 👈
- message passing with Go channels
- case studies with concurrency
- performance and scalability
- case studies with parallel algorithms
Today¶
Shared memory between workers¶
- race conditions
- mutex, rw-mutex
- example: bank account with shared variables
Shared memory model¶
Motivation¶
challenges with concurrency 🧗
- single goroutine: operations happen in the expected order
- multiple goroutines: operation \(A\) of one goroutine can happen before/after/overlap with operation \(B\) of another goroutine
Go supports both ✌️
- shared memory → via goroutines + shared variables
- message passing → via goroutines + channels
gopl.io, p. 257:
In a program with two or more goroutines, the steps within each goroutine happen in the familiar order, but in general we don’t know whether an event \(x\) in one goroutine happens before an event \(y\) in another goroutine, or happens after it, or is simultaneous with it. When we cannot confidently say that one event happens before the other, then the events \(x\) and \(y\) are concurrent.
Memory shared by multiple goroutines¶
if multiple goroutines access a shared variable, then
- we need synchronization 🚦
- otherwise we risk race conditions ⚡️
- concurrency itself is fine – the problem: unsynchronized shared access
→ outcomes depend on nondeterministic and uncontrolled timing of concurrent memory accesses
Example with a shared memory conflict¶
var counter int // shared variable (why?)
// increment a counter with multiple goroutines
// WARNING: not concurrency safe
for range 100_000 {
go func() {
counter++
}()
}
counter++ is not one operation! … it expands to:
- read
counter - add
1 - write
counter
Race conditions¶
when the program does not give the correct result for some interleavings
- results are incorrect but only for some interleavings of operations 🎲
- race conditions can remain latent sometimes 😴
- are hard to detect and diagnose 🤯
gopl.io, p. 258:
A race condition is a situation in which the program does not give the correct result for some interleavings of the operations of multiple goroutines. Race conditions are pernicious because they may remain latent in a program and appear infrequently, perhaps only under heavy load or when using certain compilers, platforms, or architectures. This makes them hard to reproduce and diagnose.
Go’s Data Race Detector tool¶
The Go tool has a Data Race Detector.
It can only detect race conditions if they happen during a test run, not if they can happen in general.
go run -race main.go
- only fully understanding the logic of a program can reliably tell if race conditions can happen
- best practice: carefully think about memory access and minimize accessing shared memory
Next: How do we make sure only one goroutine can access a variable at a time to avoid conflicts?
Synchronize goroutines to avoid race conditions¶
sync.Mutex (exclusive read & write)¶
managing race conditions with mutual exclusion
- a Mutex ensures mutual exclusion
- only one goroutine at a time may access a critical section
| Method call | What it does |
|---|---|
Lock() |
acquire a lock |
Unlock() |
release a lock |
Serializing access to shared variables¶
back to the previous example:
var mu sync.Mutex // by convention: intended for the variable below
var counter int // shared variable
for range 100_000 {
go func() {
mu.Lock() // 🔒 only one goroutine at a time
counter++
mu.Unlock() // 🔓 goroutines in parallel again
}()
}
sync.RWMutex (multiple reads ok)¶
reading a variable with multiple goroutines, but writing only with one goroutine
- multiple readers are allowed concurrently
- writers get exclusive access
| Method call | What it does |
|---|---|
Lock() |
acquire read/write |
Unlock() |
release read/write |
RLock() |
acquire read-only |
RUnlock() |
release read-only |
When to use sync.RWMutex¶
- many reads
- few writes
- reads dominate runtime
otherwise, a normal sync.Mutex is often simpler
Example: cache shared by goroutines¶
var cache = make(map[string]string)
func read(key string) string {
v := cache[key]
return v
}
func write(key, val string) {
cache[key] = val
}
// in main; WARNING: not concurrency safe
go write("foo", "bar")
//...
var val string
go func() { val = read("foo") }()
🧐 which variables are shared and which non-shared?
Serial write – parallel reads¶
var rw sync.RWMutex
var cache = make(map[string]string)
func read(key string) string {
rw.RLock() // request read-only lock
v := cache[key] // read concurrently with other goroutines
rw.RUnlock()
return v
}
func write(key, val string) {
rw.Lock() // request read/write lock
cache[key] = val // write exclusively
rw.Unlock()
}
⚠️ Go maps are not concurrency safe → simultaneous read/write can crash the program
Example: bank account¶
Recall a previous example¶
code/go/bank2_modules demonstrated the use of modules, structures, and methods
now:
the seriousness of race conditions through the metaphor of financial loss
take the previous (sequential) bank account example to the concurrent world: code/go/bank3_race
code/go/bank3_race/bank/bank.go
package bank
// WARNING: not concurrency safe
type Account struct {
balance int
}
func (a *Account) Balance() int {
return a.balance
}
func (a *Account) Deposit(amount int) {
a.balance = a.balance + amount
}
previous sequential main/main.go
func main() {
var a bank.Account
// Alice:
a.Deposit(200) // A1
fmt.Println("$", a.Balance()) // A2
// Bob:
a.Deposit(100) // B1
}
Now, concurrent function calls¶
code/go/bank3_race/main/main.go
func main() {
var a bank.Account
// Alice:
go func() {
a.Deposit(200) // A1
fmt.Println("$", a.Balance()) // A2
}()
// Bob:
go a.Deposit(100) // B1
}
Concurrent interleavings¶
intuitively, we see only three possible orderings:
| Alice first | Bob first | Alice/Bob/Alice |
|---|---|---|
a= 0 |
a= 0 |
a= 0 |
A1 a=200 |
B1 a=100 |
A1 a=200 |
A2 $ 200 |
A1 a=300 |
B1 a=300 |
B1 a=300 |
A2 $ 300 |
A2 $ 300 |
(“a=...” tells the value of the variable a.balance)
Wrong intuition¶
the problematic interleaving:
| Data race ⚡️ | Context |
|---|---|
a= 0 |
|
A1r a= 0 |
... = a.balance + amount |
B1 a=100 |
|
A1w a=200 |
a.balance = ... |
A2 $ 200 |
💸 |
this is not one operation!
a.balance = a.balance + amount
it expands to:
tmp := a.balance // read
tmp = tmp + amount // modify
a.balance = tmp // write
gopl.io, p. 259:
There is a fourth possible outcome, in which Bob’s deposit occurs in the middle of Alice’s deposit, after the balance has been read
... = a.balance + amountbut before it has been updated
a.balance = ...causing Bob’s transaction to disappear. This is because Alice’s deposit operation
A1is really a sequence of two operations, a read and a write; call themA1randA1w.
sync.Mutex avoid race conditions¶
type Account struct {
mu sync.Mutex // guards balance
balance int
}
Lock() acquires and Unlock() releases a lock
func (a *Account) Balance() int {
a.mu.Lock()
bal := a.balance
a.mu.Unlock()
return bal
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
a.balance = a.balance + amount
a.mu.Unlock()
}
gopl.io, p. 263:
Each time a goroutine accesses the variables of the bank (just balance here), it must call the mutex’s
Lockmethod to acquire an exclusive lock. If some other goroutine has acquired the lock, this operation will block until the other goroutine callsUnlockand the lock becomes available again. The mutex guards the shared variables. By convention, the variables guarded by a mutex are declared immediately after the declaration of the mutex itself. If you deviate from this, be sure to document it.
How do we make withdrawal concurrency safe?¶
code/go/bank3_race/bank/bank.go
// WARNING: not concurrency safe
func (a *Account) Withdrawal(requested int) (int, bool) {
if requested <= a.balance {
a.Deposit(-requested)
return requested, true
} else {
// insufficient funds, no withdrawal
return 0, false
}
}
// in main, we want to do:
go a.Deposit(200)
go a.Withdrawal(100)
→ exercise: create a version with mutexes
The full solution is given in the README.md file in bank3_race.
Race conditions in data science¶
Concurrency is common in data science¶
- parallel data processing
- parallel model training
- caches
- metrics and counters
→ unprotected shared variables silently break results
1. Parallel data processing¶
multiple goroutines process data chunks and update a shared result
Example:
total += partialSum
⚠️ If updates happen concurrently, the final result may be too small.
2. Parallel model training¶
training algorithms often update shared parameters
Example: gradient updates to shared model weights
⚠️ Concurrent updates without synchronization can lose updates.
3. Caches¶
store expensive computations for later reuse
Example:
cache[input] = result
⚠️ Race conditions can cause: corrupted state, runtime crash, and inconsistent results.
4. Metrics and counters¶
common in data pipelines and web services
Example:
requestsProcessed++
⚠️ Multiple goroutines incrementing the counter may produce incorrect statistics.
Concurrency bugs are tricky¶
Concurrency bugs often do not crash programs. Instead they produce:
- wrong statistics
- incorrect model parameters
- inconsistent cache entries
These are the hardest bugs to detect in data science pipelines.
Shared memory in other languages¶
| Language | Typical model | Example |
|---|---|---|
| Go | goroutines + mutexes | sync.Mutex, sync.RWMutex |
| C/C++ & OpenMP | compiler directives | #pragma omp parallel for |
| Python | threads or multiprocessing | threading.Lock() |
| Java | threads + synchronized objects | synchronized, ReentrantLock |
| Rust | ownership + safe concurrency | Mutex<T>, Arc<T> |
Key takeaways¶
- non-shared memory → safe to use concurrently
- shared memory → requires synchronization
- design critical sections to be small & fast
- in Go, use Mutex for exclusive access
- in Go, use RWMutex for reads with high concurrency
Looking ahead¶
Go’s main concurrency mantra:
Do not communicate by sharing memory; instead, share memory by communicating.