Free lesson · Concurrency Interview Mastery
Creating Goroutines
Go was designed from the ground up around concurrency, and it shows the moment you create your first goroutine: where every other language in this module makes you reach for a thread class or a task abstraction, Go gives you a single keyword - go - and a unit of concurrency so cheap you can have millions of them. This is the language I reach for when I want concurrency to feel native rather than bolted on, and goroutines are why. This lesson covers how to launch them, the lightweight nature that makes Go's whole concurrency story work, and the handful of gotchas that catch people coming from heavier threading models.
The go keyword
To run a function concurrently, you put go in front of the call. That's it.
func worker(id int) {
fmt.Println("working:", id)
}
func main() {
go worker(1) // launches worker(1) as a goroutine, returns immediately
go worker(2) // another goroutine
// ... main continues without waiting ...
}
The go statement starts the function running concurrently and returns immediately - it doesn't wait for the function to finish. The most common form uses an anonymous function (closure), which lets you run inline work and capture surrounding variables:
go func() {
fmt.Println("running concurrently")
}() // note the () - you're calling the function
That's the whole syntax. No thread object to construct, no pool to manage, no start() to remember. A goroutine is just "this function, running concurrently," and you launch it with one keyword. This minimalism is deliberate - Go wants concurrency to be so cheap and easy that you reach for it naturally.
Goroutines are extraordinarily lightweight
The reason go can be this casual is that goroutines are not OS threads. They're lightweight, runtime-managed units - Go's version of the green/virtual threads from Module 1 - multiplexed onto a small pool of OS threads by the Go scheduler (the subject of an upcoming lesson). A goroutine starts with a tiny stack (a few kilobytes) that grows and shrinks as needed, where an OS thread reserves around a megabyte. Creating one costs almost nothing.
The practical consequence is staggering: a Go program can comfortably run hundreds of thousands or millions of goroutines, where a thread-per-task program in a heavier language would exhaust memory in the low thousands. This is what lets Go handle a goroutine per connection in a server serving enormous concurrency - something that would be impossible with OS threads.
flowchart TD
subgraph G["Millions of goroutines (runtime-managed, ~KB each)"]
G1[goroutine 1]
G2[goroutine 2]
G3[goroutine 3]
Gn[goroutine ...millions]
end
subgraph OS["Few OS threads (~MB each)"]
M1[OS thread 1]
M2[OS thread 2]
end
G1 --> M1
G2 --> M2
G3 -.scheduler multiplexes.-> M1
Gn -.-> M2
This diagram shows how goroutines map onto OS threads in Go. At the top are millions of goroutines, each managed by the Go runtime and costing only a few kilobytes. At the bottom are just a few OS threads, each costing around a megabyte. The Go scheduler multiplexes the many goroutines onto the small number of OS threads - goroutine one runs on OS thread one, goroutine two on OS thread two, and the scheduler shuffles the rest across the available threads. So a handful of OS threads can run an enormous number of cheap goroutines.
Important: The mental shift coming from threads: you do not need to be frugal with goroutines. Where you'd carefully pool OS threads, in Go you launch a goroutine freely - per request, per connection, per task - because they're cheap. "Just start a goroutine" is idiomatic Go, not the resource extravagance it would be with threads. This abundance is foundational to how Go concurrency is written; the whole language assumes goroutines are nearly free.
The main goroutine gotcha
Here's the first thing that bites newcomers. main itself runs in a goroutine - the main goroutine - and when main returns, the program exits immediately, killing all other goroutines, finished or not.
func main() {
go func() {
time.Sleep(time.Second)
fmt.Println("this may never print")
}()
// main returns immediately - program exits, goroutine killed before it prints
}
The goroutine above never gets to print, because main returns instantly and the program tears down before the goroutine's sleep finishes. Unlike Java (where non-daemon threads keep the JVM alive), Go does not wait for goroutines - when main ends, everything ends.
Warning: Because the program exits when
mainreturns, you must explicitly wait for goroutines you care about. There's no automatic "wait for all goroutines" -gogives you no handle to join on, no goroutine ID, nothing. You coordinate completion through channels or async.WaitGroup. Forgetting this produces the classic "my goroutine didn't run" confusion, where the work was launched correctly but the program exited before it finished. Always have an explicit mechanism to wait for the goroutines whose completion matters.
Waiting for goroutines
Since there's no join, the idiomatic way to wait is sync.WaitGroup (covered fully in the sync lesson, previewed here because you need it immediately):
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1) // register one goroutine
go func(id int) {
defer wg.Done() // signal completion when this goroutine returns
worker(id)
}(i)
}
wg.Wait() // block until all registered goroutines call Done()
WaitGroup is a counter: Add increments it, each goroutine calls Done (best done with defer so it runs even on a panic), and Wait blocks until the counter hits zero. This is the standard "launch N goroutines and wait for all of them" pattern. The other coordination tool is channels, where a goroutine signals completion by sending on a channel and the waiter receives - we'll see that in the channels lesson.
The loop variable capture gotcha
One more classic, worth knowing because it's a frequent interview and bug topic. Before Go 1.22, a loop variable captured by a goroutine closure was shared across iterations, so all goroutines often saw the loop's final value:
// Pre-Go 1.22 BUG: all goroutines may print the same (final) value of i
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }() // captures shared i
}
// FIX (works on all versions): pass i as an argument, capturing its value
for i := 0; i < 3; i++ {
go func(i int) { fmt.Println(i) }(i) // each goroutine gets its own copy
}
Note: Go 1.22 changed loop semantics so each iteration gets a fresh copy of the loop variable, fixing this footgun for new code. But you'll still encounter the bug in older codebases and interview questions, so know both the problem (shared loop variable captured by reference) and the universal fix (pass the value as an argument to the goroutine, giving each its own copy). It's one of the most famous Go gotchas, and being able to explain it - and that 1.22 addressed it - signals you know the language's history.
Why Go made concurrency this cheap
It's worth pausing on the design intent, because the cheapness of goroutines isn't an incidental optimization - it's the bet the whole language is built on. Go's creators observed that the dominant concurrency model of the time, OS-thread-per-task, didn't scale: threads were expensive enough that you had to pool them carefully, and the resulting code (thread pools, futures, callback chains) was complex and easy to get wrong. Their wager was that if they made the concurrent unit cheap enough - cheap enough to spawn one per request, per connection, per small task without a second thought - then programmers could write concurrency in a straightforward, blocking style and let the runtime handle the efficiency.
That's the payoff hiding in goroutines. In many languages, to handle ten thousand simultaneous connections efficiently, you're forced into asynchronous, non-blocking, callback-or-future-based code, because you can't afford ten thousand threads. In Go, you write ten thousand goroutines that each do simple, blocking-style reads and writes, and the runtime multiplexes them onto a few threads, parking the ones that block and running the ones that are ready. You get the scalability of async I/O with the readability of sequential blocking code. This is the central ergonomic win of Go concurrency, and it traces directly back to the decision to make goroutines lightweight. When you write a Go server as "one goroutine per connection, each reading and writing as if it were the only one," you're cashing in exactly the bet the language made.
This also reframes how you should think relative to the other languages in this module. In Java or C#, the instinct drilled into you is to conserve threads - pool them, don't block them, use async for I/O. In Go, that instinct is actively wrong: goroutines are not a scarce resource to conserve, and writing simple blocking goroutine code is idiomatic and efficient. Carrying the thread-scarcity mindset into Go leads to over-complicated code that fights the runtime instead of using it. The Go mindset is abundance - spawn goroutines freely and let the scheduler earn its keep.
Goroutine leaks and other pitfalls
The flip side of goroutines being cheap and easy is that it's also easy to create them carelessly, and the most important pitfall to internalize is the goroutine leak. A goroutine that blocks forever - waiting to receive on a channel no one will ever send to, or to send on a channel no one will ever receive from - never terminates and never frees its stack and resources. Unlike a memory leak you might spot, a leaked goroutine sits invisibly blocked, and in a long-running server these accumulate until they degrade or exhaust the process.
func leak() {
ch := make(chan int)
go func() {
val := <-ch // blocks forever - nobody ever sends
use(val) // never reached; this goroutine leaks
}()
// function returns; the goroutine is stranded, blocked forever
}
The discipline against leaks is to ensure every goroutine has a guaranteed path to completion - every channel it waits on will eventually receive a value or be closed, every operation it blocks on will eventually unblock. The common tool for this is a cancellation mechanism: a context.Context or a done channel that signals goroutines to stop, so a goroutine waiting on work also watches for a cancellation signal and exits when told to. We'll see these patterns later, but the principle to carry from this lesson is that launching a goroutine creates an obligation to ensure it can finish. A second related pitfall is fire-and-forget goroutines whose panics you never see: a panic in a goroutine that nothing recovers will crash the entire program, so goroutines doing risky work often need their own recovery. The throughline is that goroutines being easy to start doesn't make them free to ignore - each one you launch is something you're responsible for being able to stop.
A complete worked example
Let me tie the pieces together with a small but complete program that launches several goroutines, waits for all of them, and collects their results - the shape of countless real Go programs. It exercises the go keyword, the main-returns gotcha, WaitGroup coordination, and the argument-passing fix for loop variable capture all at once.
func main() {
urls := []string{"a.com", "b.com", "c.com"}
results := make([]string, len(urls))
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1) // register this goroutine before launching
go func(idx int, u string) { // pass idx and u as args - each goroutine its own copy
defer wg.Done() // signal completion even if fetch panics-and-recovers
results[idx] = fetch(u) // each writes its OWN slot - no shared-write race
}(i, url)
}
wg.Wait() // block until all goroutines have called Done
fmt.Println(results) // safe: all writes happened-before Wait returned
}
Trace what makes this correct. Each iteration calls wg.Add(1) before launching the goroutine - adding to the WaitGroup must happen before the goroutine might call Done, or you can race the counter. The goroutine takes idx and u as parameters rather than capturing the loop variables, so each gets its own copy (the universal fix for the capture gotcha, and clear even on Go 1.22+). The defer wg.Done() guarantees the counter is decremented when the goroutine returns, no matter how it returns. Crucially, each goroutine writes to its own slice slot results[idx], so although they all touch the same slice, no two write the same element - there's no data race, because the accesses don't overlap. And wg.Wait() provides the happens-before relationship that makes reading results afterward safe: every goroutine's Done happens-before Wait returns, so all their writes are visible to the final Println.
This pattern - fan out work across goroutines into pre-assigned result slots, wait with a WaitGroup, then read - is one of the most common in Go, and it's worth being able to write it from memory. Notice how little ceremony it takes compared to the equivalent in a thread-based language: no thread pool, no futures, no manual joining, just go, a WaitGroup, and disjoint result slots. That economy is Go concurrency working as designed, and it's exactly what an interviewer wants to see you reach for naturally.
Note: The contrast that captures this lesson: in a thread-based language, "spawn a thread per request" is a mistake that exhausts resources, so you build pools and queues to avoid it. In Go, "spawn a goroutine per request" is the recommended design, because goroutines are cheap enough to make it efficient. That inversion - from conserving concurrent units to spawning them freely - is the single biggest mindset shift when you come to Go from threads, and internalizing it is what lets you write Go the way it's meant to be written.
The interview angle
Interview: Common questions: (1) "What's a goroutine and how does it differ from a thread?" - A lightweight, runtime-managed concurrent function launched with
go; far cheaper than an OS thread (KB vs MB stack), multiplexed onto OS threads by the Go scheduler, so you can run millions. (2) "How do you wait for goroutines to finish?" - There's no join; use async.WaitGroup(Add/Done/Wait) or channels. (3) "What happens whenmainreturns?" - The program exits immediately, killing all goroutines, finished or not - so you must explicitly wait for ones you care about. (4) "The loop variable capture bug?" - Pre-1.22, goroutine closures shared the loop variable; pass it as an argument (or use 1.22+). Knowing goroutines are cheap enough to spawn freely, and that you coordinate via WaitGroups/channels rather than joining, is the core of Go-concurrency fluency.
Quick reference
go f(args) // launch f as a goroutine; returns immediately
go func() { /* work */ }() // anonymous goroutine
// main returns -> program exits -> all goroutines killed. Wait explicitly!
var wg sync.WaitGroup // standard "wait for N goroutines"
wg.Add(1)
go func() { defer wg.Done(); work() }()
wg.Wait()
// pass loop vars as arguments to capture by value (pre-1.22 safety / clarity)
go func(i int) { use(i) }(i)
// goroutines are CHEAP (~KB) - spawn freely, per request/task/connection
Next, the rules that govern when one goroutine's writes become visible to another - Go's memory model - along with Go's standout feature for catching concurrency bugs: the built-in race detector.