1. Solving the Race
To fix the race condition, we need to ensure Mutual Exclusion. We need a “lock” that prevents two threads from entering the Critical Section simultaneously.
But how do we implement a lock? If we just use a boolean flag:
while (locked == true); // Wait
locked = true; // Enter
// Critical Section
locked = false; // Exit
This fails! The check while(locked == true) and the set locked = true are distinct operations. Two threads can both see locked == false and enter together.
2. Hardware Primitives: The Secret Sauce
We cannot build a lock using just software (on modern CPUs). We need help from the hardware. The CPU provides Atomic Instructions that perform a Read-Modify-Write in a single, indivisible step.
1. Test-and-Set (TAS)
Atomically writes 1 (true) to a memory location and returns its old value.
boolean TestAndSet(boolean *lock) {
boolean old = *lock;
*lock = true;
return old;
}
2. Compare-and-Swap (CAS)
The basis of all modern synchronization (including Java’s AtomicInteger).
Atomically checks if a value matches expected, and if so, updates it to new.
boolean CAS(int *ptr, int expected, int new) {
if (*ptr == expected) {
*ptr = new;
return true;
}
return false;
}
3. Interactive: CAS Visualizer
Understand the atomic nature of Compare-And-Swap. Try to update the value from 0 to 1. Then try to update it again assuming it’s still 0 (which will fail).
4. Spinlocks vs Mutexes
Once we have atomic instructions, we can build locks.
Analogy: The Fitting Room Imagine a clothing store with a single fitting room. The “Critical Section” is the room itself.
- Spinlock: You stand right outside the door, repeatedly jiggling the handle to see if the person is done. You waste your energy (CPU cycles), but the moment they step out, you get in immediately (zero latency).
- Mutex: You give your name to an attendant and go sit on a bench. The OS scheduler has put you to sleep. When the room is free, the attendant walks over and wakes you up. This saves your energy, but there is a slight delay (latency) in getting back up and walking to the room.
1. The Spinlock (Busy Waiting)
The thread runs a loop checking the lock status repeatedly.
while (CAS(&lock, 0, 1) == false); // Spin
- Pros: Zero latency if the lock is held briefly. No context switch.
- Cons: Wastes CPU cycles (“spinning”).
- Use Case: Kernel interrupt handlers, low-level driver code.
2. The Mutex (Sleeping)
If the lock is busy, the thread Yields the CPU and tells the OS Scheduler to put it in the Waiting Queue.
- Pros: Efficient use of CPU. Other threads can run.
- Cons: High latency. A Context Switch takes ~1-5 microseconds (thousands of CPU cycles).
- Use Case: Application level code (Java
synchronized, GoMutex).
5. Code Example: Using Locks
Always use the standard library locks. Do not implement your own spinlock in user-space code.
Java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SafeCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Acquire lock
try {
count++; // Critical Section
} finally {
lock.unlock(); // Always unlock in finally!
}
}
}
Go
package main
import (
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock() // Acquire lock
defer c.mu.Unlock() // Ensure unlock happens
c.count++ // Critical Section
}