skip to content

What does runtime.LockOSThread do, and when does a C library force you to use it?

level: middleimportance: should knowfreq 34%

answer

  1. the goroutine stops migrating
  2. the thread serves nobody else
  3. lock calls are counted
  4. exit while locked kills the thread
  5. C state that lives on a thread

basics

~20 s

runtime.LockOSThread wires the calling goroutine to the OS thread it is running on: that goroutine runs nowhere else and no other goroutine runs there until UnlockOSThread. You need it when a C library keeps per-thread state and must be driven from one fixed thread.

solid answer

~50 s

Normally the runtime is free to move a goroutine between OS threads at every safe point. `runtime.LockOSThread` removes that freedom for one goroutine: it stays on its current thread, and that thread runs no other goroutine, until it makes as many `runtime.UnlockOSThread` calls as it made lock calls. If the goroutine exits while still locked, the runtime terminates the thread rather than reusing it. You need this when the C side keeps state in thread-local storage or in a context created on a particular thread — graphics and windowing contexts, some session-based numeric or crypto engines, per-thread credentials or namespaces on Linux. Locking in an `init` function of package `main` is the idiom for holding the process's initial thread. The cost is that the thread is dedicated, so the usual shape is one long-lived goroutine that owns the engine and receives work over a channel, not a lock and unlock around every call.

code

go · 9 lines
go
// The engine keeps state on the thread that initialised it.
func engineLoop(reqs <-chan scoreReq) {
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()
	C.engine_init()
	for r := range reqs {
		r.reply <- float64(C.engine_score(C.double(r.x)))
	}
}

go deeper

for a junior

Recall that the runtime normally moves goroutines between operating-system threads, and that this call switches that off for one goroutine. Know it exists because some C libraries care which thread calls them.

for a middle

Explain both halves of the guarantee — the goroutine stays put and the thread serves nobody else — plus the counted lock, the thread being terminated if the goroutine exits locked, and concrete cases like a graphics context or per-thread credentials.

for a senior

Show the design: one long-lived owner goroutine locking once and serving requests over a channel, rather than locking per call. Be ready to say why locking in a request goroutine slowly bleeds threads.

for a principal

Treat each locked thread as a permanently allocated resource and set how many the service may own. Push back on a dependency whose thread affinity forces serialisation through a single goroutine, and weigh that constraint before adopting the library at all.

## The default, and what it breaks Go's scheduler multiplexes goroutines over OS threads and moves them freely: a goroutine can start on one thread, be parked at a channel receive, and resume on a different one minutes later. Almost nothing in Go notices, because Go's own state travels with the goroutine. C state often does not travel. A C library may store a session handle in thread-local storage, may have created a device or graphics context that only the creating thread may touch, or may rely on per-thread properties the operating system attaches to a thread rather than a process. Called from a goroutine that migrates, such a library sees its state vanish — usually as an obscure error code, sometimes as a crash. ## What LockOSThread actually guarantees `runtime.LockOSThread()` pins the calling goroutine to the OS thread it is currently running on. Two guarantees follow, and both matter: - the goroutine will only ever execute on that thread; and - that thread will execute no other goroutine. The second half is what makes the C library's thread-local state safe: nobody else can run there and disturb it. The lock is counted. Two calls to `runtime.LockOSThread` require two calls to `runtime.UnlockOSThread` before the thread is released, which makes it safe to nest inside helper functions. And there is a deliberate safety valve: if the goroutine exits without unlocking, the runtime terminates that thread instead of handing it back for reuse, on the assumption that whatever the C library did to it has left it unfit for general use. ## When you genuinely need it The honest list is short: - **Graphics, windowing and some media libraries.** A context is created on one thread and every subsequent call must come from that thread; many platforms further insist it be the process's initial thread. - **C libraries with per-thread session state.** Anything that hands you a handle stored in thread-local storage, or whose documentation says calls must come from the thread that initialised it. - **Per-thread operating-system state.** On Linux, thread credentials, namespace membership and similar attributes are properties of a thread, so code that changes them must not then be migrated. And the classic non-reasons: it is not a mutex, it does not make anything thread-safe, and it does not pin you to a CPU core — that is processor affinity, an operating-system concept, and the thread you are locked to may still be moved between cores. ## The shape that works Because the point is continuity across calls, locking and unlocking around each individual call is pointless — the state you are protecting is exactly what is lost between them. The working pattern is a single long-lived goroutine that locks its thread once, initialises the engine, then serves requests forever over a channel while every handler sends work to it. That goroutine is the engine's owner; its thread is spent, permanently, and you account for it as one dedicated thread. When the requirement is specifically the process's initial thread, the idiom is to lock in an `init` function of package `main`, so the main goroutine holds that thread from the start. ## Locking versus the pinning cgo already does A goroutine is unavoidably tied to its thread *during* a cgo call — it is running on that thread's system stack and cannot be moved. That pinning ends the moment the call returns. `runtime.LockOSThread` extends the tie across calls, and across everything the goroutine does in between. If your C library is stateless per call, you do not need it; if it remembers something between calls, you do. ## Costs to state out loud A locked thread is removed from the pool the scheduler can use, so each locked goroutine costs a dedicated thread's stack and bookkeeping for as long as it lives. Locking per request, in a handler, is therefore a slow leak of threads and a poor design; a fixed, small number of owner goroutines is a budget you can reason about. And because a locked goroutine cannot be moved, any long block inside it stalls only itself — which is one more argument for having the owner do nothing but drive the engine.

  • What happens if a goroutine exits without calling runtime.UnlockOSThread?
    The runtime terminates that OS thread instead of returning it for reuse, on the assumption that whatever the goroutine did to it makes it unfit for other work. That is harmless for a long-lived owner goroutine, and a steady leak of threads if you lock inside per-request goroutines.
  • Does runtime.LockOSThread pin the goroutine to a CPU core?
    No. It fixes the goroutine to one OS thread; the operating system remains free to schedule that thread on any core and to migrate it. Processor affinity is a separate, platform-specific concept, set through OS facilities rather than through the Go runtime.
  • How is that different from the pinning a cgo call already causes?
    During a cgo call the goroutine is running on that thread's system stack and cannot move, but that ends when the call returns and it may resume elsewhere. LockOSThread holds the tie across calls and across the code between them, which is precisely what a library with per-thread state needs.

It is assigning one nurse to one patient for the whole stay. Continuity is the point, and the ward loses a nurse for the duration.

saying these in an interview costs you the question

  • Claims LockOSThread makes shared data thread-safe
  • Says it pins the goroutine to a CPU core
  • Locks and unlocks around every individual cgo call
  • Treats unlocking as optional because the goroutine exits
  • Thinks one UnlockOSThread undoes nested locks