skip to content

What happens to throughput and latency when a machine has far more runnable threads than CPU cores, and how would you recognize that state from the outside?

level: seniorimportance: should knowfreq 45%

answer

  1. only runnable threads compete
  2. R>C: latency ~ R/C; throughput flattens then falls
  3. involuntary switches + run-queue length = the tell
  4. preempted lock holder = convoy
  5. queue in your admission control, not in the run queue

basics

~20 s

Beyond one runnable thread per core, throughput stops rising and eventually falls: extra threads add context switches, cache eviction and lock contention rather than work. Latency grows roughly with the queue of runnable threads. From outside you see high run-queue length, many involuntary context switches, high CPU with falling completion rate, and rising tail latency.

solid answer

~50 s

Cores execute one runnable thread each at a time. Adding runnable threads beyond core count adds no capacity — it adds queueing. Latency for any single request grows roughly in proportion to how many runnable threads are ahead of it, while throughput first flattens and then **declines**, because the extra threads impose real costs: involuntary context switches, mutual cache eviction so each thread runs slower after each switch, more lock contention and longer lock hold times measured in wall clock, and more memory for stacks. That decline is the difference between an idealized speedup model, which predicts a flat ceiling, and reality, where a coherence and contention term makes the curve bend downward past a peak. Signs from outside: run-queue length persistently above core count, a rising share of *involuntary* context switches, CPU near saturation while completions per second fall, and tail latency degrading far faster than the median. Crucially, only **runnable** threads count. Thousands of threads blocked on I/O are not oversubscription.

code

text · 7 lines
text
runnable  throughput      p99 latency   why
   4        rising          low          cores idle
   8        peak            low          sweet spot for CPU-bound work
  16        ~flat/-5%       ~2x          time-slicing, some switch cost
  64        -20%            ~10x+        cache eviction, convoying, switch storm

(shape: rises, peaks, then DECLINES - not a flat ceiling)

go deeper

for a junior

Say that only one thread runs per core at a time, so extra runnable threads add waiting and context switching rather than capacity.

for a middle

Quantify it — latency scales roughly with runnable-threads-per-core — and name switch overhead and cache pressure as why throughput falls instead of plateauing.

for a senior

Diagnose from run-queue length and the involuntary-switch trend against completion rate, explain convoying from preempted lock holders, and apply admission control and pool separation.

for a principal

Bring the scalability model with its coherence penalty, argue for an explicit optimal concurrency level with bounded queues you own, and address container quotas, isolation between latency-critical and batch work, and where capacity rather than tuning is the real answer.

## Runnable is the word that matters A thread is only competing for CPU when it is **runnable**: not blocked on I/O, a lock or a queue. A server with 5,000 threads of which 12 are runnable on 8 cores is mildly oversubscribed; the other 4,988 cost memory and bookkeeping, not CPU. Every statement below is about runnable count versus core count. ## What happens as runnable count rises Let C be cores and R the number of runnable threads. - **R < C**: cores idle. Adding threads adds throughput roughly linearly. - **R ≈ C**: the sweet spot for CPU-bound work. Every core busy, minimal switching. - **R > C**: no extra capacity exists. The scheduler time-slices, so each thread runs at roughly C/R speed. Total useful throughput stays flat *in the ideal model* and per-request latency grows linearly with R. - **R ≫ C**: throughput actively **falls**. This is the part candidates miss. ## Why throughput falls rather than plateaus Four compounding effects: 1. **Switch overhead.** Every preemption costs the switch plus re-warming caches, branch predictors and translation entries. With many runnable threads, each one is descheduled before its working set is fully re-established, so a growing fraction of every slice is spent re-warming. 2. **Cache capacity pressure.** R threads with distinct working sets share one cache hierarchy. As R grows, each thread's share shrinks and its miss rate rises — so each thread is slower *while running*, not just while waiting. 3. **Lock contention and hold time.** A thread preempted while holding a lock keeps holding it for the whole time it is off-CPU. Waiters pile up behind a holder that is not even running — the convoy effect — so contention grows superlinearly with R. 4. **Memory pressure.** Stacks and per-thread structures consume memory that would otherwise be cache or page cache. The useful theoretical framing: simple speedup models with a serial fraction predict an asymptotic ceiling — more workers never *hurt*. The universal scalability model adds a second, negative term for coherence and contention costs that grows faster, producing a curve with a **peak followed by decline**. Oversubscription is that decline made concrete. The practical lesson is that there exists an optimal concurrency level and running past it costs you both throughput and latency. ## Latency, especially the tail With R runnable threads on C cores, a thread's completion is stretched roughly by R/C plus the switch overhead. The median degrades, but the **tail** degrades far more: the unlucky request waits behind more competitors, and variance in wait time compounds across every stage a request passes through. A system at 2× oversubscription rarely shows a doubled median and a doubled 99th percentile — the tail moves much more. ## Recognizing it from outside Signals, roughly in order of usefulness: 1. **Run-queue length** (runnable threads waiting) persistently above core count. This is the direct measurement; load-average style metrics are a rough proxy and on some systems include threads blocked on disk, so read them carefully. 2. **Involuntary context switches rising** while voluntary ones do not. Involuntary means the scheduler took the CPU from a thread that still had work — the signature of CPU competition. Voluntary switches mean blocking, which is a different story. 3. **CPU utilization near saturation while completions per second fall or flatten.** Busy but not productive. 4. **Tail latency degrading faster than the median.** 5. **Rising time-in-lock and queue depths inside the application**, from convoying. A common confusion to call out: high CPU utilization alone does not mean oversubscription — a perfectly-sized CPU-bound service also shows near-100%. The distinguishing evidence is the run queue and the involuntary-switch trend alongside a flat or falling completion rate. ## What you do about it - **Bound concurrency explicitly** — admission control, bounded queues, a cap on concurrently-executing units — so surplus work waits in a queue you control rather than in the OS run queue where it damages everyone. - **Separate the pools.** CPU-bound work sized near core count; blocking work in its own pool where a high thread count is legitimate because those threads are mostly not runnable. - **Reduce work per request** before adding capacity; oversubscription is often a symptom of each request costing more than it should. - **Beware the container mismatch.** A runtime that sizes pools from the machine's core count while the container is limited to a fraction of that is a classic self-inflicted oversubscription: many runnable threads against a small effective quota, plus throttling stalls at every enforcement period. - **Reduce lock hold times**, since preemption inside a critical section is what turns oversubscription into contention collapse. The headline: past one runnable thread per core, extra concurrency is a queue, and a queue in the scheduler is the most expensive place to keep one.

  • A service runs 2,000 threads on 8 cores and behaves fine. How is that possible?
    Because almost all of those threads are blocked, not runnable — waiting on network I/O, database calls or queues. Only runnable threads compete for CPU, so the run queue may sit at two or three. The cost being paid is memory for stacks and some bookkeeping, not scheduler thrash; the design is wasteful but not oversubscribed.
  • Why does an oversubscribed system show worse lock contention, beyond just having more threads?
    A thread can be preempted while holding a lock, and it keeps holding it for the entire time it is off-CPU — which under oversubscription can be many milliseconds. Waiters queue behind a holder that is not even running, so effective critical-section length is inflated by the scheduling delay. That convoy effect makes contention grow faster than the thread count alone would suggest.
  • How does running inside a CPU-limited container create oversubscription that would not happen on bare metal?
    Runtimes that size thread pools from the visible core count will create pools for the whole machine while the container is entitled to a fraction of it. The result is many runnable threads against a small quota, plus throttling stalls whenever the quota is exhausted within an enforcement period. The fix is sizing pools from the container's effective quota, not from the host's core count.

Ten cashiers, a hundred customers: hiring more cashiers than tills does not help. Rotating half-served customers back to the queue every thirty seconds makes it strictly worse, because each one has to re-explain their order.

saying these in an interview costs you the question

  • Believing throughput merely plateaus past core count rather than declining.
  • Counting total threads instead of runnable threads when judging oversubscription.
  • Reading high CPU utilization alone as proof of oversubscription.
  • Adding threads to fix a latency problem that is really a queueing problem.
  • Ignoring container CPU quotas when sizing concurrency from visible core count.

context