Goroutine Lab
liveRun the same batch twice — sequentially and with one goroutine each — as I/O-bound waits or as real CPU work, and compare the wall time.
Learn Goroutines
What is it?
A goroutine is a function the Go runtime can run concurrently with others. You start one with the go keyword. It is not an operating-system thread.
The runtime multiplexes many goroutines (G) onto a smaller set of OS threads (M) using logical processors (P). GOMAXPROCS is how many Ps the process may use — usually the number of logical CPUs.
- G — goroutine: the unit of work you spawn.
- P — processor: a scheduler context. There are GOMAXPROCS of them.
- M — machine: an OS thread that actually runs a G while holding a P.
Why does it exist?
Sequential I/O wastes wall time: one call waits, everything behind it sits idle. Concurrent goroutines let those waits overlap. When the work is CPU, more than one P can run at once — that is parallelism, and it is a different ceiling.
How does Go implement it?
go starts a goroutine. It does not wait. If you need the results, you wait explicitly — this Lab uses sync.WaitGroup.
go process()
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()
What does our Lab demonstrate?
We run the same N tasks twice: once in a loop, then once with a goroutine per task and a WaitGroup. I/O mode parks each task with time.Sleep. CPU mode burns a FNV-1a loop. The JSON reports sequentialMs, concurrentMs, speedup, NumCPU and GOMAXPROCS.
GoroutinesObserved is runtime.NumGoroutine() sampled right after launch — a snapshot, not a guaranteed peak. Speedup is measured on this request; it is not a constant baked into the page.
Real-world use case
A checkout handler that must call fraud, inventory and pricing independently. Each HTTP client call is I/O. Launch one goroutine per call, wait, then assemble the response. The wall time is close to the slowest dependency, not the sum.
Go vs Java
Start work
go f()
thread.start() or executor.submit(task)
Platform Thread
Not 1:1 with a goroutine. An M is the OS thread.
java.lang.Thread mapped to an OS thread. Heavier stack, 1:1 scheduling.
Virtual Thread (21+)
Closer analogue: both are user-mode, M:N scheduled.
Thread.startVirtualThread / Executors.newVirtualThreadPerTaskExecutor().
Wait for a group
sync.WaitGroup, or errgroup
ExecutorService.invokeAll, CompletableFuture.allOf, StructuredTaskScope
Do not treat a goroutine as a Java Platform Thread. A Platform Thread is an OS thread. A Virtual Thread is the fair modern comparison for I/O-bound fan-out: both park cheaply while blocked.
They are still different models. Go has channels and select in the language. Java coordinates with executors, locks, queues and StructuredTaskScope. Virtual Threads can pin a carrier on some synchronized native calls; Go parks on syscalls through its own scheduler. Neither fact makes one language 'better'.
Other languages
JavaScript async/await is a single-threaded event loop — concurrency without parallel CPU in one isolate (Workers are a separate heap). Python asyncio is cooperative too, and the GIL still serializes CPU threads. C# Task usually runs on the thread pool; it is not a goroutine. Rust has OS threads and async tasks, not green threads in the standard library.
Common mistakes
- Spawning one goroutine per request fan-out with no cap — that is what the Worker Pool Lab is for.
- Starting work and never waiting or canceling it. The goroutine outlives the request.
- Sharing a slice, map or pointer across goroutines without a mutex or a channel.
- Assuming more goroutines means more speed on CPU-bound work. Parallelism stops at GOMAXPROCS.
When should I not use it?
Skip the extra goroutine when the work is already short and sequential — the scheduling cost is the whole story. Do not use goroutines as a substitute for a profiler: measure I/O overlap and CPU scaling separately, which is why this card has two modes.
Learn I/O-bound vs CPU-bound
What is it?
I/O-bound work spends most of its time waiting: the CPU is idle. Database queries, HTTP, filesystem and network calls look like this.
CPU-bound work spends its time computing: compression, image processing, cryptography, encoding, simulation. The core is busy until the scheduler preempts it.
Why does it exist?
The same go keyword does not buy the same speedup. Two hundred goroutines waiting on HTTP can overlap almost completely. Two hundred goroutines hashing compete for GOMAXPROCS logical processors.
How does Go implement it?
When a goroutine blocks on I/O, the runtime can park that G and run another G on the same M. When a goroutine burns CPU, it occupies a P until it yields. GOMAXPROCS is the parallelism knob, not the concurrency knob.
// I/O stand-in: the goroutine parks.
time.Sleep(wait)
// CPU stand-in: the goroutine computes.
for i := 0; i < n; i++ {
hash = fnv(hash, data)
}
What does our Lab demonstrate?
This card is one experiment with two modes. I/O uses time.Sleep per task. CPU uses a FNV-1a loop whose 'work' is iteration count, not milliseconds — wall time depends on the machine, so the page never hardcodes a speedup.
On I/O, concurrent wall time can approach the per-task wait, so speedup can approach the task count. On CPU, speedup tends toward NumCPU / GOMAXPROCS, not toward the task count. The response includes both numbers so you can see the ceiling on this run.
Real-world use case
Fetching twenty partner HTTP APIs in one request is I/O-bound: more goroutines help until you hit rate limits or sockets. Resizing twenty product photos in the same request is CPU-bound: more goroutines past the core count mostly add contention. Same fan-out shape, different limit.
Go vs Java
Blocked I/O
G parks; M can run another G.
Platform Thread stays occupied. Virtual Thread unmounts the carrier while blocked on most JDK I/O.
Heavy compute
Limited by GOMAXPROCS.
Limited by cores. A huge Platform or Virtual Thread flood still serializes on the CPU.
Pool choice
Goroutines are cheap; you still bound CPU work.
FixedThreadPool for compute. newVirtualThreadPerTaskExecutor() is for I/O, not a substitute for a compute bound.
Java 21 Virtual Threads removed the old '200 blocking calls need 200 Platform Threads' tax. They did not remove physics: 200 hash loops still share the same cores. That is the same lesson GOMAXPROCS teaches here.
Other languages
Node.js overlaps I/O well and moves CPU to worker threads. CPython asyncio overlaps I/O; CPU needs processes or a C extension. The names differ; the split does not.
Common mistakes
- Reading a big I/O speedup and assuming CPU mode will match.
- Spawning hundreds of CPU goroutines 'because goroutines are cheap'.
- Using Sleep as a model of compute — it models wait, which is why this Lab has a real hash loop.
When should I not use it?
Do not add concurrency to a CPU path that already saturates the cores. Do not treat every handler as I/O-bound because it 'calls a database' if the hot path is serialization or image work in-process.