Sunday, October 25, 2026
Why Does My Go Program Deadlock On Select And Unbuffered Channels?
Posted by

Introduction
Your Go program deadlocks on select and unbuffered channels because every channel operation in it is waiting for a partner that never arrives. An unbuffered send completes only when a receiver takes the value, a receive completes only when a sender sends a value, and a select with no ready case and no default case blocks until one of its cases can proceed. When no goroutine can make progress, the program stops instead of continuing.
Before You Start
- Goroutine: a function running concurrently with other functions in the same program, started with the
gokeyword. A goroutine runs until its function returns. - Channel: a typed conduit that carries values between goroutines, created with
make. A channel is a reference to an underlying structure, so goroutines that share the channel share the same conduit. - Send: an operation of the form
ch <- value. On an unbuffered channel, a send blocks until a receiver receives the value. - Receive: an operation of the form
value := <-ch. A receive blocks until a value is available, or until a closed channel is drained, in which case it returns the zero value. - Unbuffered channel: a channel made with no buffer size, such as
make(chan int). It has no storage for values in flight. A send and a receive must meet, in what is called a rendezvous. - Buffered channel: a channel made with a buffer size, such as
make(chan int, 3). A send completes when the value is copied into the buffer, and blocks only when the buffer is full. - Select: a statement that waits on a set of send and receive cases, and runs the case that can proceed. If several cases can proceed, one is chosen by a uniform pseudo-random selection, not by source order.
- Default case: the case in a select that runs when no other case can proceed. A select with a default case never blocks on its channel cases.
- Nil channel: a channel variable that was declared but never created with
make, so its value isnil. Communication on a nil channel can never proceed. - Blocked: a goroutine that is stopped at an operation, waiting for an event that would let it continue, such as a receiver, a sender, or a closed channel.
- Deadlock: a state in which every goroutine in a program is blocked, and no goroutine can make progress. The runtime stops the program in that state, instead of leaving it hanging with no possible recovery.
- Context: a value from the
contextpackage that carries cancellation and deadlines. When a context is cancelled, or its deadline passes, itsDonechannel closes, and a select case onDonebecomes ready. - Timeout: a bound on how long an operation may wait, usually built from a context with a deadline, or from a channel returned by
time.After.
Prerequisites
- Go 1.20 or later, because the article uses
context,time, and channel patterns that are stable in that version. - A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Requires: Go 1.20 or later.
Project Layout
deadlock-demo/
├── go.mod # module declaration, Go version
├── main.go # deadlock shape, fixed worker, timeout receive, and main function
└── main_test.go # completion, timeout, value, and default case tests
- go.mod: declares the module and the minimum Go version.
- main.go: holds the deadlock shape, clearly labelled and never called, plus the fixed worker and timeout receive that
mainuses. - main_test.go: holds tests that prove the fixed shapes complete, return on timeout, and do not wait on an empty channel when a default case is present.
The Rendezvous Rule
An unbuffered channel is a meeting point, not a queue. A sender and a receiver must both arrive at the operation before the value moves. Effective Go states the rule in both directions: receivers always block until there is data to receive, and on an unbuffered channel, the sender blocks until the receiver has received the value.
ch := make(chan int)
go func() {
ch <- 42
}()
value := <-ch
This code works because the send runs in a goroutine while main receives. The two operations meet. Remove either side, and the remaining side waits with no partner.
A buffered channel changes the timing, not the rule. A send completes when the value is copied into the buffer, and blocks only when the buffer is full, waiting until a receiver retrieves a value. A buffer of size 3 lets three sends complete with no receiver, and the fourth send blocks exactly like an unbuffered send. A buffer is storage for a known burst, not a guarantee that a receiver exists.
That distinction matters for every deadlock in this article. A deadlock is not a slow program, and it is not a program that is still working. It is a program in which each blocked operation is waiting on a partner, and the partner is either missing, blocked itself, or waiting on the first operation in return.
How Select Chooses
A select statement chooses which of a set of send or receive operations will proceed. The Go specification gives its execution in three steps that explain every select deadlock:
- If one or more communications can proceed, a single one that can proceed is chosen by a uniform pseudo-random selection.
- Otherwise, if there is a default case, that case is chosen.
- If there is no default case, the select blocks until at least one communication can proceed.
Three consequences follow from those steps, and each one is a deadlock shape in the next section:
- A select with no ready case and no default case waits, with no time limit, for a case to become ready.
- A select with a default case never waits on its channel cases, because the default case is always able to proceed.
- Communication on a nil channel can never proceed, so a select with only nil channels and no default case blocks forever, as the specification states directly.
An empty select, written as select {}, has no cases at all, so it blocks forever by the same rule. It is a deliberate way to park a goroutine, and a mistake when it appears where a case was meant to be present.
A select does not choose cases in source order. When several cases are ready, the choice is pseudo-random. Code that depends on the first case winning when two cases are ready is relying on behaviour the specification does not promise, and it will fail intermittently even when it does not deadlock.
What Deadlock Means, And What It Does Not Mean
Deadlock, in the channel sense, means no goroutine can make progress. Every goroutine is blocked on a channel operation, a wait, or a combination of them, and there is no outside event that could unblock any of them. The runtime stops the program in that state, because continuing would mean waiting forever with no possible change.
That stopping behaviour is a diagnostic aid, and it has a limit worth stating plainly. It appears only when no goroutine can make progress. In a server with other work running, a single request handler blocked on a channel does not stop the whole program, because other goroutines are still running. That handler hangs that request instead, and it looks like a slow request, not a crash. Tests with timeouts, and request timeouts, are what catch that partial case, because the runtime report cannot, by its own rule.
Deadlock is also distinct from a goroutine leak, although both involve a blocked goroutine. If a goroutine stays blocked in a loop that reads a channel that never closes, while the rest of the program keeps running, that is a leak, covered in Why Do My Goroutines Leak When I Range Over A Channel?. This article is about the case where the program itself stops, because no goroutine at all can move.
Deadlock Shape 1: Send With No Receiver
The classic shape is a send in the same goroutine that was meant to receive later:
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
The send blocks at the second line, because no receiver is ready. The receive on the third line is never reached, because the goroutine that would run it is the same goroutine that is blocked on the send. The program has one goroutine, it is blocked, and no progress is possible.
The fix is to put the send and the receive in different goroutines, so they can meet:
ch := make(chan int)
go func() {
ch <- 1
}()
fmt.Println(<-ch)
Now the sender runs concurrently, the receiver is waiting in main, and the rendezvous completes. The change is not the channel, and it is not the value. The change is that a partner exists at the same time as the send.
Deadlock Shape 2: Receive With No Sender
The mirror shape is a receive with no sender:
ch := make(chan int)
value := <-ch
fmt.Println(value)
The receive blocks, because no value is available and no goroutine will ever send one. A channel with no sender is a wait with no possible end, unless the channel is closed. Closing is the sender side signal that no more values will arrive, and after a closed channel is drained, receives return the zero value without blocking, with the two value form setting ok to false. A receive on a channel that is never closed, and never sent on, has neither a value nor a close to wait for.
The fix is one of three, chosen by what the code actually needs: start a sender goroutine, close the channel from its sender when the stream is finished, or give the receive a second case that can proceed, such as a timeout or a cancellation case, as shown in the complete example.
Deadlock Shape 3: Select With No Ready Case
A select without a default case waits until a case can proceed, and that wait has no limit:
a := make(chan int)
b := make(chan int)
select {
case value := <-a:
fmt.Println(value)
case value := <-b:
fmt.Println(value)
}
If no goroutine ever sends on a or b, the select waits forever. Adding more cases does not help, because every case is a wait, and a select waits for at least one of them. The number of cases changes which wait might end first, not whether a wait can end at all.
The fix is a case that is guaranteed to become ready, or a default case that runs at once. A timeout case, from time.After or from a context deadline, becomes ready when its time passes. A cancellation case, from a context Done channel, becomes ready when the context is cancelled. A default case runs at once when no other case is ready, which turns a blocking wait into a non-blocking check, at the cost that the caller must decide what to do when nothing is ready, and must loop if it wants to poll.
Deadlock Shape 4: Select On A Nil Channel
A nil channel is a channel variable with no channel behind it:
var ch chan int
select {
case value := <-ch:
fmt.Println(value)
}
The specification states that communication on nil channels can never proceed, so this select blocks forever. The variable looks like a channel, it has the right type, and it compiles, but there is no conduit for a value to travel on, and no sender or receiver can ever be paired with it.
A nil channel in a select is also a useful tool, when it is deliberate. Setting a channel variable to nil after its stream closes disables that case, because a nil case can never be chosen, while other cases in the same select keep working. The difference between a tool and a deadlock is intent and a second case: a disabled case inside a select that has other live cases is a pattern, while a select whose only cases are nil is a wait with no possible end.
Common ways a nil channel appears by accident are a channel variable that was declared but never created with make, a function that returns a nil channel on an error path while the caller still selects on it, and a channel field that was never initialised in a struct. In each case, the fix is to create the channel before selecting on it, or to add a timeout, cancellation, or default case so the select does not depend on a case that can never proceed.
Deadlock Shape 5: Waiting Before Receiving
A WaitGroup deadlock is a channel deadlock with the wait in the middle:
var wg sync.WaitGroup
results := make(chan int)
wg.Add(1)
go func() {
defer wg.Done()
results <- 42
}()
wg.Wait()
fmt.Println(<-results)
The worker sends on an unbuffered channel, and blocks, because main is in wg.Wait, not receiving. main waits for Done, which the worker cannot call, because it is blocked on the send. Each side waits for the other, and neither can move first.
The fix is to receive before waiting, so the worker can complete its send and call Done:
var wg sync.WaitGroup
results := make(chan int)
wg.Add(1)
go func() {
defer wg.Done()
results <- 42
}()
fmt.Println(<-results)
wg.Wait()
The receive unblocks the send, the worker calls Done, and the wait returns. The same ordering rule applies to a closing goroutine in a fan in pattern: close the results channel after every worker has finished, in a separate goroutine, and drain results in the caller, rather than waiting for workers before draining, while workers are blocked trying to send.
Deadlock Shape 6: Two Goroutines Waiting On Each Other
Two goroutines can deadlock without any single missing partner, by waiting on each other in opposite order:
a := make(chan int)
b := make(chan int)
go func() {
value := <-a
b <- value
}()
value := <-b
a <- value
The worker waits to receive on a before it sends on b. main waits to receive on b before it sends on a. Each operation is waiting for the other operation to run first, and neither runs first. The program has two goroutines, both blocked, and no progress is possible.
The fix is to agree on one order, and to keep it in every goroutine. If the worker receives on a and then sends on b, the caller must send on a before it receives on b, so the first rendezvous completes and the second can follow. When two channels are involved, drawing the order on paper, sender first on one side and receiver first on the other, catches this shape before running the code, because the deadlock is in the order, not in either channel on its own.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Partner goroutine for every unbuffered operation | A send or receive must complete, and a partner can run concurrently | A goroutine per partner, and the partner must be started before the operation is waited on |
| Buffered channel | A known burst must be absorbed without a receiver present at once | Capacity must be chosen for a real burst, and a full buffer blocks exactly like an unbuffered channel |
| Select with a default case | A non-blocking check is correct, and dropping or deferring the work is acceptable | The default case runs at once, so polling needs a loop, and a dropped value must be counted or handled |
| Select with a timeout case | A wait must end after a known bound, even if no partner arrives | A timeout is an error path, not a value, and the caller must decide whether to retry or fail |
| Select with a context Done case | A wait must end on cancellation or shutdown, not only on a clock | Every wait in the chain needs the context, or cancellation stops only part of the chain |
| Close the channel from its sender | A stream is finished, and receivers must learn that no more values will arrive | Only the sender may close, exactly once, and a send on a closed channel panics |
| Receive before WaitGroup Wait | Workers send results on unbuffered channels | The caller must drain every result, or a worker blocked on a send keeps the wait from returning |
| Nil channel to disable a case | A merged stream has finished one input, and the other input must keep running | The select must have another live case, or disabling the last case leaves a select that blocks forever |
| Single agreed order for two channels | Two goroutines exchange values on two channels | The order must be the same in every code path, or opposite orders deadlock |
A design in this article has a deliberate compromise, stated openly. The complete example uses one worker, not a worker pool, so the rendezvous, timeout, and default rules can be read without the extra WaitGroup closing pattern. In a real worker pool, the same rules apply, with one extra ordering rule: drain results before waiting for workers, or close results from a separate goroutine after every worker finishes. The single worker here is a teaching compromise, not a production layout. The rendezvous rule itself is not compromised: an unbuffered send still needs a receiver, and a select still needs a ready case or a default case.
Complete Example
The complete example is a small worker with a timeout receive. A sender goroutine sends jobs on an unbuffered channel, a worker doubles each job and sends results on an unbuffered channel, and main receives results until the worker closes its output. A separate timeout receive function shows the select fix for a wait that may have no partner. The deadlock shape is included in the same file, clearly labelled, and it is never called by main or by any test, so it cannot deadlock the program or the test binary.
// go.mod
module deadlockdemo
go 1.20
// main.go
package main
import (
"context"
"fmt"
"time"
)
type Job struct {
ID int
Input int
}
type Result struct {
JobID int
Doubled int
}
// deadlockShape is the deadlocking shape, shown for comparison only.
// It is never called. A send in the same goroutine that would receive
// later blocks forever, because no receiver is ready, and the receive
// on the next line is never reached.
func deadlockShape() {
ch := make(chan int)
ch <- 42
fmt.Println(<-ch)
}
func startWorker(ctx context.Context, jobs <-chan Job) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for job := range jobs {
result := Result{JobID: job.ID, Doubled: job.Input * 2}
select {
case out <- result:
case <-ctx.Done():
return
}
}
}()
return out
}
func sendJobs(ctx context.Context, jobs chan<- Job, list []Job) {
defer close(jobs)
for _, job := range list {
select {
case jobs <- job:
case <-ctx.Done():
return
}
}
}
func receiveWithTimeout(ctx context.Context, ch <-chan int) (int, error) {
select {
case value := <-ch:
return value, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
jobs := make(chan Job)
results := startWorker(ctx, jobs)
list := []Job{
{ID: 1, Input: 1},
{ID: 2, Input: 2},
{ID: 3, Input: 3},
}
go sendJobs(ctx, jobs, list)
for result := range results {
fmt.Printf("job %d doubled is %d\n", result.JobID, result.Doubled)
}
}
Commands to run it, from the deadlock-demo folder:
go run .
Expected output:
job 1 doubled is 2
job 2 doubled is 4
job 3 doubled is 6
Why this program completes, in order:
sendJobsruns in a goroutine, sends each job on the unbuffered jobs channel, and closes the channel when the list is finished, so the worker range can end.startWorkerreceives jobs, sends each result on the unbuffered results channel, and closes that channel when the jobs channel closes, so themainrange can end.mainreceives results in its own goroutine context, which is the partner the worker send needs, so no send waits with no receiver.- If the context deadline passed in the middle, both
sendJobsandstartWorkerwould return onctx.Done, the worker would close its output, and themainrange would end by that path instead, rather than waiting for a partner that never arrives.
The timeout receive function follows the same rule in a smaller form. Its select has two cases: a value case, and a context case. When the context is cancelled or its deadline passes, the context case becomes ready, the function returns an error, and the caller learns that the wait ended without a value, instead of waiting forever.
Testing Channels
Test a fixed shape by completion, with a timeout, so a deadlock in the code under test becomes a test failure, not a test binary that hangs forever. No test in this file calls the deadlock shape, and no test waits on a channel with no sender and no timeout, because such a test would reproduce the deadlock it is meant to guard against.
// main_test.go
package main
import (
"context"
"testing"
"time"
)
func TestWorkerCompletes(t *testing.T) {
ctx := context.Background()
jobs := make(chan Job)
results := startWorker(ctx, jobs)
list := []Job{
{ID: 1, Input: 1},
{ID: 2, Input: 2},
{ID: 3, Input: 3},
}
go sendJobs(ctx, jobs, list)
want := []int{2, 4, 6}
var got []int
for result := range results {
got = append(got, result.Doubled)
}
if len(got) != len(want) {
t.Fatalf("got %d results, want %d", len(got), len(want))
}
for i, w := range want {
if got[i] != w {
t.Errorf("result %d = %d, want %d", i, got[i], w)
}
}
}
func TestReceiveWithTimeoutReturnsError(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ch := make(chan int)
_, err := receiveWithTimeout(ctx, ch)
if err == nil {
t.Fatal("receiveWithTimeout returned no error, want a context error")
}
}
func TestReceiveWithTimeoutReturnsValue(t *testing.T) {
ctx := context.Background()
ch := make(chan int)
go func() {
ch <- 42
}()
value, err := receiveWithTimeout(ctx, ch)
if err != nil {
t.Fatalf("receiveWithTimeout returned error %v, want nil", err)
}
if value != 42 {
t.Errorf("receiveWithTimeout returned %d, want 42", value)
}
}
func TestDefaultCaseDoesNotWait(t *testing.T) {
ch := make(chan int)
waited := true
select {
case <-ch:
waited = true
default:
waited = false
}
if waited {
t.Error("select with a default case waited on an empty channel, want an at once default")
}
}
The completion test checks the full contract: every job is doubled, in order, and the range loop ends. The timeout test checks the early exit path: with no sender and a 100 millisecond context, the receive must return an error, not wait forever. The value test checks the normal path: with a sender goroutine, the same function returns the value and no error. The default test checks the non-blocking path: with no value available, the default case runs at once.
Commands to run the tests and the static check:
go vet ./...
go test ./...
Expected output for the tests is a pass for every test, including the timeout test, because the receive function selects on ctx.Done. go vet reports suspicious constructs. It does not detect a deadlock, so do not treat a clean vet run as proof that a program completes. The completion and timeout tests are the proof for this example, together with a test timeout, which turns a hanging test into a failure.
Common Mistakes
- Sending and receiving in the same goroutine: a send on an unbuffered channel in
main, followed by a receive inmain, blocks on the send, and the receive is never reached. The fix is a partner goroutine for one side of the rendezvous. - Receiving with no sender and no close: a receive on a channel that no goroutine sends on, and that is never closed, waits forever. The fix is a sender, a close from the sender, or a timeout or cancellation case.
- Selecting with no ready case and no default case: a select that waits on channels that no goroutine sends on, or receives from, waits forever. The fix is a timeout case, a cancellation case, or a default case, chosen by whether the caller must wait, must fail, or must continue at once.
- Waiting before receiving: calling
Waitbefore draining results, while workers are blocked sending on unbuffered channels, waits forDonecalls that cannot happen. The fix is to drain results before waiting, or to close results from a separate goroutine after workers finish. - Using a nil channel by accident: a channel variable declared but never created with
make, or a function that returns a nil channel on an error path, makes every case on that channel unable to proceed. The fix is to create the channel before selecting on it, or to add a case that can proceed. - Treating a nil channel case as an error: a nil case in a select is not an error, and it is not a bug in the select statement. It is a case that can never be chosen, which is a deliberate disable only when other live cases remain.
- Assuming select order: when several cases are ready, the specification chooses one by pseudo-random selection, not by source order. Code that needs a priority must check cases in order with separate selects, or with a default case and a loop, not with one select.
- Using a default case as a wait: a default case runs at once when no other case is ready, and it does not retry. Code that needs to wait for a later value must loop, or must use a timeout or cancellation case, not a single default case.
- Using a buffer as a deadlock cure: a buffer lets a limited number of sends complete with no receiver, and the next send blocks exactly like an unbuffered send. The fix for a missing receiver is a receiver, a timeout, or a cancellation case, not a larger buffer.
- Closing a channel to create a receiver: closing a channel signals receivers that the stream is finished, and it does not create a receiver for a blocked sender. A sender still needs a receiver, or a select case that can proceed, to complete its send.
- Waiting on two channels in opposite order: two goroutines that each receive on one channel before sending on the other, in opposite order, wait on each other forever. The fix is one agreed order, kept in every code path.
- Relying on the runtime report in a server: the runtime report appears only when no goroutine can make progress. A single blocked handler in a running server hangs that request without a report, so request timeouts and tests with timeouts are the guard for that case.
Production Notes
- Timeouts: every wait that can block on a channel from outside the process, such as a network peer, a queue, or a worker pool, needs a timeout or a cancellation context from its caller. A wait with no bound is a deadlock waiting for a missing partner.
- Shutdown: a server shutdown should cancel the context first, then wait for workers, in that order. Cancelling first gives every select a ready
Donecase, so workers can return, close their outputs, and let the wait return. - Observability: a total deadlock stops the program, which is visible at once. A partial deadlock, one blocked handler in a running server, shows up as requests that never return, or as a goroutine count that grows with load and never falls. Look for a select with no ready case, a send with no receiver, or a wait before a drain.
- Resource limits: a blocked goroutine holds its stack and its referenced data. In a loop that starts a blocked operation per request, that cost grows with every request, even when the program as a whole keeps running.
- Deployment: the example needs Go 1.20 or later. Pin that version in
go.mod, rungo vet ./...andgo test ./...with a test timeout in continuous integration, and do not deploy a change that fails the completion or timeout tests. A vet pass alone is not enough for a channel program, because vet does not prove that a wait can end.
Decision Guide
- When a value must arrive, and a partner can run concurrently: use a plain send and receive with a partner goroutine, and keep the rendezvous in mind at every call site.
- When a burst must be absorbed without a receiver present at once: use a buffered channel with a capacity chosen for that burst, and still provide a receiver, because a full buffer blocks.
- When a check must not wait: use a select with a default case, and decide what happens to the value when nothing is ready, including counting a drop if the value is discarded.
- When a wait must end after a known bound: use a select with a timeout case, from
time.Afteror from a context deadline, and treat the timeout as an error path. - When a wait must end on shutdown: use a select with a context
Donecase, and pass the same context to every wait in the chain, so cancellation reaches every stage. - When a stream is finished: close the channel from its sender, exactly once, so receivers learn that no more values will arrive, and do not close from a receiver.
- When workers send results: drain results before waiting for workers, or close results from a separate goroutine after workers finish, so a blocked send cannot keep a wait from returning.
- When one input of a merged select finishes: set that channel variable to
nilto disable its case, and keep at least one live case in the select, or the select will block forever. - When two channels are exchanged between two goroutines: agree on one order, send on the first channel before receiving on the second in one goroutine, and receive on the first before sending on the second in the other, and keep that order in every code path.
Conclusion
A deadlock on select and unbuffered channels is a missing partner, stated in code. An unbuffered send needs a receiver at the same moment, a receive needs a sender or a close, and a select needs a ready case, a default case, or a case that will become ready. When none of those exists, the wait has no end, and a program in which every goroutine waits that way stops.
Summary Of Key Points
- Rendezvous is the rule: an unbuffered send completes only when a receiver takes the value, and a receive completes only when a sender sends a value or the channel closes.
- Select waits, unless it has an out: a select with no ready case and no default case blocks until a case can proceed, and a default case runs at once when nothing is ready.
- Nil never proceeds: communication on a nil channel can never proceed, so a select with only nil cases and no default case blocks forever, while a nil case among live cases is a deliberate disable.
- Order matters: waiting before receiving, and opposite orders on two channels, deadlock even when every partner exists, because each side waits for the other to move first.
- Buffer delays, it does not cure: a buffered channel absorbs a known burst, and a full buffer blocks exactly like an unbuffered channel.
- Timeout and cancellation are exits: a timeout case and a context
Donecase give a wait an end that does not depend on a partner arriving. - Partial deadlock needs tests: the runtime report appears only when no goroutine can make progress, so a single blocked handler in a running server needs a timeout test to be caught.
Fix the partner, the order, or the exit, and the same code that stopped becomes code that completes: a receiver for every send, a drain before every wait, and a timeout or cancellation case for every wait that may have no partner.
Related Posts
- Why Do My Goroutines Leak When I Range Over A Channel?
- Defer In A Go Loop
- Why Did My Closure Capture The Wrong Loop Variable?
Learn more:
Additional Resources
- Go Documentation: Effective Go - share by communicating, goroutines, unbuffered and buffered channels, and the select based leaky buffer
- Go Documentation: The Go Programming Language Specification - select execution, default case, nil channels, and the empty select
- Go Packages: builtin package - the close function, with the sender only rule and the closed channel receive rule
- Go Packages: context package - cancellation, deadlines, and the Done channel
- Go Documentation: Go Blog: Pipelines and cancellation - stages that close outbound channels, and cancellation as an exit for blocked stages
References
- Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go#concurrency. Accessed 2026-10-25.
- The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec#Select_statements. Accessed 2026-10-25.
- builtin package. Go team, Go Packages. https://pkg.go.dev/builtin#close. Accessed 2026-10-25.
- context package. Go team, Go Packages. https://pkg.go.dev/context. Accessed 2026-10-25.
- Go Concurrency Patterns: Pipelines and cancellation. Go Blog. https://go.dev/blog/pipelines. Accessed 2026-10-25.