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.
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"
}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).