What is the difference between goroutines and threads

Goroutines are lightweight, runtime-managed concurrent units, while threads are heavier, kernel-managed execution units that goroutines multiplex onto.

The thread tax

A Python developer writes a web scraper that spawns a new thread for every URL. At fifty URLs the program runs smoothly. At five hundred the operating system starts thrashing. Memory usage climbs into the gigabytes. Context switches eat CPU cycles. The program grinds to a halt. The developer switches to Go, replaces the thread creation with go, and runs ten thousand concurrent requests. The machine barely notices.

The difference is not magic. It is a fundamental shift in who manages execution. Operating system threads are heavy, kernel-scheduled units that demand dedicated memory and CPU time. Goroutines are lightweight, user-space execution units managed entirely by the Go runtime. The runtime multiplexes thousands of goroutines onto a small pool of OS threads. This design removes the thread tax and makes concurrency feel cheap.

Goroutines are not threads in disguise. They are a higher-level abstraction that hides kernel complexity behind a predictable scheduling model.

How Go handles concurrency

An OS thread requires a dedicated stack allocated by the kernel, usually one to eight megabytes. Creating thousands of threads exhausts virtual memory and forces the kernel to spend cycles switching between them. The kernel scheduler decides which thread runs next, and it has no awareness of your application logic.

Goroutines start with a stack of roughly two kilobytes. That stack lives on the heap and grows or shrinks automatically as the goroutine executes. If a function call needs more stack space, the runtime allocates a larger backing array, copies the old stack over, and continues. When the call returns, the runtime can shrink it back down. This dynamic resizing means you never have to guess how much stack memory you need.

The Go runtime uses an M:N scheduling model. M stands for OS threads, N stands for goroutines. The runtime maintains a pool of OS threads and a global run queue of ready goroutines. When a goroutine becomes ready, the scheduler attaches it to an available OS thread. When a goroutine blocks on I/O, the scheduler parks it, detaches the OS thread, and hands it to another ready goroutine. The handoff happens entirely in user space. No kernel context switch occurs.

Think of OS threads as dedicated elevators assigned to single floors. Goroutines are a single high-speed elevator that picks up passengers from many floors, drops them off efficiently, and never sits idle. The runtime is the elevator operator.

Goroutines are cheap. Channels are not magic.

A minimal goroutine

Here is the standard way to spawn a batch of goroutines and wait for them to finish.

package main

import (
	"fmt"
	"sync"
)

// worker prints an identifier and signals completion.
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Decrement the counter when the function returns
	fmt.Println("Running", id)
}

func main() {
	var wg sync.WaitGroup // Tracks how many goroutines are still active
	for i := 0; i < 5; i++ {
		wg.Add(1) // Reserve a slot before spawning the background task
		go worker(i, &wg) // Launch in the background
	}
	wg.Wait() // Block until all reserved slots are cleared
}

The go keyword tells the compiler to schedule the function call asynchronously. The runtime allocates a goroutine structure, assigns it a tiny stack, and places it in a run queue. The sync.WaitGroup prevents main from exiting before the background work finishes. Without it, the program would terminate immediately and the goroutines would never run.

The receiver name in Go is usually one or two letters matching the type. You will rarely see this or self in idiomatic code. Keep names short and consistent.

What happens under the hood

When the program starts, the runtime creates a single OS thread for main. The for loop runs sequentially on that thread. Each iteration calls wg.Add(1) and then go worker(i, &wg). The go statement does not block. It registers the function with the scheduler and returns immediately.

The scheduler picks up the ready goroutines and distributes them across available OS threads. If your machine has four cores, the runtime will typically create four OS threads and run goroutines on them in parallel. When a goroutine finishes, it calls wg.Done(), which decrements the internal counter. When the counter reaches zero, wg.Wait() unblocks and main continues.

If a goroutine calls a blocking function like net.Dial or os.ReadFile, the runtime detects the block. It parks the goroutine, saves its state, and moves the underlying OS thread to another ready goroutine. When the I/O completes, the runtime wakes the goroutine, restores its state, and schedules it back onto an available thread. This cooperative blocking is what makes goroutines scale to tens of thousands without exhausting system resources.

The scheduler also uses work-stealing. If one OS thread runs out of ready goroutines, it steals work from the local queues of other threads. This keeps CPU cores busy and prevents load imbalance.

Context is plumbing. Run it through every long-lived call site.

When you actually need an OS thread

Goroutines handle almost everything. There are rare cases where you must pin a goroutine to a dedicated OS thread. C libraries that rely on thread-local storage, GUI frameworks, or strict real-time systems sometimes require a stable thread identity. The Go runtime provides runtime.LockOSThread for this exact scenario.

Here is how you pin a goroutine to a dedicated OS thread when external code demands it.

package main

import (
	"fmt"
	"runtime"
)

// pinnedTask runs on a dedicated OS thread for C interop.
func pinnedTask() {
	runtime.LockOSThread() // Steal an OS thread and bind it to this goroutine
	defer runtime.UnlockOSThread() // Return the thread to the pool when done
	fmt.Println("Running on a dedicated OS thread")
	// Call C library or GUI framework here
}

func main() {
	go pinnedTask()
	runtime.Goexit() // Exit main without waiting for background tasks
}

Calling LockOSThread removes the current OS thread from the scheduler pool and ties it to the calling goroutine. All subsequent blocking calls on that goroutine will block the entire OS thread, not just the goroutine. This defeats the M:N multiplexing advantage. Use it only when absolutely necessary. When the goroutine exits or calls UnlockOSThread, the thread returns to the pool.

The Go community accepts verbose error handling by design. The if err != nil { return err } pattern makes the unhappy path visible. Do not hide errors behind silent ignores.

Pitfalls and compiler reality

Goroutine bugs rarely show up at compile time. The compiler will catch missing imports with undefined: runtime or type mismatches with cannot use ch (type chan int) as type chan string in argument. It will not catch a goroutine that leaks memory or a channel that never closes.

The most common mistake is forgetting to wait for background work. If main exits while goroutines are still running, the process terminates and the goroutines are killed without cleanup. The compiler does not warn you. You must explicitly synchronize with sync.WaitGroup, channels, or context.Context.

Goroutine leaks happen when a goroutine waits on a channel that never receives a value, or when a loop spawns goroutines faster than they finish. The leaked goroutine holds onto its stack and any captured variables. Over time, memory usage climbs and the program slows down. The runtime provides runtime.NumGoroutine() for debugging, but production code should rely on structured cancellation.

Always pass context.Context as the first parameter, conventionally named ctx. Functions that accept a context should check ctx.Done() before starting long work and respect deadlines. This gives you a single cancellation path that propagates through the entire call tree.

The worst goroutine bug is the one that never logs.

Picking the right tool

Concurrency is a spectrum. The right choice depends on your workload, your dependencies, and your performance requirements. Follow these patterns to keep your code predictable.

Use a goroutine when you have independent I/O calls that can run while others wait. Use a goroutine when you need to parallelize CPU-bound work across multiple cores. Use runtime.LockOSThread when a C library or GUI framework requires thread-local state. Use an OS thread directly via CGO when you need strict real-time guarantees that the Go scheduler cannot provide. Use sequential code when your task finishes in milliseconds: the overhead of concurrency outweighs the benefit.

Trust the scheduler. Argue logic, not formatting.

Where to go next