cgroup

On this page

cgroups organize tasks into a hierarchy for resource accounting, allocation, and limiting. Namespaces change the view of resources, while cgroups control usage behavior; entering a new cgroup namespace does not automatically grant more CPU or memory.

This article focuses on v2. v1 allowed placing multiple controllers at different levels, and it is not absolutely impossible to limit a group of processes simultaneously; however, v2's unified hierarchy and more consistent rules make control relationships easier to compose. Whether v2 is mounted should be checked against the current system, not guessed by the distribution year.

Hierarchy limits apply along the ancestry

Child cgroup configurations cannot bypass parent limits. A parent's CPU quota may be consumed jointly by multiple child groups, and the parent's memory limit constrains the entire subtree; checking only leaf files will miss the true bottleneck.

cgroup.controllers indicates which controllers are available for child groups at the current level, while cgroup.subtree_control determines which are enabled for child levels. mkdir does not mean all controller files and policies are automatically ready. Controllers need to be enabled top-down along the hierarchy.

Regular domain controllers generally follow the no-internal-process constraint: when internal nodes allocate resources to child groups, they cannot simultaneously place regular worker processes to compete with child groups; root and threaded modes have specific rules, so no single generalization covers all cases. cgroup v2

Delegation is more important than directly modifying the system root

Modern systems often manage the cgroup tree via systemd or similar managers. Applications should use delegated subtrees or manager interfaces and cannot assume they can remount /sys/fs/cgroup or arbitrarily modify upper-level controllers.

Writing to cgroup.procs in an allowed target group moves the threads of the entire process; in threaded mode, cgroup.threads has different semantics. Migration permissions also involve requirements such as common ancestors; having write permission on a directory does not allow pulling in arbitrary processes. Process migration does not guarantee that existing memory accounting is fully transferred.

CPU: weight, quota, and placement are configured separately

InterfaceExampleMeaning
cpu.weight200Weight relative to siblings, default 100
cpu.max50000 100000Max 50000 microseconds of CPU time per 100000 microsecond period
cpuset.cpus0-3Request available CPU set; also check effective limits and ancestor constraints

weight=200 does not guarantee two CPUs, nor does it guarantee being twice as fast as another group at any time; weight mainly manifests when tasks compete, and idle resources can be used.

The quota in cpu.max is consumed jointly by relevant tasks within the group. Two threads can run on two CPUs simultaneously and exhaust the quota faster, after which they are throttled; it is not "fixed 50% per CPU." Observe increments in nr_throttled, throttled_usec, etc., in cpu.stat to determine if the quota is actually the bottleneck.

cpuset placement limits do not equate to exclusive access to those CPUs; partitioning and exclusive configurations have separate rules. cpuset.mems should also be understood in conjunction with effective nodes and NUMA allocation policies.

Both children can throttle with 30 ms left each

Let parent P have cpu.max=100000 100000 and children A/B each 80000 100000. Assume aligned periods from t=0, no burst, one continuously runnable fair-class task per child, and two available CPUs, omitting slice-distribution details.

In 50 ms wall time, each consumes 50 ms CPU, exhausting P's 100 ms total. Each child retains 30 ms but cannot exceed its ancestor. Raising child limits does not remove this bottleneck before replenishment.

Two CPUs consume one shared parent budget Task A Run: 50 ms CPU Parent exhausted: throttled Task B Run: 50 ms CPU Parent exhausted: throttled 0 ms 50 ms 100 ms 50 ms wall time × two CPUs = 100 ms parent CPU budget Aligned periods, no burst; children retain 30 ms each but the ancestor limits both.
def one_period(active):
    parent_left = 100
    leaf_left = {"A": 80, "B": 80}
    used = {"A": 0, "B": 0}
    for _ in range(100):  # One hundred 1 ms slots; each task uses at most one CPU
        for name in active:
            if parent_left > 0 and leaf_left[name] > 0:
                parent_left -= 1
                leaf_left[name] -= 1
                used[name] += 1
    return used, leaf_left, parent_left

assert one_period(("A", "B")) == ({"A": 50, "B": 50}, {"A": 30, "B": 30}, 0)
assert one_period(("A",)) == ({"A": 80, "B": 0}, {"A": 0, "B": 80}, 20)

With only A running, it reaches its own 80 ms limit while P retains 20 ms. Inspect competition and per-level cpu.stat deltas. The ideal 50 ms is not a universal Linux timeline across period alignment, slice caches, classes, and load. CPU controller

Weights are hierarchical too. If root children P/Q have equal weight, P contains equally weighted A/B, and Q contains C, a saturated proportional model yields A=1/4,B=1/4,C=1/2, not equal thirds. These shares no longer apply when a group stops competing or other limits intervene.

Run A and B separately and watch both charge the common ancestor. A child's remaining quota can become unusable when its sibling exhausts the parent.

R is remaining CPU time in this period. A deeper real hierarchy checks all relevant ancestors; the diagram retains two levels.

Preparing the visual
Why can a child stop despite its remaining budget?

A child’s configured quota is not a reservation and cannot bypass its ancestors. Availability is min(child remaining, ancestor remaining); a sibling’s use of the ancestor can constrain the other child.

Memory: high is pressure control, max is the boundary

memory.high and memory.max have different responsibilities Exceeding memory.high Reclaim and throttle; temporary excess possible Touching memory.max Enforce limit, reclaim, possibly memcg OOM Combine memory.events and memory.pressure Distinguish breach counts, OOM/kill events, and actual wait times high itself does not trigger OOM kill; it is not a simple kernel call graph executed sequentially by threshold.

Exceeding memory.high applies reclaim and throttling pressure to allocation tasks; it can be temporarily breached and does not trigger the OOM killer itself. memory.max is a hard limit; upon reaching it, the system attempts reclaim and other handling first; if unsuccessful, it may enter memcg OOM. It does not kill processes immediately for exceeding by even one byte.

memory.low is best-effort reclaim protection, while memory.min provides stronger protection semantics, but effective protection is influenced by ancestors and competing allocations, not pre-allocated physical RAM. memory.swap.max has independent control over swap.

memory.oom.group can require OOM handling to treat workloads as a group; you cannot directly draw an arrow from "high reclaim failure" to "killing the whole group due to oom.group." It is necessary to distinguish the level where events occur and the triggering causes.

Reclaim selection depends on page state, activity, writability, swap, and policies, and does not guarantee clearing all caches first and then anonymous pages. memory.current includes the corresponding accounted memory and does not equal a specific process's RSS.

I/O and Task Count

io.max limits bytes or IOPS per device; the major/minor device numbers in the format should come from the actual target device; the effect of io.weight depends on block layer and corresponding scheduler/policy support. Cache writes, writebacks, and underlying device paths affect visible performance; you cannot directly equate arbitrary read/write call rates with block device rates.

pids.max limits the number of kernel tasks, including threads. Reaching the limit prevents corresponding fork/clone creation, typically resulting in EAGAIN; setting a lower limit or moving into existing tasks does not automatically kill excess tasks. Observe pids.current and pids.events.

PSI: Observe Waiting, Not Just Usage

PSI's some represents the percentage of time in a window where at least one task was blocked by the corresponding resource; full represents the percentage of time where all non-idle tasks within the scope were blocked. avg10/avg60/avg300 are average percentages, and total is cumulative microseconds, not another percentage.

High CPU usage might indicate sufficient execution of useful work, while PSI helps observe waiting losses; high memory usage might mainly be valuable cache and should not be directly equated with memory pressure. System-level CPU full has special semantics and cannot be mechanically applied to memory full interpretations. PSI

PSI some and full combine wall-time intervals

Observe 100 ms of cgroup I/O pressure with exactly two non-idle tasks demanding work throughout. A waits during [0,40), B during [20,60), and each progresses otherwise.

Interval msABPressure contribution
0–20StalledProgresssome +20
20–40StalledStalledsome/full +20 each
40–60ProgressStalledsome +20
60–100ProgressProgressNeither

Thus some=60 ms and full=20 ms. Adding task waits gives 80 ms, not the union of stalled wall time.

def pressure_ms(blocked, window=100):
    assert blocked and window > 0
    points = {0, window}
    for intervals in blocked:
        for start, end in intervals:
            assert 0 <= start <= end <= window
            points.update((start, end))
    points = sorted(points)
    some = full = 0
    for start, end in zip(points, points[1:]):
        waiting = [any(a <= start < b for a, b in intervals) for intervals in blocked]
        if any(waiting):
            some += end - start
        if all(waiting):
            full += end - start
    return some, full

waits = [[(0, 40)], [(20, 60)]]
assert pressure_ms(waits) == (60, 20)
assert pressure_ms(waits + [[]]) == (60, 0)  # C always progresses, making full zero
assert pressure_ms([[(0, 10)], [(10, 20)]]) == (20, 0)
assert pressure_ms([[], []]) == (0, 0)
assert pressure_ms([[(0, 40), (20, 60)]]) == (60, 60)

This fixed non-idle-task model omits dynamic task/accounting scope. A voluntarily idle thread with no work is not a permanently progressing task that forces full to zero. PSI

A some total increase of 60000 μs over 100000 μs is approximately 60% for that sampling window. It does not recompute avg10; avg10/60/300 are kernel-maintained smoothed averages. Keep interval, counter resets, and group lifetime consistent before subtracting totals.

Read-Only Check for Current Process

cat /proc/self/cgroup
findmnt -t cgroup2
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io

Locate the belonging group based on the actual cgroup mount root and process path, then read available files such as cpu.stat, memory.events, memory.pressure, and pids.current. Containers may only expose a segment of the hierarchy, and /proc paths are also affected by namespaces; do not unconditionally concatenate strings to the host root directory.

The diagnostic order can be: confirm the belonging group and ancestor constraints, check if controllers are enabled, then compare statistical increments with actual request latency. Reading counters does not explain all causality, but it can distinguish "configured limits" from "actually being limited."

Kernel Reading Entry Points

kernel/cgroup manages core hierarchy and membership, mm/memcontrol.c manages memory accounting and control, and block/blk-cgroup.c manages block layer associations. The css of a controller is the subsystem state, and not all controllers attach the same object to the request_queue.

Entering the source code from user-visible interface contracts, then viewing specific version charge, reclaim, or scheduling paths, is more reliable than disguising dozens of lines of comments as a universal try_charge call graph. The other half of the isolation view can be found in Namespaces.