When the scheduler needs a nudge
You have a goroutine running a tight calculation loop. It never touches the network, never blocks on a channel, and never calls a function that triggers a preemption point. The scheduler has no reason to pause it. Meanwhile, another goroutine is waiting to update a shared state, but it never gets a turn. The program feels stuck, even though the CPU is spinning at 100%. Or imagine a deep call stack where an error occurs three levels down, and you need to unwind everything immediately while running cleanup code, but returning would propagate the error up the chain in a way you don't want.
These edge cases are where runtime.Gosched and runtime.Goexit live. They are low-level primitives that interact directly with the Go scheduler. Most Go programs never need them. The scheduler is robust, and idiomatic Go favors channels and context cancellation over manual control flow. When you do reach for these functions, you are stepping outside the normal abstractions and talking to the runtime itself.
Gosched yields the processor
runtime.Gosched tells the Go scheduler to pause the current goroutine and let another one run. It is a voluntary yield. The goroutine goes back to the ready queue and will resume later when the scheduler picks it again. There is no guarantee when it resumes. The scheduler might pick it immediately if no other goroutine is ready, or it might wait.
Think of Gosched like a worker at a potluck saying, "I'll let someone else grab the chips before I take more." The worker steps aside, allows others to move, and then goes back to the line. The worker doesn't leave the party. They just pause their current action.
package main
import (
"fmt"
"runtime"
)
// worker runs a tight loop and yields periodically.
func worker() {
// Loop without I/O would starve other goroutines on older Go versions.
// Modern Go preempts automatically, but Gosched forces a yield here.
for i := 0; i < 5; i++ {
fmt.Println("Working...")
runtime.Gosched() // Yield control so other goroutines can run
}
}
func main() {
go func() {
// This goroutine prints after the worker yields.
fmt.Println("Other task running")
}()
worker()
}
The code above spawns a background goroutine and then runs worker. The worker function prints and then calls Gosched. When Gosched runs, the scheduler pauses worker and checks the ready queue. It finds the background goroutine waiting and runs it. The background goroutine prints and exits. The scheduler then picks worker again, and the loop continues.
Gosched does not return a value. If you try to capture the result, the compiler rejects the program with runtime.Gosched() used as value. The function signature is func Gosched(). It performs an action and returns nothing.
Gosched is a hint. The scheduler decides when you resume.
Goexit terminates the goroutine
runtime.Goexit terminates the current goroutine immediately. It runs all deferred functions in the current goroutine and then exits. It does not return to the caller. If a goroutine calls Goexit, the function that launched it never sees a return value. The goroutine vanishes.
This is different from return. A return statement exits the current function and passes control back to the caller. Goexit kills the entire goroutine. The caller never resumes. If you launch a goroutine with go f(), and f calls Goexit, the go statement completes, but f never returns to the scheduler in a normal way. The goroutine just ends.
Think of Goexit like a worker clocking out early. They clean their station, hand in their badge, and leave. The boss doesn't get a report back. The shift just ends for that worker.
package main
import (
"fmt"
"runtime"
)
// abortDeeply terminates the goroutine from a nested call.
func abortDeeply() {
defer fmt.Println("Running defer in abortDeeply")
runtime.Goexit() // Exit goroutine immediately, defers run
fmt.Println("Unreachable code")
}
// wrapper calls the function that aborts.
func wrapper() {
defer fmt.Println("Running defer in wrapper")
abortDeeply()
fmt.Println("Unreachable code in wrapper")
}
func main() {
done := make(chan struct{})
go func() {
defer close(done) // Signal main when goroutine exits
wrapper()
fmt.Println("This line is never reached")
}()
<-done // Wait for goroutine to finish
fmt.Println("Main continues after goroutine exits")
}
The output shows the defers running in reverse order of declaration. abortDeeply calls Goexit. The runtime walks up the call stack and executes the defer in abortDeeply, then the defer in wrapper, then the defer in the anonymous goroutine function. After all defers run, the goroutine terminates. The line This line is never reached never prints. The main function waits on the channel, receives the close signal, and continues.
Goexit runs defers. Cleanup survives the exit.
How the runtime handles these calls
The Go scheduler manages goroutines using a work-stealing model with M/P/G structures. M is the OS thread, P is the processor context, and G is the goroutine. Gosched moves the current G from the running state back to the local run queue of the P. The scheduler then picks the next G from the queue and runs it on the M. The original G waits until the scheduler picks it again.
Goexit marks the G as dead. The runtime runs the defers associated with that G. Once defers are done, the scheduler reclaims the G and removes it from the P's queue. The M continues running other goroutines. If the G was the last one on the P, the scheduler might steal work from another P or block waiting for new work.
The runtime package is part of the compiler toolchain. Functions here are not guaranteed to stay stable across minor versions in the same way standard library functions are. The community treats runtime as a last resort. You import runtime only when you have a specific need that higher-level abstractions cannot solve. gofmt sorts imports alphabetically, so runtime appears after fmt and before sync. The tool handles the order. You don't argue about import placement.
Don't use Gosched to fix design problems. Fix the design.
Realistic usage patterns
runtime.Gosched is rarely needed in modern Go. Since Go 1.14, the scheduler uses asynchronous preemption. The runtime can pause a goroutine at any point, even inside a tight loop, by injecting a signal. This means CPU-bound loops no longer starve other goroutines. The scheduler forces a context switch automatically. You only need Gosched if you are running on an older Go version, or if you have a custom scheduler, or if you are debugging a starvation issue and want to force a yield at a specific point.
A realistic use case for Gosched is a tight loop that must yield to allow a control channel to be read, and you cannot introduce blocking operations. For example, a worker that does heavy math but needs to check a stop signal. If the math takes too long, the stop signal is delayed. Gosched allows the scheduler to run the goroutine that sends the stop signal.
package main
import (
"fmt"
"runtime"
"sync"
)
// computeHeavy does work and yields to allow channel reads.
func computeHeavy(stop <-chan struct{}, results chan<- int) {
i := 0
for {
select {
case <-stop:
return
default:
i++
// Yield occasionally to let the scheduler run other goroutines.
// This prevents starvation in CPU-bound loops.
if i%1000 == 0 {
runtime.Gosched()
}
}
results <- i
}
}
func main() {
stop := make(chan struct{})
results := make(chan int, 10)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
computeHeavy(stop, results)
}()
// Drain results and stop.
for i := 0; i < 5000; i++ {
<-results
}
close(stop)
wg.Wait()
fmt.Println("Done")
}
The loop checks the stop channel using a select with a default case. This is non-blocking. The loop does work and then sends to results. If results is buffered, the send succeeds. If results is full, the send blocks, and the scheduler switches anyway. Gosched here forces a yield every 1000 iterations. This ensures that other goroutines get a chance to run, even if the loop is tight. In modern Go, the async preemption would likely handle this, but Gosched makes the intent explicit.
runtime.Goexit is useful when a goroutine needs to terminate itself from deep within a call stack, and the caller should not resume execution. For example, a background worker that detects a fatal configuration error and wants to shut down immediately, running cleanup defers, without returning to the loop that spawned it. Goexit ensures that defers run, so resources are cleaned up. It also ensures that the caller doesn't continue, which might be dangerous if the goroutine is in an invalid state.
Goexit is a sledgehammer. Use return for normal flow.
Pitfalls and gotchas
runtime.Gosched forces a context switch. It adds latency. In a hot loop, calling Gosched every iteration can degrade performance significantly. The scheduler has to pause the goroutine, update queues, and pick a new one. This overhead adds up. Use Gosched sparingly. Yield occasionally, not constantly. If you find yourself needing Gosched to prevent starvation, consider restructuring the code to use blocking operations or channels. The scheduler is designed to handle concurrency efficiently. Fighting it with manual yields usually hurts more than it helps.
runtime.Goexit does not return to the caller. If you expect the calling function to continue, you will get a silent bug. The goroutine just vanishes. This is easy to miss if you are used to return. Always check the call stack. If a function calls Goexit, the caller never resumes. This can break assumptions about control flow. Use Goexit only when you explicitly want the goroutine to die.
Goexit runs defers in the current goroutine. It does not run defers in other goroutines. If you have a goroutine that launches other goroutines, and you call Goexit, the child goroutines continue running. They are not killed. You must manage child goroutines separately. Goexit only affects the goroutine that calls it.
The compiler rejects x := runtime.Goexit() with runtime.Goexit() used as value. Like Gosched, Goexit returns nothing. It is a statement, not an expression.
If you call Goexit from the main goroutine, the program exits. The main goroutine is special. When it finishes, the program terminates. Goexit in main runs defers and then exits the program. This is equivalent to returning from main, but it skips the rest of the function body.
Defers run on Goexit. Cleanup happens even when you vanish.
Decision matrix
Use runtime.Gosched when you have a tight CPU-bound loop that must yield to allow other goroutines to run, and you cannot introduce blocking operations. Use runtime.Goexit when a goroutine must terminate immediately from a deep call stack, run all deferred functions, and prevent the caller from resuming execution. Use return when you need to exit a function and pass control back to the caller with a value. Use context.Context cancellation when you need to signal a goroutine to stop and let it clean up gracefully. Use channels to coordinate work between goroutines instead of forcing yields. Use sync.WaitGroup when you need to wait for multiple goroutines to finish.
Trust the scheduler. Yielding manually is usually a performance trap.