← Login Counter | Series Overview | Ticket Seller →
The Problem: Money Vanishing Into Thin Air
Two people share a bank account with $100. Both check the balance at the same time, see $100, and both withdraw $100. The bank just lost $100. This isn’t a hypothetical—race conditions in financial systems have caused real monetary losses.
The bank account drama illustrates the fundamental challenge of concurrent programming: read-modify-write operations are not atomic. What seems like a simple operation actually involves multiple steps, and when multiple goroutines execute these steps concurrently, chaos ensues.
The Naive Implementation
Let’s start with the broken version:
package main
import (
"fmt"
"sync"
)
type BankAccount struct {
balance int
}
func (a *BankAccount) Withdraw(amount int) bool {
// Read the balance
if a.balance >= amount {
// Simulate some processing time
// In reality, this could be logging, validation, etc.
// Modify the balance
a.balance -= amount
return true
}
return false
}
func (a *BankAccount) Balance() int {
return a.balance
}
func main() {
account := &BankAccount{balance: 100}
var wg sync.WaitGroup
// Two people trying to withdraw $100 simultaneously
for i := 0; i < 2; i++ {
wg.Add(1)
go func(person int) {
defer wg.Done()
if account.Withdraw(100) {
fmt.Printf("Person %d withdrew $100\n", person)
} else {
fmt.Printf("Person %d failed to withdraw\n", person)
}
}(i)
}
wg.Wait()
fmt.Printf("Final balance: $%d\n", account.Balance())
}
Output (race condition):
Person 0 withdrew $100
Person 1 withdrew $100
Final balance: $-100
Both goroutines read balance = 100, both see sufficient funds, both withdraw. The bank account goes negative.
The “Easy” Fix: Mutex Lock
The obvious solution is to use a mutex:
type BankAccount struct {
mu sync.Mutex
balance int
}
func (a *BankAccount) Withdraw(amount int) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance >= amount {
a.balance -= amount
return true
}
return false
}
func (a *BankAccount) Balance() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}
This works! But the drama is just beginning…
The Transfer Problem: Deadlock Risk
Now let’s try to transfer money between two accounts:
func Transfer(from, to *BankAccount, amount int) error {
from.mu.Lock()
defer from.mu.Unlock()
if from.balance < amount {
return fmt.Errorf("insufficient funds")
}
to.mu.Lock() // ⚠️ DEADLOCK RISK!
defer to.mu.Unlock()
from.balance -= amount
to.balance += amount
return nil
}
The Deadlock Scenario:
- Goroutine A:
Transfer(account1, account2, 50)- Locks account1
- Waits for account2 lock
- Goroutine B:
Transfer(account2, account1, 30)- Locks account2
- Waits for account1 lock
Both goroutines wait forever. Classic deadlock.
Deadlock Prevention: Lock Ordering
The solution is to always acquire locks in the same order:
func Transfer(from, to *BankAccount, amount int) error {
// Determine lock order based on memory address
first, second := from, to
if uintptr(unsafe.Pointer(from)) > uintptr(unsafe.Pointer(to)) {
first, second = to, from
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if from.balance < amount {
return fmt.Errorf("insufficient funds")
}
from.balance -= amount
to.balance += amount
return nil
}
This ensures locks are always acquired in a consistent order, preventing circular wait conditions.
Interest Calculation While Withdrawing
Now add a goroutine that periodically calculates interest:
func (a *BankAccount) AddInterest(rate float64) {
a.mu.Lock()
defer a.mu.Unlock()
interest := float64(a.balance) * rate
a.balance += int(interest)
}
// Background goroutine
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
account.AddInterest(0.01) // 1% interest
}
}()
New problem: If a withdrawal is in progress, interest calculation waits. If interest calculation is running, withdrawals wait. The longer the critical section, the more contention.
Overdraft Protection: The Complexity Cascade
Let’s add overdraft protection with a separate overdraft account:
type BankAccount struct {
mu sync.Mutex
balance int
overdraftLimit int
overdraftUsed int
overdraftAccount *OverdraftAccount
}
func (a *BankAccount) WithdrawWithOverdraft(amount int) error {
a.mu.Lock()
defer a.mu.Unlock()
available := a.balance + (a.overdraftLimit - a.overdraftUsed)
if available < amount {
return fmt.Errorf("insufficient funds including overdraft")
}
if a.balance >= amount {
// Normal withdrawal
a.balance -= amount
} else {
// Need overdraft
shortfall := amount - a.balance
a.balance = 0
// ⚠️ Calling external method while holding lock!
if err := a.overdraftAccount.Debit(shortfall); err != nil {
return err
}
a.overdraftUsed += shortfall
}
return nil
}
Problem: Calling overdraftAccount.Debit() while holding the mutex can:
- Cause deadlock if
Debit()tries to lock the same account - Increase lock contention (longer critical sections)
- Make the code harder to reason about
A Better Design: Message Passing
Instead of shared state with locks, use channels for serialization:
type BankAccount struct {
operations chan func(*accountState)
}
type accountState struct {
balance int
}
func NewBankAccount(initial int) *BankAccount {
acc := &BankAccount{
operations: make(chan func(*accountState)),
}
state := &accountState{balance: initial}
// Single goroutine processes all operations
go func() {
for op := range acc.operations {
op(state)
}
}()
return acc
}
func (a *BankAccount) Withdraw(amount int) bool {
result := make(chan bool)
a.operations <- func(state *accountState) {
if state.balance >= amount {
state.balance -= amount
result <- true
} else {
result <- false
}
}
return <-result
}
func (a *BankAccount) Balance() int {
result := make(chan int)
a.operations <- func(state *accountState) {
result <- state.balance
}
return <-result
}
Benefits:
- No explicit locks
- No deadlock risk (single goroutine)
- Operations are naturally serialized
- Easier to reason about
Tradeoffs:
- More goroutines (one per account)
- Channel overhead
- Transfer between accounts requires coordination
Implementing Transfer with Channels
func Transfer(from, to *BankAccount, amount int) error {
result := make(chan error)
// Send operation to 'from' account
from.operations <- func(fromState *accountState) {
if fromState.balance < amount {
result <- fmt.Errorf("insufficient funds")
return
}
fromState.balance -= amount
// Send operation to 'to' account
to.operations <- func(toState *accountState) {
toState.balance += amount
result <- nil
}
}
return <-result
}
Performance Considerations
Let’s benchmark the different approaches:
func BenchmarkMutexWithdraw(b *testing.B) {
account := &BankAccountMutex{balance: 1000000}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
account.Withdraw(1)
account.Deposit(1)
}
})
}
func BenchmarkChannelWithdraw(b *testing.B) {
account := NewBankAccount(1000000)
defer close(account.operations)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
account.Withdraw(1)
account.Deposit(1)
}
})
}
Typical results:
BenchmarkMutexWithdraw-8 3000000 450 ns/op
BenchmarkChannelWithdraw-8 1000000 1200 ns/op
Mutex approach is ~2.5x faster for simple operations, but:
- Channel approach is easier to extend
- Channel approach has no deadlock risk
- For complex operations with multiple accounts, channel overhead becomes less significant
Testing Race Conditions
Use Go’s race detector:
func TestConcurrentWithdrawals(t *testing.T) {
account := &BankAccount{balance: 1000}
var wg sync.WaitGroup
// 100 goroutines each withdrawing $10
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
account.Withdraw(10)
}()
}
wg.Wait()
// Should have $0 left
if balance := account.Balance(); balance != 0 {
t.Errorf("Expected balance 0, got %d", balance)
}
}
Run with: go test -race
The race detector will catch the data race even if the test “passes” due to timing.
Real-World Applications
- Financial Systems: Banking, payment processing, cryptocurrency wallets
- Inventory Management: E-commerce stock counts, warehouse systems
- Gaming: Virtual currency, item trading, player stats
- Resource Pools: Database connections, thread pools, memory allocators
Common Pitfalls
- Forgetting to lock reads: Even reading shared state requires synchronization
- Lock scope too large: Holding locks during I/O or external calls
- Lock scope too small: Splitting read-modify-write into separate critical sections
- Inconsistent lock ordering: Different functions lock in different orders
- Returning pointers to locked data: Caller can modify without lock
Best Practices
- Keep critical sections short: Only lock what you must
- Consistent lock ordering: Document and enforce lock hierarchy
- Avoid calling unknown code with locks held: It might try to acquire the same lock
- Consider lock-free alternatives: Atomic operations for simple cases
- Use channels when appropriate: Especially for complex coordination
- Always use the race detector:
go test -raceshould be in your CI/CD
Advanced: Lock-Free with Atomic Operations
For simple counters, use atomic operations:
import "sync/atomic"
type BankAccount struct {
balance int64 // Must be 64-bit aligned for atomic ops
}
func (a *BankAccount) Withdraw(amount int64) bool {
for {
current := atomic.LoadInt64(&a.balance)
if current < amount {
return false
}
if atomic.CompareAndSwapInt64(&a.balance, current, current-amount) {
return true
}
// CAS failed, retry
}
}
This is lock-free but only works for simple operations. Complex operations still need locks or channels.
Conclusion
The bank account drama teaches us that:
- Simple fixes reveal complex problems: Adding a mutex is easy; handling transfers, deadlocks, and overdrafts is hard
- Every solution has tradeoffs: Locks are fast but risky; channels are safe but slower
- Race conditions are subtle: The race detector is your friend
- Design matters: Sometimes the best fix is redesigning the system
The question isn’t “should I use locks or channels?” but “what properties does my system need?” If you need:
- Performance: Use mutexes with careful design
- Safety and simplicity: Use channels
- Both: Use the right tool for each component
Remember: Correctness first, performance second. A fast system that loses money is worse than a slow system that doesn’t.