New: Try Voli The Bear, Fast package manager (and not only) for Windows
Updated Jun 19, 2026 Edit on GitHub

Goroutines & Channels - Go's Concurrency

Concurrency is where a lot of languages make you feel stupid - threads, locks, mutexes, a race condition you can't reproduce. Go's pitch: two small ideas, designed to fit together, that make this approachable.

A goroutine is a task running alongside your other tasks, cheap enough to start thousands of. A channel is a pipe between tasks: one goroutine puts a value in, another takes it out. The famous Go mantra falls out of those two facts:

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

Instead of two tasks poking at the same variable behind a lock (and racing each other), you hand the value down a channel from one to the other. Whoever holds it owns it. No lock, no race.

Goroutines: a concurrent task, started with one word

A goroutine is a function that runs concurrently with the rest of your program. Put the word go in front of a function call to start one - the call returns immediately while the function runs on its own.

📝 Terminology. A goroutine is not an operating-system thread. It's a lightweight task the Go runtime schedules onto a small pool of real threads. It starts with a tiny stack (a couple of kilobytes) that grows as needed, which is why running thousands is normal and cheap - something you can't do with OS threads. (For what a thread actually is underneath, see What Happens When Code Runs.)

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("hello from the goroutine")
	fmt.Println("hello from main")
	time.Sleep(10 * time.Millisecond) // give the goroutine a moment to run
}
$ go run main.go
hello from main
hello from the goroutine

go fmt.Println(...) launched that print as a separate task and returned instantly, so main printed its own line first, then the goroutine got a turn. The time.Sleep is a crude hack: without it, main would reach the end and exit before the goroutine ever ran (main ending kills every goroutine with it). That sleep is a placeholder - channels and WaitGroup, below, are the real way to wait.

⚠️ Gotcha. When main returns, the program exits immediately and takes all goroutines with it, finished or not. A goroutine isn't a promise the work will complete - it's a request to run alongside, and you need an explicit way to wait for it.

Channels: a typed pipe between goroutines

A channel is a pipe you send values into and receive values out of - every channel carries one specific type. A chan int carries ints; a chan string carries strings. Make one with make, send with ch <- v, receive with v := <-ch.

Why this is the heart of it. A channel does two jobs at once: it moves a value from one goroutine to another, and it synchronizes them. On a plain (unbuffered) channel, a send blocks until someone is ready to receive, and a receive blocks until someone is ready to send - a handshake. When the value comes out the other end, you know the sender reached that point. Coordination for free, no lock needed.

Picture the handshake - one goroutine produces a number, sends it down the channel, and main receives it:

The proper version of "wait for the goroutine," using a channel instead of time.Sleep:

package main

import "fmt"

func work(ch chan int) {
	ch <- 42 // send the result into the channel
}

func main() {
	ch := make(chan int) // an unbuffered channel of ints
	go work(ch)
	v := <-ch // blocks here until work() sends
	fmt.Println("got", v)
}
$ go run main.go
got 42

main started work as a goroutine, then sat on <-ch, blocked, waiting. work computed its result and sent 42 into the channel; main woke, received the value, and printed it. No Sleep, no race - the channel made main wait for exactly the right moment. Value and timing, coordinated in one line each.

📝 Terminology. An unbuffered channel (make(chan int)) holds nothing - a send waits for a receiver, hand-to-hand. A buffered channel (make(chan int, 3)) has room for a few values, so a send only blocks when the buffer is full. Start with unbuffered; reach for a buffer only when you have a reason.

⚠️ Gotcha - deadlock on an unbuffered channel. Because an unbuffered send blocks until someone receives, sending on a channel that nobody is listening to freezes forever. Go's runtime can often detect when every goroutine is stuck and bails out:

func main() {
	ch := make(chan int)
	ch <- 1 // nobody is receiving - this blocks forever
}
$ go run main.go
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/tmp/main.go:3 +0x28
exit status 2

main tried to send 1, but no goroutine was ready to receive, so the send blocked. Since main was the only goroutine and now stuck, nothing could progress - the runtime recognized everyone was asleep and reported deadlock!. Fix: make sure a receiver exists (start it as a goroutine before you send, or use a buffered channel if a send genuinely shouldn't wait).

Closing a channel and ranging over it

When a sender is done, it can close(ch) to signal "no more values are coming." A receiver loops with for v := range ch to pull values until the channel is closed and drained, then stops cleanly.

package main

import "fmt"

func main() {
	ch := make(chan int)
	go func() {
		for i := 1; i <= 3; i++ {
			ch <- i
		}
		close(ch) // tell the receiver we're done
	}()

	for v := range ch { // loops until ch is closed and empty
		fmt.Println("received", v)
	}
}
$ go run main.go
received 1
received 2
received 3

The goroutine sent 1, 2, 3 then close(ch). The for ... range ch loop in main received each value as it arrived, and when the channel was closed and emptied, the loop ended on its own - no counter, no sentinel value. Closing is how a sender says "that's all"; range is how a receiver listens for it.

⚠️ Gotcha. Only the sender should close a channel, and only once. Closing an already-closed channel, or sending on a closed channel, panics. Rule of thumb: whoever owns the sending end owns the close.

select: waiting on several channels at once

select is like a switch, but its cases are channel operations. It blocks until one case can proceed, then runs it - how a goroutine listens to multiple channels, "whichever speaks first."

A common shape: do some work, but give up if it takes too long.

package main

import (
	"fmt"
	"time"
)

func main() {
	result := make(chan string)
	go func() {
		time.Sleep(2 * time.Second) // pretend this is slow work
		result <- "done"
	}()

	select {
	case r := <-result:
		fmt.Println("got result:", r)
	case <-time.After(1 * time.Second):
		fmt.Println("timed out waiting")
	}
}
$ go run main.go
timed out waiting

select waited on two channels: result, and the one returned by time.After, which delivers a value after the given delay. The work took 2 seconds but the timeout fired at 1, so the time.After case won. select is your tool for "wait for whichever happens first" - results, timeouts, cancellation, all at once.

sync.WaitGroup: wait for a batch of goroutines to finish

Sometimes you don't need to pass values back - just launch goroutines and wait until all are done. A sync.WaitGroup is a counter for that: Add how many you're starting, each goroutine calls Done when it finishes, and Wait blocks until the counter hits zero.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1) // one more goroutine to wait for
		go func(id int) {
			defer wg.Done() // mark this one done on the way out
			fmt.Println("worker", id, "finished")
		}(i)
	}
	wg.Wait() // block until all three call Done
	fmt.Println("all workers finished")
}
$ go run main.go
worker 3 finished
worker 1 finished
worker 2 finished
all workers finished

Each iteration called wg.Add(1) before launching a worker, and each worker called wg.Done() (via defer, so it runs no matter how the function exits). wg.Wait() held main until the counter dropped to zero. Workers ran in whatever order the scheduler picked (here 3, 1, 2), but "all workers finished" is guaranteed to print last, since Wait doesn't return until all report in.

💡 Key point. defer wg.Done() is the safe habit - it guarantees the counter decrements even if the goroutine returns early or panics. Forget a Done and Wait blocks forever; call it twice and the counter goes negative and panics. One Add, one Done, per goroutine.

⚠️ Gotcha - goroutine leaks. A goroutine that blocks forever never gets cleaned up - it sits holding memory for the life of the program. Usual cause: a goroutine waiting to send on (or receive from) a channel nothing will ever touch again:

func leak() {
	ch := make(chan int)
	go func() {
		val := <-ch // waits forever - nobody ever sends
		fmt.Println(val)
	}()
	// function returns; the goroutine is stranded, blocked, leaked
}

leak started a goroutine that blocks on <-ch, then returned without sending anything. The goroutine can never progress or exit - it's leaked. Unlike the all-stuck case earlier, the runtime won't warn you here, since the rest of the program keeps running. Leaks are silent. Fix: always give a blocked goroutine a way out - close the channel, or use a select with a cancellation/timeout case.

Recap

  1. Goroutine - a cheap concurrent task; start it with go f(). Thousands are fine. When main exits, they all die, so you need a way to wait.
  2. Channel - a typed pipe (make(chan T)) that both moves a value and synchronizes the two goroutines. An unbuffered send blocks until a receiver is ready.
  3. close + range - the sender calls close(ch) to say "no more"; the receiver loops with for v := range ch until it's drained.
  4. select - wait on several channels at once and act on whichever is ready first; pair with time.After for timeouts.
  5. sync.WaitGroup - Add, Done (via defer), Wait to block until a batch of goroutines all finish.
  6. The mantra - share memory by communicating: hand values down channels instead of locking shared variables. ⚠️ Watch for deadlock (send with no receiver) and leaks (a goroutine blocked forever).

You can now run work concurrently and coordinate it safely. Next: handling what goes wrong - in Go, errors are ordinary values you pass around, not exceptions thrown from the shadows.


← Phase 5: Modules & Project Layout · Phase 7: Errors & I/O →

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 2 questions

1. A goroutine is...

2. A send on an unbuffered channel...