Lesson 01 / 14
Goroutines
Of twelve goroutines, only 2 stopped on their own; 5 stopped because they waited for a step some other goroutine took, 2 because a context was cancelled, and 3 never stopped at all: the course's binding distribution is produced in this lesson. Starting a goroutine is one line, and that line never says when it will stop — the decision to stop usually sits not in the goroutine's own body, but in a step taken by someone else. Bounding measurement: the "stopped on its own" class is also a contract, because a goroutine whose body has finished never announces this to anyone on its own.
Published Updated
In this lesson
- Shows that starting a goroutine and its stopping are two separate jobs, and that the starting line never states the condition for stopping
- Produces the course's binding 2/5/2/3 distribution by building and running a framework that sorts twelve goroutines into four ways of stopping
- Distinguishes a goroutine from a thread in a single sentence
- Proves, by running it, that the "stopped on its own" class also requires an announcement, and is not visible on its own
Contents
The Interfaces, Errors and Generics course closed by measuring where a bond forms, depending
on whether the same job was written with an interface or with a generic: eight of sixteen
bonds were carried not in the declaration or the call site, but inside the value itself, at
runtime. This lesson carries the same question into time. When the word go is placed in
front of a function call, a new concurrent unit of execution starts, and the calling line
moves on without waiting at all — a one-line decision that looks free. But that line never
says one thing: when and how the goroutine it started will stop.
Someone looking at the source sees the word go, not the condition for stopping. The
decision to stop is often not even written in the goroutine’s own body; it depends on a step
taken by some other goroutine — a value sent on a channel, a lock released, a context
cancelled. This is this course’s one question: how will this goroutine stop?
Starting and Stopping Are Two Separate Jobs
Starting a goroutine differs from calling the same function directly by a single word:
f() runs in the calling goroutine and blocks the caller until it returns; go f() runs in
a new goroutine, and the calling line moves straight on to the next line. This difference is
the entire promise the word go makes. When the started goroutine will finish, whether it
will finish at all, how its finishing will reach the caller — none of this is part of what
go commits to.
The compiler never asks about this gap at all: whether the function a goroutine calls will
ever return, and when, is not a condition for the go word to compile. A goroutine can run
forever, get stuck without ever running, or finish in a microsecond — the compiler accepts
all three with the same single line. This is entirely unlike the “rejected” class seen in
the Go Fundamentals course: there, the compiler refused a bond that was left unwritten;
here, it does not even ask about an unwritten bond at all.
This gap gets filled in four different ways, and the filling is usually done not by the goroutine itself, but by someone else:
- the goroutine finishes its own body and stops on its own (own);
- it is waiting on a channel, a lock, or a wait group, and a step taken by some other goroutine releases it (arrived);
- what it is waiting for never comes, but it stops because a context was cancelled (cancelled);
- or none of this happens, and the goroutine never stops.
These four classes are the measurement axis for this lesson and the whole course. The name Go gives its concurrent unit of execution is goroutine, and this name is used untranslated; a goroutine is not the same thing as a thread scheduled by the operating system — the language’s own runtime runs thousands of goroutines on a small number of real threads, with its own scheduler. This distinction itself is not this lesson’s subject; the two mechanisms are not compared here, it is only put on record once that a goroutine is a concept in its own right.
A Framework That Counts Why It Stopped
The way to make the four classes measurable is to ask every goroutine to write its own
reason for stopping onto a channel, then gather the reports and print them in order —
goroutines give no order of their own, so the order has to be built into the measurement
itself. The Ledger type does this: report sends every entry onto a channel, collect
reads exactly the expected number of entries and sorts them by name.
// stop.go — how a goroutine stops: on its own, because what it waited for arrived, because it was cancelled, or never
package main
import (
"fmt"
"runtime"
"sort"
)
var own, arrived, cancelled, never int
type entry struct{ name, reason string }
// Ledger collects entries and prints them sorted; goroutines give no order.
type Ledger struct {
channel chan entry
baseline int
}
func newLedger(capacity int) *Ledger {
return &Ledger{channel: make(chan entry, capacity), baseline: runtime.NumGoroutine()}
}
func (l *Ledger) report(name, reason string) { l.channel <- entry{name, reason} }
func (l *Ledger) collect(expected int) []entry {
r := make([]entry, 0, expected)
for i := 0; i < expected; i++ {
r = append(r, <-l.channel)
}
sort.Slice(r, func(i, j int) bool { return r[i].name < r[j].name })
return r
}
func observe(name, reason string) {
switch reason {
case "own":
own++
case "arrived":
arrived++
case "cancelled":
cancelled++
default:
never++
}
fmt.Printf("%-48s %s\n", name, reason)
}
func (l *Ledger) hasLeak() bool { return runtime.NumGoroutine() > l.baseline }
func summary() {
fmt.Printf("stopped: %d own, %d what it waited for arrived, %d cancelled, %d never stopped\n",
own, arrived, cancelled, never)
}
GC1. — report only writes onto a channel; no goroutine ever prints to the screen on
its own — every fmt.Printf call is inside observe and is run only by the main
goroutine. This separation is not arbitrary: if more than one goroutine printed to the
screen at the same time, the order the lines came out in would give no order either, and
the measurement would break itself. The baseline field holds what hasLeak compares
against — the goroutine count before the measurement began.
Gathering and sorting the order is not a matter of taste, it is a requirement that holds
across every lesson in this course: which order goroutines finish in depends on the
runtime’s scheduling decision, and that decision can change from run to run. The only way to
get the same result every time is to force the order with a channel, a lock, or a wait
group, or to gather the results and sort them by our own criterion — here, the alphabetical
order of name. Setting a fixed wait time and assuming “it probably finishes by then” falls
into neither path; it depends on the environment’s load and is never used in this lesson.
Twelve Goroutines, Four Ways of Stopping
The measurement uses this framework in the same package and sorts twelve goroutines into
four classes. The first two finish their own bodies and stop. The next five wait through
five separate mechanisms — a receive on an unbuffered channel, a range over a closed
channel, a lock being released, a wait group being zeroed out, a select over more than one
channel — and all five are released by a step taken by some other goroutine (or by the main
goroutine). Two stop with the cancellation of a context. The last three never stop: one
tries to send on a channel with no receiver, one ranges over a channel that never closes,
one waits on a wait group whose Done is never called.
// measurement.go — twelve goroutines, four ways of stopping
package main
import (
"context"
"fmt"
"sync"
)
func main() {
ledger := newLedger(32)
go func() { ledger.report("01 goroutine whose body finished", "own") }()
go func() {
t := 0
for i := 0; i < 3; i++ {
t += i
}
_ = t
ledger.report("02 goroutine whose loop finished", "own")
}()
unbuffered := make(chan int)
go func() {
<-unbuffered
ledger.report("03 unbuffered receive", "arrived")
}()
unbuffered <- 1
alreadyClosed := make(chan int, 2)
alreadyClosed <- 7
close(alreadyClosed)
go func() {
for range alreadyClosed {
}
ledger.report("04 range over a closed channel", "arrived")
}()
var lock sync.Mutex
lock.Lock()
go func() {
lock.Lock()
lock.Unlock()
ledger.report("05 waiting for the lock", "arrived")
}()
lock.Unlock()
var group sync.WaitGroup
group.Add(1)
go func() {
group.Wait()
ledger.report("06 waiting for the wait group", "arrived")
}()
group.Done()
ready := make(chan int, 1)
other := make(chan int)
ready <- 5
go func() {
select {
case <-ready:
ledger.report("07 select from the first ready branch", "arrived")
case <-other:
ledger.report("07 select from the second branch", "arrived")
}
}()
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-ctx.Done()
ledger.report("08 waiting for context cancellation", "cancelled")
}()
go func() {
for {
select {
case <-ctx.Done():
ledger.report("09 checking context in a loop", "cancelled")
return
default:
}
}
}()
cancel()
abandoned := make(chan int)
go func() {
abandoned <- 1
ledger.report("10 send with no receiver", "never")
}()
neverClosed := make(chan int)
go func() {
for range neverClosed {
}
ledger.report("11 range over a channel that never closes", "never")
}()
var neverDone sync.WaitGroup
neverDone.Add(1)
go func() {
neverDone.Wait()
ledger.report("12 waiting on a wait group that never gets Done", "never")
}()
fmt.Println("-- how the goroutine stopped --")
for _, r := range ledger.collect(9) {
observe(r.name, r.reason)
}
observe("10 send with no receiver", "never")
observe("11 range over a channel that never closes", "never")
observe("12 waiting on a wait group that never gets Done", "never")
fmt.Println("would the send go through now :", tryToSend(abandoned))
fmt.Println("is a goroutine left that never stopped :", ledger.hasLeak())
summary()
}
func tryToSend(ch chan int) bool {
select {
case ch <- 1:
return true
default:
return false
}
}
-- how the goroutine stopped -- 01 goroutine whose body finished own 02 goroutine whose loop finished own 03 unbuffered receive arrived 04 range over a closed channel arrived 05 waiting for the lock arrived 06 waiting for the wait group arrived 07 select from the first ready branch arrived 08 waiting for context cancellation cancelled 09 checking context in a loop cancelled 10 send with no receiver never 11 range over a channel that never closes never 12 waiting on a wait group that never gets Done never would the send go through now : false is a goroutine left that never stopped : true stopped: 2 own, 5 what it waited for arrived, 2 cancelled, 3 never stopped
GC2. — The first nine lines come from the call ledger.collect(9) and come out sorted
by name; the last three are printed by hand by the measurement itself, through observe,
because these three never send a report at all, and collect could never wait for them.
The number nine, in this lesson, comes not from data but from the writer’s design — the
writer knows from the start how many goroutines will send a report, the measurement does
not confirm this; that is the subject of the bounding measurement in the third section.
Of the twelve goroutines, 2 stopped on their own, 5 stopped because what they waited for
arrived, 2 because they were cancelled, and 3 never stopped. This distribution is binding
for the rest of the course; later lessons will print their own slice’s lines and refer back
to this number in a single sentence. The same output is produced on every run, including
with GOMAXPROCS=1, because no order was left to the scheduler — every step was either
forced with a channel, a lock, or a wait group, or the results were gathered and sorted.
Three Claims
Reading this table rests on three claims. First: starting a goroutine does not mean it
will finish. Only two of the twelve stopped on their own; the other ten depend on a step
taken by someone else — five on a step arriving, two on a cancellation, and three on
nothing at all, because they never stopped. Someone looking at the source sees the word
go, but cannot read from the source which of these ten lines will stop, or when.
Second: not stopping raises no error. All three leaks are silent; the program exits
normally, no warning is produced. The proof is only indirect: sending on the abandoned
channel still does not go through now either (tryToSend returns false), and a goroutine
is left over the baseline count (hasLeak returns true). The count itself is never
printed anywhere, because runtime.NumGoroutine can also count the runtime’s own
goroutines; this is the rule — the count of a leak is never printed in any lesson, only
its existence is written as true/false.
Third: what makes it stop is not the channel, it is the step someone took. The five
“arrived” lines were each set up with a different mechanism — an unbuffered receive, a
range over a closing channel, a lock, a wait group, a select. The mechanism changes on
every line; what does not change is the dependency: in every one of them, if some other
goroutine (sometimes the main goroutine) had not taken a step, the waiting party would never
have stopped. The choice between communication and sharing is a subject for later lessons;
what matters here is that, whichever mechanism is chosen, stopping always stays dependent on
someone else’s step.
These three claims can be traced line by line against the code. Lines three and four (03,
04) fall into the same class but use different mechanisms: one is a single-value receive
from an unbuffered channel, the other a range that runs until a closed channel drains.
Lines five and six (05, 06) are also “arrived,” but what is waited for is not a value
but a state — a lock being freed, a counter being zeroed. Line seven (07) uses
select to choose the one ready source among several; here one of the two channels was
already filled, so the choice is deterministic — had both been ready at once, the choice
would have been random, and that case is measured separately in the fourth lesson.
Bounding Measurement: “Stopped on Its Own” Is Also a Contract
GC3. — The “stopped on its own” class, even though it looks the easiest, is not free of
a contract of its own. The first and second goroutines finished their bodies and stopped on
their own — but they announced this finish to no one; if the ledger.report call did not
exist, the main goroutine would have had no way at all to know whether these two had
finished. The measurement itself relies on this: nine of the twelve goroutines could be
counted because they wrote their report onto a channel; the measurement waits for these
nine knowing in advance how many will come (ledger.collect(9)), not for the ones that
never come.
This shows that knowing a goroutine has finished is a job separate from its finishing. When a goroutine finishes its own body, the language does nothing at all — it publishes no event, flips no flag. Having finished is not something visible from outside; to become visible, the goroutine itself has to write onto a channel, exactly as in this measurement. This is why the difference between “stopped on its own” and “never stopped” is sometimes invisible to someone looking from outside: both are silent; the only difference is that one has genuinely finished, the other is still running.
The practical consequence of this is that the assumption “I started the goroutine, its body
is short, it must have finished by now” can never be confirmed from the source. The source
code of the first and second goroutines does not say this; the only thing that knows they
finished is the channel this lesson’s measurement set up. Had the same body been written
without the ledger.report call, the compiler would have changed nothing, but the
measurement itself would have become impossible — there would be no way left to wait for
anything past the ninth line.
Summary
- Starting a goroutine (
go f()) and its stopping are two separate jobs; the wordgonever states the condition for stopping. - A goroutine stops in four ways: by finishing its own body (own), by waiting for another goroutine’s step (arrived), through the cancellation of a context (cancelled), or by never stopping at all (never stopped).
- Of the twelve goroutines, 2 stopped on their own, 5 because what they waited for arrived, 2 through cancellation, 3 never stopped — this distribution is the course’s binding number.
- A goroutine is not a thread; the language’s own runtime runs goroutines on a small number of real threads with its own scheduler.
- A leak is never printed as a count in any lesson, only its existence is written as
true/false. - “Stopped on its own” is also a contract: knowing a goroutine has finished is a job separate from its finishing, and it has to be announced separately in order to become visible.
Next Step
The first of the five “arrived” lines came from a channel: unbuffered <- 1 was written,
and the goroutine waiting on the other side received it and stopped. But two things
happened at once in that line — a value was carried across, and two goroutines met at the
same point. The next lesson separates these two jobs: a channel carries a value and
synchronizes the sender with the receiver at the same time, and the two do not always have
to come together.
To keep your progress and take notes, Log in
My notes
Log in to take notes.