Map
โ†‘Message Passing vs Shared Memory

Go Channel Bug Patterns

Wiki conceptgolangconcurrency โ†ณ show in map Markdown
title
Go Channel Bug Patterns
type
concept
summary
Four failure modes (deadlock, leak, race, protocol violation) that channels reproduce one-for-one from shared-state bugs
tags
golang, concurrency
created
2026-04-25
updated
2026-04-25

A taxonomy from Tu et al.'s study of 171 real Go concurrency bugs (ASPLOS 2019), framed by Arthur O'Dwyer and the author of message-passing-shared-mutable-state. Every classic shared-mutable-state bug has a channel equivalent. Channels in Go are not the directional, two-endpoint primitive of pi-calculus โ€” they are concurrent queues that any goroutine holding a reference can send to or receive from. Once you see them as shared mutable structures, these patterns become predictable.

Deadlock

Goroutine A sends to one channel and blocks waiting for a response on another. Goroutine B does the reverse. Both block. This is a circular dependency on shared state โ€” structurally identical to a mutex deadlock, expressed through queues instead of locks. Found in real bugs in Docker, Kubernetes, and gRPC.

Go ships a built-in deadlock detector but it caught only 2 of the 21 blocking bugs the paper tested. The detector only fires when every goroutine is blocked; the common case is a few goroutines deadlocked while the rest of the program keeps running.

Leak

A goroutine sends to an unbuffered channel that nobody reads, or receives from one nobody sends to. The blocked goroutine holds onto stack and heap references, and the channel object retains the goroutine. Go's garbage collector can't collect goroutines blocked on channels.

The Kubernetes timeout pattern is the canonical example:

func finishReq(timeout time.Duration) ob {
    ch := make(chan ob)
    go func() {
        result := fn()
        ch <- result  // blocks forever if timeout wins
    }()
    select {
    case result = <-ch:
        return result
    case <-time.After(timeout):
        return nil  // ch never read; child leaks
    }
}

The fix is one character: make(chan ob, 1). The buffered channel lets the child send and exit even after the parent gives up.

This is the channel version of a memory leak from a dangling reference to a shared object. Under load these accumulate until the process degrades or gets OOM-killed.

Race

Multiple goroutines read from the same channel. Which one gets each message? The Go runtime scheduler picks one nondeterministically. This is concurrent access to a shared resource with the nondeterminism mediated by the scheduler instead of explicit locking. Found in etcd and CockroachDB.

Go's race detector helps here โ€” it caught roughly half of the non-blocking bugs in the study. Half is still uncaught.

Protocol violation

A goroutine sends a message the receiver doesn't expect, sends on a closed channel (which panics), or closes an already-closed channel (which also panics). The shared queue's implicit contract was violated. Same category of bug as misusing any shared object โ€” accessing it in the wrong state, in the wrong order, or after teardown.

The "who closes the channel" question has no language-enforced answer. Convention says the sender closes, but with multiple senders that breaks down; common workarounds are close-coordinator goroutines or refcounted senders, both of which are extra coordination layered on top of the supposed coordination primitive.

Why this taxonomy matters

Each pattern is the channel-flavored version of a pre-existing shared-state bug. The framing in message-passing-vs-shared-memory follows: if the same bugs reappear, the coordination primitive isn't doing the work it was advertised to do. See also message-passing-shared-mutable-state for the full empirical case.