Lesson 16 · Concurrency & context

sync primitives

When you do share memory — Mutex, WaitGroup, Once.

Your win: use Mutex, WaitGroup, and Once correctly, and answer the classic question — "channels or a mutex?"

In plain English Plain English: `sync` tools coordinate access to shared state when message-passing would be awkward or slower.

The three you'll actually use

sync.Mutex

Guards shared state. Lock() before touching it, Unlock() after (usually defer mu.Unlock()). RWMutex allows many concurrent readers.

sync.WaitGroup

Wait for a set of goroutines. Add(n) before launching, Done() as each finishes, Wait() to block until all are done.

sync.Once

Do(f) runs f exactly once, ever — for lazy init or an idempotent "close this channel only once".

Remember Lesson 2: their zero values are readyvar mu sync.Mutex works with no init.

Your Kafka engine uses all three

internal/golibs/kafka/kafka.go · conversationmgmt/.../taskrunner/task_runner.go
// WaitGroup: await every consumer goroutine before shutting down
k.consumerWaitGroup.Add(1)  ...  k.consumerWaitGroup.Wait()   // kafka.go:587,825

// Mutex: guard the shared "which consumers are running" state
k.mutex.Lock(); member.setRunning(false); k.mutex.Unlock()    // kafka.go:590

// Once: close a channel exactly once, even if reached from many paths
closeOnce.Do(func() { close(ch) })                            // task_runner.go
Why Once for channel close Closing an already-closed channel panics (Lesson 15). When several code paths might all try to close it, sync.Once makes the close idempotent — a small, idiomatic guard our task_runner and worker pool both use.

Channels or a mutex? (the interview question)

The decision Use a channel to transfer ownership of data or hand work between goroutines (share memory by communicating). Use a mutex to protect shared state that stays put and is read/written in place — a counter, a cache, a status map. The Go team's advice: use whichever makes the code simpler.1

Our engine shows both in one place: messages flow through channels, but the "is this consumer running?" map is shared state, so it's guarded by a mutex. Forcing everything through channels there would be more code, not less.

Mutex gotchas A Mutex is not reentrant — locking it twice in the same goroutine deadlocks. And never copy a struct containing a Mutex (copies the lock state); that's why such structs are passed by pointer (Lesson 3). go vet catches the copy mistake.
Backend use case Backend use case: caches, maps of in-flight work, lifecycle flags, and shutdown bookkeeping often fit a mutex or `sync.Once` better than a channel protocol.
Common mistake Interview trap: “channels are always more idiomatic than mutexes” is wrong. Senior Go prefers the simpler concurrency story, not the flashier one.
Read this next

Effective Go — Concurrency + the sync package

Effective Go frames the channel-vs-mutex choice; the sync docs give the exact semantics of each primitive.

go.dev/doc/effective_go#concurrency
pkg.go.dev/sync

Check yourself (from memory)

Q1. sync.WaitGroup is for…

Add/Done/Wait — block until a set of goroutines completes. Our shutdown waits on the consumer group.

Q2. sync.Once's Do(f) guarantees f

Exactly once across all callers — ideal for lazy init or an idempotent channel close.

Q3. Prefer a mutex over a channel when you…

Shared state that stays put (a map, counter) → mutex. Handing data between goroutines → channel. Simpler wins.
Channel or mutex — how do you choose?
recall, then click to reveal
Use a channel to transfer ownership of data or coordinate/hand off between goroutines (share memory by communicating). Use a mutex to protect shared state that stays put and is read/written in place (counter, cache, status map). Rule of thumb: whichever is simpler. Our Kafka engine — channels carry messages, a mutex guards the running-state map, a WaitGroup awaits shutdown.
Want to see RWMutex, sync.Map, or atomic and when each earns its place over a plain Mutex? Ask me.

1. Effective Go — concurrency; sync package docs; Go Wiki — Mutex or Channel.