|
// Solution of a Problem in Concurrent Program Control |
|
// |
|
// Dijkstra, 1965: http://rust-class.org/static/classes/class19/dijkstra.pdf |
|
// https://i.imgur.com/weHFuip.png |
|
// |
|
// This is a corrected Go implementation that uses atomic operations to ensure |
|
// the sequential consistency that Dijkstra's algorithm requires. |
|
// |
|
// EDUCATIONAL OVERVIEW: |
|
// ==================== |
|
// This algorithm solves the mutual exclusion problem - ensuring that only one |
|
// process can be in its "critical section" at a time, without using any |
|
// built-in synchronization primitives like mutexes. |
|
// |
|
// KEY CONCEPTS: |
|
// |
|
// 1. CRITICAL SECTION: A piece of code that must not be executed by multiple |
|
// processes simultaneously (e.g., updating shared data, accessing hardware). |
|
// |
|
// 2. MUTUAL EXCLUSION: The guarantee that at most one process is in its |
|
// critical section at any given time. |
|
// |
|
// 3. THE CHALLENGE: How do we achieve mutual exclusion using only: |
|
// - Shared memory (boolean arrays b[] and c[], integer k) |
|
// - Atomic read/write operations |
|
// - NO locks, semaphores, or other high-level primitives |
|
// |
|
// THE ALGORITHM'S SHARED VARIABLES: |
|
// ================================= |
|
// |
|
// b[i] (boolean): Process i's "not trying" flag |
|
// - true = process i is NOT trying to enter critical section (in "remainder") |
|
// - false = process i IS trying to enter critical section (in "looping") |
|
// |
|
// c[i] (boolean): Process i's "willing to yield" flag |
|
// - true = process i is willing to yield to others |
|
// - false = process i wants to enter critical section |
|
// |
|
// k (integer): The "privileged process" selector (0 to N-1) |
|
// - Points to the process that has priority to enter critical section |
|
// - Only changes when b[k] is true (k is not trying) |
|
// |
|
// THE ALGORITHM'S STAGES (for process i): |
|
// ======================================== |
|
// |
|
// REMAINDER (R): |
|
// - Process is doing non-critical work |
|
// - b[i] = true, c[i] = true |
|
// |
|
// Li0: ANNOUNCE INTENT |
|
// - Set b[i] = false: "I want to enter critical section" |
|
// - Enter the "looping" stage |
|
// |
|
// Li1-Li3: TWO-STAGE CHECK (the heart of the algorithm) |
|
// |
|
// FIRST STAGE (Li1 check: k != i): |
|
// Ask: "Am I the privileged process?" |
|
// - If k == i: I have priority, skip to SECOND STAGE (Li4) |
|
// - If k != i: Someone else has priority, proceed to Li3 |
|
// |
|
// Li3: YIELD AND TRY TO BECOME PRIVILEGED |
|
// - Set c[i] = true: "I'm willing to yield" |
|
// - Check if b[k] is true (privileged process not trying) |
|
// * If yes: Set k = i (make myself privileged) |
|
// - Loop back to Li1 to recheck |
|
// |
|
// SECOND STAGE (Li4 check: all other c[j] are true): |
|
// Ask: "Are all other processes willing to yield to me?" |
|
// - Set c[i] = false: "I'm not yielding anymore" |
|
// - Check all other processes: if any c[j] is false, loop back to Li1 |
|
// - If all other c[j] are true: proceed to critical section |
|
// |
|
// CRITICAL SECTION: |
|
// - Only one process can be here at a time (mutual exclusion) |
|
// - Do the critical work |
|
// |
|
// EXIT: |
|
// - Set c[i] = true, b[i] = true: "I'm done, back to remainder" |
|
// |
|
// WHY THIS WORKS (Informal): |
|
// ========================== |
|
// |
|
// 1. SAFETY (mutual exclusion): |
|
// A process enters critical section only after finding all other c[j] true |
|
// while its own c[i] is false. Two processes cannot both see this condition |
|
// simultaneously. |
|
// |
|
// 2. LIVENESS (progress): |
|
// If multiple processes are looping and no one is in critical section: |
|
// - Eventually k stabilizes to point to one of the looping processes |
|
// - That process (k) will find k == i and proceed to Li4 |
|
// - All other looping processes will set their c[j] = true (yielding) |
|
// - Process k will enter critical section |
|
// |
|
// 3. THE ROLE OF k: |
|
// k acts as a "tie-breaker" to prevent deadlock. When multiple processes |
|
// compete, k determines who gets priority. The protocol ensures k eventually |
|
// stabilizes, allowing progress. |
|
// |
|
// WHY ATOMIC OPERATIONS ARE NEEDED: |
|
// ================================== |
|
// |
|
// Dijkstra's algorithm assumes SEQUENTIAL CONSISTENCY: |
|
// - All reads and writes to shared variables are atomic |
|
// - All processes see operations in the same total order |
|
// |
|
// Modern languages like Go do NOT provide sequential consistency for plain |
|
// variables. Without atomic operations: |
|
// - Writes may not be visible to other goroutines |
|
// - Reads may see stale values |
|
// - Operations can be reordered by compiler/CPU |
|
// - The algorithm's invariants break down |
|
// |
|
// Solution: Use sync/atomic operations to ensure: |
|
// - Atomic reads and writes (no torn reads/writes) |
|
// - Proper memory ordering (acquire/release semantics) |
|
// - Visibility of changes across goroutines |
|
package main |
|
|
|
import ( |
|
"bytes" |
|
"flag" |
|
"fmt" |
|
"io" |
|
"math/rand" |
|
"sync/atomic" |
|
"time" |
|
) |
|
|
|
const ( |
|
Enter = "\u001b[32mE\u001b[0m" |
|
Exit = "\u001b[31mX\u001b[0m" |
|
False = "\u001b[34mF\u001b[0m" |
|
True = "\u001b[37mT\u001b[0m" |
|
) |
|
|
|
var ( |
|
N = flag.Int("n", 3, "number of processes") |
|
T = flag.Duration("t", 1000*time.Millisecond, "exit simulation after t (e.g. 1s, 100ms, ...)") |
|
Cdur = flag.Duration("C", 10*time.Millisecond, "critical section duration") |
|
Rdur = flag.Duration("R", 100*time.Millisecond, "remainder duration") |
|
) |
|
|
|
func main() { |
|
flag.Parse() |
|
|
|
// SHARED VARIABLES (all accessed atomically) |
|
// k: the privileged process index (starts at 0) |
|
var k atomic.Int32 |
|
|
|
// b[i]: true = process i is not trying to enter critical section |
|
// Initially all true (all processes start in remainder section) |
|
b := make([]atomic.Bool, *N) |
|
for i := 0; i < *N; i++ { |
|
b[i].Store(true) |
|
} |
|
|
|
// c[i]: true = process i is willing to yield |
|
// Initially all true (no one is competing yet) |
|
c := make([]atomic.Bool, *N) |
|
for i := 0; i < *N; i++ { |
|
c[i].Store(true) |
|
} |
|
|
|
// The program for the ith computer (process) |
|
computer := func(i int) { |
|
fmt.Printf("[%d] R .. k=%v, b=%v, c=%v\n", i, k.Load(), BtoaAtomic(b), BtoaAtomic(c)) |
|
|
|
Li0: // ANNOUNCE INTENT TO ENTER CRITICAL SECTION |
|
fmt.Printf("[%d] Li0 k=%v, b=%v, c=%v\n", i, k.Load(), BtoaAtomic(b), BtoaAtomic(c)) |
|
// Set b[i] = false: "I am now looping (trying to enter critical section)" |
|
b[i].Store(false) |
|
|
|
Li1: // FIRST STAGE: CHECK IF I AM THE PRIVILEGED PROCESS |
|
// Read current privileged process index |
|
currentK := int(k.Load()) |
|
|
|
if currentK != i { |
|
// I am NOT the privileged process |
|
// Set c[i] = true: "I am willing to yield to others" |
|
c[i].Store(true) |
|
|
|
// TRY TO BECOME THE PRIVILEGED PROCESS (Li3) |
|
// Check if the current privileged process is not trying to enter |
|
if b[currentK].Load() { |
|
// b[k] is true, meaning process k is not trying |
|
// Make myself the privileged process |
|
k.Store(int32(i)) |
|
} |
|
// Loop back to Li1 to recheck (maybe I'm privileged now, or k changed) |
|
goto Li1 |
|
|
|
} else { |
|
// I AM the privileged process (k == i) |
|
|
|
// SECOND STAGE: CHECK IF ALL OTHERS ARE WILLING TO YIELD (Li4) |
|
// Set c[i] = false: "I am NOT yielding, I want to enter" |
|
c[i].Store(false) |
|
|
|
// Check if all other processes have c[j] = true (willing to yield) |
|
for j := 0; j < *N; j++ { |
|
if j != i && !c[j].Load() { |
|
// Process j is not willing to yield (c[j] = false) |
|
// We cannot enter yet, go back to Li1 |
|
goto Li1 |
|
} |
|
} |
|
// All other c[j] are true, we can enter critical section! |
|
} |
|
|
|
// ======================================================================== |
|
// CRITICAL SECTION START |
|
// ======================================================================== |
|
// At this point, we have guaranteed that: |
|
// - We are the privileged process (k == i) |
|
// - Our c[i] = false |
|
// - All other c[j] = true |
|
// This ensures mutual exclusion: no other process can enter |
|
fmt.Printf("[%d] %s >> k=%v, b=%v, c=%v\n", i, Enter, k.Load(), BtoaAtomic(b), BtoaAtomic(c)) |
|
time.Sleep(*Cdur) |
|
fmt.Printf("[%d] %s << k=%v, b=%v, c=%v\n", i, Exit, k.Load(), BtoaAtomic(b), BtoaAtomic(c)) |
|
// ======================================================================== |
|
// CRITICAL SECTION END |
|
// ======================================================================== |
|
|
|
// EXIT PROTOCOL: Release our claim |
|
c[i].Store(true) // "I am willing to yield again" |
|
b[i].Store(true) // "I am not trying to enter anymore" |
|
|
|
// REMAINDER SECTION: Do non-critical work |
|
fmt.Printf("[%d] Rem k=%v, b=%v, c=%v\n", i, k.Load(), BtoaAtomic(b), BtoaAtomic(c)) |
|
time.Sleep(time.Duration(rand.Int63n(Rdur.Milliseconds())) * time.Millisecond) |
|
|
|
// Loop back to try entering critical section again |
|
goto Li0 |
|
} |
|
|
|
// Launch N concurrent processes |
|
for i := 0; i < *N; i++ { |
|
go computer(i) |
|
} |
|
|
|
// Run simulation for duration T |
|
time.Sleep(*T) |
|
fmt.Println("[X] timeout") |
|
} |
|
|
|
// BtoaAtomic converts an array of atomic bools to a colored string representation |
|
func BtoaAtomic(b []atomic.Bool) string { |
|
var buf bytes.Buffer |
|
for i := range b { |
|
if b[i].Load() { |
|
io.WriteString(&buf, True) |
|
} else { |
|
io.WriteString(&buf, False) |
|
} |
|
} |
|
return buf.String() |
|
} |
|
|
|
// PROOF OF CORRECTNESS (from Dijkstra's paper): |
|
// ============================================== |
|
// |
|
// PART 1: SAFETY (Mutual Exclusion) |
|
// ---------------------------------- |
|
// We must prove that no two computers can be in their critical section |
|
// simultaneously. |
|
// |
|
// To enter critical section, a process must: |
|
// 1. Be the privileged process (k == i) |
|
// 2. Set its c[i] = false |
|
// 3. Find all other c[j] = true (Li4 check) |
|
// |
|
// Suppose two processes P and Q both enter critical section: |
|
// - Both must have found all other c[] = true while their own c[] = false |
|
// - But if P has c[P] = false, Q cannot find all c[] = true |
|
// - Contradiction! Therefore, mutual exclusion holds. |
|
// |
|
// PART 2: LIVENESS (Progress) |
|
// ---------------------------- |
|
// We must prove that if no process is in critical section and some processes |
|
// are looping, at least one will eventually enter. |
|
// |
|
// Case 1: Process k is not looping |
|
// - Then b[k] = true |
|
// - All looping processes will find k != i (in Li1) |
|
// - One or more will find b[k] = true (in Li3) |
|
// - They will assign k := i |
|
// - Eventually k stabilizes to one of the looping processes |
|
// - Fall through to Case 2 |
|
// |
|
// Case 2: Process k IS looping |
|
// - Process k will find k == i (in Li1) and proceed to Li4 |
|
// - All other looping processes have k != i, so they set c[] = true (in Li1) |
|
// - Process k will find all other c[] = true |
|
// - Process k enters critical section |
|
// - Progress! |
|
// |
|
// COMPARISON WITH MODERN SYNCHRONIZATION: |
|
// ======================================== |
|
// |
|
// This algorithm is HISTORIC and EDUCATIONAL. In practice, use: |
|
// - sync.Mutex for mutual exclusion |
|
// - sync.RWMutex for reader-writer locks |
|
// - Channels for communication and synchronization |
|
// |
|
// Why? Modern primitives are: |
|
// - Simpler to use correctly |
|
// - Better optimized by the runtime |
|
// - More composable |
|
// - Less error-prone |
|
// |
|
// But understanding Dijkstra's algorithm teaches: |
|
// - The fundamentals of concurrent programming |
|
// - Why atomicity and memory ordering matter |
|
// - The complexity of building synchronization from scratch |
|
// - Appreciation for higher-level abstractions |
|
// |
|
// EXERCISES FOR THE READER: |
|
// ========================= |
|
// |
|
// 1. Run with -race flag: Notice no data races! |
|
// go run -race dijkstra65_fixed.go -n 3 -t 1s |
|
// |
|
// 2. Try different parameters: |
|
// go run dijkstra65_fixed.go -n 5 -t 2s -C 50ms -R 200ms |
|
// |
|
// 3. Verify mutual exclusion: Check output, only one process should show |
|
// "E >>" at a time (green E for enter) |
|
// |
|
// 4. Compare with original: Run dijkstra65.go with -race and see the races |
|
// |
|
// 5. Modify to add counters: Track how many times each process enters critical |
|
// section. Are some processes starved? (Hint: Dijkstra's algorithm is NOT |
|
// starvation-free in the strict sense, though in practice it works well) |
|
// |
|
// 6. Replace atomic operations with plain variables: See the algorithm break! |
|
// |