Skip to content

Go

Goroutines and the scheduler, channels, implicit interfaces, errors as values, and context.

01

Goroutines and the scheduler

beginner

A goroutine is a function running concurrently, started by putting go in front of a call. It begins with a stack of about two kilobytes that grows and shrinks as needed, which is why a program can hold hundreds of thousands of them where the same number of operating system threads would exhaust the machine.

The runtime multiplexes them onto a small number of operating system threads, one per available core by default. When a goroutine blocks on a channel or on IO the scheduler runs something else on that thread, and when it blocks in a system call the runtime can hand the thread's queue of work to another thread entirely. All of it is invisible from the code, which is the point: you write blocking code and get non-blocking behaviour.

What is not invisible is that goroutines are not free of responsibility. Starting one without knowing how it ends is the standard Go bug: a goroutine blocked forever on a channel nobody will send to is a leak, holding its stack and whatever it captured, and nothing will report it. Every goroutine wants an answer to how it stops, usually a context or a closed channel.

There is also no supervision. An unrecovered panic in any goroutine takes the whole process down, not just that goroutine, so a background worker doing risky work needs its own recover at its top level. And a goroutine's result has to be delivered deliberately, through a channel or a WaitGroup, because there is nothing to return to.

The mental model that keeps this straight is that go is cheap and coordination is not. Starting work concurrently costs almost nothing; knowing when it finished, what it produced and how it stops is the actual design, and it is the part the language deliberately leaves to you.

Check yourself

What makes a goroutine cheap enough to start hundreds of thousands of?

runs what is readyyields the threadif nobody ever sends100,000 goroutines2KB stacks, growableRuntime schedulerone queue perprocessorOS threadsroughly one per coreBlocked on sendunmounted, costsnothingBlocked forevera leak nothingreports
ClientEdge / CDNServiceData storeExternalDashed = asynchronousConsidered, not chosen
Many goroutines, few threads, and the scheduler in between
Evidence · 1 source
  1. 01
    Go blog: Go concurrency patterns, context

    The rule that a goroutine's lifetime should be clear before it is started, and that a context is the usual mechanism for saying how it stops.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

02

Channels, select and the patterns that work

intermediate

A channel is a typed conduit with a rule attached: send and receive block until the other side is ready. An unbuffered channel is a rendezvous, so the send completes at the moment a receive happens, which makes it a synchronisation primitive as much as a data one. A buffered channel decouples them up to its capacity and then behaves like the unbuffered one, which is bounded backpressure by construction.

select waits on several channel operations and proceeds with whichever is ready, choosing at random between several that are. Combined with a case on a context's done channel, it is how nearly every cancellable Go loop is written, and combined with a default case it is how a non-blocking attempt is expressed.

The conventions around closing are worth learning exactly, because breaking them panics. The sender closes, never the receiver, since only the sender knows there is nothing more coming. Closing twice panics. Sending on a closed channel panics. Receiving from a closed channel returns the zero value immediately and forever, which is why the two-value receive exists to distinguish a real value from a closed channel.

Three patterns cover most real use. A worker pool: several goroutines receiving from one channel of jobs and sending to one of results. Fan-in: several producers writing to a channel a single consumer drains. And done-channel cancellation: a channel closed to broadcast to every listener at once, which works because a closed channel is permanently readable and is the reason cancellation is a close rather than a send.

The advice the community repeats, share memory by communicating, is not a prohibition on mutexes. A mutex around a struct is the right answer for protecting shared state, and a channel is the right answer for handing ownership of a value from one goroutine to another. Reaching for a channel where a mutex would do produces the most convoluted Go code there is.

Check yourself

Why is cancellation broadcast by closing a channel rather than sending on it?

hands over directlyreturns until fullor give up on cancelSenderUnbufferedblocks until receivedBuffered, cap 10blocks when fullReceiverselectplus ctx.Done()
ClientQueue / streamServiceEdge / CDNDashed = asynchronous
Unbuffered is a rendezvous; buffered is a bounded queue
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

03

Interfaces, satisfied implicitly

intermediate

A Go type satisfies an interface by having the methods, with no declaration that it intends to. That means an interface can be defined after the types that satisfy it, and by the package that consumes them rather than the one that provides them, which inverts the usual dependency direction and is the single most distinctive thing about designing in Go.

The convention that follows is to keep interfaces small and to define them where they are used. A consumer that needs one method declares a one-method interface and accepts anything with it, so the provider package does not have to know the consumer exists. The standard library sets the tone: io.Reader and io.Writer are one method each and compose into most of the ecosystem.

The trap is the typed nil. An interface value holds a type and a value, so an interface holding a nil pointer of a concrete type is itself not nil, and the classic version is a function returning a concrete error type as an error interface: the pointer is nil, the interface is not, and the caller's check for nil is false. Return the interface type, and return a literal nil for the no-error case.

Type assertions and type switches recover the concrete type when it is genuinely needed, and the two-value form is the safe one, because the single-value assertion panics on a mismatch. Reaching for them constantly is usually a sign that the interface is the wrong shape rather than that the language is in the way.

Generics, added in Go 1.18, cover the cases interfaces never could: a function that works over any ordered type without boxing, a container parameterised by element type. They did not replace interfaces, which remain the mechanism for behaviour, and the useful split is that generics abstract over types while interfaces abstract over behaviour.

Check yourself

A function returns a nil pointer as an error interface. Why does the caller's nil check fail?

satisfies it, silentlyno import of the providerreturning a concrete error typeProvider packagehas the methodInterfacedeclared by theconsumerConsumer packageneeds the behaviourTyped nilnon-nil interface,nil pointer
Data storeEdge / CDNServiceExternalDashed = asynchronousConsidered, not chosen
The consumer declares the interface, so the provider need not know it exists
Evidence · 1 source
  1. 01
    Go: type parameters proposal

    That generics were added in Go 1.18 to abstract over types, alongside interfaces which abstract over behaviour.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

04

Errors as values

beginner

Go has no exceptions for ordinary failure. A function that can fail returns an error alongside its result, and the caller deals with both. The consequence is visible immediately in the code: the failure path is written out rather than implied, which is verbose and is the point, because the alternative is a failure path nobody looked at.

Errors are values satisfying a one-method interface, so they can be compared, wrapped, inspected and defined by any package. Wrapping with the percent-w verb records the cause, and errors.Is walks that chain to answer whether a particular sentinel is anywhere inside it, while errors.As finds a specific error type and gives you access to its fields. Those three together replaced a decade of string matching on error messages.

The habit that makes the verbosity pay is adding context at each layer. Returning an error unchanged from five levels of call stack produces a message with no idea where it came from; wrapping it with what this layer was attempting produces a sentence that reads like a trace, which is what somebody wants at three in the morning.

panic is for programmer error and for situations where continuing is meaningless, not for a file that is missing or a request that failed. recover exists mainly to stop a panic in one request taking down a server, which is what an HTTP framework does at the top of each handler, and it should not be used as a general error mechanism because it hides the failure path the language deliberately exposes.

The thing to watch for is the ignored error, written as an underscore or simply omitted. It is the same swallowing that an empty catch block does in another language, with the difference that Go's linters can see it, which is why vetting for unchecked errors is standard in any serious pipeline.

Check yourself

What is the Go equivalent of an empty catch block?

%w keeps the causereturned five levels unchangedDatabaseconnection refusedRepositorywraps: loading order42Servicewraps: completingcheckoutA readable tracewith errors.Is intactconnection refusedreturned unchanged
Data storeServiceExternalDashed = asynchronousConsidered, not chosen
Wrap on the way up, and the message becomes a trace
Evidence · 1 source
  1. 01
    Go blog: working with errors in Go 1.13

    That the percent-w wrapping verb, errors.Is and errors.As arrived in Go 1.13, which ended the practice of matching on error message text.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

05

Context, cancellation and deadlines

advanced

A context carries a deadline, a cancellation signal and request-scoped values across API boundaries. By convention it is the first parameter of any function that does IO or can block, which is why almost every signature in a Go service starts with it, and the convention exists so that cancellation can reach anywhere without every layer inventing its own mechanism.

Cancellation propagates down a tree. Deriving a context gives a child that is cancelled when its parent is, so cancelling at the top reaches every operation started beneath it, and a deadline set at the edge becomes the budget for the whole call rather than a fresh timeout at each hop. That is the propagating deadline that other ecosystems build by hand, available as a language convention.

Using it correctly is a short list. Select on ctx.Done in any loop that might run long. Pass the context to every call that takes one, including the database driver and the HTTP client, since a cancellation that stops at your code and not at the query has cancelled nothing expensive. Call the cancel function returned when deriving a context, always with defer, because failing to do so leaks the context and its timer.

Values in a context are the part most often misused. They are for request-scoped data that genuinely crosses layers, a trace id, a request id, an authenticated principal, not for passing dependencies that a function could have been given as parameters. Because the map is untyped, anything hidden in it is invisible to the compiler and to the next reader, which is why the practice is to keep it to a handful of well-known keys.

The most common bug is a cancelled context reaching only part of the work. A handler returns, the context is cancelled, and a goroutine started with the request context finds its context already dead, or worse, a background task was given the request context and dies when the request completes. Background work needs its own context with its own lifetime, and saying so explicitly is cheaper than diagnosing it.

Check yourself

Why must the cancel function from a derived context always be called?

passed onnot passed onRequestdeadline 2sHandlerctx first parameterQueryctx passed, cancelledQuerycontext.Background()Work stopsbudget respectedQuery continuesnobody waiting
ClientServiceData storeExternalDashed = asynchronousConsidered, not chosen
One deadline at the edge, respected by everything beneath it
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.