Channels

A channel is a pipe that connects goroutines: one goroutine sends a value in, another receives it out. In this lesson you’ll send and receive, choose between unbuffered and buffered channels, close a channel and range over it, read Go’s deadlock error, and build a small pipeline and a worker pool.

You should know how to start a goroutine and wait for it with sync.WaitGroup from the Goroutines lesson.

Sending and receiving

You create a channel with make, and its type says what kind of value travels through it: chan string carries strings. The arrow operator does the rest. ch <- v sends v into the channel, and <-ch receives a value from it.

package main

import "fmt"

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

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

	msg := <-ch
	fmt.Println(msg)
}Code language: Go (go)
hello from a goroutineCode language: plaintext (plaintext)

Notice there’s no WaitGroup here, and the message still always arrives. That’s because a receive blocks: msg := <-ch waits until some goroutine sends a value. The channel both moves the data and synchronizes the two goroutines.

Unbuffered channels: a handoff

A channel made with make(chan T) is unbuffered. It has no room to store anything, so a value can only pass from sender to receiver directly, like handing someone a package in person. The sender waits until a receiver is there to take it, and the receiver waits until a sender shows up. Whoever arrives first waits for the other.

That waiting is a feature. When an unbuffered send completes, the sender knows the receiver has the value. You get a guarantee about timing for free.

Sender, channel, receiver An unbuffered channel has no storage, so a send waits until a receiver takes the value: a direct handoff. A buffered channel with capacity 3 holds values in order; the sender only waits when all slots are full, and the receiver gets the oldest value first. sender ch <- “hi” unbuffered make(chan string) receiver v := <-ch no storage: each send waits for a receive sender ch <- 3 buffered, cap 3 2 1 receiver <-ch gets 1 a send waits only when all slots are full
Unbuffered: sender and receiver meet. Buffered: values queue up in order, oldest out first.

Deadlock: when nobody is on the other side

Because an unbuffered send waits for a receiver, this innocent-looking program can never finish:

package main

import "fmt"

func main() {
	ch := make(chan string)
	ch <- "hello"
	fmt.Println(<-ch)
}Code language: Go (go)
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	…/main.go:7 +0x38
exit status 2Code language: plaintext (plaintext)

The send on line 7 waits for a receiver. The only code that would receive is the next line, in the same goroutine, which never gets to run. The Go runtime notices that every goroutine is blocked and nothing can ever wake them up, so it stops the program instead of hanging forever. The report tells you which goroutine was stuck, on what (chan send), and where (line 7; I shortened the path).

The fix is to put the sender and receiver in different goroutines, as in the first example, or to give the channel a buffer.

The runtime only reports a deadlock when all goroutines are stuck. If one goroutine is blocked forever but others keep running (a server, for example), there’s no error: that goroutine just leaks quietly. Always know who is on the other side of a channel.

Buffered channels

Give make a second argument and the channel gets a buffer of that size. Sends go into the buffer without waiting, as long as there’s room. Receives take the oldest value first. len tells you how many values are waiting and cap how many fit.

package main

import "fmt"

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

	ch <- "a"
	ch <- "b"
	ch <- "c"
	fmt.Println("waiting:", len(ch), "capacity:", cap(ch))

	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}Code language: Go (go)
waiting: 3 capacity: 3
a
b
cCode language: plaintext (plaintext)

All three sends happen in main without any other goroutine, because the buffer has room. A fourth send would block until someone receives, and since nobody else exists here, you’d get the deadlock error again.

When should you buffer? I start with unbuffered channels, because the handoff makes the program easier to reason about. A buffer helps when you know how many values will be sent (so senders never have to wait) or when you want to smooth out bursts between a fast producer and a slower consumer. A buffer is not a fix for a deadlock: it only delays it until the buffer is full.

Closing a channel and ranging over it

A receiver often doesn’t know how many values are coming. The sender tells it “that’s all” by calling close(ch). A for range loop over a channel receives values until the channel is closed and empty, then ends.

package main

import "fmt"

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

	go func() {
		for i := range 3 {
			ch <- i * 10
		}
		close(ch)
	}()

	for v := range ch {
		fmt.Println("got", v)
	}
	fmt.Println("channel closed")
}Code language: Go (go)
got 0
got 10
got 20
channel closedCode language: plaintext (plaintext)

Without the close, the loop would wait for a fourth value forever, and you’d get a deadlock. A few rules about closing:

  • The sender closes, never the receiver. Only the sender knows when there’s nothing more to send.
  • Sending on a closed channel panics with panic: send on closed channel. So does closing a channel twice.
  • You don’t have to close every channel. Close it when the receiver needs to know that no more values are coming, for example because it uses range. An unclosed channel that nobody uses anymore is cleaned up by the garbage collector like any other value.

Receiving with comma-ok

Receiving from a closed channel doesn’t block. Once the buffered values are used up, it returns the zero value of the element type immediately. To tell a real zero apart from “closed”, use the two-value form, just like the comma-ok lookup on a map:

package main

import "fmt"

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

	v, ok := <-ch
	fmt.Println(v, ok)

	v, ok = <-ch
	fmt.Println(v, ok)
}Code language: Go (go)
42 true
0 falseCode language: plaintext (plaintext)

The value 42 was still in the buffer, so the first receive gets it with ok == true, even though the channel was already closed. The second receive finds the channel closed and empty: it gets 0 and ok == false. A for range loop does this check for you.

Directional channel types

When you pass a channel to a function, you can restrict what the function may do with it. chan<- int is send-only, <-chan int is receive-only. (Read the arrow as the direction data flows relative to chan.) A normal chan int converts to either one automatically.

package main

import "fmt"

func produce(out chan<- int) {
	for i := range 3 {
		out <- i
	}
	close(out)
}

func consume(in <-chan int) {
	for v := range in {
		fmt.Println("consumed", v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}Code language: Go (go)
consumed 0
consumed 1
consumed 2Code language: plaintext (plaintext)

The function signatures now document who sends and who receives, and the compiler enforces it. Try to receive in produce:

package main

import "fmt"

func produce(out chan<- int) {
	out <- 1
	fmt.Println(<-out)
}

func main() {
	ch := make(chan int, 1)
	produce(ch)
}Code language: Go (go)
./main.go:7:16: invalid operation: cannot receive from send-only channel chan<- int out (variable of type chan<- int)Code language: plaintext (plaintext)

Closing works the same way: you can close a send-only channel, but the compiler won’t let you close a receive-only one, which neatly enforces “the sender closes”.

A pipeline

Channels shine when you connect stages. Each stage is a function that starts a goroutine, reads from an input channel, writes to an output channel, and closes the output when its input runs dry. Here generate emits numbers, square squares them, and main prints the results.

package main

import "fmt"

func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func main() {
	for n := range square(generate(1, 2, 3, 4)) {
		fmt.Println(n)
	}
}Code language: Go (go)
1
4
9
16Code language: plaintext (plaintext)

All three stages run at the same time: while main prints 1, square can already be working on 2. Because each stage closes its output with defer close(out), the end of the input ripples through the pipeline and the final range loop ends cleanly. Returning a receive-only channel (<-chan int) makes it clear the caller should only read from it.

A worker pool

The other classic pattern is a fixed number of workers pulling jobs from one channel and sending results to another. It’s how you limit concurrency, for example to at most three downloads at once.

package main

import (
	"fmt"
	"slices"
	"sync"
)

func main() {
	words := []string{"apple", "banana", "cherry", "date", "elderberry", "fig"}

	jobs := make(chan string)
	results := make(chan string)

	// Start three workers.
	var wg sync.WaitGroup
	for range 3 {
		wg.Go(func() {
			for w := range jobs {
				results <- fmt.Sprintf("%s has %d letters", w, len(w))
			}
		})
	}

	// Send the jobs, then close jobs so the workers' loops end.
	go func() {
		for _, w := range words {
			jobs <- w
		}
		close(jobs)
	}()

	// Close results once every worker has returned.
	go func() {
		wg.Wait()
		close(results)
	}()

	var lines []string
	for r := range results {
		lines = append(lines, r)
	}
	slices.Sort(lines)
	for _, line := range lines {
		fmt.Println(line)
	}
}Code language: Go (go)
apple has 5 letters
banana has 6 letters
cherry has 6 letters
date has 4 letters
elderberry has 10 letters
fig has 3 lettersCode language: plaintext (plaintext)

There are a few moving parts, so here’s who does what:

  1. Three workers each loop over jobs. Whichever worker is free takes the next word.
  2. A sender goroutine feeds in the words and closes jobs, which ends the workers’ range loops.
  3. A third goroutine waits for all workers and then closes results. Only then is it safe to close: if it were closed earlier, a worker still sending would panic.
  4. main collects results until results is closed.

The results arrive in whatever order the workers finish, so I collect them and sort with slices.Sort to get stable output. If order matters in your program, either sort like this or send an index along with each result.

Practice

1. Add a filter stage. Starting from the pipeline above, write a stage evens(in <-chan int) <-chan int that passes on only even numbers. Wire it up as square(evens(generate(1, 2, 3, 4, 5, 6))) and print the results.

Show a solution
package main

import "fmt"

func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func evens(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			if n%2 == 0 {
				out <- n
			}
		}
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func main() {
	for n := range square(evens(generate(1, 2, 3, 4, 5, 6))) {
		fmt.Println(n)
	}
}Code language: Go (go)
4
16
36Code language: plaintext (plaintext)

2. Sum with workers. Send the numbers 1 to 100 into a jobs channel. Start four workers that each read numbers from jobs, add them up into their own local total, and send that total on a results channel when jobs is closed. In main, receive exactly four totals and print their sum (5050). Hint: a buffered results channel with capacity 4 means workers never wait to send.

Show a solution
package main

import "fmt"

func main() {
	const workers = 4
	jobs := make(chan int)
	results := make(chan int, workers)

	for range workers {
		go func() {
			total := 0
			for n := range jobs {
				total += n
			}
			results <- total
		}()
	}

	for n := range 100 {
		jobs <- n + 1
	}
	close(jobs)

	sum := 0
	for range workers {
		sum += <-results
	}
	fmt.Println("sum:", sum)
}Code language: Go (go)
sum: 5050Code language: plaintext (plaintext)

Receiving exactly four values from results is what waits for the workers here, so no WaitGroup is needed.

Key takeaways

  • ch <- v sends, <-ch receives. Both can block, and that blocking is what synchronizes goroutines.
  • An unbuffered channel is a handoff: the send completes only when a receiver takes the value. A buffered channel lets sends proceed until it’s full.
  • “all goroutines are asleep – deadlock!” means every goroutine is waiting on something that can never happen.
  • The sender closes a channel to say “no more values”. for range and v, ok := <-ch detect it.
  • Use chan<- T and <-chan T in function signatures so the compiler checks who sends and who receives.