GUIDE
GoTutorialBackendSystems Programming

Module 1 of 6

Module 1: Goroutines & the Scheduler

Understand Go's M:N scheduler — why goroutines are not threads, not async/await, and how to manage goroutine lifecycles without leaks.

6 min read
by Faisal Ahmed Sifat

Module 1: Goroutines & the Scheduler

The mental model shift

Here's the trap almost everyone falls into when they start using goroutines.

You see go doSomething() and think "ah, it's like spawning a thread" or "it's like async in JavaScript." Both assumptions are wrong, and both will lead you to write code that works in development and explodes in production.

Goroutines are not threads. They're not async/await. They're not Python coroutines. They're their own thing, and until you understand why, you'll keep fighting the runtime.

Here's what's actually happening. Go runs an M:N scheduler — M goroutines multiplexed onto N OS threads, where N defaults to the number of CPU cores. The runtime juggles goroutines onto threads cooperatively, switching at well-defined preemption points: channel operations, system calls, function prologues. The result? You can have 100,000 goroutines running on 8 OS threads and the scheduler barely breaks a sweat.

The first big insight: there is no function coloring. In JavaScript, you have to mark functions async and call them with await. The distinction infects your entire call stack. Go rejects this entirely. Any function can block. Any function can be a goroutine. The runtime handles the rest. This is the design decision that makes Go's concurrency feel cheap — so cheap that it changes what you're willing to do concurrently.

But cheap concurrency comes with a catch: you own the goroutine lifecycle. If you launch a goroutine, you must have a plan for how it exits. Goroutines are not garbage collected. A goroutine blocked on a channel with no writer will sit there forever, holding its stack memory, invisible to the GC, slowly counting toward your OOM kill. I've debugged production incidents that traced back to a single leaked goroutine in a hot path, replicating 50 times a second until the process died.

Your job: every go f() you write should have a visible exit path. We'll practice this relentlessly.

Key concepts

  • go f() launches a goroutine. It returns immediately — no handle, no future, no promise. The goroutine is now running, and you have no direct reference to it.
  • runtime.GOMAXPROCS(n) controls parallelism (how many OS threads can execute Go code simultaneously). runtime.NumGoroutine() tells you how many goroutines exist right now.
  • The scheduler preempts at: function calls (since Go 1.14, also at safe points in tight loops), channel ops, select, time.Sleep, system calls.
  • Each goroutine starts with a 2KB stack that grows dynamically. You won't overflow from deep recursion (the default limit is ~1GB).

Drill 1 — Goroutine lifecycle and ordering

Copy this, run it, then run it again. Then run it five more times.

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var wg sync.WaitGroup
 
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("goroutine %d running\n", id)
        }(i) // pass i as argument — captures by value, not by reference
    }
 
    wg.Wait()
    fmt.Println("all done")
}

What to watch for: The output order is different every time. This isn't a bug — it's the scheduler doing its job. There are no guarantees about which goroutine runs when. If you need ordering, you have to build it yourself (we'll do this in Module 2 with channels).

Now try this: remove the (i) argument and use i directly in the closure body. You'll probably see the same number printed five times. This is the classic Go closure-capture bug — the loop variable i is shared across all goroutines, and by the time they run, the loop has finished. Passing it as a parameter captures the value at that moment. This is the #1 goroutine bug I see in code review. Burn it into your memory.

Exercise: Add a time.Sleep(time.Millisecond * time.Duration(id*10)) inside the goroutine. Does this make the order predictable? (Spoiler: it doesn't, but it biases the scheduler. Understanding why is the first step toward understanding cooperative scheduling.)

Drill 2 — The goroutine leak (this one will scare you)

package main
 
import (
    "fmt"
    "runtime"
    "time"
)
 
func leaky() {
    ch := make(chan int)
    go func() {
        val := <-ch // blocks forever — no one ever sends
        fmt.Println(val)
    }()
    // ch goes out of scope, but the goroutine is still waiting on it
}
 
func main() {
    fmt.Printf("goroutines before: %d\n", runtime.NumGoroutine())
    for i := 0; i < 100; i++ {
        leaky()
    }
    time.Sleep(time.Millisecond * 100)
    fmt.Printf("goroutines after: %d\n", runtime.NumGoroutine())
    // You'll see ~101 goroutines — all leaked
}

This is not a toy example. I've seen this exact pattern take down a production service. The function creates a channel, spawns a goroutine waiting on it, and returns. The channel goes out of scope, but the goroutine is still alive, blocked on a receive that will never complete. Do this in a hot path and you'll leak a goroutine per request.

The fix: Every goroutine needs an exit signal. Pass a done chan struct{} and close it when you want the goroutine to unblock. We'll build this pattern properly in Module 6 with context.Context.

Drill 3 — WaitGroup and the race detector

package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
func main() {
    var wg sync.WaitGroup
    var counter int64
 
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&counter, 1) // safe — atomic operation
        }()
    }
 
    wg.Wait()
    fmt.Println("counter:", counter) // 1000
}

Run this, then replace atomic.AddInt64(&counter, 1) with plain counter++ and run it with the race detector:

go run -race main.go

The race detector will light up. counter++ is a read-modify-write — two goroutines can read the same value, both increment, both write back, and one increment is lost. The race detector catches this at runtime. Run it in CI, run it in development, never ship concurrent Go code without racing it.

Capstone — Concurrent HTTP downloader

Now build something real. Write a CLI that downloads multiple URLs concurrently with bounded workers.

$ echo "https://example.com\nhttps://example.org" | go run main.go -workers=3
[OK]   https://example.com (1.2KB, 201ms)
[OK]   https://example.org (0.9KB, 188ms)
Summary: 2 total, 2 ok, 0 failed, 0.41s elapsed

Your constraints:

  • Use a worker pool: a fixed number of goroutines reading from a jobs channel
  • Track success/failure per URL
  • Print a summary with totals, successes, failures, and elapsed time
  • Zero goroutine leaks — every worker must exit cleanly when the jobs channel closes

This capstone forces you to combine everything from this module — goroutine lifecycle, WaitGroups, channel communication (preview of Module 2), and proper cleanup. Don't just make it work. Make it clean.