Why Locking a Mutex Usually Doesn't Need a System Call

On Linux, pthread_mutex_lock() may execute just a handful of user-space instructions, or it may enter the kernel and put the thread to sleep. The difference is not which function you called — it is whether the lock is contended, and which implementation path the specific mutex type takes.
This article focuses on glibc’s ordinary private pthread_mutex_t.
The short version
- When the lock is free, locking usually completes with a user-space atomic operation and needs no system call; uncontended unlocking is usually the same.
- Only once the lock is contended may a thread wait with
futex(FUTEX_WAIT); the releasing thread callsfutex(FUTEX_WAKE)when there may be waiters.- Futex is not a complete lock object that can replace a mutex. It is the wait-and-wake mechanism Linux provides for user-space synchronization libraries.
- Entering
futex()does not necessarily cause a thread context switch. A scheduling-level switch happens only when the thread actually blocks and is scheduled out.
Why can the lock state live in user space?
Threads in the same process share an address space, so all of them can access the lock state inside pthread_mutex_t. The CPU in turn provides atomic read-modify-write instructions, so multiple cores can race on the same memory word without entering the kernel.
For glibc’s ordinary mutex, the common states of the underlying lock word simplify to:
| Value | Meaning |
|---|---|
0 |
unlocked |
1 |
locked, no known waiters |
>1 |
locked, possibly with waiters |
This is only a conceptual model of the plain low-level lock path. Recursive, robust, process-shared, and priority-inheritance mutexes carry more state and do not fit these three values.
The kernel does not need to participate in answering “is the lock free right now”. User space only needs kernel help when it cannot make progress on its own and must let a thread wait efficiently.
Uncontended locking: one user-space atomic operation
The fast path of an ordinary mutex reduces to pseudocode like this:
int mutex_lock_fast(atomic_int *lock)
{
int expected = 0;
if (atomic_compare_exchange_acquire(lock, &expected, 1))
return 0;
return mutex_lock_slow(lock);
}The thread atomically tries to change the lock from 0 to 1. Success means there is no current holder, and locking is done — no system call anywhere in the process.
The atomic operation here is more than modifying an integer. It must also provide acquire semantics: after locking succeeds, memory accesses inside the critical section cannot be reordered before the lock, and the thread must observe the writes the previous holder completed before unlocking.
The fast path avoids the kernel, but it is not “free”. An atomic read-modify-write requests write permission on the cache line; if the lock word is currently held by another core, the cache-coherence communication alone can be expensive. “Uncontended locks are fast” means they avoid system calls, wait queues, and thread scheduling — not that they cost a fixed handful of CPU cycles. When many cores ping-pong the same lock word, the bottleneck is the same coherence traffic described in False Sharing: Why Unrelated Writes Slow Each Other Down.
Once contended, what does futex do?
When the atomic operation finds the lock already held, the thread cannot wait efficiently by just re-reading the lock word. Spinning on it burns CPU; going straight to sleep must be the kernel’s job, because user space cannot safely move a thread off and back onto the run queue by itself.
Futex splits the two responsibilities:
- user space owns the lock state and the uncontended fast path;
- the kernel owns waiting, waking, and scheduling once there is contention.
The slow path usually first marks the lock as “may have waiters”, then calls:
futex(&lock_word, FUTEX_WAIT_PRIVATE, expected, NULL, NULL, 0);The key semantics of FUTEX_WAIT are “compare and block”: the kernel adds the thread to the wait queue and blocks it only if lock_word still equals the expected value supplied by the caller. This comparison and the blocking are atomic with respect to other futex operations.
That prevents a classic race: the waiting thread checks in user space and sees the lock held; the holder then unlocks and issues a wake; and the waiter only actually goes to sleep after the wake has already happened. If the kernel sees before enqueuing that the lock value has changed, it returns immediately, the user-space library re-contends for the lock, and the state change is not missed.
A return from the wait also does not mean the current thread now holds the lock. Spurious wakeups, signals, and other conditions can all return from futex(), and the thread must check and contend for the lock again. The correct model is “retry after wakeup”, not “the unlocking thread hands the lock directly to one waiter”.
Why does unlocking sometimes skip the kernel too?
The low-level unlock path of an ordinary mutex simplifies to:
int old = atomic_exchange_release(&lock_word, 0);
if (old > 1)
futex(&lock_word, FUTEX_WAKE_PRIVATE, 1, NULL, NULL, 0);Unlocking uses release semantics, ensuring the writes inside the critical section are visible before another thread can successfully acquire the lock.
If the old value is 1, there are no known waiters: set the lock to 0 and return, no system call needed. If the old value indicates there may be waiters, the unlocking thread calls FUTEX_WAKE so the kernel wakes threads from the corresponding wait queue — the same wait-queue machinery walked in How epoll Wakes a Waiting Thread in the Linux Kernel, applied to synchronization instead of I/O readiness.
“May have waiters” does not guarantee that a sleeping thread is actually found at wake time. The race state may have changed already; the library implementation prefers one redundant wake over missing a real waiter.
System calls and thread switches are two different things
Once the contended path enters futex(), there are two main outcomes:
- The kernel finds the lock value has changed; the system call returns quickly and the thread retries in user space.
- The lock value still satisfies the wait condition; the thread joins the wait queue, blocks, and the scheduler runs another thread.
The first case performs a system call but not necessarily a thread context switch. Only the second case involves real blocking and scheduling. Conversely, a thread can also be switched away by time-slice expiry or preemption without calling futex at all.
So a single lock contention cannot be mechanically converted into “one system call plus one context switch”. The true cost depends on when the lock is released, whether waiters actually sleep, run-queue load, and CPU topology.
How to observe the fast path and the slow path
Prepare two small programs: one where a thread repeatedly locks and unlocks alone, and another where two threads contend on the same lock. Then run:
strace -f -e trace=futex ./mutex_uncontended
strace -f -e trace=futex ./mutex_contendedThe uncontended program’s loop usually produces no futex calls attributable to this mutex; the contended program shows FUTEX_WAIT_PRIVATE, FUTEX_WAKE_PRIVATE, or the corresponding newer operations. Add -c when you want aggregate counts. Program startup, the runtime, and other synchronization facilities also use futex, so seeing a single futex() call does not prove it came from your mutex. Narrow the program’s scope, and where necessary keep the full call trace and analyze by thread ID.
Do not preset the timing to some fixed nanosecond figure. Even on the uncontended path, CPU model, compiler, glibc version, where the lock word sits in the cache, frequency policy, and measurement method all affect results. The most reliable verification target here is “did we enter the futex slow path”, not reproducing a specific number.
Does every mutex spin?
A short spin can avoid the case where the lock is released just after the thread goes to sleep: the contender retries a limited number of times in user space before falling into a futex wait. Whether this strategy exists, when it activates, and how long it spins depend on the runtime and the lock type.
glibc provides the non-standard PTHREAD_MUTEX_ADAPTIVE_NP type; the ordinary default mutex is not equivalent to an adaptive mutex. Runtimes like Go also spin in a limited way under specific conditions, but their state machines and schedulers differ from pthreads. C++’s std::mutex does not mandate futex or spinning underneath at all.
Spinning trades CPU time for lower sleep-and-wake latency. It only pays when hold times are short and the holder is running on another core. When contention persists or the CPU is already overloaded, spinning amplifies the overhead instead.
What actually matters in engineering
- Shorten critical sections: do no unrelated computation while holding the lock; move whatever can be moved outside.
- Avoid uncontrollable blocking inside the lock: disk, network, and cross-service calls can leave every other thread stuck outside.
- Confirm contention before restructuring: combine lock wait time, CPU usage, and call stacks to identify the bottleneck — do not look at lock call counts alone.
- Reduce sharing: shard high-frequency counters or accumulate per thread; stripe data structures by business key.
- Choose read-write locks or spinning based on measurement: read-write locks carry extra state-maintenance cost and spinning consumes CPU; neither is an unconditional upgrade over a plain mutex.
Application code should normally use the synchronization primitives from pthreads, the C++ standard library, or the language runtime rather than calling futex directly. Robust mutexes, priority inheritance, timeouts, cancellation, and memory ordering all contain error-prone edges; futex is only the low-level interface those capabilities are built on. (For how threads and processes themselves are constructed on Linux, see Linux Processes vs Threads: NPTL and Clone() Explained.)
Conclusion
The reason a mutex usually needs no system call is not complicated: lock state plus atomic operations already solve the uncontended case, and the kernel only handles the waiting and waking that user space cannot complete alone. What is truly expensive is not the function name pthread_mutex_lock() — it is the cache traffic, thread blocking, and scheduling caused by contending for shared data.
Related reading: False Sharing: Why Unrelated Writes Slow Each Other Down explains the coherence cost that atomic lock words share with unrelated hot fields, and How epoll Wakes a Waiting Thread in the Linux Kernel walks the kernel wait-queue and wakeup path that futex also relies on.
References
- Linux man-pages,
futex(2) - Linux man-pages,
futex(7) - glibc,
nptl/lowlevellock.h - glibc,
nptl/pthread_mutex_lock.c - glibc,
nptl/pthread_mutex_unlock.c - Linux 6.10,
kernel/futex/waitwake.c


