Skip to content
academia.sh

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close