A parser reuses one *Document per file to cut allocations; how do you keep the handoff to the indexer safe?
answer
- one writer per document at a time
- measure before you design around reuse
- let ownership travel in a circle
- a second channel hands the document back
- the free list size is your memory ceiling
basics
~20 sReuse only what you own. Either allocate a fresh document per file, or add a second channel on which the indexer returns each document once it is done, so exactly one goroutine may write to a given document at any time.
solid answer
~50 sReusing one struct across sends breaks the invariant the pipeline depends on: after a send the document belongs to the indexer, and overwriting it for the next file is a write to memory the indexer is reading. First check whether the reuse buys anything, with a benchmark reporting allocations on the real workload. If it does, make the reuse explicit with a free-list channel: a `chan *Document` primed with a few documents, from which the parser takes one, fills it and sends it downstream; the indexer sends it back on that same channel when finished. Ownership then travels in a loop and only one goroutine may write a given document at a time. The capacity also bounds how many documents exist and provides backpressure. Prove the invariant with a test that mutates after a send and asserts what the consumer observed.
code
go · 18 linesfree := make(chan *Document, 4)
for i := 0; i < 4; i++ {
free <- &Document{}
}
// parser
for _, f := range files {
d := <-free // taking one grants the right to write it
d.Reset()
fill(d, f)
work <- d // and sending it gives that right away
}
// indexer
for d := range work {
index(d)
free <- d // returns ownership to the parser
}go deeper
Recall the simple safe version first: allocate a new document inside the loop for each file, so nothing that has been sent is ever written again.
Explain why reusing one struct across sends breaks the handoff, and describe how a second channel that carries finished documents back to the producer restores a single-writer rule.
Show the whole reasoning chain: measure whether reuse pays, design ownership to travel in a circle, size the free list as a deliberate memory and backpressure knob, and cover the error paths that would starve the pipeline.
Weigh whether the codebase should carry a reuse scheme at all — it is a permanent correctness liability every future contributor must respect — and set the bar of evidence required before one is merged.
## The invariant the pipeline runs on A pointer handoff over a channel is safe because of one rule: **at any moment, exactly one goroutine may write to a given document.** The send moves that right from the parser to the indexer. Reusing the same `*Document` for the next file takes the right back without asking, and the indexer is still using it. The symptom is not a crash; it is documents indexed with the next file's title, page counts that are occasionally zero, and a defect that reproduces once a week. ## First: is the reuse buying anything? Before designing around it, measure. A benchmark that reports allocations per operation on the real document shape will tell you whether allocation is a meaningful share of the work, or whether the parser is dominated by reading and decoding the file. Very often it is the latter, and the correct answer is the simple one — allocate a fresh document per file, inside the loop, and the ownership problem disappears by construction. Removing a hand-rolled reuse scheme is a legitimate and common outcome of this conversation, and an interviewer will be glad to hear you check before you build. ## If it is buying something: make ownership travel in a circle When the allocation really does dominate, do not remove the reuse — make it explicit, with a **free-list channel**: - Create `free := make(chan *Document, N)` and prime it with `N` documents. - The parser **receives** a document from `free`. That receive is what grants it the right to write. - It resets and fills the document, then sends it on the work channel to the indexer. It must not mention that pointer again. - The indexer uses the document, and when finished sends it back on `free`. Ownership now moves parser to indexer and back, always by a channel operation, and every operation carries the memory-model edge that makes the previous holder's writes visible to the next one. The rule stays a convention rather than a compiler-checked property, but it is now expressed in the shape of the code: the only way to get a document to write into is to receive one. Two bonuses come with it. The buffer size `N` **bounds the number of live documents**, which is a memory ceiling you chose rather than one the allocator picked. And a parser that runs ahead blocks on the empty free channel, giving you natural **backpressure** against a slow indexer. The costs are real too, and you should name them. Every reset must be complete — a leftover slice or map field from the previous document is a silent data-correctness bug, and it is worse than the allocation you saved. Any path that drops a document without returning it starves the pipeline permanently, so error and early-return paths need the same care as the happy path. And if the indexer hands the document to anything that outlives it — a downstream stage, a cache, a response body — the loop is broken, because the free list will hand the same memory back to the parser while another component still reads it. ## Proving it, not hoping The diagnostic that fits here is a targeted test rather than a production investigation. Stand up the two stages in a test, have the producer overwrite the value immediately after the send, and assert on exactly what the consumer recorded. If the consumer can ever see the second document's fields, the handoff is broken. Keep that test after the fix: it is the executable statement of the invariant, and it is what stops the next contributor from reintroducing the reuse as an optimization. ## What decides the answer The good answer has three moves in it: check whether reuse is justified at all; if it is, make ownership explicit rather than implicit; and pin the invariant with a test. The weak answer jumps straight to reuse plus a lock around the document, which reintroduces shared mutable state into a pipeline whose entire design was built to avoid it — and which usually still has the correctness bug, because the indexer holds a pointer whose contents may legally change under it while it is locked out.
- How do you decide the capacity of the free-list channel?It is a deliberate ceiling on live documents and on how far the parser may run ahead of the indexer. Start small — enough to cover normal jitter between the stages, often single digits — and raise it only if a measurement shows the parser blocking on an empty free list while the indexer is idle. A large value hides a slow consumer and buys latency you did not intend.
- What breaks the scheme even when every return path is correct?A document escaping the indexer. If the indexer stores the pointer in a cache, passes it to another stage, or lets it reach a response that is written later, the free list will hand that memory back to the parser while someone still reads it. Reuse is only safe while the lifetime of the value is fully contained between the take and the return.
- Why not just take a lock on the shared document instead?Because the problem is not access ordering, it is content. Locking lets the indexer read a consistent snapshot of a document whose contents may legitimately have become the next file's, so it fixes the race and keeps the bug. Reuse needs an ownership rule, and a lock does not express one.
It is a set of numbered trays in a kitchen: you cannot plate a dish until a clean tray comes back, and the number of trays decides how far ahead the kitchen can run.
saying these in an interview costs you the question
- Reuses one struct across sends and calls it zero-allocation
- Adds a mutex around the document instead of moving ownership
- Never measured whether allocation mattered here
- Forgets the early-return path that never returns the document
- Lets the consumer store the pointer beyond its turn