main does not wait¶
Level: 101 · anyone whose goroutine's output went missing
One line: When main returns, the program exits on the spot: a goroutine that is still running is stopped wherever it is, its deferred calls never run, and nothing waits for it unless you write the wait — a sync.WaitGroup, or a channel.
The Go specification ↗ settles the question in its section on program execution: when the call to main returns, the program exits, and it does not wait for other goroutines to complete. The Concurrency library's Who waits when main returns? ↗ shows that much with a goroutine that never prints. This page is about what the rule costs a Go program — work that was done and still lost — and about runtime.Goexit, the one way out of main that does not end the program.
A goroutine that did its work, and lost it¶
Verified output of main_does_not_wait_go.go — regenerated by tools/run_examples.py, never hand-typed.
worker: writing three orders to a buffered writer
main: the worker has written its orders; returning
main: deferred call runs as main returns
Reading the output¶
Three lines, and not one order. The worker ran — its first line is there — and it wrote all three orders: main waits on the written channel, which the worker closes only after its three Fprintln calls. But it wrote them into a bufio.Writer ↗, which keeps output in memory until its buffer fills or Flush is called, and the Flush was deferred. A deferred call ↗ runs when its function returns. The worker's function never returned: it was asleep when main returned, and the program ended with it still asleep.
The last line is the contrast. main's own deferred call did run, because returning from main is an ordinary return, and deferred calls run on the way out of it. What never runs is a deferred call in any other goroutine that is still going.
The three-second sleep only keeps the worker alive past the end of main. The order of the lines that did print is fixed by the written channel, not by the sleep.
A lost Flush is one example of a general hazard: a Close, a final log line, a "done" metric — correct code in a goroutine's deferred call, never reached.
main_does_not_wait_go.go
main_does_not_wait_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// main does not wait. When main returns, the program exits, and a goroutine
// that is still running is stopped where it stands. This one has already
// written three orders -- into a buffered writer. Its deferred Flush never
// runs, so nobody ever sees them.
//
// go run main_does_not_wait_go.go
package main
import (
"bufio"
"fmt"
"os"
"time"
)
func main() {
defer fmt.Println("main: deferred call runs as main returns")
written := make(chan struct{})
go func() {
out := bufio.NewWriter(os.Stdout)
defer out.Flush() // would write the three orders out; never runs
fmt.Println("worker: writing three orders to a buffered writer")
for _, order := range []string{"A-1001", "A-1002", "A-1003"} {
fmt.Fprintln(out, "worker: order", order)
}
close(written)
time.Sleep(3 * time.Second)
fmt.Println("worker: finished")
}()
<-written
fmt.Println("main: the worker has written its orders; returning")
}
Waiting, written two ways¶
Verified output of main_does_not_wait_waitgroup_go.go — regenerated by tools/run_examples.py, never hand-typed.
wg.Go : order A-1001
wg.Go : order A-1002
Add/Done: order A-1003
Add/Done: order A-1004
main: both workers flushed; returning
Reading the output¶
All four orders appear, written out by the same deferred Flush that never ran above, because main did not get past each Wait until that worker's function had returned.
A sync.WaitGroup ↗ is a counter of tasks, and Wait blocks until the counter is back to zero. The program fills it in two ways:
wg.Go(f), added in Go 1.25 ↗, adds a task to the counter, callsfin a new goroutine, and removes the task whenfreturns. There is noAddto put in the wrong place, and noDoneto forget.wg.Add(1), thengo func() { defer wg.Done(); … }(), is the same three steps spelled out, as code written before Go 1.25 does it. TheAdddocumentation ↗ is specific about the order: anAddthat raises the counter from zero must happen beforeWait, which in practice means before thegostatement, not inside the new goroutine. Go 1.25'sgo vetgained awaitgroupanalyzer that reports a misplacedAdd↗, and the same documentation now says callers should preferWaitGroup.Go.
A WaitGroup counts goroutines has the rest of the type's rules.
main_does_not_wait_waitgroup_go.go
main_does_not_wait_waitgroup_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// The same kind of worker, twice, and this time main waits for it: first with
// WaitGroup.Go, new in Go 1.25, then with the Add and Done calls that code
// written before Go 1.25 spells out. Each worker's deferred Flush runs.
//
// go run main_does_not_wait_waitgroup_go.go
package main
import (
"bufio"
"fmt"
"os"
"sync"
"time"
)
// logOrders writes orders to a buffered writer, and its deferred Flush empties it.
func logOrders(worker string, orders ...string) {
out := bufio.NewWriter(os.Stdout)
defer out.Flush()
time.Sleep(1 * time.Second) // a second's work before anything is written
for _, order := range orders {
fmt.Fprintf(out, "%s: order %s\n", worker, order)
}
}
func main() {
// Go 1.25: Go counts the task, starts the goroutine, and marks the task
// done when the function returns.
var withGo sync.WaitGroup
withGo.Go(func() { logOrders("wg.Go ", "A-1001", "A-1002") })
withGo.Wait()
// Before Go 1.25: the same three steps, written out.
var withAdd sync.WaitGroup
withAdd.Add(1) // before the go statement, never inside the goroutine
go func() {
defer withAdd.Done()
logOrders("Add/Done", "A-1003", "A-1004")
}()
withAdd.Wait()
fmt.Println("main: both workers flushed; returning")
}
runtime.Goexit: main's goroutine ends, and main does not return¶
The spec's rule is about main returning. runtime.Goexit ↗ ends the goroutine that calls it, running that goroutine's deferred calls first, without its function ever returning. Called from main, it ends the main goroutine and leaves the program running.
Verified output of main_does_not_wait_goexit_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go build goexit.go
$ ./goexit
main: calling runtime.Goexit
main: deferred calls run
worker: still running a second later
fatal error: no goroutines (main called runtime.Goexit) - deadlock!
exit status 2
Reading the output¶
main called Goexit, and its deferred call ran — that call is what closed mainEnding. The worker then slept for a second and printed: the program was still alive after the main goroutine had ended, which a return from main never allows. When the worker's function returned, no goroutine was left, and the runtime stopped the program with a fatal error and exit status 2. The Goexit documentation says as much: once all other goroutines have exited, the program crashes.
So Goexit at the end of main is not a polite way to wait for goroutines; it swaps a lost goroutine for a crash at the end. Where it does earn its keep is tests: t.FailNow ↗ stops a test by calling runtime.Goexit, which is why it must be called from the goroutine running the test, and why it does not stop other goroutines the test started.
main_does_not_wait_goexit_sh.sh
main_does_not_wait_goexit_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# runtime.Goexit in main: main's goroutine ends and its deferred calls run, but
# func main never returns -- so the program does not exit, and the worker goes
# on. When the worker ends too, no goroutine is left, and the runtime stops the
# program with a fatal error and exit status 2. The script prints stdout, the
# fatal error line from stderr, and the exit status.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cat >"$dir/goexit.go" <<'GO'
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
mainEnding := make(chan struct{})
defer func() {
fmt.Println("main: deferred calls run")
close(mainEnding)
}()
go func() {
<-mainEnding
time.Sleep(1 * time.Second)
fmt.Println("worker: still running a second later")
}()
fmt.Println("main: calling runtime.Goexit")
runtime.Goexit()
}
GO
say() { printf '$ %s\n' "$*"; }
cd "$dir" || exit 1
say 'go build goexit.go'
go build goexit.go || exit 1
say './goexit'
./goexit 2>stderr.txt
status=$?
grep '^fatal error: ' stderr.txt
echo "exit status $status"
Four ways a Go program ends¶
| How the program ends | main's deferred calls |
Deferred calls in other goroutines still running | Exit status |
|---|---|---|---|
main returns |
run | never run | 0 |
runtime.Goexit() in main |
run | run, because the program goes on until those goroutines end | 2, once the last one ends |
os.Exit(n) ↗ |
never run | never run | n |
| a panic in another goroutine | never run | only the panicking goroutine's own run | 2 — see A panic ends the whole program |
The first two rows are the programs on this page. The os.Exit row is its documentation: the program terminates immediately, and deferred functions are not run. (Not machine-checked here.)
What to do¶
- Give every goroutine with work to finish something that waits for it —
wg.Goandwg.Waitfor a group, or a channel thatmainreceives from for a single goroutine. - Prefer
wg.GotoAddandDoneon Go 1.25 and later. When you do writeAdd, write it before thegostatement. - Do not leave the only copy of anything in a goroutine's deferred call — a
Flush, aClose, a last log line — unless something waits for that goroutine to return. - Tell a goroutine that runs forever to stop, then wait for it, rather than abandoning it when
mainreturns: Cancel reaches every goroutine. - Do not end
mainwithruntime.Goexitto keep goroutines running. The program still ends, with a crash.
In other languages¶
- The Concurrency library's Who waits when main returns? ↗ puts this question to six languages: Rust, C and C++ end the process as Go does, while Java and Python wait for every thread that is not a daemon. Its C section is the counterpart of
runtime.Goexitabove —pthread_exitat the end ofmainends only the main thread — but there, once the last thread ends, the process exits with status 0, where Go crashes with status 2. - The Rust library's Spawning a thread ↗: a Rust program's threads also end when
mainreturns, andthread::scopeis a wait that cannot be forgotten, because the scope cannot end before the threads started in it. AWaitGroupis a wait the compiler never checks.
Sources¶
- The Go Programming Language Specification: Program execution ↗, Defer statements ↗.
sync.WaitGroup↗ andWaitGroup.Go↗, added in go1.25.0;runtime.Goexit↗;os.Exit↗;bufio.Writer↗;testing.T.FailNow↗.- Go 1.25 release notes ↗:
WaitGroup.Go, and thewaitgroupanalyzer ingo vet. - Katherine Cox-Buday, Concurrency in Go (O'Reilly, 2017), chapter 3, "Go's Concurrency Building Blocks" — goroutines and
sync.WaitGroup, from beforewg.Goexisted. - Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 12, "Concurrency in Go" — the sections "Always Clean Up Your Goroutines" and "Use WaitGroups".