Go Goroutines and Channels Explained with Practical Examples

Learn how Go goroutines and channels work, how they differ from threads and async/await, and how to use them to build a concurrent URL checker, a worker pool, and a cancellable pipeline in 2026.

Gobeginner
14 min read

Concurrency is the headline feature of Go, and the two primitives that deliver it are goroutines and channels. A goroutine is a function the Go runtime can run alongside other goroutines on a tiny pool of operating-system threads. A channel is a typed pipe that lets goroutines send values to each other safely without locks. Together they let you write code that runs thousands of tasks at once with the readability of plain sequential code.

This tutorial teaches goroutines and channels from first principles, then walks through one real production pattern: a concurrent URL checker. If you have not yet installed Go or written your first program, start with our beginner's guide to Go first.

What a Goroutine Actually Is

A goroutine is a function call prefixed with the keyword go. The runtime starts the function on a lightweight, user-space thread (with a starting stack of about 2 KB) and immediately continues executing the next line. The Go scheduler multiplexes many goroutines onto a small pool of OS threads, so a program can comfortably run hundreds of thousands of goroutines on a laptop.

Compare that to a Java thread (about 1 MB of stack) or a Python threading.Thread (limited by the GIL). A goroutine is roughly 500 times cheaper to create, which is why Go programs reach for concurrency where other languages reach for queues.

The catch: when main returns, the program exits, even if other goroutines are still running. You need a way to wait for them. That is where channels — and sync.WaitGroup — come in.

What a Channel Actually Is

A channel is a typed pipe. You create one with make(chan T) and you send values with ch <- value, receive them with value := <-ch. Sends and receives block until both sides are ready, which is exactly what makes channels safe: they synchronise without locks.

Channels come in two flavours. Unbuffered channels (make(chan int)) block the sender until a receiver is ready — perfect for handing off work one item at a time. Buffered channels (make(chan int, 100)) hold up to N items, blocking the sender only when the buffer is full — useful when producers and consumers run at different speeds.

The mental model that almost always works: do not communicate by sharing memory; share memory by communicating. Pass values through channels rather than mutating shared variables.

The Smallest Possible Example

Three goroutines do work, send results back through a channel, and main collects them.

gogo
package main
 
import "fmt"
 
func main() {
	results := make(chan int, 3)
	for _, n := range []int{2, 3, 4} {
		go func(x int) { results <- x * x }(n)
	}
	for i := 0; i < 3; i++ {
		fmt.Println(<-results)
	}
}

Three goroutines start, each squares its number and sends the result. main receives three values and prints them. Order is not guaranteed because the scheduler chooses when each goroutine runs.

A Real Pattern: Concurrent URL Checker

This is the kind of thing Go gets reached for in real life. Check a list of URLs in parallel, but cap the concurrency so you do not open ten thousand connections at once. The pattern is a worker pool: a fixed number of goroutines pull jobs from a channel and push results to another channel.

gogo
package main
 
import (
	"fmt"
	"net/http"
	"sync"
	"time"
)
 
type result struct{ url string; status int; err error }
 
func worker(jobs <-chan string, out chan<- result, wg *sync.WaitGroup) {
	defer wg.Done()
	client := &http.Client{Timeout: 5 * time.Second}
	for url := range jobs {
		r, err := client.Get(url)
		if err != nil { out <- result{url, 0, err}; continue }
		r.Body.Close()
		out <- result{url, r.StatusCode, nil}
	}
}

A few things to notice. <-chan string is a receive-only channel; chan<- result is send-only. Encoding direction in the type catches whole bug categories. sync.WaitGroup lets main wait until all workers finish. The HTTP client has a five-second timeout — networks fail, and goroutines that block forever leak forever.

Wire it up in main:

gogo
func main() {
	urls := []string{
		"https://go.dev", "https://golang.org", "https://example.com",
	}
	jobs := make(chan string, len(urls))
	out := make(chan result, len(urls))
	var wg sync.WaitGroup
	for i := 0; i < 4; i++ { wg.Add(1); go worker(jobs, out, &wg) }
	for _, u := range urls { jobs <- u }
	close(jobs)
	go func() { wg.Wait(); close(out) }()
	for r := range out { fmt.Printf("%d\t%s\n", r.status, r.url) }
}

Closing jobs tells the workers there is no more work coming, so their for range loops finish. Once all workers finish, the goroutine waiting on wg.Wait() closes out, which makes the receive loop in main exit. That is the canonical Go shutdown pattern.

select and Cancellation

Real programs need to handle timeouts and user cancellation. select waits on multiple channels at once, and context.Context is the standard way to propagate cancellation through goroutines.

gogo
select {
case r := <-out:
	fmt.Println("got", r.url)
case <-time.After(2 * time.Second):
	fmt.Println("too slow")
case <-ctx.Done():
	return ctx.Err()
}

Whichever case becomes ready first runs. Pass a context.Context into every function that does I/O, and your whole graph becomes cancellable for free.

Common Mistakes Beginners Make

  • Forgetting to close a channel. Receivers that use for range will block forever. Close the channel from the sender side when the work is done.
  • Closing a channel from the receiver side. Always close from the goroutine that owns the sending side, never from a receiver.
  • Spawning unbounded goroutines. A loop that does go work(item) for ten million items will exhaust memory. Use a worker pool.
  • Sharing variables across goroutines without synchronisation. Pass values through channels, or wrap shared state in a sync.Mutex.
  • Not using context.Context for cancellation. Without it, a goroutine doing a slow HTTP call has no way to stop early.

Quick Reference

  • Start a goroutine: go someFunc(args)
  • Make a channel: make(chan T) or make(chan T, bufferSize)
  • Send: ch <- v. Receive: v := <-ch. Close: close(ch)
  • Receive-only type: <-chan T. Send-only type: chan<- T
  • Wait for goroutines: sync.WaitGroup with Add, Done, Wait
  • Multi-channel wait: select { case ...: ; case <-time.After(d): ; case <-ctx.Done(): }
  • Detect data races: go test -race ./...
Rune AI

Rune AI

Key Insights

  • A goroutine is a function call prefixed with go — cheap to create, scheduled by the runtime onto OS threads.
  • A channel is a typed, synchronised pipe between goroutines: ch <- v to send, <-ch to receive.
  • Use a worker pool to bound parallelism; close the jobs channel to tell workers there is no more work.
  • Use select with time.After and context.Done() for timeouts and cancellation.
  • Run tests with -race from day one — the race detector catches bugs that are otherwise nearly impossible to find.
RunePowered by Rune AI

Frequently Asked Questions

How many goroutines can I create?

Practically hundreds of thousands. Each starts at about 2 KB of stack and grows on demand. The bottleneck is usually the work they do, not the goroutine itself.

When should I use a buffered channel?

When the producer and consumer run at different speeds, or when you know the maximum number of items in flight. Use unbuffered channels by default; introduce buffering only when there is a clear reason.

What is the difference between a goroutine and an OS thread?

goroutine is a unit of work scheduled by the Go runtime. The runtime maps many goroutines onto a small pool of OS threads (one per CPU core by default). You almost never think about the OS threads directly.

Do I still need mutexes?

Sometimes. Channels work for handing data off; a `sync.Mutex` is simpler when many goroutines need read/write access to a shared map or counter. The standard library's `sync/atomic` is the right tool for simple counters.

How do I cancel a goroutine?

You cannot kill a goroutine directly. Pass it a `context.Context` and have it check `ctx.Done()` periodically. That is the universal Go pattern.

Conclusion

Goroutines and channels turn concurrency from a minefield into a tool you reach for daily. Start a goroutine with go, communicate through channels, cap parallelism with a worker pool, and propagate cancellation with context. Build the URL checker above, run it under go test -race, and you will have the foundation that powers every production Go service.