No history yet

Goroutines and Schedulers

The Unit of Concurrency

In many programming languages, concurrency means working with threads. These are powerful but come with a cost. An operating system thread can be heavy, consuming a megabyte or more of memory for its stack, and the OS has a hard limit on how many you can create. Managing thousands of them can become a performance bottleneck due to the overhead of context switching.

Go takes a different approach with a much lighter primitive: the goroutine. A goroutine is a lightweight thread of execution managed by the Go runtime, not the operating system directly. They start with a tiny stack, just a few kilobytes, which can grow and shrink as needed. This efficiency means you can run hundreds of thousands, or even millions, of goroutines on a single machine without breaking a sweat.

Launching one is astonishingly simple. You just place the go keyword before a function call.

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Hello from a goroutine!")
}

func main() {
	// Start a new goroutine
	go sayHello()

	// The main function is also a goroutine.
	// We wait a bit to give the other goroutine time to run.
	fmt.Println("Hello from the main function.")
	time.Sleep(10 * time.Millisecond)
}

In this example, sayHello() runs concurrently with the main function. The main function itself runs in a goroutine, and when it exits, the entire program shuts down, taking any other running goroutines with it. That's why the time.Sleep is there; without it, main would likely finish before sayHello even got a chance to print.

The Scheduler Behind the Scenes

So if goroutines aren't OS threads, how do they actually run on the CPU? The magic lies in the Go runtime scheduler, which implements a model known as M:P:G scheduling.

Let's break down the components:

  • M stands for machine thread, which is a standard OS thread. Your program will typically have a few of these, often one per CPU core.
  • P stands for processor, a context required to run Go code. A P has a local queue of runnable goroutines.
  • G is a goroutine, which has its own stack, instruction pointer, and other state.

The scheduler's job is to assign a G to run on a P, which in turn runs on an M. This model is incredibly efficient. When a goroutine makes a blocking call (like waiting for network I/O), the scheduler doesn't let the M sit idle. It swaps out the blocked G, finds another runnable G from the P's queue, and sets the M to work on that instead. Once the original G is unblocked, it's placed back in a run queue, ready to be scheduled again.

This is a form of managed by the Go runtime itself. The scheduler doesn't wait for goroutines to voluntarily give up control; it can interrupt a running goroutine at certain safe points (like function calls) to ensure fair access to the CPU for all other waiting goroutines. This prevents one long-running, non-cooperative goroutine from starving all the others.

Lifecycle and Leaks

Goroutines are cheap to create, but they aren't free. Each one consumes memory and scheduler resources. If you start a goroutine and it never finishes, it becomes a memory leak. This is a common bug in concurrent programs called a goroutine leak and can slowly degrade and eventually crash your application.

A leak often happens when a goroutine is waiting to receive data from a channel that will never be sent, or waiting to send data to a channel that will never be read from. The goroutine blocks forever, its stack memory never reclaimed.

package main

import (
    "fmt"
    "time"
    "runtime"
)

// This function starts a goroutine that leaks.
func leakingFunction() {
    ch := make(chan int) // Create a channel
    go func() {
        val := <-ch // Block forever waiting for a value
        fmt.Println("We received", val)
    }()
}

func main() {
    fmt.Println("Number of goroutines:", runtime.NumGoroutine())

    for i := 0; i < 10; i++ {
        leakingFunction()
    }

    time.Sleep(1 * time.Second)
    fmt.Println("Number of goroutines:", runtime.NumGoroutine())
}

If you run this code, you'll see the number of goroutines increase with each loop iteration and never decrease. Each call to leakingFunction spawns a goroutine that waits on a channel ch. But nothing ever sends to that channel, and the channel itself is local to the function, so nothing can send to it after the function returns. The goroutine is orphaned and will wait forever.

Proper lifecycle management is critical. When you start a goroutine, you must have a clear plan for how it will end. This is usually achieved through channels, context cancellation, or wait groups, which provide explicit ways to signal that a task is complete.

Understanding goroutines and the scheduler is the first major step toward mastering concurrency in Go. Their lightweight nature and the intelligent runtime that manages them are what make it possible to build highly concurrent, high-throughput systems with relative ease.

Quiz Questions 1/6

What is the primary advantage of using goroutines over traditional OS threads?

Quiz Questions 2/6

In the Go runtime's M:P:G scheduling model, what does 'P' represent?