Concurrency is structure, parallelism is execution
— what one line of go really does
// sequential — the waits pile up a := fetch(u1) // 100ms b := fetch(u2) // 100ms c := fetch(u3) // 100ms // total 300ms // concurrent — the waits overlap for i, u := range urls { go func() { out[i] = fetch(u) }() } wg.Wait() // total ~100ms
go and channel are provided by the language itself. There is no step where you pick a library.
| Part | Title | Area |
|---|---|---|
| Part 1 | Overview and Goroutine Basics | Execution unit |
| Parts 2 · 3 | Channels in Depth / Select and Advanced Channel Patterns | Communication |
| Parts 4 · 5 | Complete sync Package Guide / Complete Context Guide | Sync · lifecycle |
| Parts 6 · 7 | Concurrency Patterns in Practice / Error Handling Strategies | Design patterns |
| Parts 8 · 9 | Go Memory Model and Atomic / Debugging and Race Detector | Low level · debugging |
| Parts 10 · 11 | Real Project and Best Practices / go tool trace Visualization | Practice · tooling |
Today is the first part. The other ten all sit on top of it, so what a goroutine is and how it gets scheduled is settled here.
go keyword · vs OS threads · ordering and lifecycleGOMAXPROCScontext · the exit-path ruleConcurrency and parallelism are not the same word. Starting there, we look at why Go chose CSP — and when not to use it.
| Aspect | Concurrency | Parallelism |
|---|---|---|
| Definition | Structure for dealing with many tasks | Running many tasks at the same time |
| Core | Composition of work | Execution of work |
| CPU | Holds even on a single CPU | Needs multiple CPUs |
| Analogy | One person alternating between jobs | Several people each working at once |
“Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.”
— Rob Pike
Dealing with — structure
Splitting code into independently runnable units. This is a story about the program's structure.
Doing — execution
Actually running those units at once on several CPUs. That part belongs to the runtime and the hardware.
Communicating Sequential Processes, proposed by Tony Hoare in 1978. There is only one core idea — independent processes communicate by passing messages.
| CSP concept | Go's implementation |
|---|---|
| Independent unit of execution | goroutine |
| Means of passing messages | channel |
Traditional — Shared Memory + Lock
A human has to apply the guard. Deadlocks and race conditions come from here.
The Go way — Message Passing
Because ownership moves, two places never touch it at once. The lock becomes unnecessary.
sync.Mutex and uses it when needed. Go's attitude is simply that the default choice is a channel.
Scattering goroutines does not make things faster. You pay the cost of complexity first.
Worth using
Over-engineering
A unit of execution made with one keyword. You avoid accidents only by knowing exactly how light it is and what it does not guarantee.
// create a goroutine — the go keyword go func() { fmt.Println("goroutine ran") }() // named functions work too go sayHello("World")
async/await| Aspect | Goroutine | OS Thread |
|---|---|---|
| Initial stack size | ~2KB — grows dynamically as needed | ~1MB fixed |
| Creation cost | Very cheap | Relatively expensive |
| Scheduling | Go runtime — user space | OS kernel |
| Concurrent count | Hundreds of thousands | Thousands |
| Context switch | Fast — three registers | Slow — all registers |
That is why hundreds of thousands of goroutines and thousands of OS threads are the realistic limits. And a goroutine stack is not fixed — it grows when needed.
The lower bar looking like a single line is not a bug. That is the real ratio.
P's queue they land in and when they are pulled differs every runsync. It is not given by default.
const numGoroutines = 10 wg.Add(numGoroutines) for i := range numGoroutines { go func() { defer wg.Done() mu.Lock() order = append(order, i) mu.Unlock() }() } wg.Wait() t.Logf("exec order: %v", order) // exec order: [1 4 2 3 5 9 8 0 6 7]
main() runs on the main goroutine. Once it returns, the process exits regardless of whether other goroutines finished.
func TestMainExitKillsGoroutines(t *testing.T) { var completed atomic.Bool go func() { time.Sleep(100 * time.Millisecond) completed.Store(true) }() // without waiting it never completes assert.False(t, completed.Load()) }
defer does not runsync.WaitGroupfunc TestWaitGroupSolution(t *testing.T) { var completed atomic.Bool var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() time.Sleep(50 * time.Millisecond) completed.Store(true) }() wg.Wait() // block until done assert.True(t, completed.Load()) }
A channel can do the same job.
Code patched with time.Sleep is gambling, not waiting.
| Creating 10,000 | Initial stack total |
|---|---|
| Goroutine (~2KB) | about 20 MB |
| OS Thread (~1MB) | about 10 GB |
Plain arithmetic from the stack sizes on the previous slide.
const numGoroutines = 10000 var counter atomic.Int64 var wg sync.WaitGroup wg.Add(numGoroutines) for range numGoroutines { go func() { defer wg.Done() counter.Add(1) }() } wg.Wait() assert.Equal(t, int64(numGoroutines), counter.Load())
The OS knows nothing about goroutines, so how does this work? We look at how the Go runtime schedules in user space.
| Component | Role |
|---|---|
| G — Goroutine | Lightweight unit holding the function to run and its stack |
| M — Machine | OS thread. It actually executes code on the CPU |
| P — Processor | Logical processor. It manages the run queue of goroutines |
G goes into the current P's local run queueP pulls goroutines off the queue one by one and runs them on its bound Mtime.Sleep, it switches to the next G at onceP and an idle P from coexisting, evening out the load on its own.
Since Go 1.14 it is preemptive, so the runtime forcibly pulls off a goroutine that hogs the CPU.
GOMAXPROCS — how many can run at onceIt is not how many goroutines you can create; it sets how many Ps exist. The default is the CPU core count.
func TestGOMAXPROCS(t *testing.T) { // passing 0 returns the current value unchanged currentProcs := runtime.GOMAXPROCS(0) numCPU := runtime.NumCPU() t.Logf("NumCPU: %d", numCPU) // 12 t.Logf("GOMAXPROCS: %d", currentProcs) // 12 runtime.GOMAXPROCS(1) // down to one P }
GOMAXPROCS = 1
You can still create as many goroutines as you like. Only one runs at a time — concurrency yes, parallelism no. Useful for reproducing races and debugging.
GOMAXPROCS = N
At most N run physically at once. Leaving the default alone is recommended. In a container, just check that it matches the CPU limit.
Set side by side with Kotlin coroutines and Java threads, it gets clear what goroutines gave up and what they gained.
| Aspect | Go Goroutine | Kotlin Coroutine | Java Platform Thread | Java Virtual Thread (21+) |
|---|---|---|---|---|
| Stack size | ~2KB · grows | stackless · heap object | ~1MB fixed | ~few KB · dynamic |
| Scheduling | Go runtime · preemptive | cooperative · suspend/resume | OS kernel | JVM · cooperative |
| Creation cost | Very cheap | Very cheap | Expensive | Cheap |
| Concurrent count | Hundreds of thousands | Hundreds of thousands | Thousands | Millions |
| Communication | Channel — CSP | Flow, Channel | synchronized, Lock | synchronized, Lock |
Kotlin — cooperative
// suspends only at suspend points suspend fun fetchData() { delay(1000) // yields here // CPU work without suspend // never yields }
Code that just spins on the CPU holds the thread and will not let go.
Go — preemptive
// runtime switches, no keyword func fetchData() { time.Sleep(time.Second) // CPU-bound work too is // forcibly switched (Go 1.14+) }
If a goroutine hogs the CPU, the runtime pulls it off.
Kotlin — the color spreads
suspend fun a() = b() suspend fun b() = c() suspend fun c() = delay(100) // the moment c becomes suspend, // suspend spreads up the whole chain
A suspend function is callable only from a suspend function or a coroutine.
Go — they are all the same function
func a() { b() } func b() { c() } func c() { time.Sleep(t) } go a() // any func as a goroutine a() // or just call it
There is no async · await · suspend distinction at all.
CoroutineExceptionHandler makes error propagation systematic.
// Platform Thread — 1:1 with an OS thread new Thread(() -> doWork()).start(); // ~1MB stack allocated // Virtual Thread (Java 21+) — goroutine-like Thread.startVirtualThread(() -> doWork());
Past a few thousand platform threads, memory and context-switch costs spike. Virtual threads solve that conceptually the same way goroutines do.
BlockingQueue or CompletableFuture.
Strengths
go + chan come as keywords, so no extra library is neededasync/await/suspend splitWeaknesses
Context and WaitGroup by handpanic in a goroutine can terminate the whole programAs easy as they are to create, they are just as easy to forget to clean up. A leaked goroutine is not reclaimed by the GC either.
It is no longer needed yet never finishes. It holds memory and is not a GC candidate, so usage keeps climbing over time.
| Typical cause | In what code |
|---|---|
| Channel wait | Blocked forever on a channel nobody receives from or sends to |
| Infinite loop | A goroutine with no exit condition |
| No context | A goroutine with no way to receive a cancel signal |
// counting live goroutines shows it runtime.NumGoroutine()
leakyFunc := func() <-chan int { ch := make(chan int) go func() { ch <- 42 // blocks forever if nobody receives }() return ch } _ = leakyFunc() // returned but never used → leak! time.Sleep(50 * time.Millisecond) runtime.NumGoroutine() // 2 at start → 3 after the leak
safeFunc := func(ctx context.Context) <-chan int { ch := make(chan int, 1) // buffered → send never blocks go func() { defer close(ch) select { case ch <- 42: // value delivered normally case <-ctx.Done(): // exit when context is cancelled return } }() return ch } ctx, cancel := context.WithCancel(context.Background()) ch := safeFunc(ctx) cancel() // cancelling cleans the goroutine up
ch <- 42select + ctx.Done() — on cancel it leaves via returndefer close(ch) — the channel is cleaned up on exit toocontext
Slot ctx.Done() into a select. Cancel, timeout and deadline all arrive here.
done channel
For sending only a stop signal where context is awkward. Closing it wakes every receiver.
close
The sender announces it is done sending. Pair it with defer close(ch).
The full picture of Context is in Part 5, and actually catching leaked goroutines is in Part 9.
go. Tens of thousands is normalGOMAXPROCS (default = core count) sets how many run at oncesync. → Slide 16defer runs. Wait with a WaitGroup or a channel. → Slide 17 · 18P, so only one runs at a time. You can still create any number of goroutines: concurrency without parallelism. → Slide 23suspend-style split. → Slide 26 · 27select/ctx.Done(). → Slide 32Today covered only the unit of execution. Even if you can launch many goroutines, there is nothing you can do if they cannot pass data between them. Next time we take the other half of CSP, channels, from start to finish.
close exactly means, directional channels —
everything the leak slide brushed past is the subject of the next part.
References
github.com/kenshin579/tutorials-go /golang/concurrencyadvenoh.pe.kr