A goroutine is a lightweight concurrently-executing function multiplexed onto OS threads by the Go runtime — starting one costs ~2KB of stack vs ~1MB for an OS thread; goroutines communicate via typed channels that synchronise and transfer data, embodying the Go philosophy: do not communicate by sharing memory; share memory by communicating.
An unbuffered channel feels like it should work the same as any other variable — send a value, then read it back, all in the same function. It doesn't. A send on an unbuffered channel blocks until some other goroutine is ready to receive it at that exact moment. Send and receive sequentially in the same goroutine, with nothing else running, and the program doesn't get a wrong answer — it stops dead, forever.
func main() {
ch := make(chan int)
ch <- 42 // nobody is receiving concurrently -- this blocks forever
fmt.Println(<-ch) // never reached
}
// fatal error: all goroutines are asleep - deadlock!
//
// goroutine 1 [chan send]:
// main.main()Concurrency in most languages requires threads, which are expensive (~1MB of stack each) and managed by the OS. Go invented goroutines — functions that run concurrently but are managed by the Go runtime, not the OS. You can start a million goroutines for the price of a few gigabytes of RAM. Channels let goroutines talk to each other safely without locks.
Goroutines — the go keyword
Starting a goroutine is as simple as writing go before a function call. The function runs concurrently with the caller — both execute at the same time. The Go runtime schedules goroutines across available CPU cores automatically.
package main
import (
"fmt"
"sync"
)
func greet(name string, wg *sync.WaitGroup) {
defer wg.Done() // signal this goroutine is done when function returns
fmt.Printf("Hello, %s!\n", name)
}
func main() {
var wg sync.WaitGroup // WaitGroup: wait for a collection of goroutines to finish
names := []string{"Alice", "Bob", "Charlie", "Diana"}
for _, name := range names {
wg.Add(1) // register one more goroutine to wait for
go greet(name, &wg) // launch goroutine — runs concurrently
}
wg.Wait() // block until all goroutines call wg.Done()
fmt.Println("All done.")
// Output order is non-deterministic — goroutines run concurrently
}Channels — typed communication pipes
A channel is a typed conduit that goroutines use to send and receive values. Sending blocks until a receiver is ready; receiving blocks until a sender sends. This synchronisation is the key: channels coordinate goroutines without explicit locks.
package main
import "fmt"
func sum(nums []int, result chan int) {
total := 0
for _, n := range nums {
total += n
}
result <- total // send total to channel
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := make(chan int) // create an unbuffered channel
// Split work across two goroutines
go sum(nums[:5], result) // sum first half
go sum(nums[5:], result) // sum second half
a, b := <-result, <-result // receive from channel twice (blocks until ready)
fmt.Println("Total:", a+b) // 55
// Buffered channel: can hold N values without a receiver ready
buffered := make(chan string, 3)
buffered <- "first" // does not block — buffer has space
buffered <- "second"
buffered <- "third"
// buffered <- "fourth" // would block — buffer full
fmt.Println(<-buffered) // "first"
fmt.Println(<-buffered) // "second"
}That blocking behavior is exactly what causes the deadlock above: both the send and the receive were written in main, with no other goroutine around to be the "someone else" an unbuffered send needs. The fix is simply to put one side of the handoff on its own goroutine, so the send and the receive are no longer the same goroutine waiting on itself:
func main() {
ch := make(chan int)
go func() {
ch <- 42 // runs concurrently -- main is free to receive at the same time
}()
fmt.Println(<-ch) // 42
}The select statement
The select statement is like a switch for channels — it waits until one of its cases can proceed, then runs that case. If multiple are ready simultaneously, one is picked at random. A default case makes select non-blocking.
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
// select waits for either channel to have a value
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Received from ch2:", msg2)
}
}
// Prints "one" after 1s, then "two" after 2s
}The pipeline pattern
Pipelines chain goroutines together via channels — each stage receives from an upstream channel, processes the data, and sends to a downstream channel. This is the Go idiom for streaming data processing. Each stage can run in parallel with others.
package main
import "fmt"
// generate: produces integers on a channel
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out) // close signals: no more values to send
}()
return out
}
// square: reads from in, sends squares to out
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in { // range on channel reads until close
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Set up the pipeline: generate -> square -> print
c := generate(2, 3, 4, 5)
out := square(c)
for n := range out {
fmt.Println(n) // 4, 9, 16, 25
}
}Fan-out and fan-in
Fan-out: distribute work from one channel to multiple goroutines (parallelism). Fan-in: merge results from multiple channels into one. Together they build work-stealing-style worker pools.
package main
import (
"fmt"
"sync"
)
// worker receives jobs from a channel and sends results
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j // compute square
fmt.Printf("worker %d processed job %d\n", id, j)
}
}
func main() {
const numWorkers = 3
jobs := make(chan int, 10)
results := make(chan int, 10)
var wg sync.WaitGroup
// Fan-out: start 3 workers reading from the same jobs channel
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send 9 jobs
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // closing jobs signals workers to stop ranging
// Close results once all workers are done
go func() {
wg.Wait()
close(results)
}()
// Collect results
for r := range results {
fmt.Println("result:", r)
}
}Context: cancellation and deadlines
The context package propagates cancellation, deadlines, and request-scoped values through goroutine trees. Every goroutine doing work on behalf of a request should accept a context.Context as its first argument and check ctx.Done() to know when to stop. This prevents goroutine leaks when requests are cancelled.
package main
import (
"context"
"fmt"
"time"
)
func doWork(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d stopping: %v\n", id, ctx.Err())
return
default:
fmt.Printf("worker %d doing work...\n", id)
time.Sleep(300 * time.Millisecond)
}
}
}
func main() {
// WithTimeout: automatically cancels after 1 second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // always call cancel to free resources
go doWork(ctx, 1)
go doWork(ctx, 2)
<-ctx.Done() // block until context is cancelled/timed out
fmt.Println("main: context done:", ctx.Err()) // context deadline exceeded
time.Sleep(100 * time.Millisecond) // let goroutines print stop messages
}make(chan T)) synchronises sender and receiver — the send blocks until someone receives, and the receive blocks until someone sends. A buffered channel (make(chan T, n)) can hold n values; sends only block when the buffer is full. Use unbuffered for synchronisation; buffered for queuing.The Go scheduler: GMP model
The Go runtime scheduler uses the GMP model: G (goroutine), M (machine = OS thread), and P (processor = a scheduling context that holds a local run queue). The number of Ps defaults to GOMAXPROCS, which defaults to the number of logical CPU cores. Each P has a local run queue of goroutines waiting to be scheduled onto an M. When a P's local queue is empty, it performs work stealing — taking goroutines from another P's queue. This achieves close-to-optimal CPU utilisation without programmer intervention.
Goroutines are preempted at function call sites (cooperative preemption) and, since Go 1.14, via asynchronous preemption using OS signals — a goroutine in a tight CPU-bound loop can be preempted so other goroutines get CPU time. Stack growth is handled automatically: goroutines start with a small stack (2KB in current versions) that is grown by copying when needed (segmented stacks were replaced by copying stacks in Go 1.4).
Channel implementation: hchan and the lock-free path
Internally, a channel is an hchan struct containing: a ring buffer (for buffered channels), send and receive queues of blocked goroutines (sudog structs), and a mutex for synchronisation. When a goroutine sends on a full buffered channel or an unbuffered channel with no receiver, its sudog is enqueued on the channel's send queue and the goroutine is parked (removed from the run queue). When a receiver arrives, it directly copies data from the parked sender's stack, places the sender back on the run queue, and returns — this is the direct send optimisation that avoids copying through the buffer. This means channel communication can be O(1) in the common case and avoids unnecessary memory allocations.
Select: runtime randomisation and netpoller integration
The select statement is compiled into a call to runtime.selectgo, which shuffles the case order randomly before evaluating — this is what produces the "if multiple are ready, one is chosen uniformly at random" guarantee in the specification. Channels involved in select are locked in a consistent order (by channel address) to prevent deadlock from concurrent select statements racing on overlapping channels. The Go runtime's netpoller (based on epoll/kqueue/IOCP) integrates with the scheduler: I/O operations that would block an OS thread instead park the goroutine and wake it via the netpoller when the I/O is ready, allowing one OS thread to service many concurrent network connections.
The Go Team. The Go Programming Language Specification. go.dev/ref/spec. Sections: "Go statements", "Channel types", "Select statements". Donovan, A. A. A. & Kernighan, B. W. (2015). The Go Programming Language. Addison-Wesley. Chapter 8 (Goroutines and Channels), Chapter 9 (Concurrency with Shared Variables). Go Blog: go.dev/blog/concurrency-is-not-parallelism — Rob Pike's canonical talk. go.dev/ref/mem — The Go Memory Model (revised 2022).