3.6 The Life and Death of the Main Goroutine
In 3.5, once schedinit has assembled the runtime’s foundations, it does not call runtime.main directly. Instead it pushes that function’s entry address onto the stack, hands it to newproc to fabricate the first Goroutine, and then lets mstart start the scheduling loop and pick this Goroutine out to run. The details of scheduling are left for 9 Scheduler. This section trains the camera on a single moment: the first Goroutine is already running, and it is about to execute runtime.main.
This Goroutine is unlike the others. It is the first one born in the program, it carries the user’s main.main, and it alone holds power of life and death over the whole process: the moment it returns, the process ends, even if a thousand other Goroutines elsewhere are still busy. Understanding what it does, and when it stops, is the last piece of the puzzle in understanding “how a Go program starts, and how it exits as a whole”.
3.6.1 runtime.main: The Groundwork Before User Code
The main function in the runtime package (that is, runtime.main) runs on the same Goroutine as the user’s main.main, but before it hands control to the user, it takes care of a few things that only “the first Goroutine” can do. Below is a trimmed sketch, keeping only the skeleton relevant to the lifecycle:
| |
There are several spots in this skeleton worth pausing on.
runtimeInitTime = nanotime() is the first timestamp stamped on the entire runtime, “the world begins at this instant”. The cadence of later GC triggers, scheduling statistics, and init tracing (the @x ms printed by GODEBUG=inittrace=1) all take it as their zero point, so it must be set before any init settles.
newm(sysmon, nil, -1) raises a monitor thread sysmon on the system stack that is not bound to any P. It is the runtime’s “background steward”, periodically preempting long-running Goroutines, reclaiming idle resources, and triggering GC on demand. The details of its work are covered in 9.8 System Monitor. Note that it is guarded by haveSysmon: on single-threaded platforms such as wasm, this thread does not exist.
Before gcenable(), the garbage collector is “off”; during initialization we do not want it to interfere. This step truly connects GC, after which heap growth is reclaimed at GC’s pace. The mechanism is covered in 13 Garbage Collection.
As for the lockOSThread()/unlockOSThread() pair, it exists because some platforms require certain calls during initialization to happen on the main OS thread; if the user calls runtime.LockOSThread inside an init, then main.main can also be kept on the main thread.
With the groundwork done, the true protagonist enters in two steps: first doInit runs through all the inits, then main_main runs the user’s main.main. This carries an often-overlooked fact: all of the user’s init functions and main.main execute on the same Goroutine, strictly one after another. If an init spins up another Goroutine, those Goroutines may run concurrently with later inits, but the order among inits, and from init to main.main, is always serial.
3.6.2 The Order of Package Initialization: doInit and the Dependency Graph
“What order do inits actually run in” is one of the things readers are most often confused about, and the thing most worth getting clear (this section answers tracked issue #75). The answer is built from two layers of rules: between packages, ordering follows the dependency graph; within a package, ordering follows variable dependencies and source order. Both layers are spelled out explicitly by the Go language specification, not by implementation detail.
Between Packages: Imports First, Once Per Package
The specification puts it plainly in Program initialization: if a package has imports, the imported packages are initialized before it; when several packages import the same package, that package is initialized only once. More precisely, given all packages sorted by import path, each step picks the first package that is “not yet initialized and all of whose imports have been initialized” and initializes it, repeating until all are ready. Import relations naturally form a directed acyclic graph, so this ordering can always complete; there is no circular initialization.
The linker freezes this dependency order into each module’s (moduledata’s) inittasks list, and the runtime simply takes it as given. That for m := &firstmoduledata loop in runtime.main is exactly “traverse modules in dependency order and doInit each one”. doInit itself merely hands a sequence of initTasks to doInit1 to execute one by one:
| |
The three states of state map the spec’s two constraints precisely into code: case 2 ensures any package is initialized at most once (even if imported from many places), while the throw in case 1 is a runtime assertion of “no circular initialization”; once it fires, it means the linker’s ordering went wrong.
Within a Package: Variable Dependencies First, init in Source Order
Once inside a single package, the spec requires that all package-level variables be initialized first, and then this package’s init functions be called in turn. The order of variables is not a simple top-to-bottom; it advances step by step according to dependency: each step picks the variable that is “earliest in declaration order and whose initialization expression depends on no uninitialized variable”. The example the spec gives illustrates the point well:
| |
Although a is written first, because it depends on b and c it is settled last; d has no dependency and is referenced by f, so it goes first. Dependency analysis looks only at lexical references in the source (and takes the transitive closure), not at runtime values, so a = c + b and a = b + c give the same order. Across files, a variable’s “declaration order” is determined by the order in which files are presented to the compiler; the spec recommends that build systems present files in lexical filename order for reproducibility.
Once variables are ready, this package’s init functions are called in turn in the order they appear in the source, possibly across multiple files, and the same file may declare several. An init cannot be referenced, and takes no arguments and returns no values; the sole reason it exists is “to be run once during initialization”. This corresponds exactly to that plain for i loop in doInit1: the linker has already arranged the pointers in source order, and the runtime simply calls them in that sequence.
Putting the two layers of rules together, a program’s initialization is a tree like the following, depth-first, with each node visited only once:
| |
The dependencies of fmt and net/http are each initialized cleanly in depth-first fashion, and the low-level packages shared by several packages (such as runtime, internal/*) are initialized only once; when it is the main package’s turn, x is made ready first, then init runs, and only then does control enter main.main. The entire process happens serially on the main Goroutine.
3.6.3 The Crucial Asymmetry: Once the Main Goroutine Dies, the Process Ends
After main_main returns, runtime.main does nothing to “wait for other Goroutines to wrap up”; it heads straight to exit(0), and the process vanishes with it. Hidden here is an asymmetry in Go’s concurrency model that must be kept firmly in mind:
The main Goroutine and an ordinary Goroutine are not equals. When an ordinary Goroutine finishes, only it leaves the stage; when the main Goroutine finishes, it takes the whole process with it. The moment
main.mainreturns, all Goroutines still running are terminated on the spot, with no cleanup and no goodbye.
The diagram below draws out this one-way lifecycle:
stateDiagram-v2
[*] --> schedinit: runtime bootstrap (3.5)
schedinit --> runtimeMain: mstart schedules the first G
runtimeMain --> sysmon: newm(sysmon) starts the monitor (9.8)
runtimeMain --> doInit: after gcenable
doInit --> mainMain: all init complete
mainMain --> exit: main.main returns
mainMain --> otherG: go f() in init/main
otherG --> killed: main G exits, running or not
exit --> [*]: exit(0) ends the whole process
killed --> [*]A direct consequence of this rule is: if main.main does not actively wait, a background Goroutine may be carried off before it has run a single line. So in a concurrent program the main Goroutine must synchronize explicitly; the common means are sync.WaitGroup or a channel, with mechanisms covered in 11 Synchronization Patterns. The difference between the two snippets below is exactly “waiting” versus “not waiting”:
| |
| |
This asymmetry also corroborates another design choice: Go provides no API to “kill a Goroutine from the outside” (see 11 Synchronization Primitives and Patterns). When a Goroutine ends can only be decided by itself (a normal return or runtime.Goexit), the sole exception being the “collective punishment” termination of all when the main Goroutine exits. In other words, a process-level exit is the only path in Go for “forcibly ending Goroutines from the outside”; it is crude and total. Precisely for this reason, handing the decision of when to exit to the main Goroutine, and requiring developers to synchronize explicitly, is this model’s trade-off: in exchange comes the simplicity of a Goroutine’s own state (no intermediate “asynchronously killed” state to handle), at the cost of developers having to manage the exit timing themselves.
With that, the main thread of how a Go program goes from bootstrap, through initialization, to exiting as a whole is complete. The reader may still hold a few questions: how exactly does mstart schedule the main Goroutine up? What does sysmon do in the background? And how does the GC that gcenable connected actually run? These are all left to their respective chapters (9 Scheduler, 13 Garbage Collection).
Further Reading
- The Go Authors. The Go Programming Language Specification: Package initialization & Program initialization. https://go.dev/ref/spec#Package_initialization
- The Go Authors. runtime/proc.go (
func main,doInit,doInit1,initTask). https://github.com/golang/go/blob/master/src/runtime/proc.go - The Go Authors. Effective Go: The init function. https://go.dev/doc/effective_go#init
- Russ Cox.
main_init_donecan be implemented more efficiently. Go issue #15943. https://github.com/golang/go/issues/15943 - The Go Authors. Command compile. https://go.dev/cmd/compile/
- This book: 3.5 Go Program Bootstrap, 9.8 System Monitor, 11 Synchronization Patterns.