skip to content

Under a sync.RWMutex read lock you copy a slice field into a local, then RUnlock — is reading it afterwards safe?

level: middleimportance: nice to knowfreq 30%

answer

  1. what does the assignment really copy?
  2. three words, not the elements
  3. the array is still shared after RUnlock
  4. safe only if writers never write in place

basics

~20 s

Only if writers replace the whole slice instead of writing into it. Assigning a slice copies the header — pointer, length and capacity — not the elements, so after RUnlock you are still reading a backing array the writer can reach.

solid answer

~50 s

A read lock protects you for exactly as long as you hold it. Copying a slice out of the guarded struct copies three words — a pointer to the backing array, a length and a capacity — and not the elements, so after `RUnlock` you are reading memory the writer still has a route to. Whether that is safe depends entirely on what writers do under `Lock`. If a writer only ever builds a brand-new slice and assigns it, your header keeps the old array alive and immutable, and reading it after `RUnlock` is fine — that is the publish-once discipline. If any writer does `s[i] = x`, or appends within the existing capacity, you have a data race, and the same applies to maps and to pointers carried out. The alternatives are to deep-copy under the read lock, which costs O(n) per read, or to document that published values are never modified in place.

code

go · 11 lines
go
func (r *Router) Hosts() []string {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.hosts // copies three words; the array stays shared
}

func (r *Router) HostsCopy() []string {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return append([]string(nil), r.hosts...) // elements copied too
}

go deeper

for a junior

Remember that a slice variable holds a pointer, a length and a capacity, so assigning it does not copy the elements. Until you know better, do the reading inside the locked section.

for a middle

Explain the two disciplines that make the pattern safe: deep-copy under the read lock, or guarantee that writers only ever publish a brand-new value and never modify one in place.

for a senior

Turn it into something reviewable on a shared type. Say where the copy happens, what it costs per read, and what goes in the doc comment so callers are not left guessing about the invariant.

for a principal

Decide the ownership model for the package. Hand out immutable snapshots and most of the locking question disappears; hand out live references and every caller inherits an invariant you cannot enforce.

## What the assignment actually copies A Go slice value is three words: a pointer to a backing array, a length and a capacity. `local := s.field` copies those three words. It does not copy a single element. The two slice values now describe the same array, and a write through one is visible through the other. The same is true of a map value and of any pointer: copying them copies a reference. Contrast that with a `string` (immutable bytes, so a copy can never be changed under you), an `int` or a `bool`, which are copied in full and are always safe to carry out of a locked section. So the question is not really about the lock. It is: **after `RUnlock`, is anybody able to write the memory my local still points at?** ## The three cases **1. Writers replace the whole slice.** The writer builds a fresh slice, takes `Lock`, assigns it to the field, and unlocks. Your local still points at the previous backing array — and, because your local is a live reference, the garbage collector keeps that array alive for as long as you use it. Nobody writes it any more. Reading it after `RUnlock` is safe, and this is the discipline worth adopting deliberately: *values published under the write lock are never modified afterwards.* It is what makes a read section as short as one field read. **2. Writers modify elements in place.** The writer takes `Lock` and does `s[i] = x`, or `s = append(s, v)` where the append fits in the existing capacity and therefore writes into the same array. Now the writer is writing a word your goroutine is reading with no lock held. That is a data race: unsynchronised, and undefined in the memory model. Holding `Lock` does not save the writer, because you are not holding anything. **3. You carried out a map or a pointer.** Identical reasoning, worse consequences. A map is a reference; reading through it while another goroutine stores into it races, and the runtime detects concurrent map access and terminates the process rather than continuing. A `*T` carried out of the section leaves every field of that struct unprotected. ## Making it safe, and what each option costs **Deep-copy under the read lock.** `out := append([]string(nil), r.hosts...)` inside the section gives the caller memory nobody else can reach. The cost is an allocation and an O(n) copy **on every read**, and it lengthens the critical section for all other readers. For a small slice read occasionally, fine. For a large table read thousands of times a second, this can easily cost more than the locking ever did. **Publish-once (immutable after publish).** Writers construct a new value, assign it under `Lock`, and never touch a published value again. Readers may then copy the header out and use it with no lock. This is usually the right shape for read-mostly state such as a routing table, and it makes the write path a build plus an assignment. **Keep everything inside the section.** Do the work you need under `RLock` and return only value types — a count, a bool, a found string. Safe by construction, at the price of holding the lock while you work. Whichever you choose, **write it in the doc comment of the type**, because it is an invariant the callers and every future writer must honour, and no compiler checks it. ## Why the accessor that returns the field is the classic bug A method like `func (r *Router) Hosts() []string` that takes `RLock`, defers `RUnlock` and returns `r.hosts` looks perfectly locked. Every line has a lock around it. But the value escapes the section by design, and the guarantee expires the moment the method returns. Reviewers see `RLock` and stop reading. The tell is the return type: returning a slice, a map or a pointer from a method that locks means the lock's protection does not cover what the caller will do next. ## Finding it Building with `-race` will report it — but only if a test actually runs the reader and the in-place writer concurrently on the same data. A clean race-detector run proves that the paths you exercised were race-free, not that the code is. That is why the invariant belongs in the type's documentation rather than in a test's coverage.

  • Why is carrying a string field out of the read section always safe?
    Go strings are immutable. A writer can only replace the whole string value, which it does under `Lock`, and your copy keeps its own bytes reachable for as long as you hold it. The same reasoning covers ints, bools and other value types with no interior pointers — copying them really copies them.
  • What does copying a map field out of the read section give you?
    A second reference to the same map, not a copy. Reading through it after `RUnlock` races with any writer that stores into that map, and the runtime terminates the process when it detects concurrent map access rather than returning garbage. Copy the entries under the lock, or guarantee that writers only ever publish a new map.
  • When is deep-copying under the read lock the wrong fix?
    When the value is large and reads are frequent. You turn a three-word read into an allocation plus an O(n) copy on every call, and you hold the lock longer while doing it, which slows every other reader. At that point make published values immutable and hand out the shared header instead.

saying these in an interview costs you the question

  • Thinks assigning a slice copies its elements
  • Says the read lock still protects data after RUnlock
  • Believes a clean race-detector run proves there is no race
  • Mutates elements of a slice readers have already carried out
  • Treats a map copied out of the section as private