---
title: Goroutines
source: 'https://academia.sh/en/courses/go-concurrency/goroutines'
course: Concurrency
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```go
// 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.

```go
// 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 word `go`
  never 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.
