Skip to content

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

  1. race conditions
  2. mutex, rw-mutex
  3. 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:

  1. read counter
  2. add 1
  3. 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 + amount

but before it has been updated

a.balance = ...

causing Bob’s transaction to disappear. This is because Alice’s deposit operation A1 is really a sequence of two operations, a read and a write; call them A1r and A1w.

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 Lock method to acquire an exclusive lock. If some other goroutine has acquired the lock, this operation will block until the other goroutine calls Unlock and 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

  1. parallel data processing
  2. parallel model training
  3. caches
  4. 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

\[ w = w - \eta \nabla\mathcal{L} \]

⚠️ 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.

Prepare for the next session 🌱

sessions/14-todo