Goroutines and Channels

Go’s concurrency model is more than a wrapper around threads. Effective Go summarises it as: “Do not communicate by sharing memory; instead, share memory by communicating.” The advice has limits, especially for small pieces of shared state where a lock is clearer.

Correction (2 November 2026, 18:45 Taipei): the two Effective Go references now match what it says (suggested by Claude, decided by a vote of Codex (gpt-6-astra), Grok and Claude Fable); the channel declarations use two variable names, and the buffered channel note now says a receive blocks when the buffer is empty (suggested by Codex (gpt-6-astra), checked by Claude).

Goroutines are cheap

A goroutine isn’t an OS thread; it’s a user-space coroutine scheduled by the runtime. The initial stack is around 2KB and grows and shrinks on demand, so running hundreds of thousands at once is routine. go f() starts one:

go handle(conn) // one goroutine per connection, no thread pool to manage

Cheap isn’t free, though. A leaked goroutine — one with no exit condition — holds its memory forever, and it’s Go’s most common leak. Every long-lived goroutine should have an exit: a cancelled context or a closed channel.

Channels and select

A channel is a typed pipe between goroutines that carries synchronisation. An unbuffered send and receive must rendezvous; on a buffered one, a send blocks only when the buffer is full and a receive blocks when it is empty:

unbuffered := make(chan int)   // unbuffered: send and receive must meet
buffered := make(chan int, 64) // buffered: blocks once 64 are queued

select waits on several channels at once and takes whichever is ready; pair it with ctx.Done() for cancellation and time.After for timeouts:

select {
case v := <-in:
    use(v)
case <-ctx.Done():
    return ctx.Err() // bail out on cancellation
}

Share by communicating: the actor mindset

Rather than have several goroutines fight over a lock to mutate shared state, give one goroutine sole ownership of that state and let everyone else send it messages over a channel. The state is touched by exactly one goroutine, so no lock is needed. That’s Go’s take on the actor model.

Gortex’s WebSocket Hub is exactly this: one Hub.Run() goroutine owns the whole clients map, and every register, unregister, and broadcast arrives through a channel into that single goroutine (covered in The WebSocket Hub: Go’s Take on the Actor Model).

When not to use a channel

A channel isn’t a cure-all. Effective Go says so itself: the approach “can be taken too far”, and reference counts may be best done with a mutex around an integer.

Plain counters, simple flags, a small piece of guarded state → sync.Mutex or atomic is faster and clearer. Channels earn their keep for passing ownership, event streams, and cross-goroutine coordination — not every shared value. This sets up the framework posts: gortex’s metrics counters use atomic plus sharded locks (see Concurrent Counting and Resilience: Sharded Metrics and a Circuit Breaker), while the WebSocket Hub uses a channel — one project, two tools chosen by situation.

From Effective Go: Concurrency

Effective Go’s Concurrency opens with that “share memory by communicating” line, but the same passage adds the balancing note: the approach can be taken too far, and a mutex can suit something like a reference count, though as a high-level approach, using channels to control access makes it easier to write clear, correct programs. The point is the CSP spirit, coordinating concurrency through communication rather than reaching for locks by reflex, and for something as small as a counter, pick whichever is simplest and clearest.

Notes

  • Goroutines are cheap user-space coroutines (~2KB to start), but they leak; give each one an exit condition.
  • Channels carry synchronisation; select waits on many, with ctx.Done() / time.After to finish up.
  • The actor mindset: let one goroutine own the state and others send messages; no lock.
  • Don’t fetishise channels: plain counters and small guarded state are simpler with a mutex / atomic (setup for the sharded metrics and WebSocket Hub posts).
  • The mantra is “share memory by communicating”; but reach for a lock when a lock is the right tool.

Further reading: the series goes on to dissect the gortex framework built on these ideas — source at yshengliao/gortex.


Ideas and technical judgement by Sheng; drafted with Claude · Go 1.24 (latest release at the time) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.