If you are learning concurrency in Go, you eventually ask whether a goroutine is a thread, how goroutines compare to OS threads, and why people call goroutines lightweight. This article stays conceptual: what the kernel provides, what the Go runtime adds, and how that shapes the way you write servers and workers. For how CPUs, cores, and hardware threads relate (and why GOMAXPROCS defaults track logical CPUs), read CPU, processors, cores, threads, and cache explained; on Linux you can compare real per-process thread counts to what the runtime is doing using How to check thread count per process in Linux. For hands-on Go patterns next, see goroutines in Go, WaitGroup, channels, and mutexes.
Go 1.24 on Linux.
GOMAXPROCSand timing examples depend on your machine.
Quick answer: is a goroutine a thread?
No. A goroutine is a concurrent function in your process, started with the go keyword; the Go runtime creates it and schedules it. An OS thread is the kernel’s scheduling unit (stack + registers); the kernel preempts threads and context-switches them, which is relatively expensive compared to switching ready goroutines in user space.
Many goroutines are multiplexed onto fewer OS threads (m:n scheduling), so goroutines vs threads is not one-to-one: you might have thousands of goroutines while only a handful of kernel threads are busy running your Go code at a given moment.
You only start goroutines in normal Go code; you do not spawn OS threads yourself. The snippet below starts one goroutine; the runtime still uses OS threads under the hood, but that mapping is invisible at the go keyword:
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("main runs in the starting goroutine")
go func() {
fmt.Println("this line runs in a new goroutine")
}()
time.Sleep(20 * time.Millisecond)
fmt.Println("main again after a short wait so the goroutine can print")
}After Run you should see three lines in order: the first main line, the line from the go function, then the final main line. Without the sleep, main could exit before the other message appears—that is why real programs use a WaitGroup, a server loop, or channels instead of a fixed sleep.
Goroutine vs thread: main difference
Goroutine: managed by the Go runtime
Starting work is usually go f(). Stacks start small and grow; switching between goroutines that are ready to run is handled in user space by the runtime, which is why people call goroutines cheap, not free—they still consume memory and scheduler time.
Thread: managed by the OS kernel
Threads are the kernel’s scheduling unit. Typical stacks are larger; creation and context switches involve more kernel work than switching between ready goroutines on the same thread.
Many goroutines on fewer threads
The important mental model: a goroutine is not the same thing as a thread. A long-lived server might hold thousands of goroutines while GOMAXPROCS keeps parallel execution of Go code closer to your CPU count.
What is a goroutine in Go?
Start one with the go keyword
go func() {
// runs concurrently with the rest of main
}()Goroutines run concurrently
“Concurrent” means progress can be interleaved over time; it does not guarantee two pieces of code execute at the exact same instant on two cores (that is parallelism).
Goroutines are cheap but not free
Avoid saying goroutines are “free.” They are lightweight compared to OS threads, but a goroutine still has a stack and bookkeeping. Spawning unbounded goroutines without backpressure can exhaust memory or overload the scheduler.
Goroutines exit when their function returns
There is no separate “stop” API: when the function you launched with go returns, that goroutine ends. If main returns first, the program can exit before other goroutines finish—see the WaitGroup example below.
What is an OS thread?
Threads belong to the operating system
Your Go process uses one or more kernel threads created by the runtime and the OS. Those threads run native code, runtime code, and may block in system calls.
Threads use larger stacks
Exact sizes are OS-dependent; kernel thread stacks are typically much larger than a fresh goroutine’s initial stack.
The OS scheduler manages threads
The kernel preempts threads, balances load across CPUs, and accounts for blocking I/O. That global scheduling is heavier than the runtime moving ready goroutines between threads.
Context switching is heavier
Switching OS threads usually costs more than resuming a different goroutine on a thread that already runs Go code.
How Go schedules goroutines
G, M, and P (simple view)
People summarize the modern scheduler as G–M–P:
- G — a goroutine.
- M — an OS thread (
machine) attached to the process. - P — a logical processor resource inside the runtime used to queue and run Gs on Ms.
You do not program P directly; it is how the runtime implements multiplexing many Gs onto fewer Ms.
Goroutines are multiplexed onto OS threads
Runnable goroutines wait on run queues; when an M runs a G and that G blocks in a channel receive, sleep, or similar, the runtime can park it and run another G on the same M. Since Go 1.14, long-running Gs can also be preempted so CPU-heavy loops do not starve others on the same M.
GOMAXPROCS controls parallel Go execution
runtime.GOMAXPROCS(n) sets the limit on how many Ms can run user Go code at the same time for parallel execution (the default is usually the number of logical CPUs). It does not limit how many goroutines you can create—only how much true CPU-level parallelism your runnable Go code gets.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
}After Run, the printed number should match your machine’s default (typically logical CPU count). Use runtime.GOMAXPROCS(0) to read the current setting without changing it.
Blocking I/O and system calls
When a goroutine blocks in a way the runtime understands (for example channel operations, time.Sleep), it may not need an M until it is runnable again. Some blocking system calls can tie up an M; the runtime may spin up another M so other goroutines keep progressing. Heavy cgo or blocking native code interacts badly with tight GOMAXPROCS—tune and measure when you mix those.
Endless go in a tight loop (do not run carelessly)
The program below is marked non-runnable here because it spawns goroutines without bound and can peg CPUs if you run it locally. It only illustrates that goroutines are easy to start, not that you should do this in production.
package main
import (
"fmt"
)
func main() {
for {
go fmt.Print(0)
fmt.Print(1)
}
}Goroutine vs thread comparison table
| Point | Goroutine | OS thread |
|---|---|---|
| Managed by | Go runtime | Operating system kernel |
| Created with | go keyword |
Runtime / OS (you rarely create threads directly in Go) |
| Stack | Small at start, grows and shrinks | Usually larger, OS-dependent |
| Scheduling | Go runtime on top of kernel threads | Kernel scheduler |
| Mapping | Many goroutines share and migrate across threads | One thread is one kernel scheduling unit |
| Cost | Lower creation and switch cost in typical Go workloads | Higher creation and kernel context switch cost |
| Communication | Channels, sync types, shared memory with discipline |
Shared memory, locks, OS primitives (Go can use these too) |
| Best mental model | Many concurrent tasks in one program | Execution vehicle the runtime maps goroutines onto |
Example: start goroutines and wait with WaitGroup
A tiny go call starts work in the background. sync.WaitGroup is the usual way to wait for a fixed batch of goroutines before main exits. See Golang WaitGroup for more patterns.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("worker %d\n", n)
}(i)
}
wg.Wait()
fmt.Println("all done")
}After Run you should see three worker lines (order may vary) followed by all done.
Why main must wait
If main returns while goroutines still run, the program can exit immediately. Wait(), channels, or a server loop keeps the process alive until work finishes or you shut down gracefully.
Are goroutines parallel or concurrent?
Concurrency vs parallelism
Concurrency is about dealing with many things at once (interleaved progress). Parallelism is about doing many things at once (at the same instant on multiple cores). Goroutines are a concurrency tool; they become parallel when the runtime runs multiple goroutines on multiple threads on multiple CPUs.
Multiple goroutines can share one thread
At any instant, one M runs one G’s Go code. Other goroutines may be runnable, waiting on channels, or sleeping.
Multiple goroutines can run in parallel
With GOMAXPROCS > 1 and enough CPUs, the runtime can execute different goroutines on different threads at the same time—true parallelism for CPU-bound work.
When goroutines fit better than one OS thread per task
Network servers and I/O-bound work
Lots of blocked connections map naturally to lots of goroutines; blocked goroutines do not each need a dedicated thread busy-spinning.
Background jobs and independent tasks
Small units of work with clear completion signals (WaitGroup, channels, context cancellation) scale well.
Caution on CPU-bound work
Goroutines help structure CPU-heavy pipelines, but they do not create extra CPU: too many runnable CPU-bound goroutines still contend for the same cores; profile and tune GOMAXPROCS and algorithms.
Common mistakes
Thinking one goroutine equals one OS thread
That assumption breaks scheduling intuition and capacity planning.
Starting goroutines without a stop condition
Server loops and worker pools should use context.Context, done channels, or limits so work can end.
Forgetting to wait for goroutines
Exiting main early or returning from a handler without waiting can drop work on the floor.
Sharing memory without synchronization
Data races are undefined behavior; use a mutex, channels, or other sync types, and follow the Go memory model.
Goroutine leaks
If a goroutine blocks forever waiting on a channel nobody closes or sends on, or waits on a condition that never becomes true, it never exits. That leaks stack memory and scheduler entries—watch blocked profiles and design shutdown paths.
Goroutine vs thread cheat sheet
| Question | Answer |
|---|---|
| Is a goroutine a thread? | Not exactly—it is a runtime-managed, lightweight execution unit. |
| Does Go use OS threads? | Yes. Goroutines run on kernel threads the runtime uses. |
| Does one goroutine mean one thread? | No. Many goroutines are multiplexed onto fewer threads. |
| Can goroutines run in parallel? | Yes, when multiple threads run Go code on multiple cores (subject to GOMAXPROCS). |
| What starts a goroutine? | The go keyword. |
| What limits parallel Go execution? | GOMAXPROCS (and your hardware). |
| Are goroutines free? | No. They are cheap, but still use memory and CPU when runnable. |
Summary
Goroutine vs thread in Go is a runtime versus kernel distinction: goroutines are cheap, stack-growing tasks scheduled by Go and multiplexed onto OS threads, while threads are the kernel’s heavier scheduling units. GOMAXPROCS caps how much parallel execution your Go code gets, not how many goroutines exist. Use goroutines for concurrent structure, pair them with WaitGroup, channels, or mutexes for correctness, and treat “lightweight” as “cheaper than threads,” not “free.”
References
- A Tour of Go: Goroutines
- The Go Memory Model
- runtime.GOMAXPROCS
- Go FAQ: Why goroutines instead of threads?

