What you'll learn
Quick Answer
A goroutine is a function that runs concurrently with other goroutines in the same program, scheduled entirely by the Go runtime instead of the operating system. It starts with a stack of roughly 2 KB — compare that to 1–8 MB for a typical OS thread — which is why a single Go program can comfortably run hundreds of thousands of them. Goroutines don't coordinate through shared variables and locks by default; they send typed values to each other through channels, and the receiving side blocks until something arrives.
What a Goroutine Actually Is
Put the keyword go in front of any function call in Go, and that call no longer blocks the caller — it runs as a separate goroutine, and your program moves on immediately to the next line. That's the entire syntax. The interesting part is what happens underneath it, because a goroutine is not an operating system thread with a shorter name.
An OS thread is something the kernel knows about and schedules onto a CPU core directly. Creating one means asking the kernel for a chunk of memory for its stack (typically 1–8 MB, reserved up front on most systems) and registering it with the OS scheduler, which then has to context-switch between threads using a full save-and-restore of CPU registers — a genuinely expensive operation when you're doing it thousands of times a second. A goroutine is a much smaller object that the Go runtime manages entirely in user space, without asking the kernel every time. The runtime keeps its own list of goroutines and decides which ones run on which underlying OS threads, when, using its own scheduler written in Go's own runtime package — the kernel never sees individual goroutines at all, only the handful of OS threads the runtime is actually using to run them.
This design has a name in the academic literature: Communicating Sequential Processes, a model Tony Hoare described in 1978 for programs built from independent sequential processes that only interact by passing messages, never by directly touching each other's memory. Go's creators — Robert Griesemer, Rob Pike, and Ken Thompson, building the language at Google starting in 2007 and releasing it publicly in 2009 — picked this model specifically because Google's problem was servers that needed to juggle enormous numbers of concurrent network connections cheaply, and spinning up an OS thread per connection didn't scale. A goroutine per connection, at a few kilobytes each instead of megabytes, does.
The practical consequence shows up the moment you write concurrent Go code: starting a goroutine is so cheap that doing it liberally — one per incoming request, one per item in a batch, one per subtask — is the normal style, not something you reach for only after careful cost-benefit analysis the way you might with threads in Java or C++. The language's own slogan for this, repeated throughout its documentation, is 'don't communicate by sharing memory; share memory by communicating' — which is really a statement about where bugs come from. Shared mutable memory accessed from multiple threads is where most classic concurrency bugs (races, deadlocks from lock ordering, forgotten locks) live. Channels push you toward a style where the data itself moves between goroutines instead of sitting in one place being fought over.
Goroutines vs OS Threads: The Real Numbers
A quick note on how this section is sourced: there's no Go toolchain installed in the environment this article was written in, so the figures below are not a locally run benchmark — they come directly from Go's own runtime source and documentation, and from engineering write-ups by teams (including Uber's Go platform team) who've measured goroutine stack behaviour at production scale. They're specific enough, and consistent enough across sources, to state as fact rather than approximation.
A goroutine starts with a stack of about 2 KB. An OS thread, by contrast, is typically given 1–8 MB of stack space up front, depending on the platform and its defaults. That's not a small difference — it's roughly three orders of magnitude. The reason a goroutine can start so small is that its stack isn't fixed: when a goroutine's stack runs low, the Go runtime allocates a new, larger stack (commonly double the size), copies the existing contents across, and fixes up every pointer that referred into the old stack — then keeps running as if nothing happened. The stack can keep growing this way up to a limit of 1 GB on 64-bit platforms, which is more than enough headroom for anything short of a runaway recursive bug.
This growable-stack design is also why you can't simply compare 'goroutine stack size' to 'thread stack size' as a fixed number — it depends on how deep the call stack actually gets. Go 1.19 added adaptive initial stack sizing specifically to handle this better: the runtime tracks the running average stack size goroutines have needed recently and uses that to pick a smarter starting size, so a program that routinely needs slightly bigger stacks doesn't pay the cost of repeated grow-and-copy cycles from the default 2 KB every single time.
| Property | OS Thread | Goroutine |
|---|---|---|
| Initial stack size | 1–8 MB (fixed) | ~2 KB (grows on demand) |
| Max stack size | Fixed at creation | Up to 1 GB (64-bit) |
| Scheduled by | OS kernel | Go runtime (user space) |
| Typical count per program | Tens to low thousands | Hundreds of thousands+ |
The practical upshot: a web server handling 50,000 concurrent connections with one goroutine per connection is using roughly 100 MB of stack memory in the worst case before any growth. The same design with one OS thread per connection would be reserving tens of gigabytes up front just for stacks that are mostly empty. That gap is the entire reason Go's concurrency model exists in the form it does — it isn't a style preference, it's what makes massive concurrency economically possible on ordinary hardware.
How Go Schedules Goroutines: the GMP Model
Go's scheduler is usually described by three letters: G, M, and P. A G is a goroutine — the lightweight unit of work itself, along with its stack and current execution state. An M is a machine: an actual OS thread that the runtime has acquired from the kernel, the thing that really executes instructions on a CPU core. A P is a processor — not a physical CPU, but a logical scheduling context that holds a local run queue of goroutines ready to execute, and that an M must hold in order to run Go code.
This is what's called an M:N scheduler: M goroutines get multiplexed onto N OS threads, where M is usually far larger than N. The number of Ps is controlled by GOMAXPROCS, which defaults to the number of logical CPU cores on the machine. So on an 8-core machine, you'll typically have 8 Ps actively executing Go code at once, no matter how many thousands of goroutines exist — the scheduler's job is to keep rotating which goroutines get a turn on those 8 execution contexts, picking up new work from each P's local queue and occasionally rebalancing from a global queue or stealing work from another P's queue when one P runs dry.
The reason this matters practically: increasing GOMAXPROCS doesn't make a single CPU-bound goroutine run faster, because one goroutine can only ever occupy one P at a time. What it does is let more goroutines make genuine CPU progress simultaneously, on different cores, at the same moment — that's real parallelism, not just concurrency. A goroutine that's blocked waiting on a channel, a network read, or a mutex doesn't occupy a P at all while it waits; the scheduler parks it and lets another goroutine run on that P instead, which is exactly how a program with 100,000 goroutines, almost all of them idle and waiting on I/O, stays cheap — the scheduler is only ever actively juggling the small number that are runnable at any instant.
One detail that trips people up coming from a threading background: a goroutine that makes a blocking system call (certain kinds of I/O, or calling into C code through cgo) can cause the runtime to detach its M from its P and hand that P to a different, fresh M, so other goroutines aren't starved just because one of them is stuck waiting on the kernel. This hand-off machinery is what lets Go claim 'goroutines are cheap and don't block each other' even though, underneath, some of them genuinely are sitting in a blocking kernel call at any given moment.
Channels: Typed Pipes, Not Shared Memory
A channel in Go is a typed conduit you create with make(chan int) (or chan string, chan SomeStruct, anything) and then send to and receive from using the <- operator. The type matters: a chan int can only ever carry int values, checked at compile time, which rules out an entire category of 'I sent the wrong shape of data to the other goroutine' bugs before the program even runs.
There are two flavours, and the difference changes how your program behaves under load. An unbuffered channel (make(chan int)) has no internal storage at all — a send blocks until some other goroutine is ready to receive that exact value, and a receive blocks until some other goroutine sends. This is a genuine synchronization point: the two goroutines briefly rendezvous. A buffered channel (make(chan int, 10)) has room for up to 10 values in flight; a send only blocks once the buffer is full, and a receive only blocks once it's empty. Buffered channels decouple producer and consumer speed up to the buffer's capacity, which is useful, but it's worth being honest that a buffer just moves the point where backpressure kicks in — it doesn't remove the need to handle a slow consumer, it just gives you some slack before you have to.
Here's the pattern annotated as comments, written to Go's real documented semantics (again: no local Go toolchain to actually compile and run this, so treat the comments as the authoritative description of what each line does rather than a captured program output):
// jobs is unbuffered -- each send blocks until a worker goroutine receives it
jobs := make(chan int)
results := make(chan int, 100) // buffered -- workers can get ahead of the collector by up to 100 results
// Start 4 worker goroutines. Each one blocks on "range jobs" until a job
// arrives, processes it, sends the result, and loops back to waiting.
for w := 0; w < 4; w++ {
go func() {
for job := range jobs { // receives until the channel is closed
results <- job * job
}
}()
}
// Feed 20 jobs in, then close(jobs) so the "range jobs" loops above exit cleanly.
go func() {
for j := 0; j < 20; j++ {
jobs <- j
}
close(jobs)
}()
// Collect 20 results. Receiving from results blocks until a worker has
// something ready -- this is the synchronization, no lock needed anywhere.
for i := 0; i < 20; i++ {
fmt.Println(<-results)
}Notice there is not a single mutex, lock, or shared variable anywhere in that code. The 20 jobs are distributed across 4 workers purely by which worker happens to be free to receive next, and the results come back the same way — the channel itself is doing the coordination that a thread-and-lock version would need explicit synchronization primitives for. This is the concrete meaning of 'share memory by communicating': the job value is handed from one goroutine to another through the channel, and only one of them is ever touching it at a time, by construction, not by discipline.
The select Statement and Common Patterns
A single channel receive blocks until that one channel has something. select is what lets a goroutine wait on several channels at once and proceed with whichever one becomes ready first — it's the concurrency equivalent of a switch statement, except the cases are channel operations instead of values, and whichever case can actually proceed right now is the one that runs.
select {
case msg := <-dataChannel:
handle(msg)
case <-time.After(2 * time.Second):
// no message arrived within 2 seconds -- time out instead of blocking forever
fmt.Println("timed out waiting for data")
case <-ctx.Done():
// the surrounding context was cancelled (caller gave up, or deadline hit)
return ctx.Err()
}That three-way pattern — do the real work, or time out, or respect cancellation — is close to the single most common shape of concurrent Go code in real services, because it answers the question every network call eventually has to answer: what happens if the thing I'm waiting for never arrives? Without select, a bare channel receive has no way to give up; with it, 'give up after N seconds' or 'give up if the caller cancelled' become ordinary cases alongside the success case, not special exception-handling bolted on afterward.
Two other patterns are worth knowing by name because they show up constantly in real Go codebases. Fan-out, fan-in is exactly the worker-pool shape from the previous section: one goroutine fans work out across several worker goroutines reading from a shared jobs channel, and a collector goroutine fans the results back in by reading from a shared results channel — it's the natural way to parallelize a batch of independent tasks across however many CPU cores GOMAXPROCS gives you. Pipelines chain several stages together, where stage one's output channel is stage two's input channel, each stage running as its own goroutine — a generator stage produces values, a transform stage squares or filters them, a consumer stage prints or stores them, and all three run concurrently with values flowing through as soon as they're ready, rather than the whole pipeline finishing stage one for every item before starting stage two.
The thing that makes both patterns genuinely useful rather than just a cute abstraction is that closing a channel is itself a signal: range over a channel exits cleanly when it's closed, which is how a pipeline's final stage knows there's nothing more coming without needing some separate 'done' flag passed around. Combined with select and context.Context for cancellation, this gives you a small, composable vocabulary — send, receive, select, close, cancel — that covers the overwhelming majority of concurrent control flow you'll actually need, without reaching for a mutex at all in most of it.
Goroutine Leaks, Deadlocks, and Other Honest Limits
None of this makes Go's concurrency bug-proof, and it's worth being direct about where things still break, because 'goroutines are cheap' is exactly the property that causes the most common real bug: the goroutine leak. If you start a goroutine that blocks on a channel receive, and nothing ever sends to that channel (because the other side errored out, returned early, or was never wired up correctly), that goroutine sits there forever, blocked, never getting garbage collected — because the Go runtime has no way to know it will never be needed again. Do this inside a hot path that runs thousands of times a minute, and you've built a slow, silent memory leak that doesn't show up as an obvious crash, just as a server that gets gradually heavier over days until it falls over. The fix is almost always giving every blocking goroutine an escape hatch — a cancellable context, a timeout case in a select, or a done channel it also watches — so there's always a path out even when the happy path never arrives.
Deadlocks are still possible too, just in a different shape than the classic 'two threads each waiting on the other's lock.' The simplest Go version is a single goroutine sending on an unbuffered channel with nobody ever positioned to receive it — the runtime's own deadlock detector will actually catch this specific case (all goroutines blocked, nothing left that could possibly unblock any of them) and crash the program with fatal error: all goroutines are asleep - deadlock! rather than hanging silently forever, which is a genuinely useful piece of engineering most thread-based runtimes don't give you for free. But a deadlock involving goroutines that are each waiting on something that will eventually time out, or that's blocked on real I/O the runtime can't reason about, won't be caught this way — it'll just look like unusual latency until someone notices.
The other common trap: channels don't replace the need to think about data races entirely, they just move where races tend to happen. If two goroutines both close over the same non-channel variable — a loop variable captured incorrectly, a shared map accessed without a channel or mutex guarding it — Go's channels do nothing to protect that, because you've stepped outside the 'communicate through channels' discipline back into plain shared memory. Go ships a race detector (go run -race) specifically because this still happens often enough in real code to need tooling, not just good intentions.
None of this is an argument against the model — fan-out/fan-in, pipelines, and select-based cancellation genuinely do eliminate most of the lock-ordering and shared-state bugs that make threaded code hard to reason about. It's an argument for treating 'every blocking channel operation needs an escape hatch' as a rule you follow deliberately, the same way you'd treat 'every lock you take, you must release' in a threaded language — the bug class changed shape, it didn't disappear.
Frequently Asked Questions
Is a goroutine the same thing as a green thread?
Close, but not identical. Goroutines share the general idea behind green threads — lightweight, runtime-managed units of concurrency instead of OS threads — but Go's implementation adds specifics like growable stacks (starting around 2 KB and expanding up to 1 GB) and the GMP scheduler's M:N mapping onto real OS threads, which not every green-thread system does the same way.
What happens if I send to a channel that nobody is reading from?
For an unbuffered channel, the send blocks indefinitely — the sending goroutine simply pauses there until some goroutine performs a receive. If every goroutine in the program ends up blocked this way with nothing left that could ever unblock any of them, Go's runtime detects it and crashes with a deadlock error rather than hanging silently.
Can I just increase GOMAXPROCS to make my program faster?
Only if your program actually has CPU-bound work spread across multiple goroutines that are ready to run at the same time. GOMAXPROCS controls how many goroutines can execute Go code in parallel on separate cores; it does nothing for a single goroutine's own speed, and it won't help a program that's mostly blocked waiting on network or disk I/O rather than genuinely CPU-bound.
Why does Go favor channels over mutexes if mutexes still exist in the language?
Go's standard library does include mutexes (sync.Mutex) and they're the right tool for simple, local state protection — guarding a counter or a small struct that a few goroutines touch briefly. Channels are favored for coordinating actual flow of data and control between goroutines, because the channel operations themselves double as the synchronization, which tends to produce code where the concurrency structure is visible in the code's shape rather than hidden inside scattered lock/unlock calls.
Do I need to close every channel I create?
No. You only need to close a channel when something downstream relies on detecting that no more values are coming — typically a receiver using range over the channel, or a select case on the channel closing as a signal. A channel that's simply used for a few one-off sends and receives, with the goroutines involved exiting normally afterward, doesn't need to be closed; closing is a signal, not cleanup.
