V
Vel·ToolKit
Simple · Fast · Ready to use
EN
Chapter 15 of 20

Channels

Unbuffered/buffered, select, closing, worker pool

Channel Basics

A channel is a typed synchronous queue for safely passing values between goroutines. An unbuffered channel is a "rendezvous point": both sender and receiver must arrive before either can proceed.

package main

import "fmt"

func main() {
    ch := make(chan int)
    go func() { ch <- 42 }()
    v := <-ch
    fmt.Println(v) // 42
}

Buffered Channels

make(chan T, n) creates a buffered channel of capacity n. A send doesn't block until the buffer is full; a receive doesn't block while the buffer is non-empty. A buffer is not for "speeding things up" but for smoothing spikes or decoupling producer/consumer pacing.

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
    // ch <- 4 // buffer full; the next send would block
    fmt.Println(len(ch), cap(ch)) // 3 3
}

Closing and range

close(ch) means "no more values will come". The receiver can use the second return value ok to distinguish "got a value" from "channel closed and empty"; range keeps reading until the channel is closed and drained.

package main

import "fmt"

func main() {
    ch := make(chan int)
    go func() {
        for i := 0; i < 3; i++ {
            ch <- i
        }
        close(ch)
    }()
    for v := range ch {
        fmt.Println(v)
    }

    // explicit check
    v, ok := <-ch // closed and empty, ok=false
    fmt.Println(v, ok)
}

Directional Channels

Restricting a channel to send-only or receive-only in a function signature clarifies responsibility and prevents misuse at compile time. A bidirectional channel converts implicitly to a directional one, but not the other way around.

package main

import "fmt"

func producer(out chan<- int) {
    for i := 0; i < 3; i++ {
        out <- i
    }
    close(out)
}

func consumer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

select Multiplexing

select randomly picks one ready case to run; if none is ready it blocks, or runs default immediately if present. It's the core tool for timeouts, cancellation, and merging multiple sources.

package main

import (
    "context"
    "fmt"
    "time"
)

func race(ctx context.Context) error {
    ch1 := make(chan int, 1)
    ch2 := make(chan int, 1)
    go func() { ch1 <- 1 }()

    select {
    case v := <-ch1:
        fmt.Println("from ch1:", v)
    case ch2 <- 100:
        fmt.Println("sent to ch2")
    case <-time.After(time.Second):
        fmt.Println("timeout")
    case <-ctx.Done():
        return ctx.Err()
    }
    return nil
}

func main() {
    _ = race(context.Background())
}

The Clever Use of a nil Channel

Both sending to and receiving from a nil channel block forever, so inside a select you can set a case's channel to nil to "temporarily disable that branch". Often used to stop a case from firing repeatedly after a shutdown.

package main

import (
    "context"
    "fmt"
    "time"
)

func run(ctx context.Context, resume <-chan struct{}) {
    ticker := time.NewTicker(200 * time.Millisecond)
    defer ticker.Stop()
    tickC := ticker.C // set to nil when you want to pause
    paused := false
    for {
        select {
        case <-tickC:
            fmt.Println("tick")
            if !paused {
                paused = true
                tickC = nil // mute this branch
            }
        case <-resume:
            paused = false
            tickC = ticker.C
        case <-ctx.Done():
            return
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()
    run(ctx, make(chan struct{}))
}

worker pool (fan-out / fan-in)

Multiple goroutines consume the same jobs channel (fan-out) while results are aggregated into one results channel (fan-in) — Go's most classic concurrency model. Here is a complete runnable version:

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        time.Sleep(50 * time.Millisecond)
        results <- j * j
    }
}

func main() {
    jobs := make(chan int, 5)
    results := make(chan int, 5)

    var wg sync.WaitGroup
    for w := 1; w <= 3; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(id, jobs, results)
        }(w)
    }

    // dispatch jobs
    for j := 1; j <= 5; j++ { jobs <- j }
    close(jobs)

    // close results only after all workers exit
    go func() { wg.Wait(); close(results) }()

    for r := range results { fmt.Println(r) }
}

done Channel vs context

Early Go used done := make(chan struct{}) + close(done) to broadcast cancellation; new code prefers context.Context — it unifies cancellation, timeout, and value passing in one standard interface and propagates automatically across RPC/HTTP.

package main

import (
    "fmt"
    "time"
)

func main() {
    done := make(chan struct{})
    for i := 0; i < 3; i++ {
        i := i
        go func() {
            <-done
            fmt.Println("bye", i)
        }()
    }
    close(done) // one close; all waiters are notified at once
    time.Sleep(100 * time.Millisecond)
}

Common Pitfalls

  • Sending/receiving on an uninitialized channel (var ch chan int): blocks forever
  • Multiple goroutines write the same channel and all want to close it: wrap close with sync.Once
  • For an unbounded queue: use a channel + slice buffer, or a slice + Mutex; don't grow the channel buffer without bound
  • When several select cases are ready at once the choice is random; don't rely on order