Goroutine vs Thread in Go: Are Goroutines the Same as Threads?

Understand the difference between goroutines and OS threads in Go, how the Go runtime scheduler maps goroutines onto threads, and why goroutines are lightweight.

Published

Updated

Read time 9 min read

Reviewed byDeepak Prasad

Goroutine vs Thread in Go: Are Goroutines the Same as Threads?

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. GOMAXPROCS and 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:

go
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")
}
Output

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
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.

go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
}
Output

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.

go
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.

go
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")
}
Output

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


Frequently Asked Questions

1. Is a goroutine a thread?

No. A goroutine is a lightweight execution unit managed by the Go runtime. The runtime schedules many goroutines on a smaller pool of OS threads (m:n scheduling).

2. Does Go use OS threads?

Yes. Goroutines run on real kernel threads; the runtime multiplexes goroutines onto those threads and can move them when one blocks.

3. Does one goroutine mean one OS thread?

No. Many goroutines can share a single OS thread, and parallel Go code can use several threads at once up to scheduling limits such as GOMAXPROCS.

4. What does GOMAXPROCS control?

It sets an upper bound on how many OS threads can execute user Go code in parallel at once. It does not cap how many goroutines may exist.

5. Are goroutines free?

No. They are cheap compared to OS threads, but each goroutine still uses memory and scheduler work; huge counts or runaway loops can still hurt performance.

6. What is the G, M, P model?

G is a goroutine, M is an OS thread, and P is a logical processor resource used by the runtime to run Go code; goroutines are queued on Ps and run on Ms.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …