Konrad Kowalski (rootsher)Principal Platform & Reliability Architect000001000000011001001001101111111110101111001011

CPU Contention: when a process wants CPU and does not get it

date
category
Capacity & Performance
also in
Computer Science
reading
4 min / 848 words

CPU throttling has an unambiguous cause:

text
the workload used up its CPU bandwidth limit

CPU contention is different.

Here a process may have no limit at all and still not get as much CPU as it needs.

The reason is simple:

text
more tasks want to execute
than the CPUs can execute at once

That is CPU contention.


The simplest case

We have one logical CPU:

text
CPU 0

and one CPU-bound task:

text
A

The task always has work.

So the scheduler can execute it practically all the time:

text
CPU 0 -> A

We have:

text
demand   = 1 CPU
capacity = 1 CPU

There is no contention.

Let us add a second identical task:

text
A
B

Both would like to execute all the time.

Their combined demand is:

text
2 CPU

but the available capacity is still:

text
1 CPU

At any given moment the CPU can execute only one of them:

text
CPU 0 -> A

B -> runnable, waiting

A moment later the scheduler may switch:

text
CPU 0 -> B

A -> runnable, waiting

And so on:

text
A -> B -> A -> B -> A -> B

That is the basic form of CPU contention.


Contention starts at demand > capacity

The simplest model:

text
CPU demand <= CPU capacity
    |
    v
no need to wait for CPU


CPU demand > CPU capacity
    |
    v
some runnable tasks have to wait

On a machine with four logical CPUs:

text
capacity = 4 CPU

If we have four constantly runnable single-threaded tasks:

text
A
B
C
D

all of them can execute in parallel:

text
CPU 0 -> A
CPU 1 -> B
CPU 2 -> C
CPU 3 -> D

Let us add:

text
E

Now we have:

text
5 runnable tasks
4 CPU

At least one task has to wait at any moment.

Not because it is throttled.

Not because it is sleeping.

It is ready to execute.

There simply is no CPU for it.


A runnable task may be executing nothing

This is the most important thing about contention.

A task in the runnable state tells the scheduler:

text
I am ready,
give me a CPU

But runnable does not mean:

text
I am currently executing instructions

Example:

text
time -------------------------------------->

task A:
RUNNING | RUNNING | RUNNING | RUNNING

task B:
WAIT    | WAIT    | RUNNING | WAIT

During the WAIT moments task B may still be runnable.

It is not waiting on:

text
disk
network
a timer
an application lock

It is waiting purely for the scheduler.

That is the time lost to CPU contention.


Runqueue

Runnable tasks have to be represented by the scheduler somewhere.

Simplified, you can think of a runqueue:

text
CPU 0

running:
    A

runnable:
    B
    C
    D

The more runnable work there is per unit of available capacity, the more time tasks may spend waiting.

And it is not only about the number of processes.

A single process may have:

text
32 runnable threads

and generate large CPU demand on its own.

From the scheduler's point of view what matters is tasks that are ready to execute.


CPU usage does not show the whole demand

Assume an application that, with no constraints, could use:

text
2 CPU

The node is overloaded, though, and the scheduler can give it on average:

text
1 CPU

Monitoring will show roughly:

text
CPU usage = 1 CPU

But that does not mean the application only needed one CPU.

The real situation may have been:

text
CPU demand = 2 CPU
CPU received = 1 CPU
CPU waiting = 1 CPU worth of work

Of course "waiting = 1 CPU" is a mental model here, not a direct kernel counter.

What matters most is the distinction:

text
usage = the CPU time the workload got

demand = the CPU time the workload wanted

If the scheduler did not run the task, the work it did not do will never appear as CPU usage.


An example from a Kubernetes node

Assume a node:

text
8 CPU

The running workloads generate in total:

text
4 CPU demand

We have plenty of free capacity:

text
demand   = 4
capacity = 8

There is no global contention.

Now a peak arrives:

text
Workload A wants 3 CPU
Workload B wants 3 CPU
Workload C wants 4 CPU
Workload D wants 2 CPU

In total:

text
demand = 12 CPU

on:

text
capacity = 8 CPU

We are short of:

text
4 CPU

That does not mean Linux suddenly "creates" extra CPUs.

It means:

text
some runnable work will wait

The scheduler will distribute the eight available CPUs among the competing tasks according to its own rules and the cgroup weights.


Contention does not require a limit

This is very important after the previous article.

Assume:

yaml
resources:
  requests:
    cpu: "1"

with no:

yaml
limits:
  cpu:

So the workload has no CPU bandwidth limit.

In cgroup v2:

text
cpu.max = max ...

It therefore cannot be throttled for exhausting its own quota.

But it can still wait for CPU.

If the node has:

text
8 CPU

and the runnable workloads want a total of:

text
16 CPU

then physically all that work cannot be executed at once.

No limit does not mean:

text
I will always get as much CPU as I want

It only means:

text
I have no hard bandwidth ceiling of my own

Throttling and contention look similar from the application's side

In both cases a task may have work and not be executing instructions.

Throttling

text
task runnable
     |
     v
CPU may be available
     |
     v
quota exhausted
     |
     v
task cannot execute

Contention

text
task runnable
     |
     v
quota available / no limit
     |
     v
the CPU is executing other tasks
     |
     v
the task waits

The application-level effect may be similar:

text
higher latency
lower throughput

But the cause is completely different.


More threads do not create more CPU

Assume an application on a node with:

text
4 CPU

The application creates:

text
4 worker threads

If nothing else competes, all of them can execute in parallel:

text
T1 -> CPU 0
T2 -> CPU 1
T3 -> CPU 2
T4 -> CPU 3

Adding more:

text
T5
T6
T7
T8

does not double the physical capacity.

Now eight threads compete for four CPUs.

At any given moment:

text
4 -> running
4 -> runnable and waiting

simplified, of course, because the scheduler keeps switching tasks.

Higher concurrency can therefore increase:

text
runnable pressure

without increasing the available compute power.


Scheduler latency

Assume a task that becomes runnable at:

text
t = 0 ms

but the scheduler only starts it at:

text
t = 8 ms

We have:

text
0 ms                8 ms
|--------------------|
      runnable
       waiting

And only later:

text
8 ms       10 ms
|-----------|
  running

The task needed:

text
2 ms CPU time

but the operation took at least:

text
10 ms wall-clock

of which:

text
8 ms

was waiting for a chance to execute.

In practice we call that time scheduler latency or runqueue latency.

And it is one of the most direct effects of CPU contention.


Why contention hurts latency-sensitive workloads in particular

A batch workload can get less CPU and simply finish later.

For a request-response service the situation is worse.

Assume an HTTP request that needs:

text
3 ms CPU time

Without contention:

text
~3 ms execution

On an overloaded node:

text
5 ms waiting
3 ms running
4 ms waiting

we get:

text
12 ms

even though the application still used only:

text
3 ms CPU time

The CPU work did not change.

What changed is when the process was allowed to do it.

That is why contention shows up in latency very quickly.


Mental model

CPU contention comes down to a single condition:

text
runnable CPU demand
>
available CPU capacity

Then:

text
the task has work
      |
      v
runnable
      |
      v
the CPU is taken by the competition
      |
      v
the task waits
      |
      v
the scheduler eventually picks the task
      |
      v
running

The key distinction against the previous article:

text
THROTTLING:
you cannot use the CPU
because you exhausted your quota

CONTENTION:
you may use the CPU,
but others want it right now too

In both cases the application may stop doing work.

Only the reason is different.