Concurrency Is Not Parallelism and a Go Demo Shows It
Concurrency vs parallelism in Go shown with a runnable experiment: CPU-bound and waiting tasks timed on one processor and on many, and what each tells you.

People say "concurrency is not parallelism" so often that it has become a slogan, and slogans lose their content. The distinction is useful, though, and Go makes it visible, because the language gives you concurrency (goroutines, channels) and the runtime decides about parallelism (how many processors it runs them on). This article makes the difference concrete with one experiment, then draws the consequences for how you size and structure programs.
Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64
It supports goroutines, the scheduler, GOMAXPROCS in containers and the sizing advice in worker pool size, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6 on an Apple M1 Max.
The definitions
Concurrency is a property of the design: the program is composed of tasks that are independent enough that their execution can overlap in time. A web server handling many requests is concurrent. Parallelism is a property of the execution: two tasks are running at the same instant on different cores. A concurrent program may or may not be parallel, depending on how many processors it has and what the tasks are doing at the moment.
The Go team states the distinction in the talk "Concurrency is not parallelism", summarized in the Go blog's post on it: concurrency is about dealing with lots of things at once, parallelism is about doing lots of things at once. The first is a way of structuring a program, the second is a way of running it. You can have the first on a single core, which is what an operating system with one CPU did for decades.
The experiment
Two kinds of task, each run eight times at once, each timed twice: with GOMAXPROCS(1), so only one goroutine can execute Go code at any moment, and with GOMAXPROCS(8). The first kind waits for 100 ms with time.Sleep, standing in for a network call or a disk read. The second kind computes for about 100 ms of CPU time in a tight loop:
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
var sink uint64
// spin burns CPU for a fixed amount of work, about 100 ms on this machine.
func spin() {
var x uint64 = 1
for i := uint64(0); i < 78_000_000; i++ {
x = x*6364136223846793005 + 1442695040888963407
}
sink += x
}
func wait() { time.Sleep(100 * time.Millisecond) }
func run(procs int, task func()) time.Duration {
runtime.GOMAXPROCS(procs)
start := time.Now()
var wg sync.WaitGroup
for range 8 {
wg.Go(task)
}
wg.Wait()
return time.Since(start).Round(10 * time.Millisecond)
}
func main() {
fmt.Println("one task of CPU work alone:", run(1, func() { spin() })/8)
fmt.Println()
fmt.Println("8 tasks that wait 100 ms each:")
fmt.Println(" GOMAXPROCS=1:", run(1, wait))
fmt.Println(" GOMAXPROCS=8:", run(8, wait))
fmt.Println("8 tasks that compute for about 100 ms each:")
fmt.Println(" GOMAXPROCS=1:", run(1, spin))
fmt.Println(" GOMAXPROCS=8:", run(8, spin))
}one task of CPU work alone: 101.25ms
8 tasks that wait 100 ms each:
GOMAXPROCS=1: 100ms
GOMAXPROCS=8: 100ms
8 tasks that compute for about 100 ms each:
GOMAXPROCS=1: 810ms
GOMAXPROCS=8: 120msThe first line is a calibration: eight tasks on one processor divided by eight gives the cost of one task, 101 ms here. Then the interesting part. Eight tasks that each wait 100 ms finish in about 100 ms on one processor, and in about 100 ms on eight. Waiting does not use the CPU, so one processor can have eight goroutines all waiting at once; when the sleeps end, the goroutines run for microseconds each. That is concurrency without parallelism, and it is enough: the total time is the length of the longest wait, not the sum.
The CPU-bound tasks behave differently. On one processor eight tasks of 100 ms took 810 ms, because only one can run at a time and they take turns. On eight processors they took 120 ms: eight at the same instant, which is parallelism (the extra 20 ms is scheduling and the cores not being identical in speed). The same code, the same goroutines, and a factor of nearly seven in the result depending on a single setting.
What this tells you
Concurrency is the right design for waiting. A server that spends most of its time waiting on databases and clients gets nearly all its benefit from concurrency, and extra cores help only for the CPU part of each request. This is why one small container handles thousands of connections, and why limiting a request handler's goroutines is about protecting the thing being waited on, not the CPU.
Parallelism is the right resource for computing. A hashing job, an image resize, a compression pass or a parse of a large file scales with cores until it runs out of them. The speedup is real and bounded: eight cores give at best eight times, and usually less, because of memory bandwidth, uneven chunks and the serial parts of the program (Amdahl's law). The number of goroutines past the number of cores does nothing for CPU-bound work. A hundred goroutines hashing files on an eight-core machine take as long as eight, minus nothing, plus scheduling overhead.
The runtime decides, you structure. You do not ask for parallelism in Go; you write independent tasks and the scheduler spreads them over GOMAXPROCS processors. If GOMAXPROCS is wrong, parallelism is wrong, which happens in containers with CPU limits (Go reads the machine's core count, not the cgroup limit, in older versions). A container limited to 2 CPUs on a 64-core host used to run with 64 processors and get throttled by the kernel in bursts, producing latency spikes. Go 1.25 made the runtime container-aware, reading the cgroup CPU limit to set the default, which fixed the most common case (GOMAXPROCS in containers).
Mixed workloads need both. A real request waits, then computes, then waits. For that, run many goroutines (enough to keep every wait overlapped) with GOMAXPROCS at the core count (enough to keep every computation parallel). Sizing the pool of workers for a mixed job follows the same logic in the worker pool sizing article: CPU-bound work wants about one worker per core, I/O-bound work wants as many as the other side can serve.
The mistake in both directions
The first mistake is expecting concurrency to speed up CPU-bound code. A developer splits a loop across 100 goroutines on a 4-core laptop, sees little improvement, and concludes that goroutines are slow. The work was never limited by waiting; it was limited by four cores, and the extra goroutines add stacks and switches. The fix is to partition the work into about as many chunks as cores (partitioning a slice) and to measure.
The second is expecting parallelism to fix waiting. Adding cores to a service that is slow because every request waits 200 ms on a database changes nothing, because the CPU was idle already. The fix is to reduce the wait, overlap independent waits with concurrency, or cache.
A third, subtler: a sequential program becomes concurrent without becoming faster when its tasks depend on each other. A pipeline of three stages processes three items at once only if there are three items in flight; one item passes through the stages in sequence, however many goroutines you have. Concurrency exposes the overlap that the dependencies allow, and no more.
A way to check which one you have
Ask what the program does while a task runs. If it waits for something external, more concurrency overlaps the waits. If it computes, more parallelism divides the work, and then compare the number of runnable goroutines with the number of cores. The execution tracer shows it directly: gaps in the processors' timelines mean idle capacity waiting on something, and solid blocks across all processors mean you are CPU-bound (the execution tracer). The runtime package documentation for GOMAXPROCS explains how the setting works, and the Go blog's post linked above is the original statement of the distinction.


