Skip to content

Channels and Concurrency Patterns

Unbuffered channels synchronize sender and receiver: both must be ready at the same time. Buffered Channels allow the sender to proceed up to the buffer capacity without a receiver.

unbuf := make(chan int) // blocks until receiver ready
buf := make(chan int, 10) // sender can buffer up to 10

select allows a goroutine to wait on multiple channel operations simultaneously. It blocks until One of its cases can proceed, then executes that case:

ch1 := make(chan string)
ch2 := make(chan string)
go func() { ch1 <- "from ch1" }()
go func() { ch2 <- "from ch2" }()
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}

A default case makes select non-blocking:

select {
case msg := <-ch:
fmt.Println("received:", msg)
default:
fmt.Println("no message available")
}

Use time.After to implement timeouts:

select {
case result := <-ch:
fmt.Println("result:", result)
case <-time.After(3 * time.Second):
fmt.Println("timed out")
}

select in a for loop handles multiple events over time:

for {
select {
case msg := <-ch:
fmt.Println("received:", msg)
case <-done:
fmt.Println("done")
return
case <-time.After(1 * time.Second):
fmt.Println("waiting...")
}
}

Sending to or receiving from a nil channel blocks forever. This is useful in select to Temporarily disable a case:

var ch1 chan int // nil
var ch2 = make(chan int, 1)
ch2 <- 42
select {
case <-ch1:
fmt.Println("ch1") // never selected (nil channel blocks)
case v := <-ch2:
fmt.Println("ch2:", v) // selected
}

Distribute work across multiple goroutines (fan-out), then collect results (fan-in):

func fanOut(in <-chan int, workers int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for v := range in {
out <- process(v)
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func fanIn(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range chs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}

A pipeline is a series of stages connected by channels, where each stage receives values from Upstream and sends values downstream:

func generator(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
for n := range square(square(generator(1, 2, 3, 4))) {
fmt.Println(n) // 1, 16, 81, 256
}
}

Each stage runs in its own goroutine. Stages are connected only by channels. Cancellation requires Propagating a done signal through the pipeline.

A fixed number of workers process jobs from a shared channel:

func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
for r := 1; r <= 9; r++ {
fmt.Println(<-results)
}
}

Limit the number of concurrent goroutines processing a collection:

func processItems(items []Item, maxConcurrency int) {
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire semaphore
go func(it Item) {
defer wg.Done()
defer func() { <-sem }() // release semaphore
process(it)
}(item)
}
wg.Wait()
}

The context package provides cancellation, deadlines, and request-scoped values across goroutine Boundaries. It is the standard mechanism for propagating cancellation signals in Go.

TypePurpose
context.BackgroundRoot context, never cancelled
context.TODOPlaceholder when context is not yet available
context.WithCancelReturns a cancelable context and cancel function
context.WithTimeoutReturns a context that cancels after a deadline
context.WithDeadlineReturns a context that cancels at a specific time
context.WithValueReturns a context carrying a key-value pair
func longRunning(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
return
default:
// do work
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go longRunning(ctx)
time.Sleep(2 * time.Second)
cancel() // sends cancellation signal
time.Sleep(100 * time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := slowOperation(ctx)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("operation timed out")
}
return
}
type key string
const requestIDKey key = "requestID"
ctx := context.WithValue(context.Background(), requestIDKey, "abc-123")
func handler(ctx context.Context) {
id := ctx.Value(requestIDKey).(string)
fmt.Println("request:", id)
}

Use context.WithValue sparingly and only for request-scoped data that flows through the call Chain. Do not use it as a global variable substitute.

Contexts form a tree. Cancelling a parent cancels all its children:

parent, cancel := context.WithCancel(context.Background())
child1, _ := context.WithCancel(parent)
child2, _ := context.WithCancel(parent)
cancel() // cancels parent, child1, and child2

This is how HTTP request cancellation works: the server creates a context per request, and when the Client disconnects, the context is cancelled, propagating to all goroutines handling that request.

  1. Forgetting to call the cancel function. WithCancel``WithTimeoutAnd WithDeadline return a cancel function that must be called to release resources. Use defer cancel().

  2. Not propagating context through function calls. If a function starts goroutines, accept a context.Context parameter so callers can cancel the work.

  3. Using context.WithValue for business logic data. Values in context are untyped. Prefer function parameters for structured data. Reserve context values for cross-cutting concerns (request IDs, tracing, auth tokens).

  4. Buffered channel deadlocks. If all goroutines are blocked sending to a channel and no goroutine is receiving (or vice versa), the program deadlocks. Ensure there is always at least one receiver for each sender.

  5. Leaking goroutines. A goroutine that blocks forever on a channel read with no sender (or vice versa) is a goroutine leak. Use context cancellation to ensure goroutines can exit.

  6. Closing a channel from the receiver. Only the sender should close a channel. If the receiver closes it, the sender may panic when trying to send.

  7. Race conditions with select. If multiple cases are ready, select chooses uniformly at random. Do not assume a preferred order.

flowchart TD
    A[Channels] --> B[Key Concepts]
    A --> C[Core Principles]
    A --> D[Practical Applications]
    B --> E[Fundamental definitions]
    C --> F[Design patterns]
    D --> G[Real-world usage]

This topic covers the core concepts of channels and concurrency patterns, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • core concepts and terminology
  • algorithms and computational thinking
  • practical implementation
  • security and ethical considerations
  • applications in the real world

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

Channels are typed message pipes that connect goroutines. An unbuffered channel synchronizes sender and receiver — both must be ready at the same time, which is how Go achieves coordination without locks. Buffered channels add a queue so the sender can proceed without waiting for the receiver up to the buffer capacity. Select lets a goroutine wait on multiple channel operations, choosing whichever is ready first, and a default case makes it non-blocking. Nil channels block forever, which is useful in select to temporarily disable a case. Context provides cancellation propagation — when a parent context is cancelled, all child contexts receive the signal, enabling clean shutdown of goroutine trees.