How Goroutine Scheduling Preemption Works in Go

Go preempts goroutines at compiler-identified safe points to ensure fair CPU scheduling and responsiveness.

When a goroutine won't let go

You write a function that processes a massive array of data. You spawn a goroutine to do the work so your HTTP handler can keep responding to other requests. You expect the goroutine to run in the background. Instead, your server stops responding. New requests hang. The goroutine is running, but it's holding onto the OS thread so tightly that nothing else gets a turn. You didn't block on I/O. You didn't lock a mutex. You just did too much math in a tight loop.

Go's scheduler is supposed to share threads fairly among thousands of goroutines. It needs to pause a goroutine and switch to another one. If a goroutine yields voluntarily, like waiting for a network request, the scheduler moves on instantly. But if a goroutine runs a tight loop, it won't yield. The scheduler has to force it to stop. This is preemption. Without preemption, a single runaway goroutine could starve the entire program.

The scheduler and safe points

Go runs many goroutines on a small number of OS threads. The scheduler decides which goroutine runs next. If a goroutine blocks, the scheduler puts it to sleep and wakes up another one. If a goroutine runs for too long, the scheduler preempts it. Preemption means pausing the goroutine, saving its state, and picking a new goroutine to run.

Preemption cannot happen at any instruction. Pausing a goroutine in the middle of a memory write could corrupt data. Pausing during a function call setup could crash the program. The scheduler can only pause a goroutine at a safe point. A safe point is a moment where the program state is stable enough to interrupt.

Think of a shared kitchen with one stove. Several cooks need to use it. If one cook starts chopping vegetables and never stops, no one else can cook. The head chef can't just grab the knife out of their hand while they're mid-swing. That's dangerous. The chef waits for a safe moment, like when the cook puts the knife down to check the recipe, and then says, "Okay, your turn is up." In Go, those safe moments are safe points. The compiler marks instructions where it's safe to pause execution without corrupting memory.

Minimal example: The tight loop

A tight loop that does pure computation might not hit a safe point often enough. In older versions of Go, this could cause scheduling starvation. Modern Go compilers are smarter, but understanding the mechanism helps you write responsive code.

package main

import (
	"fmt"
	"runtime"
	"sync"
)

// HogCPU runs a tight loop that performs arithmetic.
// This demonstrates how the scheduler handles CPU-bound work.
// The compiler inserts preemption checks, but very long loops
// can still delay other goroutines slightly.
func HogCPU() {
	sum := 0
	for i := 0; i < 1000000000; i++ {
		sum += i
		// The compiler may insert a preemption check here.
		// If the loop is optimized away, preemption checks
		// might not trigger as expected.
	}
	// Prevent the compiler from optimizing the loop entirely.
	if sum == 0 {
		panic("unexpected optimization")
	}
}

func main() {
	// Limit to one OS thread to make scheduling behavior obvious.
	// In production, use the default number of CPUs.
	runtime.GOMAXPROCS(1)

	var wg sync.WaitGroup
	wg.Add(2)

	// Start the CPU-heavy goroutine first.
	go func() {
		defer wg.Done()
		HogCPU()
		fmt.Println("HogCPU finished")
	}()

	// This goroutine waits for a turn.
	// If HogCPU never yields, this goroutine starves.
	go func() {
		defer wg.Done()
		fmt.Println("Hello from the second goroutine")
	}()

	wg.Wait()
}

Run this program. You will see "Hello from the second goroutine" print before "HogCPU finished". The scheduler preempts HogCPU even though it's in a loop. The compiler inserted safe points inside the loop. The scheduler detected a preemption signal, paused HogCPU, and ran the second goroutine.

The receiver name convention applies to methods, but the principle of clear naming holds everywhere. Use short, descriptive names for variables. sum is clear. x is not. The community prefers readability over brevity in variable names, even if receiver names are often one or two letters.

The scheduler is polite, but it needs permission to interrupt.

How the compiler helps

The compiler analyzes your code and inserts preemption checks at safe points. Function calls are natural safe points. When a function is called, the stack frame is established, and registers are saved. The runtime can pause the goroutine right there. Memory accesses are also safe points. The runtime ensures that pointers are valid before allowing a pause.

The compiler also inserts checks in loops. If a loop is long enough, the compiler adds a check every few iterations. This prevents a goroutine from running for too long without yielding. The frequency of checks depends on the compiler version and optimization level.

Stack growth is another safe point. Go goroutines start with a small stack. If a goroutine needs more stack space, the runtime grows the stack. This involves moving the stack to a new memory location. The runtime can check for preemption during this move. This means even recursive functions that grow the stack will eventually yield.

You can inspect scheduling behavior using the GODEBUG environment variable. Setting GODEBUG=schedtrace=1000 prints a scheduling trace every 1000 milliseconds. The trace shows the number of goroutines, threads, and CPU usage. This helps you debug performance issues.

export GODEBUG=schedtrace=1000
go run main.go

The output shows metrics like gomaxprocs, procs, threads, and goidgen. You can see how many goroutines are running and how many are waiting. This is useful for tuning your application.

Trust gofmt. Argue logic, not formatting. The scheduler cares about your code's behavior, not its indentation. Let the tool handle formatting so you can focus on concurrency.

Realistic example: HTTP handlers

In a web server, you want to handle many requests concurrently. If a request handler does heavy computation, it should not block the thread. You can break the work into smaller chunks or use explicit yields.

package main

import (
	"fmt"
	"net/http"
	"runtime"
	"time"
)

// HeavyHandler processes a request with CPU-bound work.
// It breaks the work into chunks to allow preemption.
// This keeps the server responsive to other requests.
func HeavyHandler(w http.ResponseWriter, r *http.Request) {
	// Simulate processing data in batches.
	// Breaking work into smaller units helps the scheduler
	// interleave other goroutines.
	for i := 0; i < 100; i++ {
		processBatch()
		
		// Yield explicitly if the loop is too tight.
		// runtime.Gosched() forces a handoff to the scheduler.
		// This is rarely needed but useful for debugging.
		if i%10 == 0 {
			runtime.Gosched()
		}
	}
	
	fmt.Fprintln(w, "Processing complete")
}

// processBatch simulates CPU-bound computation.
func processBatch() {
	sum := 0
	for j := 0; j < 10000; j++ {
		sum += j
	}
	// Prevent compiler from optimizing away the loop.
	if sum == 0 {
		panic("unexpected")
	}
}

func main() {
	http.HandleFunc("/heavy", HeavyHandler)
	
	// Start a background goroutine to monitor scheduling.
	go func() {
		for {
			time.Sleep(5 * time.Second)
			fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
		}
	}()
	
	fmt.Println("Server starting on :8080")
	http.ListenAndServe(":8080", nil)
}

The HeavyHandler breaks work into batches. It calls runtime.Gosched() every ten batches. This forces the scheduler to pick another goroutine. In practice, the compiler's automatic preemption is usually enough. Explicit yields are useful when you have a loop that the compiler cannot analyze well, or when you want to guarantee responsiveness.

Functions that take a context should respect cancellation and deadlines. context.Context always goes as the first parameter, conventionally named ctx. If your handler does long work, pass the context so you can stop early if the client disconnects.

Break long loops. The scheduler rewards small steps.

Pitfalls and debugging

Preemption is automatic, but you can break it. The function runtime.LockOSThread binds a goroutine to the current OS thread. This disables preemption for that goroutine. If the goroutine blocks, the thread blocks. Use this only when you must call C code that requires thread-local storage.

If you use LockOSThread and forget to unlock, you leak OS threads. Each leaked thread consumes memory and system resources. The runtime will eventually run out of threads and panic.

// LockOSThreadExample shows how to use LockOSThread safely.
// This pattern is rare and usually indicates a C interop requirement.
func LockOSThreadExample() {
	runtime.LockOSThread()
	
	// Call C code that needs thread-local storage.
	// cCallThatNeedsThreadLocal()
	
	// Always unlock the thread when done.
	runtime.UnlockOSThread()
}

If you forget to unlock, the thread stays bound. The goroutine can finish, but the thread remains reserved. This is a resource leak.

Another pitfall is a goroutine leak. A goroutine leaks when it waits on a channel that never gets closed. The goroutine blocks forever. The scheduler cannot preempt a blocked goroutine. It just sits there. The worst goroutine bug is the one that never logs.

If you have a deadlock, the runtime panics with fatal error: all goroutines are asleep - deadlock!. This error means every goroutine is blocked waiting for something that will never happen. Check your channels and mutexes. Ensure that every channel has a sender and a receiver.

The compiler rejects unused imports with imported and not used. This helps keep your code clean. If you import runtime but don't use it, the compiler complains. Remove the import or use the package.

LockOSThread is a sledgehammer. Use it only when you have no other choice.

Decision matrix

Use the default scheduler when you have mixed I/O and CPU work. The runtime handles preemption automatically.

Use runtime.Gosched() when you have a tight loop that must yield control to keep the system responsive. This is rare and usually a debugging aid.

Use runtime.LockOSThread when you must call C code that requires thread-local storage. Always pair it with runtime.UnlockOSThread.

Use worker pools when you need to limit concurrency to a fixed number of tasks. This prevents spawning too many goroutines.

Use context.Context when you need to cancel long-running operations. Pass the context as the first parameter to every function that might block.

Where to go next