From Sequential Scripts to Concurrent AI Pipelines in Go — Part 4

Part 4 — Deadlocks: When Goroutines Wait Forever

Part of the series: Production-Grade Concurrent AI Systems in Go

Full code for this post: github.com/madmmas/go-concurrent-ai-systems/tree/part-04Diff from Part 3: compare/part-03...part-04Run it: go run ./deadlocks/send-no-receive inside arc-1-foundations/part-04-deadlocks


Part 3 taught us about race conditions — two goroutines touching shared memory at the same time, producing unpredictable results. The race detector catches them. The -race flag makes them visible.

Deadlocks are different. A deadlock doesn't corrupt data. It doesn't produce wrong output. It produces no output. Your program just stops making progress, silently, indefinitely. In production, this looks like a service that's "up" but not responding. It's one of the most frustrating bugs in concurrent programming precisely because it gives you so little to go on.

Go's runtime can detect certain deadlocks and tell you exactly what happened. Understanding what it says — and more importantly, why those situations arise — is the goal of this part.


What a Deadlock Actually Is

A deadlock happens when two or more goroutines are each waiting for something the other holds, and none of them can make progress.

The simplest case: one goroutine trying to send on a channel with no receiver.

ch := make(chan string) // unbuffered — send blocks until received

ch <- "article summary" // blocks forever — nobody receives

Run this:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
    send-no-receive/main.go:27 +0x28
exit status 2

That message is Go's runtime telling you: every goroutine in the program is blocked, and since nothing can unblock anything, the program can never make progress. The runtime kills it rather than let it hang forever.

Notice what the message tells you: goroutine 1 [chan send] — the main goroutine is stuck waiting to send on a channel. That's the exact line and state. This is genuinely useful information once you know how to read it.


Three Deadlock Patterns You Will Hit

Pattern 1: Sending with No Receiver

An unbuffered channel send blocks until a goroutine receives from the other end. If that receiver never exists — or was never started before the send — you deadlock.

This is the "it seemed fine in a simple example" trap. The broken version:

func main() {
    ch := make(chan string)
    ch <- "result"    // blocks — main is the only goroutine, nobody receives
    fmt.Println("done")
}

The fix: ensure a receiver exists before sending. The sender should be in a goroutine, or the receiver should start first:

ch := make(chan string)

go func() {
    ch <- "result" // sender in goroutine
}()

msg := <-ch // receiver in main — both sides exist

Pattern 2: Circular Wait

Two goroutines each need what the other is holding, and both block waiting:

chA := make(chan string)
chB := make(chan string)

go func() {
    chA <- "from A" // blocks until main receives from chA
    msg := <-chB    // then waits for B — but B is also stuck
}()

go func() {
    chB <- "from B" // blocks until main receives from chB
    msg := <-chA    // then waits for A — but A is also stuck
}()

select {} // main waits — everyone is waiting on everyone

This mirrors a real pattern in complex pipelines: stage A feeds stage B, which feeds stage A. The fix is to break the cycle — ensure that at least one side can proceed without waiting for the other.

Pattern 3: Forgotten Channel Close

range over a channel keeps consuming values until the channel is closed and drained. If it's never closed, range blocks after the last value, waiting for more that never arrive.

articles := make(chan int)

go func() {
    for i := 1; i <= 3; i++ {
        articles <- i
    }
    // BUG: forgot close(articles)
}()

for id := range articles {
    fmt.Printf("processing article %d\n", id)
}
// blocks here after article 3 — range never exits

Output before the deadlock:

processing article 1
processing article 2
processing article 3
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
    forgotten-close/main.go:35 +0xd4

The runtime message this time shows [chan receive] — the main goroutine is stuck waiting to receive. It's consumed all three values and is waiting for a fourth that will never come.

This is the deadlock you will hit most often in real pipeline code. The sender finishes and exits without closing. The consumer hangs forever.


The Fix: Two Rules That Prevent All Three

Rule 1: Every unbuffered channel send needs a receiver, and the receiver must exist before — or concurrently with — the send.

The simplest way to guarantee this: put sends in goroutines so the sender doesn't block the receiver from starting.

Rule 2: The goroutine that sends on a channel is responsible for closing it. Use defer close(ch) to guarantee it.

go func() {
    defer close(articles) // runs no matter what — even if the function panics
    for i := 1; i <= 3; i++ {
        articles <- i
    }
}()

defer close(ch) is not optional hygiene. It's the mechanism that lets range exit cleanly. Without it, every range-based collector in the series would deadlock.


How This Applies to the News Pipeline

Part 3 gave us a working concurrent pipeline using a mutex. The results slice is protected, the race is gone, the code runs correctly.

But there's a subtler structure in the pipeline that the deadlock patterns above make explicit. When we move to channels in Part 5 — and we will — the close pattern becomes critical:

resultsCh := make(chan model.AIResult, len(articles))

// Workers send results
for _, article := range articles {
    wg.Add(1)
    go func(a model.Article) {
        defer wg.Done()
        resultsCh <- processArticle(a) // send
    }(article)
}

// THIS goroutine closes after all workers finish
go func() {
    wg.Wait()
    close(resultsCh) // Pattern 3 fix — must happen, must happen after all sends
}()

// Collector ranges until closed
for result := range resultsCh {
    results = append(results, result)
}

The closer goroutine is not decorative. Remove it and the range loop blocks forever. Call close() before wg.Wait() finishes and a worker panics with "send on closed channel." The ordering matters, and defer wg.Wait() in a separate goroutine is the pattern that gets it right.


What's Next

We now have mental models for the two main failure modes of concurrent Go programs:

  • Race conditions: multiple goroutines touching shared memory simultaneously (Part 3)
  • Deadlocks: goroutines waiting on each other in a cycle that can never resolve (this part)

In Part 5, we move to channels as the primary coordination mechanism — replacing the mutex-protected shared slice with a channel-based producer/consumer design. The deadlock patterns from this part become directly relevant: we'll apply Rule 2 (defer close) in exactly the context shown above, and the test suite includes a deadlock detector that verifies the close timing is correct.

See you in Part 5.


This is Part 4 of the series "Production-Grade Concurrent AI Systems in Go." Read Part 3 — Race Conditions and Mutexes or continue to Part 5 — Channels and Message Passing.