Goroutines & Channels

This is the feature Go is famous for: running many things at once without the heavyweight threads or complicated locking most languages force on you. A goroutine is a function that runs concurrently with the rest of your program, and a channel is how goroutines safely pass data to each other.

Starting a goroutine

The go keyword in front of a function call runs it as a goroutine — the call returns immediately, and the function runs concurrently in the background:

Go main.go
package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Hello from a goroutine!")
}

func main() {
	go sayHello()
	time.Sleep(100 * time.Millisecond)
	fmt.Println("Done")
}
Output
Hello from a goroutine!
Done
Note: that time.Sleep call is a crude teaching device, not something real code should do. main exits the instant it finishes, and when it does, every goroutine still running gets cut off mid-flight — without giving sayHello time to run first, "Hello from a goroutine!" might never print at all. Real Go code uses proper synchronization, like sync.WaitGroup, instead of sleeping and hoping.

Channels: passing data between goroutines

A channel is a typed pipe — one goroutine sends a value into it, another receives it, and the receive blocks until a value actually arrives:

Go main.go
package main

import "fmt"

func square(n int, results chan int) {
	results <- n * n
}

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

	go square(7, results)
	value := <-results

	fmt.Println("Result:", value)
}
Output
Result: 49

results <- n * n sends a value into the channel; <-results receives one. make(chan int) creates an unbuffered channel, meaning the send blocks until something is ready to receive it — that blocking is exactly what makes this safer than time.Sleep: value := <-results genuinely waits for the goroutine to finish, instead of guessing how long that takes.

Waiting for several goroutines with sync.WaitGroup

Go main.go
package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Println("Worker", id, "done")
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	wg.Wait()
	fmt.Println("All workers finished")
}
Output
Worker 3 done
Worker 1 done
Worker 2 done
All workers finished

wg.Add(1) before each goroutine increments a counter, and defer wg.Done() inside worker decrements it when that goroutine finishes. wg.Wait() blocks main until the counter hits zero — until every worker has called Done. Notice the workers print in 3, 1, 2 order here, not 1, 2, 3: goroutines run concurrently, so their relative finishing order isn't guaranteed.

Note: older Go code sometimes has a bug where a loop variable is captured by reference inside a goroutine closure, causing every goroutine to see the same, final value of the loop variable instead of the value from its own iteration. As of Go 1.22 (2024), the language changed so each loop iteration gets its own fresh copy of the loop variable, which quietly fixed this entire class of bug — but you'll still see the old workaround (passing the loop variable in as a function argument, exactly like worker(i, &wg) does above) in plenty of existing Go code, and it's still a perfectly good habit.
Course complete: that covers the Go course from top to bottom — variables and Go's static typing, operators and its strict conversion rules, conditionals, loops, multiple-return-value functions and Go's error-checking idiom, arrays and slices, maps, structs and methods, pointers, interfaces and Go's implicit style of polymorphism, and finally goroutines and channels. From here, the natural next steps are building a real command-line tool or web server with Go's standard library, and exploring generics, added to the language in Go 1.18.