The question this answers
Should this lock hand the resource to the longest waiter, and what does that decision cost me?
A hot counter lock in a request path, acquired 200 000 times a second by 16 threads, each holding it for roughly 100 nanoseconds.
One mutex and the counter it protects, plus — invisibly — the mutex's own wait queue, whose ordering policy is the entire subject of this lesson.
The counter equals the number of completed increments, under every acquisition policy — fairness does not affect that at all. The invariant fairness *adds* is a bound on waiting: no thread waits while more than k other acquisitions complete. Unfair locks preserve the first and abandon the second, and for most hot locks that is the right choice.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Barging: why your mutex is unfair on purpose
When a thread releases a mutex with waiters, the natural mental model is "the lock is handed to the first waiter". That is not what most implementations do. They mark the lock free and wake a waiter, and between the marking and the waking there is a window of a few microseconds during which any *already running* thread can walk up and take the lock. That is barging, and it is deliberate.
The reason is arithmetic. Waking a parked thread costs a system call, a scheduler decision and a context switch — call it a few microseconds, and see The Cost of a Context Switch. The critical section here costs 100 nanoseconds. If every acquisition requires a handoff, the lock spends ninety-something percent of its time idle waiting for a waiter to be scheduled, and total throughput collapses by an order of magnitude. Barging lets a thread that is *already on a CPU with the cache line hot* take the lock immediately and finish the work in the time it would have taken to wake somebody else.
The cost is the schedule below. T3 barges past T2 not once but repeatedly, and T2's wait is bounded by nothing. This is the mechanism behind the barging row of Starvation — and note that it is not a bug report, it is the documented behaviour of std::mutex, pthread_mutex_t, Java's default ReentrantLock and Go's sync.Mutex.
1// std::mutex: no ordering guarantee whatsoever.2std::mutex m;3void bump() { std::lock_guard g(m); ++counter; }4 5// Release marks the lock free and wakes a waiter. A thread already6// running on another core can acquire in the microseconds before the7// woken waiter is scheduled — and its cache line is already hot.8// throughput: high max wait: unbounded1// A ticket lock: take a number, wait for it to be served.2struct TicketLock {3 std::atomic<uint32_t> next{0}; // next ticket to hand out4 std::atomic<uint32_t> serving{0};// ticket currently allowed in5 void lock() {6 uint32_t my = next.fetch_add(1, std::memory_order_relaxed);7 while (serving.load(std::memory_order_acquire) != my)8 std::this_thread::yield(); // or park; either way, strict FIFO9 }10 void unlock() { serving.fetch_add(1, std::memory_order_release); }11};12// Strict FIFO: no thread waits behind more than (queue length) others.13// throughput: lower max wait: bounded14// Also note: every waiter spins on the SAME cache line, so coherence15// traffic grows with waiter count. See [[cache-coherence-concurrency]].These two locks give identical mutual exclusion and identical correctness for the counter. They differ only in who goes next, and that difference is worth an order of magnitude of throughput on a short critical section. Choose fairness when the *tail* is the requirement; choose barging when the *rate* is.
Where the throughput actually goes
It is worth seeing the loss rather than asserting it, because "fair is slower" is easy to say and easy to disbelieve. The timeline shows six acquisitions of a 100 ns critical section under both policies on a machine where waking a parked thread takes roughly 3 µs.
Under barging, the running threads pass the lock between themselves with no scheduler involvement: six critical sections complete back to back and the lock is essentially never idle. Under strict FIFO, each release must wake the specific next-in-line thread, and the lock sits *free but unusable* for the whole wakeup latency. The picture makes the ratio obvious: the fair lane is mostly white space, and that white space is a 30× multiplier on a 100 ns section.
This is why the practical answer is neither policy but a hybrid. Barge normally; if a waiter's wait exceeds a threshold, switch to strict handoff until the backlog clears. Go's mutex does exactly this at 1 ms, and Java's ReentrantLock(true) plus AbstractQueuedSynchronizer offers the endpoints so you can choose. The hybrid gets most of the throughput and bounds the tail, which is what the requirement almost always actually is.
Fairness is a policy question, and the answer varies by lock
The mistake is treating fairness as a global setting. It is a per-resource decision, and the input is the ratio between the critical-section length and the wakeup cost. When a critical section is measured in nanoseconds, handoff cost dominates and fairness is expensive. When it is measured in milliseconds — a lock held across a disk write, a per-tenant lock in a batch job — the handoff cost is noise and fairness is nearly free.
The same reasoning generalises past locks. Scheduler fairness (proportional-share CFS-style scheduling against strict priority), fair queueing in a load balancer, weighted fair queueing per tenant, and even fair scheduling of agent tool calls are all the same trade with different units: bookkeeping and switching cost against a bounded worst case. The Observability & Performance domain measures the effect as tail-latency; the decision lives here.
One caution about the second row of the table. Fairness bounds the *maximum wait*; it does not reduce the *mean*, and it usually increases it. A team that adopts a fair lock hoping for better latency will see p50 rise, p99 improve, and total throughput fall — and if the SLO was on the median, that is a regression they chose.
| Policy | Guarantees | Throughput under contention | Choose when |
|---|---|---|---|
| Barging / unfair (default) | Mutual exclusion only. No ordering, no bound on waiting. | Highest — a running thread reuses a hot lock with no scheduler involvement. | Short critical sections, high acquisition rate, no per-participant SLO. This is the right default for a hot counter or a small map. |
| Strict FIFO / ticket / fair mode | Bounded waiting: at most (queue length) acquisitions pass you. | Lowest — one wakeup per handoff, and the lock idles during each one. | Long critical sections (handoff cost is noise), or a hard per-participant latency requirement. |
| Hybrid (barge, then hand off past a threshold) | Bounded waiting past the threshold; unfair below it. | Near-barging in the common case, degrades gracefully under pathological waits. | Almost always, when the runtime offers it. Go's sync.Mutex does this at 1 ms without asking. |
| Reader/writer preference | Bounds one side's wait by penalising the other. | Read-preferring is fastest for read-heavy loads and starves writers; writer-preferring inverts both. | When the two roles have genuinely different SLOs. Bounded-batch policies ("at most N readers while a writer waits") sit between them. |
| Weighted fair queueing (per tenant/class) | Each class receives its configured share regardless of arrival rate. | Costs per-class bookkeeping on every dispatch, and forgoes the cheapest-next optimisation. | Multi-tenant systems where one noisy tenant must not consume the resource. See Bounding Concurrency. |
Key points
- Most production mutexes are unfair by design, because handing off costs a context switch and a cold cache line while barging costs nothing.
- Fairness bounds the maximum wait. It typically raises the mean wait and lowers total throughput — it is a tail-latency instrument, not a latency instrument.
- The deciding ratio is critical-section length over wakeup cost: fairness is expensive at nanoseconds and nearly free at milliseconds.
- Hybrid policies (barge until a wait becomes pathological, then hand off) capture most of both, and are what Go and modern Java implementations do.
- Fairness is a per-resource decision, not a global switch, and the same trade appears in schedulers, queues and multi-tenant dispatch.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • On release, an unfair lock stores "free" and wakes a waiter; the waiter must be scheduled before it can act, leaving a window.
- • Any thread already running may acquire during that window, ahead of every queued waiter — that is barging.
- • A fair lock instead transfers ownership directly to the queue head, so the lock is never observably free and no barging is possible.
- • The transfer forces the woken thread onto a CPU before any progress happens, adding the full wakeup latency to every acquisition under contention.
- • A hybrid tracks each waiter's wait time and switches to direct transfer only once a waiter exceeds a threshold, restoring barging when the queue drains.
- • Barging, uncontended: T1 acquires, increments, releases. No waiters, no policy difference, no cost either way — which is why microbenchmarks without contention show no difference at all.
- • Barging, contended: T1 releases and wakes T2; T3 (already running) acquires 0.4 µs later; T2 is scheduled 3 µs later and finds the lock held again. Repeat 10 000 times and T2 has not incremented once.
- • Fair, contended: T1 releases directly to T2; the lock is idle for 3 µs while T2 is scheduled; T3 queues behind. Every thread makes progress; the aggregate rate is a fraction of the barging case.
- • Hybrid: barging proceeds until T2's wait crosses 1 ms, at which point the lock switches to handoff, drains the queue in order, and resumes barging. T2's worst case is bounded at roughly the threshold plus the drain time.
- • Long critical section (5 ms, e.g. a lock held across a disk write): the 3 µs handoff is 0.06% overhead. Here fairness costs nothing measurable and there is no reason not to take it.
- • A fair lock guarantees bounded waiting — you are passed by at most the number of threads already queued ahead of you. It does not guarantee a *time* bound, because a holder can still be slow.
- • An unfair lock guarantees only mutual exclusion. It makes no promise about order, and any code relying on acquisition order for correctness is already broken.
- • Neither policy affects the data invariant. A counter protected by a barging mutex is exactly as correct as one protected by a ticket lock.
- • Fair mode does not guarantee better latency. It guarantees a better *worst case*, usually at the cost of a worse median and worse throughput.
- • Scheduler fairness (proportional share) guarantees a share of CPU over a window; it does not guarantee when within that window you run, which is why it is not a real-time guarantee. See The Scheduling Problem.
- • Under no contention the two policies are indistinguishable — every fairness benchmark must be run contended or it measures nothing.
- • Under contention, fair handoff serialises through the scheduler, so the effective throughput ceiling becomes 1 / wakeup-latency rather than 1 / critical-section-length.
- • A ticket lock has a second contention cost that a queued lock does not: every waiter spins on the same
servingcache line, so each release invalidates it on every core. This is why scalable fair locks (MCS, CLH) give each waiter its own cache line. - • Fairness makes Lock Convoys more likely, not less: strict ordering is exactly the lockstep arrival pattern a convoy is made of.
- • Throughput collapse after enabling fair mode on a hot, short-critical-section lock — a common and surprising production regression.
- • Starvation when fairness is absent and one waiter is systematically unlucky. See Starvation.
- • Cache-line contention on a naive ticket or test-and-set lock, where fairness is achieved but the coherence traffic dominates. See False Sharing: Different Variables, Same Cache Line.
- • Convoying under strict FIFO: threads arrive in a fixed rotation and stay in it, which destroys locality and keeps the queue full.
- • False confidence: a fair lock in one place and unfair locks everywhere else, so the end-to-end tail is unchanged and the throughput loss is pure cost.
- • When a per-participant SLO exists — a tenant, a priority class, a specific background job that must not be indefinitely delayed.
- • When the critical section is long relative to a context switch, where fairness costs a fraction of a percent and removes the entire starvation class.
- • When the workload has a persistent asymmetry (one thread pinned to a core with a hot cache always wins the barge) and you need the other participants to get turns at all.
- • On short, hot critical sections, where handoff cost can exceed the work being protected by an order of magnitude.
- • When adopted to "improve latency" without specifying which latency — p50 will get worse, and if that is the SLO you have regressed.
- • When the real problem is hold time or contention rate. Fairness redistributes waiting; it never reduces the total amount of it, and it usually increases it.
- • Acquisition-order statistics: instrument how many acquisitions occur between a thread queueing and acquiring. A long tail on that count is unfairness expressed as a number.
- • Compare throughput and p99 before and after switching policy, under *contended* load. An uncontended benchmark shows no difference and will mislead you.
- • Lock hold-time distribution — the input to the whole decision. If p50 hold time is under a microsecond, fairness will be expensive.
- • Per-thread or per-class acquisition counts. A fair lock produces near-equal counts; a barging lock under contention produces a heavily skewed histogram, and the skew is the signal.
- • Context-switch rate around the lock (
perf stat -e context-switches,pidstat -w). A jump after enabling fair mode is exactly the cost this lesson describes.
- • A fair lock carries an explicit queue: more state per lock, a wakeup path, and cache-line padding per waiter in scalable designs.
- • A hybrid adds a threshold — a tuning parameter whose right value depends on workload and hardware, and which is invisible in code review.
- • Making fairness a per-resource decision means the codebase now contains two lock types and a rule about which to use where.
- • Weighted fairness adds per-class accounting to every dispatch and a configuration surface for the weights, which then needs its own ownership and review.
- • Reduce hold time so the queue never builds and the policy stops mattering. This is nearly always the higher-leverage fix. See Lock Scope: What You Hold It Across and What Contention Actually Costs.
- • Shard the lock so contending parties rarely meet — sixteen locks by hash mean a barging lock behaves fairly in practice because there is rarely a queue.
- • Remove the shared resource: per-thread accumulators reduced at the end, or an immutable snapshot for readers. See Immutability as a Concurrency Strategy.
- • Move the ordering requirement into an explicit queue with visible policy, rather than hoping a lock's internal wait queue implements it. See Concurrent Queues.
What people believe, and what is true
Locks are FIFO by default.
Almost none are. Barging is the default in C++, POSIX, Java and Go because it avoids a context switch on every handoff, and the documentation says so in each case.
A fair lock will improve our latency.
It improves the maximum wait and typically worsens the median and the throughput. If the SLO is on p50, enabling fairness is a regression.
Fairness and throughput can both be maximised with a better algorithm.
The cost is structural: fairness means sometimes choosing a cold, parked thread over a hot, running one. Better algorithms (MCS, hybrids) reduce the constant; they do not remove the trade.
Go deeper
Overview
A fair lock gives the resource to whoever waited longest. That bounds the worst wait and costs throughput, because waking a sleeping thread is much slower than letting a running one take another turn.
Practical
Decide per lock, using hold time. Nanosecond critical sections: stay unfair and fix the contention instead. Millisecond critical sections: fairness is basically free, take it. If your runtime offers a hybrid, take that.
Advanced
Fairness in a lock is one instance of a general scheduling trade: any policy that guarantees a bound must sometimes decline the locally optimal choice. This is why fair queueing costs throughput, why CFS is not a real-time scheduler, and why weighted fair dispatch costs bookkeeping. Recognising the shape lets you price the decision in every one of those settings.
Internals
Scalable fair locks (MCS, CLH) solve the ticket lock's coherence problem by giving each waiter its own node to spin on, so a release touches exactly one remote cache line instead of all of them. The Linux kernel's queued spinlock is an MCS variant for exactly this reason. Fairness at scale is therefore not only a scheduling question but a cache-coherence one — see What a Shared Write Costs and Spin Locks.