skip to content

Who Starts the Goroutine

A library that spawns a goroutine or hands back a channel has promised something about shutdown, so the Go habit is to stay synchronous and let the caller add concurrency it can stop.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why does a Go package usually export a blocking function instead of starting a goroutine for the caller?

level: juniorimportance: must knowfreq 55%

answer

  1. concurrency is easy to add, hard to remove
  2. one keyword in front of your call
  3. a goroutine has nowhere to return an error
  4. the caller owns shutdown, count and joining

basics

~20 s

A caller can always add concurrency to a blocking function by starting it with go, but cannot remove concurrency the package started. Staying synchronous leaves the goroutine's lifetime, its errors and how many run in the caller's hands.

solid answer

~50 s

The default in Go is that an exported function does its work on the goroutine that called it and returns when it is done. Adding concurrency is one keyword at the call site: the caller writes `go`, and they choose how many run at once, how they wait for them, and what they do with the error. Removing concurrency is impossible — once my package starts a goroutine, every program that imports me inherits it. A blocking function also has somewhere to put an error: it returns one. A goroutine has nowhere to return to, so packages that start their own usually end up logging errors, which is exactly the decision the caller wanted to make. So the rule of thumb is: export the synchronous call, and only start a goroutine yourself when the abstraction genuinely cannot exist without one — and then hand back a way to end it.

code

go · 13 lines
go
// Exported by the package: synchronous, returns an error.
func Scan(ctx context.Context, root string) ([]Result, error)

// Written by the caller, in their own code, when they want concurrency.
func scanTwo(ctx context.Context, a, b string) {
	done := make(chan struct{})
	go func() {
		defer close(done)
		_, _ = Scan(ctx, a)
	}()
	_, _ = Scan(ctx, b)
	<-done
}

go deeper

for a junior

Be ready to say the rule out loud: export the blocking function, let the caller write go. Know that a caller can add concurrency but cannot take away concurrency the package started.

for a middle

Explain the mechanics behind the rule: a blocking call returns an error on the normal path, while a goroutine has no caller to return to, so its errors end up logged or dropped, and its lifetime is invisible at the call site.

for a senior

Show the production consequences — shutdown ordering, tests that outlive their work, unbounded in-flight work — and state the conditions under which you would still start a goroutine, including the stop mechanism you would export with it.

for a principal

Frame it as an API promise the whole organisation inherits. Every embedder gets the goroutine whether it suits their process or not, and adding or removing one later changes their shutdown code, so treat it as a compatibility decision rather than an implementation detail.

## The rule In Go, the normal shape for an exported function is: do the work on the goroutine that called you, and return when the work is finished. `go` is a keyword any caller can type. Starting a goroutine on their behalf is not a service the package needs to provide. This sounds like a style preference. It is actually a promise about lifetime, and lifetime promises are the hardest part of an API to change later. ## Concurrency is easy to add and impossible to remove Given a blocking `func Scan(ctx context.Context, root string) ([]Result, error)`, a caller who wants it in the background writes `go`. They decide: - **how many** run at once — one, four, or one per CPU; - **how they wait** — a `sync.WaitGroup`, a channel they own, or nothing at all; - **what happens to the error** — returned up their own stack, collected, retried, or logged in their format; - **when it stops** — their `context.Context`, their deadline, their shutdown sequence. Now invert it. Suppose the package exported `func ScanAsync(root string) <-chan Result` and started a goroutine internally. A caller who wants the synchronous version has to fake it: drain the channel, guess when the work has ended, and hope the goroutine actually exits. They cannot un-start it. Every importer of the package now runs that goroutine whether their program suits it or not — including short-lived commands, tests, and other libraries that themselves wanted to control concurrency. One direction is a keyword. The other is a rewrite of somebody else's program. ## Errors need somewhere to go A synchronous function returns `error`, and the error travels back on the caller's normal path. A goroutine has no caller to return to. Once a package starts one, its errors have only bad destinations: - a log line, in whatever format and to whatever destination the package chose (now the caller inherits your logging decision too); - a channel the caller must remember to drain, or the sender blocks; - silence. When someone says "my library logs the error and continues", that is usually a symptom of a goroutine started at the wrong layer, not a logging problem. ## The other things a caller loses - **Shutdown ordering.** A long-lived process shuts down in a sequence — stop accepting work, drain, flush, exit. A goroutine the caller never started has no place in that sequence. - **Tests.** A test that finishes while a package's goroutine is still running is a flaky test and, under the race detector, sometimes a reported race in code the test was not exercising. - **Backpressure.** If the package starts one goroutine per call, load is now unbounded in a way the call site does not show. - **Readability at the call site.** `go doThing()` shows concurrency where it happens. A constructor that quietly starts three goroutines does not. ## When starting a goroutine is legitimate The rule is a default, not an absolute. Some abstractions cannot exist without background work: a component that multiplexes one connection between many callers, one that refreshes a cached credential before it expires, one that batches writes on a timer. In those cases the goroutine is the product, not a convenience. When you do it, the API must carry the whole promise: 1. **Do not start it in `New`.** Separate allocating the value from starting the work, so nothing runs until the caller says so. 2. **Export exactly one way to stop**, and make its return mean the goroutine has actually exited — not that a stop signal was sent. 3. **Bound the count.** "One goroutine per instance" is a documentable promise; "one per call" is a surprise. 4. **Give errors a path the caller owns** — a callback they supply, a method they can query, or a channel whose contract you document. 5. **Say all of it in the doc comment.** Once teams embed the package, the goroutine is part of the API, and adding or removing one changes their shutdown code. ## The short version for an interview Export the blocking call. Let the caller write `go`. Start a goroutine only when the type cannot do its job without one, and then export the off switch in the same breath as the on switch.

  • When is it legitimate for a package to start goroutines itself?
    When the abstraction cannot exist without background work — multiplexing one connection between callers, refreshing a credential before it expires, batching on a timer. Then the API must carry the whole promise: nothing starts in the constructor, there is exactly one documented way to stop, the stop returns only after the goroutine has exited, and the goroutine count is bounded and documented rather than growing with call volume.
  • What does a caller give up if a package starts the goroutine instead?
    Error handling, because a goroutine has no caller to return to and usually logs instead. Shutdown ordering, because the caller cannot join something they did not start. Concurrency limits, because the call site no longer shows how much work is in flight. And test hygiene: work that outlives the test it belongs to.
  • Does a blocking API cost the caller anything?
    It costs them the boilerplate of starting and joining goroutines themselves, and for a caller who only ever wants the background form, that is repeated at every call site. That is a real cost, but it is theirs to pay and theirs to shape — which is the point. If it becomes painful, a package can add an obviously named background wrapper alongside the blocking core rather than replacing it.

saying these in an interview costs you the question

  • Says an asynchronous API is always faster
  • Thinks the library should manage goroutines so callers do not have to
  • Returns a channel purely to make a function non-blocking
  • Argues goroutines are cheap, so starting one costs the caller nothing
  • Logs the error inside the goroutine and calls that error handling
  • Assumes a goroutine ends when the function that started it returns
open as a page

Why should an exported Go API take a `context.Context` per call rather than storing one in its struct?

level: middleimportance: must knowfreq 52%

basics

~10 s

A context describes the lifetime of one operation. Stored in a struct, every call shares one lifetime, per-call deadlines become impossible, and the signature stops telling callers the work is cancellable.

open as a page

What must a Go package document when an exported method returns a receive-only `<-chan Event`?

level: middleimportance: should knowfreq 42%

basics

~20 s

When it closes, what stops the producer, whether a slow reader blocks or loses events, and how a terminal error arrives. The caller cannot close a receive-only channel, so the package owns every one of those answers.

open as a page

A file-watching package's `New` starts a goroutine still visible in the goroutine profile after your tests finish — what is wrong with its exported API?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The API started a lifetime it gave no way to end: work began in a constructor rather than a call the caller drives, and nothing exported signals that the goroutine has exited. The fix is a signature change.

open as a page

Several teams embed your Go package in long-lived daemons — how do you decide whether it may start goroutines itself?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Treat it as a promise every embedder inherits. Default to blocking calls they wrap; start goroutines only when the abstraction cannot exist without them, and then commit to an explicit start, a bounded count, and a stop that means exited.

open as a page