A reminder to myself when switching between languages :)
Files in this repo:
Static partition (disjoint ownership, no mutex):
threadfun.c: Win32 threads + startup slice context (lpParameter)threadfun.go: goroutines + startup slice context struct +WaitGroupthreadfun.cs: tasks + startup slice context struct +Task.WaitAll
Shared queue (dynamic dispatch, mutex required):
threadfun_queue.c: Win32 threads + shared queue (CRITICAL_SECTION)threadfun_queue.go: goroutines + shared queue (sync.Mutex)threadfun_queue.cs: tasks + shared queue (lock)
All six process 20 jobs with 4 workers and join before exit. The two sets differ in how work is assigned — see Pattern Contrast below.
Do not ask "which thread sees this write?"
Ask "what guarantees visibility and ordering for this write?"
- Static partition: disjoint ownership — no synchronization needed for job data; the join barrier covers completion ordering.
- Shared queue: explicit mutex — every access to shared state must be inside the lock, including the read and the increment together.
| Win32 C | Go | C# |
|---|---|---|
CreateThread |
go f() |
Task.Run(...) |
CRITICAL_SECTION |
sync.Mutex |
lock / Monitor |
startup lpParameter context |
startup worker context struct arg | startup worker context struct arg |
fixed [start,end) partition |
fixed [start,end) partition |
fixed [start,end) partition |
WaitForMultipleObjects(..., TRUE, ...) |
WaitGroup.Wait() |
Task.WaitAll(...) |
Static partition (threadfun.*) |
Shared queue (threadfun_queue.*) |
|
|---|---|---|
| Work assignment | Fixed [start,end) slice at startup |
Dynamic: each worker claims next available job |
| Mutex needed? | No — disjoint ownership | Yes — shared nextJob index |
| Load balance | Fixed (uneven if jobs vary in cost) | Natural (faster workers take more jobs) |
| Complexity | Lower | Higher (requires correct lock discipline) |
Start with static partition. Reach for a shared queue when job cost varies significantly or the job count isn't known at startup.
Static partition:
-
Habit: pass
NULLat thread startup and rely on globals -
Replacement: pass explicit startup context (
lpParameter, function args) -
Habit: implicit work ownership
-
Replacement: explicit per-worker ownership (
start/endpartition) -
Habit: reason from thread identity
-
Replacement: reason from ownership transfer + join barrier
Shared queue:
-
Habit: unprotected shared counter or index
-
Replacement: wrap read-increment-release of shared index in a single critical section / mutex lock
-
Habit: check-then-act without holding the lock
-
Replacement: claim the job while still holding the lock; release only after the index is updated
Static partition (threadfun.go):
- pass immutable worker context by value
- avoid shared mutable indexes/queues unless workload requires dynamic balancing
- keep
WaitGroupas the explicit join barrier
Shared queue (threadfun_queue.go):
- encapsulate the shared index and its mutex together in a struct; don't expose the mutex directly
popmust hold the lock for the full read-increment sequence, not just the read- note: a
chan int(buffered, pre-loaded) is the idiomatic Go alternative to a mutex-guarded index for this pattern
Static partition (threadfun.cs):
- pass worker context object/struct at task launch
- prefer immutable context fields (
readonly struct) for clarity - keep
Task.WaitAllas the explicit completion barrier
Shared queue (threadfun_queue.cs):
- encapsulate the shared index and its lock object together in the queue class; don't expose either directly
TryPopmust hold the lock for the full read-increment sequencelock(Monitor) is appropriate here;Interlocked.Incrementis an alternative if the only shared state is the counter itself
For each shared value, confirm one of:
- single owner only (no concurrent sharing)
- protected by lock/mutex/monitor
- transferred by startup context handoff
Static partition — lifecycle:
- partitioning logic covers all jobs exactly once
- worker function bounds are correct (
start <= i < end) - main thread joins workers before process exit
Shared queue — lifecycle:
TryPop/popholds the lock across the full read-increment (no gap between check and update)- queue is fully populated before any worker starts, or writes to the queue are also protected
- main thread joins all workers before process exit
- The static partition files show the same startup context transfer pattern across three languages — compare language/runtime differences without mixing in synchronization design differences.
- The shared queue files show the same mutex-guarded dynamic dispatch pattern across three languages — compare language/runtime differences without mixing in partitioning differences.
- Pairing the two sets makes the design trade-off concrete: disjoint ownership eliminates synchronization; shared state requires it.
Mental shift: reach for a mutex when you genuinely have shared mutable state, not as a default.