43. Channels

📖 Reading · 12 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

A channel is a typed pipe between goroutines. One sends, another receives, and the channel handles the synchronisation. Go's slogan for this:

Don't communicate by sharing memory; share memory by communicating.

Read that as a preference, not a prohibition. Shared state behind a sync.Mutex is the right answer often enough that it gets its own lesson later in this module. The split in practice: channels move data between goroutines; a mutex guards data that several goroutines touch. Pick whichever makes the ownership easier to see.

Sending and receiving

package main

import "fmt"

func main() {
    ch := make(chan string)

    go func() {
        ch <- "hello from the goroutine"
    }()

    msg := <-ch
    fmt.Println(msg)
}

The arrow always points in the direction the data moves:

  • ch <- vsend v into ch
  • v := <-chreceive from ch into v

The channel is typed: a chan string carries strings and nothing else.

Unbuffered channels synchronise

That example has no WaitGroup, no sleep, and no race. Here's why:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        fmt.Println("goroutine: sleeping before sending")
        time.Sleep(20 * time.Millisecond)
        fmt.Println("goroutine: sending 42")
        ch <- 42
        fmt.Println("goroutine: send completed")
    }()

    fmt.Println("main: waiting to receive")
    v := <-ch
    fmt.Println("main: received", v)
}

An unbuffered channel is a rendezvous. The sender blocks until a receiver is ready; the receiver blocks until a sender arrives. They meet, the value moves, and both continue.

That blocking is a feature, not a cost. It's how you get ordering guarantees without locks: after <-ch returns, you know the sending goroutine reached its send.

Channels as results

The natural way to get a value back out of a goroutine:

package main

import "fmt"

func sum(nums []int, out chan<- int) {
    total := 0
    for _, n := range nums {
        total += n
    }
    out <- total
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8}

    left := make(chan int)
    right := make(chan int)

    go sum(nums[:4], left)
    go sum(nums[4:], right)

    a, b := <-left, <-right
    fmt.Println("halves:", a, b)
    fmt.Println("total:", a+b)
}

Note the parameter type chan<- int — a send-only channel. The compiler now rejects any attempt to receive from out inside sum. The mirror is <-chan int for receive-only.

Directions cost nothing and document intent precisely: a function signature tells you whether it produces or consumes. Use them.

Closing a channel

A sender closes a channel to say "no more values are coming":

package main

import "fmt"

func main() {
    ch := make(chan int)

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i * 10
        }
        close(ch)
    }()

    for v := range ch {
        fmt.Println("received", v)
    }
    fmt.Println("channel closed, loop ended")
}

for v := range ch receives until the channel is closed, then ends. Without the close, that loop would block forever waiting for a sixth value — a deadlock, and Go's runtime would tell you so.

Three rules about closing:

  1. Only the sender closes. A receiver can't know whether more values are coming.
  2. Sending on a closed channel panics. Receiving from one is fine.
  3. Closing twice panics. So does closing a nil channel.

Most channels are never closed at all — closing matters when receivers use range or need to know the stream ended.

Detecting a closed channel

Receiving from a closed channel returns the zero value immediately. The comma-ok form tells you whether that zero was real data:

package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 1
    ch <- 0
    close(ch)

    for i := 0; i < 4; i++ {
        v, ok := <-ch
        fmt.Printf("value=%d open=%t\n", v, ok)
    }
}

The third line is the interesting one: value=0 open=false means "the channel is drained and closed". The second line — value=0 open=true — was a real zero somebody sent. Same value, different meanings, and comma-ok is what separates them. (The same shape as map lookups and type assertions, for the third time.)

Deadlocks

Go detects the case where every goroutine is blocked and crashes with a clear message rather than hanging:

func main() {
    ch := make(chan int)
    ch <- 1          // nobody is receiving
    fmt.Println(<-ch)
}
// fatal error: all goroutines are asleep - deadlock!

That's an unbuffered send with no receiver: main blocks, and there's no other goroutine to rescue it. The common causes:

  • sending with no receiver (or receiving with no sender)
  • forgetting to close a channel that something is ranging over
  • a WaitGroup whose Done is never reached
  • two goroutines each waiting for the other

The runtime only catches the case where all goroutines are stuck. A single leaked goroutine blocked forever is invisible — which is why the context lesson matters.

Channels of structs

Channels carry any type, and a struct is the usual choice for real work:

package main

import (
    "fmt"
    "sort"
)

type Result struct {
    ID    int
    Value int
    Err   error
}

func process(id int, out chan<- Result) {
    if id == 3 {
        out <- Result{ID: id, Err: fmt.Errorf("item %d is cursed", id)}
        return
    }
    out <- Result{ID: id, Value: id * 100}
}

func main() {
    out := make(chan Result, 5)

    for i := 1; i <= 5; i++ {
        go process(i, out)
    }

    var results []Result
    for i := 0; i < 5; i++ {
        results = append(results, <-out)
    }

    sort.Slice(results, func(i, j int) bool { return results[i].ID < results[j].ID })
    for _, r := range results {
        if r.Err != nil {
            fmt.Printf("%d: error: %v\n", r.ID, r.Err)
            continue
        }
        fmt.Printf("%d: %d\n", r.ID, r.Value)
    }
}

Carrying the error in the result is the standard way to report failure from a goroutine — a goroutine can't return anything to its starter, so the error travels with the value.

And note the sort: results arrive in whatever order the goroutines finish. If you need order, either sort at the end (as here) or use the one-slot-per-worker technique from the last lesson.

Your turn

Have a goroutine send the numbers 1 to 5 into a channel and close it; sum them in main with a range loop:

15
package main

import "fmt"

func main() {
    ch := make(chan int)

    // start a goroutine that sends 1..5 and closes ch

    sum := 0
    for v := range ch {
        sum += v
    }
    fmt.Println(sum)
}
package main

import "fmt"

func main() {
    ch := make(chan int)

    go func() {
        for i := 1; i <= 5; i++ {
            ch <- i
        }
        close(ch)
    }()

    sum := 0
    for v := range ch {
        sum += v
    }
    fmt.Println(sum)
}

Next: giving a channel a queue.