Golang Concurrency · Part 1 / 11

Overview and
Goroutine Basics

Concurrency is structure, parallelism is execution
— what one line of go really does

Concurrency Goroutine GMP scheduler Leak prevention
goroutine schedule timeline GOMAXPROCS = 2
go func() { … }() ×8 M = OS thread
Greet the room + note this is Part 1 of 11. Say up front that the picture on the right is today's conclusion — 8 goroutines taking turns on 2 OS threads. Understanding this one slide is the goal.
problemwhy concurrency

Time spent waiting becomes wall-clock time

  • You must call three external APIs and merge the results
  • 100ms each — but most of that 100ms is waiting for a response
  • Sequentially that is 300ms. Meanwhile the CPU sits idle
  • The three calls are independent. Nothing stops the waits from overlapping
// 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's answer go and channel are provided by the language itself. There is no step where you pick a library.
Opening with "who has written code that calls three APIs one after another?" lands fast. The key point is that the CPU is not busy, it is waiting. Preview that Chapter 3 (when to use it) also covers the opposite case — CPU-bound work.
series11 parts

Where today sits in the whole series

PartTitleArea
Part 1Overview and Goroutine BasicsExecution unit
Parts 2 · 3Channels in Depth / Select and Advanced Channel PatternsCommunication
Parts 4 · 5Complete sync Package Guide / Complete Context GuideSync · lifecycle
Parts 6 · 7Concurrency Patterns in Practice / Error Handling StrategiesDesign patterns
Parts 8 · 9Go Memory Model and Atomic / Debugging and Race DetectorLow level · debugging
Parts 10 · 11Real Project and Best Practices / go tool trace VisualizationPractice · 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.

Do not try to introduce the whole series. Just place today as "the part that lays the floor". Drawing a line at "channels come next time" keeps questions from running ahead.
agendaagenda

What we cover today

01
What concurrency is
Concurrency vs Parallelism · CSP model · when to use it and when not
02
Goroutine basics
the go keyword · vs OS threads · ordering and lifecycle
03
GMP scheduler
roles of G · M · P · work stealing · GOMAXPROCS
04
Comparison with other languages
Kotlin Coroutine · Java Platform / Virtual Thread · function coloring
05
Goroutine leak
why they leak · stopping them with context · the exit-path rule
Chapters 1 and 2 are concept and basics, 3 is the mechanism, 4 is for an audience with background, 5 is the practical trap. If time runs short, cut Chapter 4 (language comparison) entirely and keep 5.
01

What concurrency is

Concurrency and parallelism are not the same word. Starting there, we look at why Go chose CSP — and when not to use it.

This chapter is conceptual and can drag. Change the rhythm — one table, one diagram, one quote — and move quickly.
01 · conceptconcurrency vs parallelism

Often mixed up, but different words

AspectConcurrencyParallelism
DefinitionStructure for dealing with many tasksRunning many tasks at the same time
CoreComposition of workExecution of work
CPUHolds even on a single CPUNeeds multiple CPUs
AnalogyOne person alternating between jobsSeveral people each working at once
Remember just this Concurrency holds on a single-core machine. Dealing with at once and running at once are separate things.
Ask the room "is concurrency possible on one CPU?" first — most say no. Flipping that once here makes the rest easy. Directly tied to quiz Q1.
01 · concepttimeline

Same two tasks, two different pictures

Concurrency — 1 CPU · taking turns
A B A B A B A
Parallelism — 2 CPUs · truly at the same time
Task Aidle
Task Bidle
starttime →end
Go's stance Designing the program concurrently is the developer's job; how many CPUs it runs on is the runtime's call.
Point with your hand at how the lower two lanes finish earlier (horizontal length) for the same amount of work. Add one line that speed is not always the point of concurrency — structure comes first. With only one CPU you get the top picture, and concurrency still holds.
01 · conceptrob pike

One sentence from Go's creator

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

The sentence comes from the Go Blog talk "Concurrency is not parallelism". The link to the original video is on the last reference slide. Pause here and give people time to read.
01 · conceptcsp

The model Go picked — CSP

Communicating Sequential Processes, proposed by Tony Hoare in 1978. There is only one core idea — independent processes communicate by passing messages.

CSP conceptGo's implementation
Independent unit of executiongoroutine
Means of passing messageschannel
Goroutine Aowns the data
Goroutine Breceives the data
Why this is safe Since ownership of the data moves through the channel, only one goroutine touches it at any moment.
The name CSP itself does not matter. Leaving one line — "they hand things over as messages" — is enough. If the actor model in Erlang/Akka comes up, just note that Go has channels as first-class objects instead of mailboxes, and move on.
01 · conceptgo proverb

Do not communicate by sharing memory; share memory by communicating

Traditional — Shared Memory + Lock

A human has to apply the guard. Deadlocks and race conditions come from here.

The Go way — Message Passing

Goroutine A
Goroutine B

Because ownership moves, two places never touch it at once. The lock becomes unnecessary.

But it is not an absolute rule Go has sync.Mutex and uses it when needed. Go's attitude is simply that the default choice is a channel.
"So we should never use a mutex?" always comes up. The last callout is the answer. Cases like a counter that must share state are covered in Part 4 (the sync package).
01 · conceptwhen to use

Concurrency is not free

Scattering goroutines does not make things faster. You pay the cost of complexity first.

Worth using

  • Work with a lot of I/O waiting — HTTP requests, DB queries, file reads/writes
  • Parallel handling of independent work — calling several APIs at once
  • Event-driven handling — request handling in a web server
  • Pipeline processing — chaining data transform stages

Over-engineering

  • When plain sequential code is enough — simple data transforms
  • Spawning too many goroutines for CPU-bound work
  • So much shared state that locking gets complex — revisit the design
  • Complex enough that debugging becomes hard
The right-hand card matters more. In particular, throwing 10,000 goroutines at CPU-bound work does not go faster than the core count — this connects back in Chapter 3 with GOMAXPROCS.
02

Goroutine basics

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.

The core of this chapter is not "it is light" but two things: "order is not guaranteed" and "when main ends, everything ends". The mine people step on most in practice is right here.
02 · Goroutinego keyword

Put go in front of a call. That is all

// create a goroutine — the go keyword
go func() {
    fmt.Println("goroutine ran")
}()

// named functions work too
go sayHello("World")
  • A lightweight unit of execution managed by the Go runtime — not an OS thread
  • No extra library, no thread-pool config, no async/await
  • The call returns immediately. The function starts running elsewhere
  • The whole standard library is designed on this premise
What comes with it As easy as it is to create, it is just as easy to forget to clean up. Chapter 5 pays that price.
A quick demo works well here. Showing that a Println with go in front prints nothing leads naturally into slide 17 (the main goroutine).
02 · Goroutinevs os thread

How is it different from an OS thread

AspectGoroutineOS Thread
Initial stack size~2KB — grows dynamically as needed~1MB fixed
Creation costVery cheapRelatively expensive
SchedulingGo runtime — user spaceOS kernel
Concurrent countHundreds of thousandsThousands
Context switchFast — three registersSlow — all registers
The key sentence Goroutines are multiplexed onto OS threads. Thousands to tens of thousands run on a handful of OS threads.
Why "scheduling in user space" is fast — because there is no switch into kernel mode. Do not go deep here; say it will be drawn out in Chapter 3 on GMP.
02 · Goroutinestack size

Put 2KB and 1MB on the same axis

1 MB÷2 KB=
0 × — that many more in the same memory

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.

OS Thread 1 MB
Goroutine 2 KB

The lower bar looking like a single line is not a bug. That is the real ratio.

Pause while the number counts up. Once 512 sinks in, "10,000 goroutines" on slide 19 needs no explaining.
02 · Goroutinenon-deterministic

Execution order is not guaranteed

  • Launching 10 in order does not mean they run in order
  • Which P's queue they land in and when they are pulled differs every run
  • If a test happens to pass, that is luck
If you need order You have to build it yourself with a channel or sync. 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]
Running the demo two or three times so a different result appears each time needs no further explanation. Note that since Go 1.22 the for-loop variable is fresh each iteration, so you no longer pass i as an argument.
02 · Goroutinelifecycle

When main ends, everything ends

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())
}
  • The goroutine just vanishes with no chance to clean up — even defer does not run
  • Log flushes and connection closes are lost too
  • 90% of “why is nothing printed?” is this
Rule If you create a goroutine, decide who waits for it at the same time.
If you ran the go Println demo on slide 13, give the answer here. People have seen code patched with time.Sleep; why that is bad gets explained next when it is replaced by a WaitGroup.
02 · Goroutinewaitgroup

How to wait — sync.WaitGroup

func 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())
}
01
wg.Add(n)
Raise the count before launching the goroutine
02
defer wg.Done()
Put it on the first line inside the goroutine — the counter drops even on panic
03
wg.Wait()
Blocks until the count reaches zero

A channel can do the same job. Code patched with time.Sleep is gambling, not waiting.

Mention in one line the trap of calling Add inside the goroutine, which creates a race. The rest of WaitGroup's details are covered in Part 4.
02 · Goroutinelightweight

Create 10,000 and nothing happens

0 goroutines — all finished fine
Creating 10,000Initial 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())
Say "10,000" in a tone that makes it the default, not something to marvel at. It does not mean you can create them without limit though — if you need a cap, use the semaphore pattern (Part 6).
03

GMP scheduler

The OS knows nothing about goroutines, so how does this work? We look at how the Go runtime schedules in user space.

This is internals, and going deep has no end. The goal is to leave only three letters — G, M, P — plus work stealing and GOMAXPROCS.
03 · schedulergmp model

A structure in three letters — G · M · P

G1running
G2waiting — run queue
G3waiting — run queue
P1logical processor · owns run queue
M1OS Thread — runs on the CPU
CPU Corehardware
ComponentRole
G — GoroutineLightweight unit holding the function to run and its stack
M — MachineOS thread. It actually executes code on the CPU
P — ProcessorLogical processor. It manages the run queue of goroutines
Why P is needed comes up every time. Explaining it as "M (the thread) can block, so the list of work (P) was pulled off the thread" meshes with the three steps on the next slide.
03 · schedulerscheduling flow

One trip around the loop

01
go f()
A new G goes into the current P's local run queue
02
P → M
P pulls goroutines off the queue one by one and runs them on its bound M
03
block → switch
On I/O, channel waits or time.Sleep, it switches to the next G at once
04
work stealing
When the local queue empties, it steals from another P's queue
Why this switch is cheap It never goes through the kernel. In user space, swapping a few registers is the whole job.
What work stealing does It keeps a busy P 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.

Step 3 is the answer to slide 2 (waiting on I/O). Connect the picture: while waiting, M is not idle — it grabs the next G.
03 · schedulergomaxprocs

GOMAXPROCS — how many can run at once

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

The most common misunderstanding is "isn't it a cap on the number of goroutines?". Quiz Q4 asks exactly that. Do not go deep on the container issue where GOMAXPROCS picks up the host core count — one line is enough.
04

Comparison with other languages

Set side by side with Kotlin coroutines and Java threads, it gets clear what goroutines gave up and what they gained.

Adjust the weight to the audience. With many JVM people this is the chapter that lands best; otherwise one table is enough before moving on.
04 · comparisonside by side

All four side by side

AspectGo GoroutineKotlin CoroutineJava Platform ThreadJava Virtual Thread (21+)
Stack size~2KB · growsstackless · heap object~1MB fixed~few KB · dynamic
SchedulingGo runtime · preemptivecooperative · suspend/resumeOS kernelJVM · cooperative
Creation costVery cheapVery cheapExpensiveCheap
Concurrent countHundreds of thousandsHundreds of thousandsThousandsMillions
CommunicationChannel — CSPFlow, Channelsynchronized, Locksynchronized, Lock
The rows to watch Not the numbers — scheduling and communication. The remaining slides just unpack those two.
Do not read the table cell by cell. Point at two rows and move on. If "millions" for Virtual Thread gets a reaction, preview that the missing channel is covered on slide 28.
04 · comparisonpreemptive vs cooperative

The biggest difference is when it yields

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.

Where you feel it in practice In Go you never have to think about “inserting a yield point”.
One line of history: before Go 1.14 a tight loop really could starve the scheduler. Today signal-based asynchronous preemption is in place.
04 · comparisonfunction coloring

Function coloring — Go has no colors

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.

Where Kotlin is better instead Structured concurrency is built into the language, so cancelling a parent cancels its children, and CoroutineExceptionHandler makes error propagation systematic.
This slide keeps the balance. Taking a "Go wins" tone closes off the JVM crowd. State on the next slide that Go has no structured concurrency, so Context and WaitGroup must be managed by hand.
04 · comparisonjava

Java — closer now with virtual threads

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

What still differs Java has no channel-like communication mechanism built into the language. You pick a separate tool such as BlockingQueue or CompletableFuture.
So the conclusion “Lightweight units of execution” are no longer Go's alone. Where Go still differs is that the unit of execution and the means of communication ship together in the language.
If people know Loom/virtual threads, the conversation can run long here. Steer it back to the axis of "communication built into the language" and move on.
04 · comparisonsummary

Recap — what goroutines got and did not get

Strengths

01
Built into the language
go + chan come as keywords, so no extra library is needed
02
No coloring problem
Every function is the same — no async/await/suspend split
03
Preemptive
The runtime switches even CPU-bound goroutines automatically
04
Consistent ecosystem
The whole standard library is designed around goroutines

Weaknesses

01
No structured concurrency
Parent–child ties are not in the language. You manage Context and WaitGroup by hand
02
Panic propagation
A panic in a goroutine can terminate the whole program
Coming next What happens when you get lazy about "managing it by hand" — that is a goroutine leak.
The weakness card is the bridge into the next chapter. Go straight to Chapter 5 from here.
05

Goroutine leak

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

The chapter that touches real work most directly. Even when time is short, keep this one.
05 · Leakwhat & why

A goroutine that stays alive instead of ending

It is no longer needed yet never finishes. It holds memory and is not a GC candidate, so usage keeps climbing over time.

Typical causeIn what code
Channel waitBlocked forever on a channel nobody receives from or sends to
Infinite loopA goroutine with no exit condition
No contextA goroutine with no way to receive a cancel signal
Why the GC cannot catch it A running goroutine is a live root. Even blocked, it counts as "running", so it is not reclaimable.
// counting live goroutines shows it
runtime.NumGoroutine()
"I have heard of memory leaks but never goroutine leaks" is a common reaction. Tell them that on a long-running server, NumGoroutine trending upward is the signal. Part 9 covers catching it with pprof.
05 · Leakbefore → after

The leaky code, and the fixed code

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
  • The channel is unbuffered, so the send finishes only once a receiver shows up
  • If the caller never reads it, the goroutine stops forever at ch <- 42
  • Repeat this per request and goroutines pile up
The symptom Memory usage climbs slowly but steadily. A restart makes it fine for a while.
  • Buffered channel — the send does not block even with no receiver
  • select + ctx.Done() — on cancel it leaves via return
  • defer close(ch) — the channel is cleaned up on exit too
What changed The goroutine now has a way to be ended from outside.
Press the two buttons alternately to compare. Ask "can you see why this code leaks?" first, and if no answer comes, hint only that a send on an unbuffered channel blocks. Ties to quiz Q6.
05 · Leakthe rule

When you create a goroutine, create its exit path too

context

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

One question to ask in code reviewWhen and by whom does this goroutine end?” — if there is no answer, it is a leak.

The full picture of Context is in Part 5, and actually catching leaked goroutines is in Part 9.

If you take exactly one line from today's talk, it is this callout. Stress that it was given in a form you can use in a real code review.
wrap-uprecap

What to take away today

  • Concurrency is structure, parallelism is execution — write the structure, the runtime supplies the execution
  • Go picked CSP — do not communicate by sharing memory; share memory by communicating
  • A goroutine is a ~2KB unit made with one go. Tens of thousands is normal
  • Order is not guaranteed, and when main ends everything ends — build it yourself if needed
  • Scheduling is three pieces, G · M · P. GOMAXPROCS (default = core count) sets how many run at once
  • Always create the exit path along with the goroutine — otherwise it leaks
Reading the six lines out loud is enough of a summary. You can start taking questions from here.
checkclick to reveal

Answer these yourself

Yes. Concurrency is the structure of dealing with many tasks at once; needing several CPUs is parallelism. On one core they simply take turns. → Slide 06
No. Execution order is not guaranteed. If you need order, build it yourself with a channel or sync. → Slide 16
The process exits and the goroutine never completes. Not even defer runs. Wait with a WaitGroup or a channel. → Slide 17 · 18
No. There is just one P, so only one runs at a time. You can still create any number of goroutines: concurrency without parallelism. → Slide 23
① Scheduling — Kotlin is cooperative and yields only at suspend points; Go is preemptive and the runtime forces a switch. ② Function coloring — Go has no suspend-style split. → Slide 26 · 27
The send blocks forever and the GC cannot reclaim it — a goroutine leak. Build an exit path with a buffered channel plus select/ctx.Done(). → Slide 32
Let the audience answer first, then click to open. If time is tight, picking just Q4 and Q6 is enough.
nextpart 2

Next up — Channels in Depth

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

Left for later today Unbuffered vs buffered, what close exactly means, directional channels — everything the leak slide brushed past is the subject of the next part.

References

  • Full code — github.com/kenshin579/tutorials-go /golang/concurrency
  • Effective Go — Concurrency go.dev/doc/effective_go#concurrency
  • Concurrency is not parallelism go.dev/blog/waza-talk
  • Share Memory By Communicating go.dev/blog/codelab-share
  • Go Proverbs go-proverbs.github.io
Thank you Questions welcome — advenoh.pe.kr
Take Q&A with the GitHub repository address left on screen.
Speaker notes
← Article Goroutine Basics · Go Concurrency 1
01 / 36

Shortcuts

→ · Space · PgDn
Next slide
← · PgUp
Previous slide
Home · End
First · Last
O
Slide overview
N
Speaker notes
T
Light / dark theme
F
Fullscreen
Esc
Close