Skip to content
gohacks.dev
Concurrency

LockOSThread in Go and the Rare Cases That Need It

What runtime.LockOSThread guarantees, a run showing the same thread every time, what it costs in a ping-pong benchmark, and the patterns that use it safely.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

For almost every Go program, which OS thread runs a goroutine is none of the program's business, which the previous article showed. runtime.LockOSThread is the escape hatch for the exceptions. It tells the scheduler: this goroutine and this thread are a pair from now on. It is a small, sharp tool that most programs never need, and the ones that do need it tend to need it precisely, so it is worth knowing what it promises, what it costs, and how to use it without leaving a mess.

Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64, cgo enabled

This article demonstrates the guarantee, measures the price, and lists the patterns that hold up. It belongs to the concurrency patterns cluster, continues from goroutines and OS threads, and sets up cgo and blocked threads. Everything ran on Go 1.25.6 on an Apple M1 Max.

What it guarantees

The documentation states it in two sentences. LockOSThread wires the calling goroutine to its current operating system thread. The calling goroutine will always execute in that thread, and no other goroutine will execute in it, until the calling goroutine has made as many calls to UnlockOSThread as to LockOSThread. If the goroutine exits without unlocking the thread, the thread will be terminated.

The first half is what thread-affine code wants: every call from this goroutine lands on one thread, so thread-local storage, a per-thread OS setting or a library context created on the thread is still there on the next call. The second half is what makes the pairing safe from the other direction: nobody else's code can run on the thread and stumble over, or change, that state.

This program checks the first half. Without the lock, a goroutine that sleeps and wakes repeatedly may see several threads; with it, it should see one. It asks the C library for the thread ID, as in the previous article:

main.goGo
package main

/*
#include <pthread.h>
static unsigned long thread_id(void) { return (unsigned long)pthread_self(); }
*/
import "C"

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

func distinctThreads(lock bool) int {
	result := make(chan int)
	go func() {
		if lock {
			runtime.LockOSThread()
			defer runtime.UnlockOSThread()
		}
		seen := map[uint64]bool{}
		for range 200 {
			seen[uint64(C.thread_id())] = true
			time.Sleep(200 * time.Microsecond) // park, which normally frees the thread
		}
		result <- len(seen)
	}()
	return <-result
}

func main() {
	fmt.Println("threads seen without LockOSThread:", distinctThreads(false))
	fmt.Println("threads seen with LockOSThread:   ", distinctThreads(true))
}
Text
threads seen without LockOSThread: 2
threads seen with LockOSThread:    1

With the lock the goroutine sees exactly one thread in 200 wakeups. Without it the number is whatever the scheduler produced on that run; here it was 2. The other half of the guarantee, that no other goroutine ever runs on that thread, is not something a program can observe easily, but it follows from how the runtime implements the lock: while the goroutine is parked, the thread sleeps with it instead of picking up other work.

What it costs

That last point is the bill. A normal goroutine hand-off is cheap because both goroutines can run on the same thread, one after the other: the scheduler switches stacks in user space. A locked goroutine cannot share its thread, so waking it means waking its thread, and when it blocks, the thread blocks too. Each hand-off to or from a locked goroutine becomes an OS-level context switch, which is an order of magnitude more than a goroutine switch.

A ping-pong benchmark shows it. Two goroutines pass a token back and forth over unbuffered channels, which forces a hand-off on every operation. The baseline runs both unlocked; the second locks one of them; the third locks both:

main_test.goGo
package main

import (
	"runtime"
	"testing"
)

func pingPong(b *testing.B, lockA, lockB bool) {
	ping, pong := make(chan struct{}), make(chan struct{})
	done := make(chan struct{})
	go func() {
		if lockB {
			runtime.LockOSThread()
		}
		for range ping {
			pong <- struct{}{}
		}
		close(done)
	}()
	if lockA {
		runtime.LockOSThread()
		defer runtime.UnlockOSThread()
	}
	b.ResetTimer()
	for b.Loop() {
		ping <- struct{}{}
		<-pong
	}
	close(ping)
	<-done
}

func BenchmarkUnlocked(b *testing.B)   { pingPong(b, false, false) }
func BenchmarkOneLocked(b *testing.B)  { pingPong(b, true, false) }
func BenchmarkBothLocked(b *testing.B) { pingPong(b, true, true) }
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkUnlocked-10      	 4088965	       287.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkOneLocked-10     	  124899	     10125 ns/op	       0 B/op	       0 allocs/op
BenchmarkBothLocked-10    	   62296	     18846 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	3.850s

On this machine the unlocked pair needed about 287 ns per round trip. Locking one side raised it to about 10,125 ns, roughly 35 times more, and locking both raised it to about 18,846 ns, roughly 66 times more, because both sides now need a thread switch. The exact ratio depends on the operating system and hardware, and on a loaded machine it widens. The rule that follows is simple: keep the locked section small. Do the thread-affine work in a dedicated goroutine, and talk to it over a channel at a coarse grain (one request per batch of work) rather than a fine one (one request per call).

Patterns that use it safely

A dedicated worker goroutine. The thread-affine library gets one goroutine that locks itself at the top and serves requests from a channel. Every caller goes through the channel; none of them touches the library directly:

Go
type glWorker struct{ calls chan func() }

func newGLWorker() *glWorker {
	w := &glWorker{calls: make(chan func())}
	go func() {
		runtime.LockOSThread() // never unlocked: the thread belongs to this worker for life
		initContext()          // thread-local library state is created here
		for f := range w.calls {
			f()
		}
	}()
	return w
}

func (w *glWorker) Do(f func()) {
	done := make(chan struct{})
	w.calls <- func() { f(); close(done) }
	<-done
}

This is the pattern behind most Go bindings for OpenGL and similar APIs. The closure passed to Do runs on the locked thread, so any call it makes into the library is on the right thread, and callers on any goroutine can use it.

Pinning the main goroutine. Some platforms require UI calls on the process's first thread. Go starts main on that thread but the goroutine can later move. The fix, from the runtime documentation, is to lock in init, which runs on the main thread before main:

Go
func init() {
	runtime.LockOSThread() // main() now stays on the main thread
}

Then run the toolkit's event loop from main and do everything else in other goroutines.

Temporary pinning. For a short thread-scoped operation, lock, do the work, unlock with defer. Changing the network namespace of the current thread with setns, calling a library function that stores an error code in a thread-local errno, or reading a per-thread property all fit. Keep the section tight and always pair the calls.

Discarding a thread. The documentation's last sentence is useful as well as dangerous. A goroutine that changes per-thread state it does not want to leak, such as switching a thread into another namespace or dropping its capabilities, can lock itself, make the change, do its work and exit without unlocking. The runtime terminates the thread instead of returning it to the pool, so no other goroutine can ever run on a thread in the odd state.

Rules and traps

Locks nest. Two LockOSThread calls need two UnlockOSThread calls before the thread is released, which makes it safe for a helper to lock without knowing whether its caller already did, and makes an unmatched call a leak of the thread.

Goroutines started by a locked goroutine are not locked. This surprises people who start a goroutine from inside a thread-affine setup function and then call the library from it. Anything that needs the thread must run in the locked goroutine.

A locked goroutine that blocks forever holds its thread forever. A hundred locked goroutines are a hundred OS threads, and unlike normal goroutines they cannot be multiplexed. If you find yourself locking per request, the design needs a pool of locked workers instead.

Do not use it as a performance tool, or as a way to "make sure code runs on one core". It neither pins to a CPU core nor improves cache behavior; it pins to a thread, which the kernel may still move between cores. For CPU affinity, use the operating system's facilities, and for fewer cores, GOMAXPROCS.

The related thread usage in cgo and blocking syscalls articles explain why the runtime treats locked and blocked threads specially. The runtime package documentation is the authority on the guarantee, and the Go wiki's page on LockOSThread walks through the main-thread pattern in more detail.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.