Skip to content
beginner Phase 8 · Goroutines and Shared-State Concurrency

Starting Goroutines

Run functions concurrently with the go keyword and understand process lifetime.

35m
0 problems
Topic Progress 0%

Three Calls That Should Not Add Up

An account overview handler needs three lookups: profile (300 ms), preferences (300 ms), and quota (300 ms). Run sequentially, the user waits 900 ms for data that was never dependent on itself. The go statement is the tool that fixes it, and it is smaller than most people expect: go f(args) evaluates the arguments in the caller, then starts f on a new goroutine and continues immediately.

func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	var profile, prefs, quota Profile

	go func() { profile = s.fetchProfile(r.Context()) }()
	go func() { prefs = s.fetchPrefs(r.Context()) }()
	go func() { quota = s.fetchQuota(r.Context()) }()

	// the handler does not know when they finish — this sleep is a guess
	time.Sleep(400 * time.Millisecond)

	fmt.Fprintf(w, "%s/%s/%s in %s", profile, prefs, quota, time.Since(start))
}

The three goroutines overlap, so wall time drops toward 300 ms plus scheduling overhead. Two properties are worth fixing in memory now.

Arguments are captured at the go statement. go fetch(url, timeout) reads url and timeout in the caller before the goroutine starts; the goroutine receives copies. Execution, however, begins whenever the runtime gets to it — there is no guarantee the new goroutine runs before the parent's next line.

Goroutines are cheap, not free. A goroutine starts with a small stack (kilobytes) that grows on demand, and the runtime multiplexes many goroutines onto a handful of OS threads — thousands are routine, millions need deliberate engineering. Concurrency here means structuring independent work to overlap; whether it runs in parallel on several cores depends on GOMAXPROCS (by default, the CPU count) and whether the goroutines are actually CPU-bound.

Now the uncomfortable part of that handler: time.Sleep(400ms) is not synchronization. On a loaded machine one lookup takes 500 ms and the response renders empty structs; on a fast one you have wasted 100 ms of every request. Sleep is a guess about other goroutines' progress, and guesses fail at p99. The honest fix is to be told when the work is done — knowing how many goroutines you started and waiting for exactly those is the subject of Coordinating with sync.WaitGroup, which follows this topic. Until then, treat the sleeping handler as the problem statement, not the solution.

The Program That Exits Mid-Sentence

Here is the smallest program that teaches process lifetime:

func main() {
	for _, msg := range []string{"one", "two", "three"} {
		go func(m string) {
			fmt.Println("got:", m)
		}(msg)
	}
	fmt.Println("main done")
}

Run it a few times. Sometimes you see one or two got: lines, sometimes none, occasionally all three. The rule behind the lottery: when main returns, the process exits immediately. The runtime does not wait for other goroutines — not politely, not partially, not with a flush. Goroutines are cut off wherever they are: mid-print, before a file write lands, halfway through an append. A goroutine that would have finished two microseconds later simply never runs.

Waiting by sleeping through it has the same flaw as before, only worse — you are guessing how long the whole process's work takes. The minimal honest version waits for the actual events:

func main() {
	done := make(chan struct{})
	for _, msg := range []string{"one", "two", "three"} {
		go func(m string) {
			fmt.Println("got:", m)
			done <- struct{}{}
		}(msg)
	}
	for i := 0; i < 3; i++ {
		<-done
	}
	fmt.Println("main done")
}

Each <-done blocks until one goroutine finishes, and three receives match three starts. Channels arrive formally in a later phase; the shape to remember now is that waiting must reference the work itself, not the clock. sync.WaitGroup expresses the same idea with a counter instead of messages, and it is the next topic.

Two related traps fall out of the same rule. log.Fatal and os.Exit skip deferred functions and terminate the process on the spot — a log.Fatal deep inside a goroutine kills everything without running cleanup anywhere. And a goroutine started after main begins returning has no chance at all: the exit decision is already made.

The pattern to internalize: every program has a lifetime question written implicitly as who waits for whom. Write it down explicitly — a counter, a channel, a WaitGroup — and the truncation bug disappears at the design level rather than being papered over with duration guesses.

One Panic Down, Everyone Out

Goroutines share the process but not the failure semantics people expect. Two surprises govern everything you write from here.

A panic in any goroutine kills the entire program. Recovering in main does not help, because recover only works inside the panicking goroutine's own deferred calls:

go func() {
	defer func() {
		if r := recover(); r != nil {
			log.Printf("recovered in worker: %v", r)
		}
	}()
	riskyDecode(payload)
}()

Without that defer, one malformed payload takes down the HTTP server, the background jobs, and every in-flight request — not just the worker that panicked. The recovery boundary must be inside each goroutine that can fail, a direct extension of the rules in panic, recover, and When to Fail.

Goroutines share memory by default, and sharing is where bugs hide. The first real concurrency defect most code hits:

var count int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		count++ // read-modify-write, three steps, no lock
	}()
}
wg.Wait()
// count is often 97 or 101 — and run with -race, it is reported as a data race

count++ is a read, an add, and a write; two goroutines interleaved in the middle of it lose an update. The fix — a mutex, an atomic, or moving the work behind a channel — gets full treatment in The Race Detector and Mutexes, RWMutex, and Atomics. The lesson to plant now: starting a goroutine is trivial, and every correctness problem arrives afterward.

One capture detail differs by toolchain version. Before Go 1.22, loop variables were shared across iterations, so for i := 0; i < 3; i++ { go func() { print(i) }() } commonly printed 3 3 3; Go 1.22 gives each iteration its own i. Passing the value as an argument — go func(i int) { ... }(i) — stays the clearer habit regardless, because it makes the data dependency visible in the signature rather than in a release note.

When a Goroutine Is the Wrong Tool

go is one keyword, so overuse is the natural failure mode. Four decisions keep it honest.

Sequential dependencies do not benefit. Parsing output before writing it to the database is a pipeline; a second goroutine between the steps adds a synchronization point and buys nothing:

rows, err := parse(csvFile)  // step 2 needs step 1's result
if err != nil { return err }
if err := db.BulkInsert(rows); err != nil { return err }

Parallelize only where work is independent — different rows, different URLs, different shards.

Fire-and-forget when you need the answer is a bug, not an optimization. go save(profile) followed by writing the HTTP response means the request can 200 before the save runs — and if the save fails, nobody hears about it. If the outcome matters to the caller, either do the work synchronously or hand the result somewhere explicit. The same reasoning bans go in front of a function whose error you were about to check.

Unbounded spawning has costs you can see. Ten thousand concurrent goroutines means ten thousand stacks (growing, not fixed), ten thousand things to schedule, ten thousand network connections perhaps — and when each holds a file descriptor, the process dies with too many open files. Fan-out needs a bound: a pool of workers, a semaphore, or a limit on outstanding requests. Designs that ignore this graduate from bug to outage (Worker Pools).

Forgotten goroutines leak. One blocked forever on a send nobody will receive sits there for the life of the process — invisible in a two-minute test, fatal in a service handling 50k requests a day. Make every spawn have a visible exit condition before writing it (Finding and Preventing Goroutine Leaks).

A compact rule for review: reach for go when (a) the work is independent of the caller's next step, (b) the caller does not need the result inline, and (c) the goroutine has a defined way to finish — a done signal, a context cancellation, a channel close. Fail any of the three and the honest version is a plain function call. With spawning disciplined and waiting understood, the next move is measuring what unsynchronized sharing actually does to your program — which is what The Race Detector is for.

Quiz

1. msg := "hi"; go print(msg) — when is msg evaluated?

Question 1 options

2. main starts five goroutines that print progress, then returns immediately. What happens to the goroutines?

Question 2 options

3. A goroutine panics with no recover in its own call stack. What does the rest of the program experience?

Question 3 options

4. A handler reads a request and starts `go db.Save(record)` without waiting, then writes the 200 response. What is the flaw?

Question 4 options

Flashcards

Question

What are the two timing guarantees of the `go` statement?

Answer

Arguments are evaluated in the caller at the go statement (so they are fixed before anything else runs), and execution of the function begins sometime afterward with no ordering guarantee relative to the caller's next line. Start order of several goroutines says nothing about run order.

Question

Why does a Go program sometimes print only some of its goroutines' output?

Answer

When main returns, the process exits immediately and every other goroutine is cut off mid-execution. The prints that landed are whichever goroutines happened to run first — output truncation is a race against process lifetime, fixed by waiting on the work (channel, WaitGroup) rather than sleeping.

Question

Can a panic in a goroutine be recovered by a defer/recover in main?

Answer

No. Recover only catches a panic inside the same goroutine, via deferred calls in that goroutine's stack. Each goroutine that can fail must install its own defer/recover, or one bad input crashes the whole process.

Question

What makes `count++` from many goroutines lose updates?

Answer

It is a read-modify-write of three machine steps; two goroutines interleaved between read and write overwrite each other. The fix is a lock or an atomic add — not a sleep, not GOMAXPROCS, not a channel of size 1 around the value.

Question

Name three conditions that must hold before you start a goroutine.

Answer

The work is independent of the caller's next step; the caller does not need the result inline (or the result has somewhere explicit to go); and the goroutine has a defined exit — a done signal, context cancellation, or channel close. Failing any of them usually means a plain function call is correct.

Revision Notes

Key Takeaways

  • 1. go evaluates arguments in the caller immediately but schedules execution without ordering guarantees
  • 2. main's return kills the process — waiting must be expressed as events (channel/WaitGroup), never as sleeps
  • 3. Recovery from a panic must live in the panicking goroutine; main's defer cannot catch it
  • 4. Sharing memory is the default and count++ is a data race — detection and fixes come from the race detector and mutex topics
  • 5. Goroutine discipline: independent work, no inline result, defined exit — otherwise call it synchronously

Interview Tips

  • • "What does `go f(x)` do?" — answer in two halves: args evaluated in the caller now, body runs later with no ordering guarantee; add that the go statement happens-before the goroutine's start
  • • "How do you make sure goroutines finish before main exits?" — name the event-based options (WaitGroup counter, done channel, errgroup) and explicitly reject Sleep as a guess
  • • "What happens when a goroutine panics?" — whole process dies; recover must be a defer inside that goroutine; mention where you'd install it in a worker loop
  • • "When would you NOT use a goroutine?" — when the caller needs the result, when work is sequential, or when you cannot bound or exit it; interviewers probe for the unbounded fan-out failure

Cheat Sheet

Starting Goroutines

The go statement: evaluates args now, runs later, caller continues immediately. No ordering guarantee among spawned goroutines.

Process lifetime: main returns → process exits → all goroutines terminated mid-flight. log.Fatal/os.Exit do the same and skip defers.

Waiting — reference the work, not the clock:

done := make(chan struct{}, 3)
for _, u := range urls {
	go func(u string) {
		fetch(u)
		done <- struct{}{}
	}(u)
}
for range urls { <-done }   // or use sync.WaitGroup (next topic)

Decision table:

Situation Do
next step needs the result plain call
independent work, result unneeded inline go + explicit wait
unbounded per-request spawning bound it: pool/semaphore
loop body before Go 1.22 capture concern pass the value as a parameter
worker that can panic defer recover() inside that goroutine

Never: time.Sleep as synchronization; go f() for an error you planned to check; spawning without an exit condition.

Continue Learning