Goroutines and Channels
Goroutines and Channels
Go’s concurrency isn’t threads with sugar. Effective Go boils it to a slogan: “Do not communicate by sharing memory; instead, share memory by communicating.” This post unpacks it, and where it stops applying.
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; a buffered one blocks only when full:
ch := make(chan int) // unbuffered: send and receive must meet
ch := 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 B6, “The WebSocket Hub”, gortex-websocket-actor-hub).
When not to use a channel
A channel isn’t a cure-all. Effective Go says so itself: when all you want is to guard one variable so a single goroutine accesses it at a time, wrapping that counter in a mutex is often simpler and more direct.
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 (B5), while the WebSocket Hub uses a channel (B6) — 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: channels aren’t meant to replace mutexes, and sometimes guarding a small piece of state with a plain lock fits better. The point is the CSP spirit — coordinate concurrency through communication rather than reaching for locks reflexively — and for something as small as a counter, pick whichever is simplest and clearest.
Takeaways
- Goroutines are cheap user-space coroutines (~2KB to start), but they leak; give each one an exit condition.
- Channels carry synchronisation;
selectwaits on many, withctx.Done()/time.Afterto 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 B5 vs B6).
- 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.
Outline 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.