TL;DR Link to heading

A Go channel is one primitive covering what other languages split across locks, condition variables, semaphores, and queues. That consolidation is the thread I kept pulling on while reading.

Motivation Link to heading

Channels come up in every Go pitch. I wanted to understand why.

What a channel actually is Link to heading

In most languages you get shared memory plus coordination tools: mutexes for exclusion, condition variables for waiting, semaphores for counting, queues for handoff. Synchronisation and data are separate problems with separate primitives.

A channel is a point where two goroutines meet: the sender hands a value to the receiver, and both continue. The synchronisation and the data transfer happen in the same step.

The memory model makes this explicit: a send happens-before the matching receive, so the receiver sees every write the sender did before the send. That one event covers exclusion, waiting, counting, handoff, and visibility.

Syntax in 30 seconds Link to heading

If Go isn’t your day-to-day language, skim the idioms below.

  • make(chan int) is unbuffered. make(chan int, N) has a buffer of N.
  • ch <- v sends. v := <-ch receives. The two-value form v, ok := <-ch sets ok to false once the channel is closed and drained.
  • chan<- T is send-only, <-chan T is receive-only. The arrow direction matches the data direction.
  • for v := range ch reads values until the channel closes.
  • chan struct{} is a zero-byte signal channel. Use it when only the send or close matters and you don’t need to carry a value.
  • close(ch) means “no more sends coming”. It isn’t a destructor.
  • context.Context is Go’s cancellation type. ctx.Done() returns a channel that closes when the context is cancelled, so you can drop <-ctx.Done() into a select.

Four things that clicked Link to heading

select composes waits Link to heading

With select, you wait on sends, receives, and timeouts at once, and the runtime picks whichever is ready first.

select {
case msg := <-incoming:
    handle(msg)
case outgoing <- reply:
case <-ctx.Done():
    return ctx.Err()
}

Mutex acquires don’t compose like this. You can’t say “lock whichever one comes free first” without wrapping things in goroutines yourself. select does it in one statement, and this was the bit that made the rest of the design make sense for me.

close as broadcast Link to heading

Call close and every receiver unblocks at once.

done := make(chan struct{})
for i := 0; i < 10; i++ {
    go func(id int) {
        <-done
        fmt.Printf("worker %d stopping\n", id)
    }(i)
}
close(done)

Compare with a condition variable, where you call Broadcast() on every state change. You close the channel once and every receiver wakes. In real code you’d reach for context.Context, whose ctx.Done() is a channel that closes when the context is cancelled.

nil channels disable select cases Link to heading

A nil channel blocks forever on both send and receive, so select never picks that case.

func merge(a, b <-chan int, out chan<- int) {
    for a != nil || b != nil {
        select {
        case v, ok := <-a:
            if !ok {
                a = nil
                continue
            }
            out <- v
        case v, ok := <-b:
            if !ok {
                b = nil
                continue
            }
            out <- v
        }
    }
    close(out)
}

Go has no dedicated syntax for disabling a select case. The channel variable holds the state, and setting it to nil turns the case off.

Channels of channels Link to heading

Channels can carry other channels. Put a reply channel inside your request struct and the response comes back through it, so you skip the correlation table you’d otherwise build.

type request struct {
    query string
    reply chan string
}

func client(reqs chan<- request) string {
    r := request{query: "hello", reply: make(chan string, 1)}
    reqs <- r
    return <-r.reply
}

func server(reqs <-chan request) {
    for r := range reqs {
        r.reply <- process(r.query)
    }
}

When a mutex is still simpler Link to heading

Use channels for coordination and ownership handoff. A counter or a config map is simpler with sync.Mutex. Bryan Mills argues people overuse the standard channel patterns (Rethinking Classical Concurrency Patterns).

Further reading Link to heading