skip to content

What does embedding `sync.Mutex` in an exported Go struct expose to that package's importers?

level: middleimportance: should knowfreq 55%

answer

  1. the field name is the type name
  2. promotion does not stop at the package boundary
  3. your lock becomes callable API
  4. prefer an unexported mu and named operations

basics

~10 s

It exports the lock itself. Promotion puts Lock, Unlock and TryLock on the struct, so any importing package can lock and unlock your type's internal mutex. Use an unexported named field, mu sync.Mutex, instead.

solid answer

~50 s

Embedding promotes the whole method set, so `Lock`, `Unlock` and `TryLock` become methods of my struct and therefore part of the package's exported API. Importers can lock my internal mutex, hold it across their own calls, or forget to unlock it, and I can never take those methods back because their code compiles against them. The embedded field is also named after the type, so `&c.Mutex` is reachable from outside too. It freezes my implementation as well: dropping the lock for atomics or sharding it deletes exported methods, and even switching to `sync.RWMutex` quietly adds `RLock` and `RUnlock` to my API. The only defensible reason to embed a lock is when the locking protocol genuinely is the contract the type offers, which is rare. Otherwise I write `mu sync.Mutex` and expose intent-revealing methods that lock internally.

code

go · 17 lines
go
// Importers can call c.Lock(), c.Unlock(), and take &c.Mutex.
type Counter struct {
	sync.Mutex
	n int
}

// Nothing about the locking is exported.
type SafeCounter struct {
	mu sync.Mutex
	n int
}

func (c *SafeCounter) Add(d int) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n += d
}

go deeper

for a junior

Recall that an embedded field's methods become methods of the outer struct, and that sync.Mutex brings Lock, Unlock and TryLock along with it.

for a middle

Explain that a promoted method is exported when the method's own name is exported, regardless of the field's visibility, so promotion carries straight across the package boundary.

for a senior

Demonstrate the review instinct: an embedded lock is a public locking protocol you did not design. Say what you would ask the author to change and what future change it protects.

for a principal

Frame it as surface ownership. Locking strategy is the implementation detail you most want to keep free to change, and embedding trades that freedom away for a handful of saved keystrokes.

## An embedded lock is a published locking protocol `sync.Mutex` has three exported methods - `Lock`, `Unlock` and `TryLock`. Embedding it in a struct promotes all three onto that struct: ```go type Counter struct { sync.Mutex n int } ``` `Counter` now has `Lock`, `Unlock` and `TryLock` in its method set, exactly as if you had declared them. If `Counter` is exported, so are those methods, and every package that imports yours can call them. ### Why promotion crosses the package boundary The rule that trips people up is that **export is decided by the method's own name, not by the field's**. `sync.Mutex` is an exported type, so the embedded field is called `Mutex` and is itself exported - `c.Mutex` and `&c.Mutex` are reachable from another package, and your lock can be handed around as a `sync.Locker`. But even embedding an *unexported* type promotes its exported methods: an unexported `logger` embedded in an exported `Server` still gives importers `Server.Log`. Only methods whose own names begin with a lower-case letter stay inside the package. ### What that costs you **You have published a protocol you did not design.** A caller can now write: ```go c.Lock() somethingSlow() c.Unlock() ``` and hold your internal lock for as long as they like, across code you have never seen. They can also unlock a mutex they never locked, which panics, or lock twice - `sync.Mutex` is not reentrant, so that deadlocks. Every one of those failures shows up as your type misbehaving. **You have frozen the implementation.** Locking is the kind of decision you want to keep changing: coarse mutex first, then a read-write lock, then a sharded set of locks, then atomics for a hot counter. Once `Lock` and `Unlock` are exported methods, removing them is a breaking change to your module's API. Even a benign-looking swap to `sync.RWMutex` silently adds `RLock` and `RUnlock` to your exported surface, which you then also cannot withdraw. **You have widened interface satisfaction.** Your type now satisfies `sync.Locker` and anything else shaped like it. Code elsewhere can accept it in places you never intended. ### The alternative, and what it costs Keep the lock unexported and named, and expose operations rather than mechanism: ```go type SafeCounter struct { mu sync.Mutex n int } func (c *SafeCounter) Add(d int) { c.mu.Lock() defer c.mu.Unlock() c.n += d } ``` The cost is that you write a method for each operation. The benefit is that the *only* concurrency contract you publish is "these methods are safe to call from multiple goroutines" - which is what a doc comment should say - and you can implement that however you like forever. Types in the standard library that need internal locking hold it in an unexported field for exactly this reason. ### Two details worth knowing - **Copying.** A struct containing a mutex must not be copied after first use; copying duplicates the lock state and the two copies no longer exclude each other. The `copylocks` analyzer in `go vet` reports passing or assigning such a value, whether the lock is embedded or named. - **Zero value.** A `sync.Mutex` is usable at its zero value, so both forms work with no constructor. That convenience is often what tempts people into embedding: there is no initialisation to write either way, so embedding looks like it costs nothing. ### When embedding a lock is actually right Rarely, but it happens: a type whose entire purpose is to *be* a lock with a little extra state, where callers are expected to bracket their own critical sections. If that is genuinely the contract - and you would write and document `Lock` and `Unlock` by hand - embedding is honest. The test is that question: would you hand-write each promoted method and stand behind it in your API docs? If not, the method should not be there.

  • Does embedding an unexported type keep its methods out of the exported API?
    No. Export is decided by the method's own name, not the field's. An unexported embedded `logger` inside an exported `Server` still promotes its exported `Log` method, so importers can call `s.Log(...)` even though they can never name `s.logger`. Only lower-case method names stay internal.
  • What else changes about a struct once it contains a mutex, embedded or not?
    It must not be copied after first use - copying duplicates the lock state - and `go vet`'s copylocks analyzer reports passing or assigning it by value. Embedding adds one more thing on top: the exported field name `Mutex` means importers can take `&c.Mutex` and pass your lock elsewhere.
  • Is there any case where embedding a lock is the right call?
    Only when bracketing critical sections really is the contract the type offers, so you would hand-write and document `Lock` and `Unlock` anyway. That is unusual. For everything else, publish operations that lock internally and keep the locking strategy free to change.

Embedding the mutex is taping the key to the outside of the filing cabinet: anyone who can reach the cabinet can now decide when it is open.

saying these in an interview costs you the question

  • Says promoted methods are only visible inside the package
  • Calls embedding a mutex the idiomatic way to make a type thread-safe
  • Believes an unexported embedded type hides its exported methods
  • Assumes a promoted method can be removed in a minor release
  • Forgets that a struct holding a mutex must not be copied