Should a shared library call runtime.LockOSThread internally, or push thread pinning onto its callers?
answer
- it is not an implementation detail
- how far does the affinity reach
- who runs inside the pinned region
- an exported requirement cannot be withdrawn
- threads are the resource, and someone owns the budget
basics
~20 sDecide by how far the thread affinity reaches. If the whole affected operation happens inside one of your calls, pin internally and hide it. If the affinity outlives the call or caller code runs inside the pinned region, make pinning a documented obligation on the caller.
solid answer
~50 sPinning is not an implementation detail, because it leaks in three directions: it consumes an OS thread for its duration, it constrains where callbacks and caller goroutines may run, and once the requirement is in an exported API you cannot take it back without breaking callers. So the test is containment. If a single exported call can lock a goroutine you created, do the whole affected operation, and end that goroutine, hide it — callers never learn the word. If the affinity outlives the call, for example you return a handle whose later methods must hit the same thread, then either serialise every use through one long-lived pinned goroutine that you own, or state the requirement in the doc comment and let callers own it. Never call `runtime.UnlockOSThread` on a goroutine you did not create: the counter is shared with the caller. And the thread budget is somebody else's decision, so bound the concurrency rather than assuming threads are free.
go deeper
Take away the boundary: if a package needs a specific OS thread, that fact usually reaches the people calling it, so it is not something to bury quietly in a helper function.
Be able to state what leaks out of a pinned region: a thread held for its duration, callbacks that must run inline, and goroutines started inside that are not pinned.
Argue the containment test on a concrete API and show the two implementations — a goroutine pinned per operation versus one long-lived pinned owner — with their shutdown and backpressure stories.
Own the decision and its cost. Publish the thread cost in the package documentation, agree the in-flight limit with whoever owns capacity, and treat an exported pinning requirement as permanent once callers depend on it.
## Why this is a design decision and not a coding one `runtime.LockOSThread` looks local: one call, one goroutine. It is not, for three reasons. **It costs a thread.** While the lock is held, that OS thread serves one goroutine. If the affected state cannot be restored, the correct cleanup is to let the goroutine exit while still locked so the runtime terminates the thread, which means a thread creation and teardown per operation. The resource that scales is threads, not goroutines. **It constrains the caller's code.** Pinning is per goroutine and is never inherited. Any callback you invoke runs on the pinned goroutine, so the caller must not start goroutines inside it, must not park it for long, and must not assume anything it starts sees the thread state. That is a contract, whether or not you write it down. **It cannot be withdrawn.** Once the exported documentation says "call this on a goroutine you have locked", every caller has code shaped around it. In a package other teams import, that sentence is as permanent as a function signature. ## The containment test Ask how far the affinity reaches. - **Contained in one call.** The operation begins and ends inside your function, and no caller code runs while the thread is dirty. Then hide it entirely: create a goroutine, lock it, do the work, return the result over a channel, and let the goroutine die. Callers see a plain function. This is the right default. - **Outliving the call.** You return a handle, a session, or a context whose later use must land on the same thread. Now the affinity spans calls you do not control, and hiding it means owning a long-lived pinned goroutine that serialises every use of the handle: one thread for the process, requests funnelled to it, results sent back. That is a real design with real consequences — serialisation, an owner to shut down, and a queue that can back up — and it should be chosen deliberately. - **Caller code inside the pinned region.** You take a callback that must run on the thread. Then the requirement is unavoidably part of the API and belongs in the doc comment, spelled out: what must happen on the locked goroutine, that the callback must not offload work to other goroutines, and that the caller must not unlock. ## The rule about somebody else's goroutine The lock count belongs to the goroutine, and if your function is called on the caller's goroutine, the count is shared with them. Locking and unlocking symmetrically is safe because the counter nests. Unlocking more than you locked is not: it can silently release pinning the caller established for their own per-thread state, producing a bug in their code with no trace of your package. The discipline is simple — only unlock what you locked, and prefer to lock a goroutine you created. ## The capacity side, which is not yours to decide alone A design that pins one thread per in-flight operation converts request concurrency into process threads. The runtime has a ceiling on live threads — 10000 by default, changeable with `runtime/debug.SetMaxThreads` — and crossing it is not an error you can handle: the runtime prints a line about the program exceeding the thread limit and aborts with a fatal thread-exhaustion error. That means the ceiling is a tripwire, not a capacity knob, and raising it converts a crash into a slower crash plus memory spent on kernel stacks. So two owners appear. The library author owns whether pinning is in the API. Whoever owns the service's capacity owns how many pinned operations may be in flight and what the process thread ceiling should be, and they can legitimately overrule a library design that assumes threads are free. The way to keep that conversation short is to publish the cost: state in the package documentation that each concurrent operation holds an OS thread, so the caller can size a limiter rather than discovering the ceiling in production. ## When the answer is neither If the pinned region would be large, or the state you must set is dangerous to leave on any thread, moving the work to a separate process is often better than either option. Re-execute the binary, set the state in the child before it becomes concurrent, and communicate over a pipe. It costs a process instead of a thread, removes the affinity from your API completely, and makes it impossible for unrelated code in your process to inherit the state. ## What a reviewer should insist on A proposal to add pinning to an exported API should answer four questions: which calls must observe the thread state; whether caller code ever runs inside the pinned region; who terminates the pinned goroutine and when; and how many can be in flight at once. If the design cannot answer the fourth, it is not ready, whatever the code looks like.
- What is the capacity question this design raises, and who owns it?How many pinned operations may be in flight, because each holds an OS thread. Whoever owns the service's capacity owns that number and the process thread ceiling, which defaults to 10000 and is changeable with runtime/debug.SetMaxThreads. Crossing it aborts the process with a fatal thread-exhaustion error, so it is a tripwire rather than a knob, and the limiter belongs above the library.
- Is it ever safe for a library to call runtime.UnlockOSThread on a goroutine it did not create?Only to balance a lock it made itself. The count belongs to the goroutine and is shared with the caller, so an extra unlock can release pinning the caller set up for their own per-thread state and break their code with no evidence pointing at your package. Prefer locking a goroutine you own.
- If you push the requirement to callers, what exactly does the documentation have to say?Which calls must run on a locked goroutine, that the goroutine must be dedicated rather than shared with other work, that the caller must not unlock or start goroutines that need the thread state, and what the caller should do with the thread afterwards — usually let the goroutine exit while locked so the runtime discards it.
- When is a single long-lived pinned goroutine better than pinning per operation?When the affinity outlives a single call, such as a handle whose later methods must hit the same thread, or when operations are frequent enough that a thread per operation is wasteful. You pay serialisation and need a shutdown path and a bounded queue, but the process holds one extra thread instead of one per in-flight request.
saying these in an interview costs you the question
- Calls pinning an implementation detail with no caller impact
- Unlocks a thread the caller had locked
- Assumes callbacks and child goroutines see the pinned thread
- Treats one thread per request as free because goroutines are cheap
- Raises the thread ceiling instead of bounding concurrency
- Adds the requirement to an exported API expecting to remove it later