Learn Go
intermediate1 min read

Channels

Channels are typed conduits for sending values between goroutines. Go's mantra is: "Do not communicate by sharing memory; share memory by communicating." Channels are the mechanism that makes this possible.

Creating and Using Channels

Use make(chan T) to create an unbuffered channel:

package main
 
import "fmt"
 
func sum(nums []int, ch chan int) {
    total := 0
    for _, n := range nums {
        total += n
    }
    ch <- total // send total to channel
}
 
func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    ch := make(chan int)
 
    go sum(nums[:5], ch)
    go sum(nums[5:], ch)
 
    a, b := <-ch, <-ch // receive two values
    fmt.Println(a + b)  // 55
}

An unbuffered channel synchronises sender and receiver — the send blocks until a receiver is ready, and vice versa.

Buffered Channels

A buffered channel has an internal queue. Sends only block when the buffer is full; receives only block when the buffer is empty:

package main
 
import "fmt"
 
func main() {
    ch := make(chan string, 3)
 
    ch <- "first"
    ch <- "second"
    ch <- "third"
    // ch <- "fourth" // would block — buffer full
 
    fmt.Println(<-ch) // first
    fmt.Println(<-ch) // second
    fmt.Println(<-ch) // third
}

Idiomatic Go: Prefer unbuffered channels for synchronisation and buffered channels only when you know the exact capacity needed (e.g., a semaphore).

Ranging over Channels and Closing

Use range to receive all values until the channel is closed:

package main
 
import "fmt"
 
func generate(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < n; i++ {
            ch <- i * i
        }
    }()
    return ch
}
 
func main() {
    for v := range generate(6) {
        fmt.Print(v, " ") // 0 1 4 9 16 25
    }
    fmt.Println()
}

Only the sender should close a channel. Sending to a closed channel panics.

Directional Channels

Functions can restrict channels to send-only (chan<- T) or receive-only (<-chan T):

package main
 
import "fmt"
 
func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}
 
func consumer(ch <-chan int) {
    for v := range ch {
        fmt.Println("received:", v)
    }
}
 
func main() {
    ch := make(chan int, 5)
    go producer(ch)
    consumer(ch)
}

Directional channel types make the data flow explicit and prevent accidental misuse.

select

select waits on multiple channel operations and executes the first ready case. If multiple cases are ready, one is chosen at random:

package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
 
    go func() {
        time.Sleep(1 * time.Millisecond)
        ch1 <- "one"
    }()
    go func() {
        time.Sleep(2 * time.Millisecond)
        ch2 <- "two"
    }()
 
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("received from ch1:", msg1)
        case msg2 := <-ch2:
            fmt.Println("received from ch2:", msg2)
        }
    }
}

Non-blocking Operations with default

Adding a default case makes select non-blocking:

package main
 
import "fmt"
 
func main() {
    ch := make(chan int, 1)
 
    select {
    case v := <-ch:
        fmt.Println("received:", v)
    default:
        fmt.Println("no value ready") // runs because ch is empty
    }
 
    ch <- 42
 
    select {
    case v := <-ch:
        fmt.Println("received:", v) // 42
    default:
        fmt.Println("no value ready")
    }
}

Timeout Pattern

Use time.After in a select to implement timeouts:

package main
 
import (
    "fmt"
    "time"
)
 
func slowOperation(ch chan<- string) {
    time.Sleep(200 * time.Millisecond)
    ch <- "result"
}
 
func main() {
    ch := make(chan string, 1)
    go slowOperation(ch)
 
    select {
    case result := <-ch:
        fmt.Println("got:", result)
    case <-time.After(100 * time.Millisecond):
        fmt.Println("timed out")
    }
}

Key Takeaways