When should a Go program guard shared state with sync.Mutex instead of handing it over a channel?
answer
- ask one question: does ownership move?
- state in place versus value handed over
- count what an owner goroutine costs you
- channels are not lock-free underneath
- select and backpressure only exist on one side
basics
~20 sUse a mutex when goroutines share one structure in place and nothing changes hands: a cache, a counter, a registry. Use a channel when a value is handed off, so the sender stops using it and the receiver takes over.
solid answer
~50 sThe deciding question is whether ownership moves. If several goroutines need to read and update the same structure that stays where it is — an in-memory cache, a counter map, a set of registered handlers — a mutex around that structure is the simpler, faster and more obvious answer. If instead a value is produced by one goroutine and then belongs to another, a channel expresses the handoff and gives you the ordering guarantee for free. Recasting shared state as a channel means inventing an owner goroutine, a request type, a reply channel per call, and a shutdown story for that goroutine — a lot of machinery to reimplement mutual exclusion, and it is not lock-free anyway, since channels are themselves built on a lock. Go's proverb argues against sharing memory carelessly, not against `sync`; the standard library is full of mutexes.
go deeper
Recall the two shapes and one example of each: many goroutines updating one shared cache points to a mutex; one goroutine passing finished work to another points to a channel.
Explain the ownership test and what an owner-goroutine rewrite actually costs — a request type, a reply channel, and a goroutine lifetime — plus what channels give you that a lock cannot, namely select and backpressure.
Show judgment on a real component: pick per piece of state, expect both constructs in one service, and name the shutdown bug an unnecessary owner goroutine introduces.
Own it as a convention across a codebase: state the team rule in one line, decide where the exception is allowed, and recognise that the choice leaks into exported APIs where it becomes expensive to reverse.
## The question underneath the slogan Go's proverb — do not communicate by sharing memory, share memory by communicating — is often read as an instruction to avoid `sync`. It is not. The Go standard library uses mutexes extensively, and the same distribution ships `sync` and channels side by side on purpose. The real decision rule is much narrower and much more useful: > **Does ownership move?** If a value is produced by one goroutine and thereafter belongs to another, use a channel. If several goroutines need concurrent access to one structure that stays put, use a mutex. ## Where a mutex is the right answer The classic shape is **shared state in place**: an in-memory cache, a counter map, a registry of subscribers, a piece of config that is read constantly and updated rarely. Nobody hands anything over; everyone touches the same thing and needs to not collide. Wrapping that structure in a small type that keeps the lock and the data together, and exposing only methods, gives you: - **Simplicity.** The critical section is visible in a few lines, and every reader of the code knows the pattern. - **Speed.** A short uncontended critical section is measured in tens of nanoseconds. A channel round-trip to an owner goroutine is a send, a scheduler hand-off, and a reply — considerably more work. - **Composability of reads.** A read is a call, not a request-and-wait, so it can happen on the caller's goroutine with no extra lifetime to manage. ## Where a channel is the right answer The shape channels fit is **transfer**: a pipeline stage that parses a file and passes the parsed document on for indexing; a producer feeding a set of workers; a request handed to a background writer; a result handed back. Here the channel is not a lock substitute, it is a statement about ownership — after the send, the value is the receiver's problem. You also get, for free: - The memory-model guarantee that everything the sender wrote before the send is visible to the receiver after the receive. - **Backpressure**, because a full or unbuffered channel stops the producer. - Composition with `select`, so waiting for work and waiting for cancellation are the same construct. A mutex gives none of those. There is no way to `select` on acquiring a mutex, no way to wait on a mutex with a timeout, and no natural place to hang cancellation. ## What it actually costs to replace a mutex with a channel The channel-only rewrite of a shared cache is the **owner goroutine**: one goroutine holds the map, and everyone else sends it messages. To do that you must invent a request type, give each request a reply channel, run the owner goroutine for the whole lifetime of the component, and add a way to shut it down without leaking it or dropping in-flight requests. That is a service where you had a lock. Worse, it looks concurrent but is strictly serialized, and it is a mutex underneath anyway — Go's channel implementation is guarded by a lock, so the owner-goroutine design buys no lock-freedom, only indirection. The owner goroutine does earn its keep when the state must also react to time or cancellation, when it needs to coalesce or batch updates, or when it is genuinely a state machine rather than a container. That is the exception, not the default. ## Hybrids are normal Real services use both, and often in the same file: a channel carries work between stages, and the stage that owns a cache guards it with a mutex. That is not indecision; it reflects that the two constructs answer different questions. A reviewer should be able to point at each one and say which question it is answering. ## The failure mode of getting it wrong in each direction Picking a mutex where ownership really moves gives you a value two goroutines both believe they may write. Picking a channel where nothing moves gives you a shutdown bug: an owner goroutine that nobody stops, blocked forever on a receive, keeping its whole map alive. That second one is why the mistake is not free — reaching for a channel because it feels more idiomatic adds a lifetime you now have to own. ## How to answer this in an interview Lead with the ownership test in one sentence, give one concrete example of each shape, then name the cost of the wrong choice. Saying only that channels are idiomatic and locks are old-fashioned is the answer interviewers are probing for, and it is wrong.
- Are channels lock-free, so an owner goroutine avoids locking altogether?No. Go's channel implementation is guarded by a lock internally, so sending to an owner goroutine does not remove mutual exclusion — it moves it and adds a scheduler hand-off on top. The owner-goroutine design is worth choosing for what it expresses, such as serializing a state machine or combining state with cancellation, not for an imagined lock-free speedup.
- What extra failure mode do you take on by replacing a mutex with an owner goroutine?A lifetime. The goroutine has to be started, kept alive as long as callers exist, and stopped, and if a caller goes away while it is waiting on a reply channel, or the owner exits while requests are in flight, you get a leaked goroutine or a blocked caller instead of a simple lock. A mutex has no lifetime to manage.
- When would you keep both in the same component?Routinely. A pipeline stage receives work on a channel because ownership of each item genuinely moves, and the same stage keeps a mutex around a cache or a metrics struct that stays in place and is read by several goroutines. Each construct answers its own question, and a reviewer should be able to say which question each one is answering.
A mutex is a talking stick around one whiteboard everyone edits; a channel is a courier who takes the finished page away, and once it goes you do not write on it any more.
saying these in an interview costs you the question
- Says channels are always more idiomatic than sync
- Claims an owner goroutine is lock-free and therefore faster
- Cannot name a case where a mutex is clearly right
- Forgets the owner goroutine now needs a shutdown path
- Thinks the proverb forbids using the sync package