skip to content

A parser sends a *Document on a channel and then sets a field on it — why is that a bug?

level: middleimportance: should knowfreq 48%

answer

  1. what exactly did the send copy?
  2. one pointer, two goroutines
  3. the guarantee points backwards, not forwards
  4. writes before the send are covered
  5. the compiler cannot check the convention

basics

~10 s

The channel copies the pointer, not the document. After the send both goroutines reach the same struct, so the parser's write races with the indexer's reads. Sending a pointer transfers ownership: stop touching it.

solid answer

~50 s

A send copies the channel's element type, and here that element is a pointer, so both goroutines end up holding the same `*Document`. The memory-model guarantee attached to the send covers writes made **before** it — the indexer is guaranteed to see the document as the parser had built it — but it says nothing about writes made afterwards. Setting `doc.Title` after the send is therefore unsynchronized access to memory another goroutine is already reading, and the indexer may observe the old value, the new value, or a torn mix for a multi-word field. Nothing in the compiler or the runtime stops this: the safety of a pointer handoff rests entirely on a convention, that the sender treats the value as gone. The fixes are to finish the document before sending, to send a `Document` by value so the sender keeps its own copy, or to allocate a fresh document for the next iteration.

code

go · 8 lines
go
func parse(files []string, out chan<- *Document) {
	for _, f := range files {
		doc := &Document{Title: titleOf(f)}
		out <- doc // ownership moves to the indexer here

		doc.Pages = countPages(f) // race: the indexer may already be reading doc
	}
}

go deeper

for a junior

Recall that a pointer channel copies only the pointer, so both goroutines can reach the same struct, and that the sender is expected to stop using a value once it has sent it.

for a middle

Explain the direction of the guarantee: the send orders writes made before it against the receiver's reads, and says nothing about writes made after it. Then give a fix that removes the aliasing rather than shifting the timing.

for a senior

Show how you would surface such a bug deliberately with a focused test, and how you shape producer code — allocate per item, send last — so the ownership rule is enforced by structure rather than by memory.

for a principal

Own the convention: decide whether your codebase passes pointers across channels at all, and where it does, require the ownership statement to be documented on the channel, because the compiler will never enforce it for a team.

## What the send actually moved A channel send copies a value of the channel's element type. For `chan *Document`, that value is one pointer. The struct it points at is not copied, not frozen, and not made private to the receiver — it is exactly where it was, and now two goroutines have a way to reach it. That is not a flaw. It is the whole point of the pointer form: you use it precisely when you do not want to copy a large document. But it changes what the channel is doing. With `chan Document` the channel gives you **isolation**. With `chan *Document` the channel gives you **a transfer of responsibility**, and the transfer is only as real as the discipline of the code on the sending side. ## Why the send does not cover the later write Go's memory model attaches an ordering edge to a channel operation: everything the sending goroutine wrote before the send happens-before the receive completes. That is the guarantee that makes the pattern useful — the indexer can read every field the parser filled in, with no lock, and see a complete document. Read the direction carefully. The edge orders **prior** writes against the receive. A write the sender performs *after* the send has no ordering relationship with the receiver's reads at all. Two goroutines, one location, at least one write, no ordering: that is a data race by definition, and the possible outcomes include the indexer seeing the old title, the new title, or — for a field wider than a machine word, such as an interface or a slice header — a combination that never existed as a whole value. ## Why it survives testing The window is small and the wrong answer is usually still a plausible-looking document, so the bug shows up as rare, unreproducible wrong output: an entry indexed under the previous file's title, a page count that is occasionally zero. It is exactly the class of defect that looks like a downstream problem for weeks. The cheapest way to expose it deliberately is a targeted unit test that reproduces the ordering: run the consumer in the test, have the producer mutate the value immediately after the send, and assert on what the consumer actually recorded. If your test can make the consumer observe the post-send write even once, the handoff is not a handoff. ## The three fixes, and when each is right 1. **Finish before you send.** Usually the real bug is code shape: the parser computes a field after publishing when it could have computed it before. Moving the write above the send removes the race outright and costs nothing. 2. **Send by value.** Change the channel to `chan Document`. The sender keeps its own copy and may write to it freely. Watch the shallow-copy boundary: if `Document` has a `[]byte` body, the value form still shares that array, so this fix is complete only when the struct is genuinely self-contained. 3. **Allocate a fresh value per item.** `doc := &Document{...}` inside the loop, so the pointer you send is never touched again by construction. This is the standard shape and the one a reviewer expects to see. What is *not* a fix: buffering the channel, adding a mutex the receiver forgets to take, or sending a second message to say the document changed. Buffering only alters when the sender resumes; it does not stop the receiver from reading the struct while the sender writes it. ## Making the convention visible Because the compiler cannot check it, the ownership rule has to live where people will see it. In practice that means: allocate inside the loop rather than above it, so there is no variable left to reuse; keep the send as the last statement that mentions the value; and, where the channel is exported or long-lived, say in the doc comment which side owns the value after the send. A comment on the channel declaration is worth more here than one at the send site, because the next person to add a producer will read the declaration. ## What an interviewer is checking They want to see that you know the send copied a pointer, that you can state the direction of the happens-before edge rather than waving at synchronization in general, and that your fix removes the aliasing rather than papering over the timing. A candidate who answers that the channel makes it safe, or that a buffer would fix it, has the mental model that produces this bug in the first place.

  • Does making the channel buffered fix it?
    No. Buffering changes when the sender resumes, not who can reach the struct. Once the pointer is in the channel the receiver may take it and start reading at any moment, while the sender is still writing to the same memory. The fix has to remove the aliasing: finish the value before sending, send it by value, or allocate a fresh one per item.
  • How would you demonstrate the bug on purpose rather than waiting for it in production?
    Write a targeted unit test that pins the ordering: run the consumer inside the test, have the producer mutate the value immediately after the send, and assert on the field the consumer actually recorded. If the consumer can ever observe the post-send write, the handoff is not a handoff, and the test documents the invariant for the next person.
  • Would switching to chan Document always remove the problem?
    Only if the struct is self-contained. The value form copies the struct, so scalar and string fields become private to each side, but a slice, map or pointer field still refers to the same underlying data, so writes through those fields remain shared. Check the field types before calling the value form a fix.
  • How do you keep the ownership rule visible to the next contributor?
    Shape the code so there is nothing left to touch: allocate inside the loop, and make the send the last statement mentioning the value. Where the channel is exported or long-lived, state in the doc comment on the channel which side owns the value after the send — that is where someone adding a second producer will look.

saying these in an interview costs you the question

  • Says the channel makes all access to the value safe
  • Thinks the send copied the whole Document
  • Believes adding a buffer removes the race
  • Cannot state which writes the send actually orders
  • Reuses one struct per loop and calls it an optimization