Konrad Kowalski (rootsher)Principal Platform & Reliability Architect010010000111100001111001100011101111010001010001

cgroups and CPU: how Linux splits the processor between groups of processes

date
category
Infrastructure
also in
Computer Science
reading
5 min / 915 words

In the previous article we reduced CPU scheduling to a simple model:

text
task has work
    |
    v
runnable
    |
    v
scheduler picks the task
    |
    v
running

That works well as long as we look at individual processes.

The trouble starts when we want to say:

text
these 3 processes belong to application A,
and these 20 processes belong to application B

and we want the scheduler to treat those two applications as groups, not as 23 independent tasks.

That is what cgroups are for.


Why group processes at all

Assume one CPU and two applications.

Application A:

text
A1

Application B:

text
B1
B2
B3
B4
B5
B6
B7
B8
B9

If the scheduler looked only at individual tasks and each of them had the same share, we would have ten competitors in total.

In a very simplified model:

text
A1 ~ 10% CPU

B1..B9 ~ 90% CPU

So application B gets nine times more CPU purely because it created nine tasks.

Sometimes that is exactly what we want.

But often it is not.

We may want to say:

text
application A = one group
application B = another group

and first split the CPU between the groups:

text
A ~ 50%
B ~ 50%

and only then distribute group B's share among its nine tasks.

That is one of the problems cgroups solve.

The Linux scheduler supports group scheduling: tasks can be grouped, and CPU time distributed between groups instead of treating every process as one entry in a flat list of equal competitors.


What a cgroup is

cgroup stands for control group.

Put simply:

a cgroup is a kernel mechanism that lets you place a set of processes into a hierarchical group and apply resource management rules to that group.

The kernel describes cgroups as a mechanism for organising processes hierarchically and distributing resources along that hierarchy in a controlled way.

Imagine:

text
root
├── application-a
│   ├── process A1
│   └── process A2
│
└── application-b
    ├── process B1
    ├── process B2
    ├── process B3
    └── process B4

The processes are still ordinary Linux tasks.

A cgroup does not create a new kind of process.

What it does give the kernel is extra information:

text
A1 and A2 belong to one group

B1..B4 belong to another

On that basis a resource controller can apply rules to the whole group.


A cgroup is not only about CPU

The cgroup mechanism itself is mainly responsible for:

text
organising processes
+
hierarchy

It is the controllers that implement behaviour for specific resources.

So we can have control over, among others:

text
CPU
memory
I/O
pids

In this article we care only about:

text
cpu controller

Its job is to influence how groups of tasks share the processor.


Without cgroups: the scheduler sees tasks

Imagine four CPU-bound tasks:

text
A1
A2
B1
B2

Under identical conditions we can simplify the situation to:

text
A1 ~ 25%
A2 ~ 25%
B1 ~ 25%
B2 ~ 25%

In total:

text
A ~ 50%
B ~ 50%

Now application B creates six more workers:

text
A1
A2

B1
B2
B3
B4
B5
B6
B7
B8

If all tasks competed on the same terms, application B would end up with a much larger share of the CPU.

That shows why the number of threads should not automatically define an application's share of the machine.


With cgroups we can split CPU between applications first

We create:

text
root
├── group-A
│   ├── A1
│   └── A2
│
└── group-B
    ├── B1
    ├── B2
    ├── B3
    ├── B4
    ├── B5
    ├── B6
    ├── B7
    └── B8

And we set both groups as equals.

Then we can get this split:

text
group-A ~ 50%
group-B ~ 50%

Only inside group A do:

text
A1
A2

divide its share of the CPU between themselves.

Likewise, B's eight tasks divide group-B's share.

So the scheduler can reason hierarchically:

text
CPU
 |
 +-- 50% -> group-A
 |           |
 |           +-- A1
 |           +-- A2
 |
 +-- 50% -> group-B
             |
             +-- B1
             +-- B2
             +-- ...

That is the essence of group scheduling.


CPU weight

Modern cgroup v2 exposes a CPU parameter:

text
cpu.weight

The default value is:

text
100

and the available range is:

text
1 .. 10000

cpu.weight defines the group's relative weight against its active siblings.

Assume:

text
group-A:
cpu.weight = 100

group-B:
cpu.weight = 100

If both groups need CPU all the time, we expect roughly:

text
A : B
1 : 1

That is:

text
A ~ 50%
B ~ 50%

Let us change the configuration:

text
A = 100
B = 200

Now the ratio is:

text
1 : 2

so under full competition, roughly:

text
A ~ 33%
B ~ 67%

Weight is therefore a proportion.

Not an amount of CPU.


Weight does not say "how much CPU you get"

This is important.

text
cpu.weight = 100

does not mean:

text
100% CPU

Nor:

text
1 CPU

Nor:

text
100 ms CPU

Weight on its own says almost nothing without knowing the competition.

If:

text
A = 100
B = 100

we get about:

text
50% : 50%

If we add:

text
C = 100

we get about:

text
33% : 33% : 33%

We did not change A's configuration.

And yet its share changed.

That is why weight should always be read as:

text
my weight
relative to the weights of other active groups

"Active" is very important here

Assume again:

text
A weight = 100
B weight = 100

But workload B is currently doing nothing.

Then A is not artificially capped at 50%.

It can use the available CPU.

The kernel describes weight-based distribution as work-conserving: only groups that can actually use the resource at a given moment take part in the split.

So:

text
A wants CPU
B idle

may give:

text
A ~ 100%
B ~ 0%

despite identical weights.

Weights start defining a proportion only when real competition appears.


Hierarchy matters

cgroups form a tree.

That means the CPU split does not have to happen globally between all groups at once.

Example:

text
root
├── A weight=100
│
└── B weight=100
    ├── B1 weight=100
    └── B2 weight=100

First A and B compete:

text
A
B

If both are active:

text
A ~ 50%
B ~ 50%

Only then is B's share divided between:

text
B1
B2

so roughly:

text
B1 ~ 25% of the whole CPU
B2 ~ 25% of the whole CPU

Finally:

text
A  ~ 50%
B1 ~ 25%
B2 ~ 25%

And this is exactly why you cannot take two cpu.weight values sitting in different places of the tree and compare them directly.

Weight matters mainly between sibling cgroups, that is, groups competing at the same level of the hierarchy.


Historically: cpu.shares

If you have worked with older systems or cgroup v1, instead of:

text
cpu.weight

you may have met:

text
cpu.shares

The idea was similar.

An example from the kernel documentation:

text
multimedia = 2048 shares
browser    = 1024 shares

means a relative proportion of:

text
2 : 1

between those groups while competing for CPU.

There is no need to go deeper into the differences between v1 and v2 yet.

At this stage all that matters is:

text
cgroup v1 -> cpu.shares
cgroup v2 -> cpu.weight

Both describe a group's relative share of the CPU.


Mental model

Without cgroups we can think:

text
scheduler
   |
   +-- task A
   +-- task B
   +-- task C
   +-- task D

With CPU cgroups:

text
scheduler
   |
   +-- group A
   |     |
   |     +-- task A1
   |     +-- task A2
   |
   +-- group B
         |
         +-- task B1
         +-- task B2
         +-- task B3

The scheduler still executes concrete tasks.

But cgroups let us tell the kernel:

before you start looking at individual processes, treat them as groups and take the rules assigned to those groups into account.

For CPU, one of the most basic such rules is relative weight.

The most important things to remember:

text
cgroup = a group of processes

cpu controller = CPU rules for that group

cpu.weight = the group's relative weight

weight != limit

weight matters under competition

the split is hierarchical

That is enough for this stage.

We have not capped maximum CPU yet, we have not introduced quota, and we are not talking about Kubernetes yet.

We simply taught the kernel to look at many processes as one group.