cgroups v2 Unified Hierarchy
The unified hierarchy is the defining design of cgroups version 2: where cgroups v1 allowed an arbitrary number of independent hierarchies — one tree per controller, mounted separately — v2 has, in the kernel doc’s words, “only single hierarchy” (Tejun Heo’s cgroup-v2.rst, v6.12). Every process lives in exactly one node of this one tree, and all controllers (cpu, memory, io, pids, …) view that same tree. Because one tree is now the shared substrate, a single placement decision governs every resource at once, and controllers can finally cooperate. The cost of this unification is a small set of new rules that did not exist in v1: controllers must be enabled top-down through the
cgroup.subtree_controlfile, a domain controller can only act where no processes sit on an internal node (the “no internal process” rule), and the model distinguishes ordinary domain cgroups from threaded ones. This note explains the v2 tree mechanics; the deep treatment of delegation and the no-internal-process rule lives in its own sibling, and the controller internals live in theirs.
This is a leaf of the Linux Containers and Isolation MOC (section F) and the natural follow-on to Control Groups Overview. For why the single tree replaced v1’s many trees, see cgroups v1 vs v2; for the full delegation model, see cgroup Delegation and the No Internal Process Rule.
Mental Model: One Tree, Many Controllers
In cgroups v1 each controller had its own filesystem mount and its own tree. To bound a workload’s CPU and memory you placed it into a /sys/fs/cgroup/cpu/... directory and a separate /sys/fs/cgroup/memory/... directory, keeping the two trees manually in sync. The two views could disagree, and utility features like the freezer could only live on one tree. v2 collapses this: there is one tree, mounted once, and on each node you enable whichever controllers should govern that node’s children.
flowchart TB subgraph V1["cgroups v1 — one tree PER controller"] CPUTREE["/sys/fs/cgroup/cpu/<br/>(cpu tree)"] MEMTREE["/sys/fs/cgroup/memory/<br/>(memory tree)"] PIDTREE["/sys/fs/cgroup/pids/<br/>(pids tree)"] end subgraph V2["cgroups v2 — ONE unified tree, controllers enabled per node"] R["/ (root)<br/>cgroup.controllers: cpu io memory pids"] R -->|"subtree_control: +cpu +memory"| W["/workloads<br/>(cpu, memory govern children)"] W --> S1["/workloads/svc-a"] W --> S2["/workloads/svc-b"] end
v1’s per-controller trees versus v2’s single tree. What it shows: v1 forced a process into N separate directories to bound N resources; v2 places it once in one tree, and each node declares (via cgroup.subtree_control) which controllers regulate its children. The insight to take: the unified tree is why a v2 cgroup path like /workloads/svc-a means the same node to the CPU controller, the memory controller, and the pids controller — the structural mismatch that plagued v1 is gone by construction.
The Controller Roster: What Crossed Over, What Changed, What Was Dropped
The unified tree is only half the migration. The other half is that controllers were not ported unchanged — several were renamed, several had their interface redesigned around v2’s distribution models, and a handful were deleted outright because the core absorbed their function. The table below is built from include/linux/cgroup_subsys.h at v6.12 (the canonical SUBSYS() roster) cross-checked against each controller’s struct cgroup_subsys initialiser, where the fields .dfl_cftypes (files on the default/v2 hierarchy), .legacy_cftypes (files on v1), .threaded and .implicit_on_dfl say authoritatively what each controller is.
| v1 controller | v2 name | Status on the unified hierarchy | What changed |
|---|---|---|---|
cpu + cpuacct | cpu | domain-capable, threaded | merged; cpuacct’s statistics folded into cpu.stat. cpu.shares→cpu.weight (1–10000, default 100), cfs_quota_us/cfs_period_us→ a single cpu.max line |
memory | memory | domain | memory.limit_in_bytes→memory.max; new memory.high (throttle, don’t kill), memory.low/memory.min (protection), memory.pressure. Kernel memory accounted by default rather than opt-in |
blkio | io | domain | renamed; unified with writeback so buffered writes are finally attributable (CONFIG_CGROUP_WRITEBACK) — the single biggest thing v1’s split hierarchies made impossible |
pids | pids | threaded | essentially unchanged |
cpuset | cpuset | threaded | v2 adds cpuset.cpus.partition; .cpus/.mems gain .effective variants |
hugetlb | hugetlb | domain | same interface on both (.dfl_cftypes == .legacy_cftypes) |
rdma | rdma | domain | same interface on both |
misc | misc | domain | v2-era addition (scalar resources, e.g. AMD SEV ASIDs) |
| — | dmem | domain | device-memory controller, added in 6.14 — present on the 7.1 test system, absent at the v6.12 pin |
perf_event | perf_event | implicit + threaded | .implicit_on_dfl = true and no .dfl_cftypes — it is always on, has no interface files, and never appears in cgroup.controllers |
devices | — | replaced | no v2 controller; device access is now an eBPF program attached with BPF_CGROUP_DEVICE. See cgroup BPF Programs |
freezer | — | replaced | absorbed into the core as cgroup.freeze (and cgroup.events’ frozen key). Its subsys has only .legacy_cftypes. See The cgroup Freezer |
net_cls, net_prio | — | not ported | classid/priority tagging was judged the wrong model; use eBPF socket programs |
debug | — | v1 only | the header marks it explicitly “not supported on the default hierarchy” |
Two rows repay a second look. perf_event is invisible but active: because it is implicit, it is enabled everywhere without ever being listed. On the test system a fresh cgroup’s cgroup.controllers reads cpu io memory pids, yet its cgroup.stat reports nr_subsys_perf_event 1. That is not an inconsistency — nr_subsys_* counts live subsystem instances, and perf_event has one at every node. And devices/freezer are the template for how v2 handles “utility” features: rather than a controller with a tree of its own, the function moves either into the cgroup core (freezer → cgroup.freeze, cgroup.kill) or into eBPF (devices). This is a direct consequence of unification — in v1 the freezer needed its own hierarchy because it could not share one; in v2 there is only one tree, so a core file is the natural home.
Uncertain
Verify: the precise release that introduced the
misccontroller, and whethernet_cls/net_prioare formally deprecated upstream or merely un-ported. Reason: both facts were inferred from the v6.12SUBSYS()roster plus the absence of.dfl_cftypes, which proves no v2 interface exists but not the maintainers’ stated intent;lore.kernel.orgis HTTP 403 from this environment so the design discussion could not be read. To resolve: read themisccontroller’s introducing commit and the cgroup maintainers’ deprecation notes. Thedmem-in-6.14 row is verified by existence-checkingSUBSYS(dmem)ininclude/linux/cgroup_subsys.hacross v6.12 (absent), v6.13 (absent), v6.14 (present). uncertain
The Core Interface Files
Every cgroup, whatever controllers are enabled, carries a set of files prefixed cgroup. — the core interface, owned by the cgroup itself rather than by its parent. This is the vocabulary the rest of the note uses, so it is worth having in one place (cgroup-v2.rst “Core Interface Files”, v6.12):
| File | Mode | Exists on | Contents / effect |
|---|---|---|---|
cgroup.type | rw, single value | non-root | domain, domain threaded, domain invalid, or threaded. Writing threaded converts — one-way |
cgroup.procs | rw, newline-separated | all | PIDs in the cgroup; write one PID to migrate that process (all its threads). Unordered, may repeat, not a stable snapshot |
cgroup.threads | rw, newline-separated | all | TIDs; write one TID to migrate a single thread — only within one resource domain |
cgroup.controllers | read-only, space-separated | all | controllers available to enable here. Exactly equals the parent’s cgroup.subtree_control |
cgroup.subtree_control | rw, space-separated | all | controllers enabled for this cgroup’s children. Starts empty. Written as +name/-name tokens |
cgroup.events | ro, flat-keyed | non-root | populated (1 if this cgroup or any descendant has live processes), frozen. Changes generate a file-modified event — poll()-able |
cgroup.stat | ro, flat-keyed | all | nr_descendants, nr_dying_descendants, and per-subsystem nr_subsys_<ss> / nr_dying_subsys_<ss> counts |
cgroup.freeze | rw, 0/1 | non-root | freeze this cgroup and all descendants; hierarchical — frozen if any ancestor is |
cgroup.kill | write-only, 1 | non-root | SIGKILL the whole subtree, race-free against concurrent fork(). EOPNOTSUPP in a threaded cgroup |
cgroup.max.descendants | rw, default max | all | cap on descendant count; creation fails past it |
cgroup.max.depth | rw, default max | all | cap on nesting depth below here |
cgroup.pressure | rw, 0/1, default 1 | non-root | disable per-cgroup PSI accounting. Not hierarchical. See Pressure Stall Information |
<controller>.pressure | rw | all | cpu.pressure, memory.pressure, io.pressure, irq.pressure — core files, present regardless of which controllers are enabled |
The populated semantics in cgroup.events are the subtle one and the source of a recurring bug: populated is recursive. It reads 1 if the cgroup or any descendant holds a live process, so an empty inner slice whose leaves are busy reports populated 1. Measured on the 7.1 test system: a cgroup A whose own cgroup.procs is empty but whose child A/leaf holds one process reads populated 1. Code that uses cgroup.events to decide “may I rmdir this?” will be wrong; rmdir tests this cgroup’s own occupancy plus the presence of live children, which is a different question. And because a change to populated fires a file-modified event, cgroup.events is the supported way to get a notification when a subtree empties out — far better than polling cgroup.procs.
Note the last row: the *.pressure files are core files, not controller files. A freshly created cgroup with cgroup.subtree_control still empty already has cpu.pressure, io.pressure, memory.pressure, irq.pressure and cgroup.pressure. This is the one systematic exception to the “non-cgroup. prefix means the parent owns it” rule stated below, and it trips up code that probes for memory.pressure to detect whether the memory controller is active.
Mounting and Identifying the v2 Filesystem
The unified hierarchy is a filesystem of type cgroup2, mounted with:
mount -t cgroup2 none /sys/fs/cgroupThe kernel doc states plainly: “Unlike v1, cgroup v2 has only single hierarchy.” Its filesystem magic number is 0x63677270 — the ASCII bytes “cgrp” — which I verified directly in the 6.12 source: include/uapi/linux/magic.h defines CGROUP2_SUPER_MAGIC 0x63677270 (distinct from v1’s CGROUP_SUPER_MAGIC 0x27e0eb). On a modern system you rarely run this mount yourself: systemd mounts the unified hierarchy at /sys/fs/cgroup at boot. “All controllers which support v2 and are not bound to a v1 hierarchy are automatically bound to the v2 hierarchy and show up at the root,” which is what makes the unified tree the default surface.
A small but real point of friction during the v1→v2 transition: “system management software might still automount the v1 cgroup filesystem and so hijack all controllers during boot, before manual intervention is possible.” The kernel boot parameter cgroup_no_v1= exists to disable named controllers in v1 so they are forced available in v2 — useful when experimenting.
The mount accepts a few system-wide options, set on mount/remount from the init namespace (verbatim from the 6.12 doc): nsdelegate (treat cgroup namespaces as delegation boundaries — see cgroup Delegation and the No Internal Process Rule); favordynmods (trade slower fork/exit for faster task-migration and controller on/off, confirmed in kernel/cgroup/cgroup.c as the CGRP_ROOT_FAVOR_DYNMODS flag); memory_recursiveprot (apply memory.min/memory.low recursively to whole subtrees); memory_localevents, memory_hugetlb_accounting, and pids_localevents (each narrows or widens which events a .events file counts).
Available vs Enabled: cgroup.controllers and cgroup.subtree_control
This is the central v2 mechanic and the one most often misunderstood. Two core files, both present on every cgroup, govern controllers:
cgroup.controllersis read-only and lists “all controllers available for the cgroup to enable.” Think of it as: which controllers could you turn on here.cgroup.subtree_controlis read-write, starts out empty, and lists “the controllers which are enabled to control resource distribution from the cgroup to its children.” Think of it as: which controllers you have actually turned on for this node’s children.
The asymmetry is deliberate and worth dwelling on. The doc states “No controller is enabled by default.” You enable and disable by writing +name/-name tokens:
echo "+cpu +memory -io" > cgroup.subtree_controlSeveral rules apply (all from the 6.12 doc): you may only enable controllers that appear in cgroup.controllers; when multiple operations are written at once, “either they all succeed or fail”; and “if multiple operations on the same controller are specified, the last one is effective.”
The subtle part is what enabling means. The doc: “Enabling a controller in a cgroup indicates that the distribution of the target resource across its immediate children will be controlled.” So cgroup.subtree_control on cgroup A governs A’s children, not A itself. Concretely, enabling cpu in A’s subtree_control is what creates the cpu.* interface files in A’s child cgroups. The doc spells out the ownership consequence: “the controller interface files — anything which doesn’t start with ‘cgroup.’ are owned by the parent rather than the cgroup itself.” That is, B/cpu.max exists and is meaningful only because B’s parent A enabled cpu in its subtree_control; A controls the budget, B merely receives a slice. This parent-owns-the-knobs design is the foundation of v2’s clean delegation model.
flowchart TB A["A<br/>cgroup.controllers: cpu memory io<br/>cgroup.subtree_control: cpu memory"] A -->|"A enabled cpu+memory →<br/>creates cpu.* and memory.* in children"| B["B<br/>cgroup.controllers: cpu memory<br/>(io is GONE — A didn't pass it down)<br/>cgroup.subtree_control: memory"] B -->|"B enabled only memory →<br/>creates memory.* in children, NOT cpu.*"| C["C<br/>cgroup.controllers: memory<br/>C and D compete FREELY on CPU"] B --> D["D<br/>cgroup.controllers: memory"]
The propagation chain. What it shows: A has cpu/memory/io available and enables cpu+memory in its subtree_control; that makes cpu and memory available in child B (B’s cgroup.controllers reads cpu memory) and creates B’s cpu.*/memory.* files. B then enables only memory, so memory is governed for C and D while “C and D will compete freely on CPU cycles.” Because A never enabled io, io never reaches B at all. The insight to take: cgroup.controllers of a child is exactly the set the parent put in its cgroup.subtree_control — availability flows strictly downward, one hop at a time.
The Dance, Measured
Prose about subtree_control slides off the brain; watching the filesystem change under your hands does not. Everything below was run on Linux 7.1.13-200.fc44.x86_64 as uid 1000, inside the subtree systemd delegates to the login user (/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/, owned linman:linman). No root is required for any of it.
### 1. AVAILABLE vs ENABLED
delegated root:
cgroup.controllers = cpu io memory pids
cgroup.subtree_control = cpu io memory pids
fresh child B (just mkdir'd):
cgroup.controllers = cpu io memory pids <- == parent's subtree_control, exactly
cgroup.subtree_control = '' <- ALWAYS starts empty
cgroup.type = domain
cgroup.events : populated 0 frozen 0
### 2. ENABLING CREATES FILES IN THE *CHILD*, NOT HERE
before: files matching 'memory.*' in B : 21
before: files matching 'memory.*' in B/child : 1 <- just memory.pressure (a CORE file)
$ echo '+memory' > B/cgroup.subtree_control
after : files matching 'memory.*' in B : 21 <- UNCHANGED
after : files matching 'memory.*' in B/child : 21 <- 20 new files appeared
B/child/cgroup.controllers = memoryStep 2 is the whole mechanic in one measurement. Writing +memory into B’s subtree_control created twenty memory.* files in B/child and changed nothing in B. B’s own memory.max was already there — created when B’s parent enabled memory. The count going 1 → 21 rather than 0 → 21 is the core-file exception in action: memory.pressure was present all along.
### 3. TOP-DOWN: cannot enable what the parent did not pass down
B/cgroup.controllers = cpu io memory pids
B/cgroup.subtree_control = memory (io NOT enabled)
B/child/cgroup.controllers = memory (io never reached the child)
$ echo '+io' > B/child/cgroup.subtree_control
-> write error: No such file or directory (-ENOENT)
### 4. DISABLE IS BOTTOM-UP
B/subtree_control=memory child/subtree_control=memory
$ echo '-memory' > B/cgroup.subtree_control # child still has it enabled
-> write error: Device or resource busy (-EBUSY)
$ echo '-memory' > B/child/cgroup.subtree_control # disable the leaf-ward one first
-> OK
$ echo '-memory' > B/cgroup.subtree_control # now the ancestor
-> OKBoth errnos come straight from cgroup_subtree_control_write() (cgroup.c, v6.12). The enable path checks availability at line 3440:
if (!(cgroup_control(cgrp) & (1 << ssid))) {
ret = -ENOENT;
goto out_unlock;
}and the disable path walks the children at line 3451:
/* a child has it enabled? */
cgroup_for_each_live_child(child, cgrp) {
if (child->subtree_control & (1 << ssid)) {
ret = -EBUSY;
goto out_unlock;
}
}-ENOENT for “the controller is not available here” is a genuinely misleading errno — nothing is missing from the filesystem, and strace shows a write() to a file that plainly exists returning “No such file or directory”. Reading it as “no such controller here” is the way to keep it straight.
All-or-Nothing Writes, Measured
The documentation says of a multi-token write: “either they all succeed or fail.” That is a real transactional guarantee, not a figure of speech, and it is worth confirming because tools routinely batch enables:
A/cgroup.controllers = cpu io memory pids
before: A/cgroup.subtree_control = cpu pids
$ echo '-io +cpuset' > A/cgroup.subtree_control # one valid op, one impossible op
-> write error: No such file or directory
after : A/cgroup.subtree_control = cpu pids <- unchanged: NOTHING was appliedcpuset is not in A/cgroup.controllers, so the +cpuset token fails validation. Note what did not happen: the -io token, which was perfectly legal, was also not applied. The implementation makes this easy to see — cgroup_subtree_control_write() parses and validates every token into enable/disable bitmasks first, and only then calls cgroup_vet_subtree_control_enable() and cgroup_save_control(). Any failure jumps to out_unlock before a single mask is committed. The practical consequence: a management daemon can write "+cpu +memory +io +pids" as one operation and be certain it either got all four or changed nothing, with no partial state to unwind. The companion rule — “if multiple operations on the same controller are specified, the last one is effective” — falls out of the same parse loop, since later tokens simply overwrite earlier bits.
The Top-Down Constraint
The propagation rule has a formal name and a one-line statement: “Resources are distributed top-down and a cgroup can further distribute a resource only if the resource has been distributed to it from the parent.” The doc makes the consequences precise:
- “All non-root
cgroup.subtree_controlfiles can only contain controllers which are enabled in the parent’scgroup.subtree_controlfile.” You cannot enablecpuin B unless A already enabledcpuin itssubtree_control— that is the only waycpuappears in B’scgroup.controllers. - “A controller can be enabled only if the parent has the controller enabled.”
- “A controller can’t be disabled if one or more children have it enabled.” You must turn it off bottom-up: disable in the leaves before you can disable in their ancestor.
This top-down invariant is what guarantees the hierarchy is coherent: a restriction set near the root “can not be overridden from further away.” If the root grants a subtree 50% of CPU, nothing inside that subtree can claw back more — the budget only ever subdivides as you descend. This was structurally impossible to guarantee in v1’s independent trees.
A fourth consequence, called out in Corbet’s 2014 LWN article on the then-new unified hierarchy, is easy to miss and frequently inconvenient: a cgroup applies a controller to all of its children or to none of them. “If the memory controller is enabled in B’s cgroup.subtree_control file, it will apply to both C and D; there is no way (from B’s point of view) to apply the controller to only one of those subgroups.” subtree_control is a property of the parent, not a per-child setting, so “govern C’s memory but let D run free” is not expressible at one level. The workaround is structural: interpose another level, giving the unconstrained children their own ungoverned sub-parent. This is a real design constraint on how container hierarchies get shaped, not a temporary limitation.
The same article notes the corollary that follows from strict downward flow: since a controller must be enabled at every level from the root down to be usable anywhere, in practice every controller you intend to use anywhere must be enabled in the root’s subtree_control, and the interesting configuration work is disabling it selectively on the way down. On the test system this is exactly what systemd has done — the root’s cgroup.subtree_control reads cpuset cpu io memory hugetlb pids rdma misc dmem, i.e. everything available.
The Enablement Chain, Visualised
flowchart TB R["<b>/ (root)</b><br/>cgroup.controllers: cpu io memory pids<br/>cgroup.subtree_control: <b>cpu io memory pids</b>"] A["<b>A</b><br/>cgroup.controllers: cpu io memory pids<br/><i>(= exactly the parent's subtree_control)</i><br/>cgroup.subtree_control: <b>cpu memory</b>"] B["<b>B</b><br/>cgroup.controllers: <b>cpu memory</b><br/><i>io and pids never arrived</i><br/>cgroup.subtree_control: <b>memory</b>"] C["<b>C</b><br/>cgroup.controllers: <b>memory</b><br/>has memory.max, memory.high, ...<br/><b>no cpu.* files at all</b>"] D["<b>D</b><br/>cgroup.controllers: <b>memory</b>"] R -->|"root enables all four<br/>→ creates cpu.* io.* memory.* pids.*<br/>files inside A"| A A -->|"A enables cpu+memory only<br/>→ creates cpu.* and memory.* in B;<br/>io.* and pids.* are NOT created"| B B -->|"B enables memory only<br/>→ creates memory.* in C and D"| C B --> D C -.->|"C and D compete FREELY for CPU:<br/>B never enabled cpu"| D X["<b>✗ echo '+io' > B/cgroup.subtree_control</b><br/>-ENOENT: io is not in B's cgroup.controllers<br/>because A never enabled it"] B -.-> X Y["<b>✗ echo '-memory' > A/cgroup.subtree_control</b><br/>-EBUSY: child B still has memory enabled.<br/>Disable bottom-up: B first, then A."] A -.-> Y
How controller availability propagates. What it shows: the arrows are not “contains” edges but grants — each one is a parent’s subtree_control creating that controller’s interface files inside its children. A child’s read-only cgroup.controllers is exactly, always, its parent’s cgroup.subtree_control; nothing else can put a controller there. The two dashed boxes are the measured failure modes: you cannot grant downward what you were not granted (-ENOENT), and you cannot revoke a grant your children are still re-granting (-EBUSY). The insight to take: availability flows strictly one hop at a time, so a deep path is only governable if every ancestor enabled the controller. This is why the robust way to create /a/b/c/d with a memory limit is to walk down from the root enabling memory at each level — writing memory.max at the bottom of a freshly-mkdir -p’d path fails not with a permission error but with the file simply not existing.
Placing Processes: cgroup.procs and cgroup.threads
Membership is managed through two core files. cgroup.procs lists, one per line, the PIDs of every process in the cgroup; writing a PID migrates that process in. Two v2-specific details from the doc: “Only one process can be migrated on a single write(2) call,” and writing the PID of any thread “migrates all threads of the process” — under v2 a process is the unit, and “All threads of a process belong to the same cgroup.” Reading is best-effort: “the same PID may show up more than once if the process got moved to another cgroup and then back or the PID got recycled while reading,” so cgroup.procs is not a stable snapshot.
cgroup.threads is the thread-granular counterpart: it lists thread IDs (TIDs) and lets you migrate an individual thread. Critically, this is only allowed within a single resource domain — “it can only move threads inside the same threaded domain” — which is the mechanism behind threaded cgroups below.
A few lifecycle nuances the doc calls out: a forked child “is born into the cgroup that the forking process belongs to”; after exit a process “stays associated with the cgroup … until it’s reaped,” but “a zombie process does not appear in cgroup.procs and thus can’t be moved”; and /proc/$PID/cgroup under v2 always reads as the single line 0::$PATH (with (deleted) appended if the cgroup was removed while a zombie lingered).
Domain, Threaded, and cgroup.type
By default every cgroup is a domain cgroup, the normal kind, where “all threads of a process belong to the same cgroup.” But some workloads need to spread threads of one process across different cgroups for fine-grained control — a v2 feature called threaded cgroups. The current mode is reported by the core file cgroup.type, which reads one of four values (verbatim from the 6.12 doc):
domain— a normal valid domain cgroup.domain threaded— a domain cgroup serving as the root of a threaded subtree (the “threaded domain” / “thread root”).domain invalid— a cgroup in an invalid topological state; it cannot be populated or have controllers enabled (operations fail withEOPNOTSUPP).threaded— a member of a threaded subtree.
You convert a cgroup with echo threaded > cgroup.type, and “the operation is single direction” — “once threaded, the cgroup can’t be made a domain again.” Inside a threaded subtree, threads of a process may live in different cgroups, and the subtree is exempt from the no-internal-process constraint below. All the subtree’s process-level (non-thread-specific) resource consumption is hosted by the threaded-domain root — which is why, in a threaded domain, “cgroup.procs … contains the PIDs of all processes in the subtree” while reading it in the subtree proper fails with EOPNOTSUPP.
Only a subset of controllers can operate at thread granularity. The doc lists them explicitly: the threaded controllers are cpu, cpuset, perf_event, and pids; the rest are domain controllers that work only on whole processes. This is the practical limit on threaded cgroups — you can split threads for CPU and cpuset purposes, but memory and io remain process-level.
The No-Internal-Process Rule
The second of the two rules everyone gets wrong, and the one that generates the most “why won’t this work?” bug reports. This section covers what the rule does to the shape of the tree and how the kernel enforces it; the companion note cgroup Delegation and the No Internal Process Rule covers what it means for handing subtrees to less-privileged users. Stated by the doc: “Non-root cgroups can distribute domain resources to their children only when they don’t have any processes of their own.” Equivalently, “only domain cgroups which don’t contain any processes can have domain controllers enabled in their cgroup.subtree_control files.”
The purpose: “This guarantees that, when a domain controller is looking at the part of the hierarchy which has it enabled, processes are always only on the leaves.” It “rules out situations where child cgroups compete against internal processes of the parent” — the exact ambiguity (an inner node’s tasks fighting its own child cgroups for a share) that different v1 controllers each botched in their own way (see cgroups v1 vs v2).
There are two important exemptions. First, the root cgroup is exempt — it “contains processes and anonymous resource consumption which can’t be associated with any other cgroups and requires special treatment from most controllers.” Second, the rule “doesn’t get in the way if there is no enabled controller in the cgroup’s cgroup.subtree_control” — otherwise you could never create a child of a populated cgroup at all. The practical pattern the doc prescribes: “To control resource distribution of a cgroup, the cgroup must create children and transfer all its processes to the children before enabling controllers.” You push the processes down into leaves, then turn on controllers on the now-empty inner node.
# WRONG: A holds processes AND tries to govern children → fails
echo $$ > /sys/fs/cgroup/A/cgroup.procs
echo "+memory" > /sys/fs/cgroup/A/cgroup.subtree_control # -EBUSY: A is populated
# RIGHT: push processes into a leaf first, THEN enable controllers on A
mkdir /sys/fs/cgroup/A/leaf
echo $$ > /sys/fs/cgroup/A/leaf/cgroup.procs # move process to leaf
echo "+memory" > /sys/fs/cgroup/A/cgroup.subtree_control # A is now empty → OKThe canonical fix for a no-internal-process violation. The first block fails because A is both an inner node and holds a process while trying to enable a domain controller; the second works because all of A’s processes have been moved into the leaf child, leaving A empty and free to govern. This “organize processes into leaves” shape is why real cgroup trees (systemd slices, container hierarchies) keep tasks at the bottom.
The Rule Is Enforced in Exactly One Function
For all the confusion it causes, the constraint is one if statement. The whole of cgroup_vet_subtree_control_enable() (cgroup.c lines 3350–3387, v6.12) is worth reading in full, because the order of its tests is the answer to almost every “why did that fail?” question:
static int cgroup_vet_subtree_control_enable(struct cgroup *cgrp, u16 enable)
{
u16 domain_enable = enable & ~cgrp_dfl_threaded_ss_mask; /* 1 */
/* if nothing is getting enabled, nothing to worry about */
if (!enable)
return 0; /* 2 */
/* can @cgrp host any resources? */
if (!cgroup_is_valid_domain(cgrp->dom_cgrp))
return -EOPNOTSUPP; /* 3 */
/* mixables don't care */
if (cgroup_is_mixable(cgrp))
return 0; /* 4 */
if (domain_enable) {
/* can't enable domain controllers inside a thread subtree */
if (cgroup_is_thread_root(cgrp) || cgroup_is_threaded(cgrp))
return -EOPNOTSUPP; /* 5 */
} else {
/*
* Threaded controllers can handle internal competitions
* and are always allowed inside a (prospective) thread
* subtree.
*/
if (cgroup_can_be_thread_root(cgrp) || cgroup_is_threaded(cgrp))
return 0; /* 6 */
}
/*
* Controllers can't be enabled for a cgroup with tasks to avoid
* child cgroups competing against tasks.
*/
if (cgroup_has_tasks(cgrp))
return -EBUSY; /* 7 */
return 0;
}Walking the numbered points:
domain_enablemasks off the threaded controllers.cgrp_dfl_threaded_ss_maskis a bitmask assembled at boot from every subsystem whosestruct cgroup_subsyssets.threaded = true. Checking the v6.12 source directly rather than trusting the prose documentation:cpu_cgrp_subsys,cpuset_cgrp_subsys,pids_cgrp_subsysandperf_event_cgrp_subsyseach carry.threaded = true, and no others do. Sodomain_enableis non-zero only if you are enabling at least one ofmemory,io,hugetlb,rdma,misc,dmem.- Disabling only (
enable == 0) is never blocked by this rule — which is why the “move processes down, then enable” pattern works but there is no matching restriction on turning things off. -EOPNOTSUPPif this cgroup’s resource domain is invalid — the topology error, covered below.cgroup_is_mixable()is true only for the root (return !cgroup_parent(cgrp);). This single line is the root exemption. Root holds processes that belong to no one — kernel threads, init, anything not yet classified — and also governs children; no other cgroup may.- Domain controllers are refused inside a threaded subtree, with
EOPNOTSUPPrather thanEBUSY. - The exemption almost nobody knows about. If you are enabling only threaded controllers, and this cgroup either is threaded or could become a thread root, the function returns success before ever reaching the tasks check. The no-internal-process rule simply does not apply to
cpu,cpuset,pidsorperf_event. - The rule itself:
cgroup_has_tasks(cgrp)→-EBUSY.
Point 6 is the one that surprises experienced people, so here it is measured on Linux 7.1.13, uid 1000. Same cgroup A, same single parked process, same populated internal node — only the controller changes:
### A holds one process AND has a child (A/leaf) -> a populated internal node
A/cgroup.procs : 1408694
A/cgroup.events : populated 1 frozen 0
A/cgroup.stat : nr_descendants=1
$ echo '+memory' > A/cgroup.subtree_control
-> write error: Device or resource busy (-EBUSY)
$ echo '+io' > A/cgroup.subtree_control
-> write error: Device or resource busy (-EBUSY)
$ echo '+pids' > A/cgroup.subtree_control
-> (no error) <-- ACCEPTED
$ echo '+cpu' > A/cgroup.subtree_control
-> (no error) <-- ACCEPTED
A/cgroup.subtree_control now = 'cpu pids'memory and io are domain controllers and are refused. pids and cpu are threaded controllers and sail straight through, on a cgroup that plainly has a process of its own and plainly has children. Anyone who has internalised “you can’t enable controllers on a populated non-leaf cgroup” as a flat rule has it wrong — it is “you can’t enable domain controllers”, and the difference is load-bearing.
The Decision, as a Flowchart
flowchart TB START(["write '+ctrl' to<br/>cgroup.subtree_control"]) --> AVAIL{"is ctrl listed in this<br/>cgroup.controllers?"} AVAIL -->|no| ENOENT(["<b>-ENOENT</b><br/>'No such file or directory'<br/><i>parent never enabled it</i>"]):::err AVAIL -->|yes| VALID{"is cgrp->dom_cgrp<br/>a valid domain?"} VALID -->|no| EOPNOT1(["<b>-EOPNOTSUPP</b><br/>cgroup.type reads<br/>'domain invalid'"]):::err VALID -->|yes| ROOT{"is this the<br/>root cgroup?"} ROOT -->|yes| OK(["<b>allowed</b><br/>interface files created<br/>in every child"]):::ok ROOT -->|no| KIND{"is ctrl a DOMAIN controller?<br/>memory io hugetlb rdma misc dmem<br/><i>vs threaded:</i> cpu cpuset pids perf_event"} KIND -->|"threaded only"| THR{"is this cgroup threaded,<br/>or able to become<br/>a thread root?"} THR -->|yes| OKT(["<b>allowed</b> - rule does NOT apply.<br/>Side effect: if the cgroup HAS tasks,<br/>cgroup.type silently becomes<br/>'domain threaded'"]):::warn THR -->|no| TASKS KIND -->|"domain"| INTHR{"is this cgroup a thread root<br/>or itself threaded?"} INTHR -->|yes| EOPNOT2(["<b>-EOPNOTSUPP</b><br/>no domain controllers<br/>inside a threaded subtree"]):::err INTHR -->|no| TASKS{"does this cgroup hold<br/>processes of its own?<br/><i>cgroup_has_tasks()</i>"} TASKS -->|yes| EBUSY(["<b>-EBUSY</b><br/>'Device or resource busy'<br/><i>THE no-internal-process rule</i>"]):::err TASKS -->|no| OK EBUSY -.->|"the fix"| FIX["mkdir a child, move every PID<br/>from cgroup.procs into it,<br/>then retry the write"] FIX -.-> START classDef err fill:#fdd,stroke:#c33,color:#000 classDef ok fill:#dfd,stroke:#3a3,color:#000 classDef warn fill:#ffd,stroke:#ca3,color:#000
The complete decision path for enabling a controller, with every errno the kernel can return. What it shows: four distinct failure exits, three distinct errnos, and one path (the yellow box) that succeeds while silently mutating the cgroup’s type. The no-internal-process test is the last check reached, which is why it is so often mistaken for the only one. The insight to take: when a subtree_control write fails, the errno tells you which branch you fell off — ENOENT means “look at your ancestors”, EBUSY means “look at your own cgroup.procs”, and EOPNOTSUPP means “look at cgroup.type”. Those are three completely different debugging sessions, and conflating them is the usual reason people flail.
The Rule Has a Second Half Nobody Mentions
Every description of the no-internal-process constraint frames it as a restriction on enabling controllers. It is equally a restriction on migrating processes, enforced in a different function, and this half bites during normal operation rather than during setup. From cgroup_migrate_vet_dst() (cgroup.c lines 2668–2691, v6.12):
int cgroup_migrate_vet_dst(struct cgroup *dst_cgrp)
{
/* v1 doesn't have any restriction */
if (!cgroup_on_dfl(dst_cgrp))
return 0;
/* verify @dst_cgrp can host resources */
if (!cgroup_is_valid_domain(dst_cgrp->dom_cgrp))
return -EOPNOTSUPP;
/* If @dst_cgrp is already or can become a thread root or is
* threaded, it doesn't matter. */
if (cgroup_can_be_thread_root(dst_cgrp) || cgroup_is_threaded(dst_cgrp))
return 0;
/* apply no-internal-process constraint */
if (dst_cgrp->subtree_control)
return -EBUSY;
return 0;
}The invariant is symmetric: a cgroup may hold processes, or it may govern children, but never both. cgroup_vet_subtree_control_enable() stops you adding governance to a populated cgroup; cgroup_migrate_vet_dst() stops you adding processes to a governing cgroup. Measured, in one continuous session:
### A is populated and has a child; A/cgroup.subtree_control is empty
$ echo '+memory' > A/cgroup.subtree_control
-> write error: Device or resource busy (-EBUSY) [enable blocked by tasks]
$ echo $PID > A/leaf/cgroup.procs # push the process down
-> OK
A/cgroup.procs : (empty)
A/leaf/cgroup.procs : 1409999
$ echo '+memory' > A/cgroup.subtree_control # retry on the now-empty node
-> OK
A/cgroup.subtree_control = memory
A/leaf/cgroup.controllers = memory
$ echo $PID > A/cgroup.procs # try to move it back UP
-> write error: Device or resource busy (-EBUSY) [migrate blocked by subtree_control]
A/cgroup.procs : (still empty)
A/leaf/cgroup.procs : 1409999The last step is the half that surprises. Once A governs its children, A is permanently off-limits as a destination for processes — the door closes behind you. This is exactly the behaviour you want (it makes the invariant impossible to violate by any sequence of operations) but it means a container runtime cannot “park” a process temporarily in a slice on the way to somewhere else. It must move processes leaf-to-leaf.
Threaded Controllers Silently Rewrite cgroup.type
Point 6 above has a consequence that the documentation states in one easily-missed sentence and that no tutorial mentions: “A domain cgroup is turned into a threaded domain when one of its child cgroups becomes threaded or threaded controllers are enabled in the cgroup.subtree_control file while there are processes in the cgroup.” Enabling +cpu or +pids on a populated cgroup is not a neutral act — it converts that cgroup into a thread root, and any existing domain children immediately become invalid. Measured:
### B holds one process and has a domain child B/leaf
B/cgroup.type (before) = domain
B/leaf/cgroup.type (before) = domain
$ echo '+pids' > B/cgroup.subtree_control # pids is a THREADED controller
-> OK <-- not refused, unlike +memory
B/cgroup.subtree_control = pids
B/cgroup.type (after) = domain threaded <-- B was silently converted
B/leaf/cgroup.type (after) = domain invalid <-- leaf is now UNUSABLE
$ echo $PID > B/leaf/cgroup.procs
-> write error: Operation not supported (-EOPNOTSUPP)
$ echo '+memory' > B/cgroup.subtree_control # domain ctrl inside a threaded domain
-> write error: Operation not supported (-EOPNOTSUPP)
$ echo threaded > B/leaf/cgroup.type # the only way out
-> OK
B/leaf/cgroup.type = threaded
$ echo domain > B/leaf/cgroup.type # can we go back?
-> write error: Invalid argument (-EINVAL) [single direction, confirmed]
$ cat B/leaf/cgroup.procs # inside a threaded subtree
-> cat: .../B/leaf/cgroup.procs: Operation not supportedThis is a genuinely nasty operational trap, and it is worth stating as a rule: enabling a threaded controller on a cgroup that holds processes is a topology mutation, not a configuration change. A tool that loops over +cpu +memory +io +pids to “turn everything on” and does not order the writes carefully can convert a slice to a thread root on the +cpu/+pids tokens and then fail the domain tokens with EOPNOTSUPP, leaving previously-working domain children in domain invalid — a state from which the only exit is the one-way echo threaded > cgroup.type. The defence is the same as the general fix: empty the cgroup first. With no tasks in it, cgroup_is_thread_root()’s condition (cgroup_has_tasks(cgrp) && (cgrp->subtree_control & cgrp_dfl_threaded_ss_mask)) is false, and +cpu is an ordinary enable with no side effect.
The condition itself, from cgroup_is_thread_root() at cgroup.c line 399, makes the trigger explicit:
/* a domain w/ threaded children is a thread root */
if (cgrp->nr_threaded_children)
return true;
/*
* A domain which has tasks and explicit threaded controllers
* enabled is a thread root.
*/
if (cgroup_has_tasks(cgrp) &&
(cgrp->subtree_control & cgrp_dfl_threaded_ss_mask))
return true;Note the conjunction in the second test — tasks AND threaded controllers. Either alone is harmless. The deep treatment of what delegation does on top of all this lives in cgroup Delegation and the No Internal Process Rule; this section is about how the constraint shapes the tree, that one about how it makes handing a subtree to an untrusted user safe.
Why the Rule Exists
The justification is one sentence in the docs — “This guarantees that, when a domain controller is looking at the part of the hierarchy which has it enabled, processes are always only on the leaves” — but the reason that guarantee is worth a constraint this awkward is a concrete v1 failure. In v1, an inner node could hold tasks and have child cgroups, so every controller had to invent an answer to “how much CPU does the parent’s own task get relative to its child group?” and each invented a different one. The cgroup-v2.rst chapter “Competition Between Inner Nodes and Threads” records the outcome: the comparison is between a thread and a cgroup, which are not commensurable units, so each controller improvised — some treated the internal tasks as an implicit child group, some gave them a fixed weight, some ignored the question. Behaviour differed per controller on the same tree.
v2’s answer is to make the question unaskable. If domain controllers only ever see processes on leaves, there is never an inner-node-versus-child comparison to define. The cost is the awkward setup dance; the benefit is that cpu.weight means the same thing in every cgroup, under every controller, forever. Threaded controllers are exempt precisely because they did solve the problem — the docs note that “a threaded controller must be able to handle competition between threads in a non-leaf cgroup and its child cgroups. Each threaded controller defines how such competitions are handled.” That is the whole basis of the exemption in point 6: cpu and pids are allowed to break the rule because they have a defined answer, and memory and io are not because they do not. For the full history of the v1 problems see cgroups v1 vs v2.
Delegation: the Rules in Table Form
Delegation is the reason the unified hierarchy is designed the way it is — the parent-owns-the-knobs asymmetry, the no-internal-process rule and the top-down constraint all exist so that a subtree can be handed to an untrusted party safely. The mechanics are treated in depth in cgroup Delegation and the No Internal Process Rule; what belongs here is the rule set as a reference, because it is pure tabulation and the surrounding tree mechanics are what make it work.
| Method 1: to a less-privileged user | Method 2: to a cgroup namespace | |
|---|---|---|
| Trigger | grant write access on the directory and specific files | nsdelegate mount option + CLONE_NEWCGROUP |
| What the delegatee may write | the directory itself, plus cgroup.procs, cgroup.threads, cgroup.subtree_control | the same set, enforced by the kernel rejecting writes to namespace-root files outside the list in /sys/kernel/cgroup/delegate |
| What they must not get | any <controller>.* file — those distribute the parent’s resources | hidden by mount namespacing; kernel additionally refuses them |
| Containment mechanism | writer needs write access to cgroup.procs of the common ancestor of source and destination | both source and destination must be reachable from the writer’s cgroup namespace |
| Errno on a containment violation | -EACCES | -ENOENT |
| Can the delegatee exceed the parent’s limits? | No — “nothing can escape the resource restrictions imposed by the parent” | No, same guarantee |
The kernel publishes the delegatable file list at runtime rather than making you guess. On the 7.1.13 test system:
$ cat /sys/kernel/cgroup/delegate
cgroup.procs
cgroup.threads
cgroup.subtree_control
memory.oom.group
memory.reclaim
$ cat /sys/kernel/cgroup/features
nsdelegate
favordynmods
memory_localevents
memory_recursiveprot
memory_hugetlb_accounting
pids_localeventsNote that memory.oom.group and memory.reclaim appear in delegate despite being controller-prefixed files — they are the sanctioned exceptions, because they let a delegatee manage its own OOM policy and trigger reclaim within its own budget without being able to change that budget. A delegation tool should read this file rather than hard-coding the three-file list from the documentation, since it grows across releases. /sys/kernel/cgroup/features is the matching runtime advertisement of which mount options this kernel understands.
The reason delegation is safe is not access control on its own — it is the combination with the top-down constraint. A delegatee owns cgroup.subtree_control and can carve its subtree up however it likes, but every controller it can enable is one its parent already enabled, and every limit it can set is a subdivision of a budget it was given. There is no write it can perform that increases its own resources. That property is a direct consequence of the mechanics in this note, which is why the two topics belong next to each other.
Where the Tree Comes From in Practice: systemd
Nobody builds this tree by hand. On any modern distribution systemd owns /sys/fs/cgroup and maps its unit types onto v2 cgroups, which is why the measurements in this note run under user.slice/user-1000.slice/user@1000.service/. The mapping is direct:
| systemd concept | cgroup role | Holds processes? |
|---|---|---|
.slice (e.g. system.slice, user.slice) | inner node — budgeting only | no — keeps the no-internal-process rule satisfied by construction |
.service | leaf — processes systemd started | yes |
.scope | leaf — processes started elsewhere and registered | yes |
user@<uid>.service with Delegate=yes | delegation boundary | it and its subtree belong to the user |
Directives like MemoryMax=, CPUWeight= and IOWeight= in a unit file are written by systemd into the corresponding memory.max / cpu.weight / io.weight files, and systemd manages cgroup.subtree_control on the slices above them. The slice/service split is not stylistic: it is the “processes on the leaves, budgets on the inner nodes” shape the kernel demands. This is also why systemd-run --user --scope is the easiest way to get a correctly-formed cgroup for an experiment, and why the delegated user@1000.service subtree used throughout this note is writable by an unprivileged user at all.
Failure Modes and Diagnosis
echo "+cpu" > cgroup.subtree_controlreturns-ENOENT/ “No such file or directory.” The controller is not in this cgroup’scgroup.controllers— the parent never enabled it, so it never propagated down. Walk up the tree enabling it at each level. Diagnose withcat cgroup.controllersat each ancestor.- Enabling a controller returns
-EBUSY. The cgroup holds processes directly and you are trying to enable a domain controller — the no-internal-process rule. Move the processes into child cgroups first (pattern above). cgroup.typereadsdomain invalid. The cgroup was created as a domain under a parent that cannot host child domains (e.g. it sits under athreadednode but isn’t itself threaded). The doc’s example:A (threaded domain) - B (threaded) - C (domain, just created)leaves C invalid until it is made threaded; operations on it fail withEOPNOTSUPP.- Reading
cgroup.procsin a threaded subtree fails withEOPNOTSUPP. Expected: in a threaded subtree all processes are accounted to the thread root, socgroup.procsis only readable there; usecgroup.threadswithin the subtree. - You can’t
rmdira cgroup. It still has live processes or child cgroups (a cgroup associated only with zombies is considered empty and can be removed). Checkcat cgroup.procsandls. Incgroup_destroy_locked()these are two separate-EBUSYreturns (cgroup.c lines 5979 and 5988, v6.12):cgroup_is_populated(cgrp)andcss_has_online_children(&cgrp->self). - Enabling
+cpuor+pids“worked” but then everything else broke. You enabled a threaded controller on a populated cgroup, converting it todomain threadedand invalidating its domain children. See the measured session above. Checkcgroup.typeat every level before concluding anything else.
Errno Lookup Table
Every errno below was produced on Linux 7.1.13-200.fc44.x86_64 as uid 1000 during the sessions quoted in this note — none is inferred from documentation.
| Errno | strerror text | Operation | Real cause | First thing to check |
|---|---|---|---|---|
ENOENT | No such file or directory | echo +ctrl > cgroup.subtree_control | controller absent from this cgroup.controllers; an ancestor never enabled it | cat cgroup.controllers at every ancestor up to the root |
EBUSY | Device or resource busy | echo +domainctrl > cgroup.subtree_control | no-internal-process: this cgroup holds processes | cat cgroup.procs — must be empty |
EBUSY | Device or resource busy | echo -ctrl > cgroup.subtree_control | a child still has the controller enabled | disable bottom-up: children first |
EBUSY | Device or resource busy | echo PID > cgroup.procs | destination cgroup has a non-empty cgroup.subtree_control — the rule’s migration half | cat cgroup.subtree_control of the destination |
EBUSY | Device or resource busy | rmdir | live processes, or live child cgroups | cat cgroup.procs; ls -d */ |
EOPNOTSUPP | Operation not supported | echo PID > cgroup.procs, or enabling any controller | cgroup.type is domain invalid — broken topology | cat cgroup.type at this node and its ancestors |
EOPNOTSUPP | Operation not supported | echo +memory > cgroup.subtree_control | domain controller inside a threaded subtree | cat cgroup.type; look for domain threaded above |
EOPNOTSUPP | Operation not supported | cat cgroup.procs | reading procs inside a threaded subtree — they are all accounted to the thread root | use cgroup.threads, or read cgroup.procs at the thread root |
EOPNOTSUPP | Operation not supported | echo threaded > cgroup.type | parent cannot host a threaded child (has domain controllers enabled, or populated domain children) | cat ../cgroup.subtree_control |
EINVAL | Invalid argument | echo domain > cgroup.type | conversion to threaded is one-way | nothing to check — recreate the cgroup |
EACCES | Permission denied | echo PID > cgroup.procs | delegation containment: no write access to the common ancestor’s cgroup.procs | you are pulling a process in from outside your delegated subtree |
ENOENT | No such file or directory | echo PID > cgroup.procs under nsdelegate | source or destination not reachable from your cgroup namespace | cat /proc/self/cgroup, check the namespace root |
The single most valuable diagnostic habit: EBUSY and EOPNOTSUPP are not interchangeable. EBUSY means “the tree shape is legal but this cgroup is occupied” — fixable by moving processes. EOPNOTSUPP means “the tree shape is illegal” — fixable only by changing cgroup.type or rebuilding that part of the tree. Reaching for the EBUSY remedy on an EOPNOTSUPP failure (moving processes around when the real problem is a domain invalid node) is the classic wasted hour.
Alternatives and Boundaries
The only “alternative” to the unified hierarchy is legacy v1, with its per-controller trees — still present in 6.12 but documented separately and treated as legacy; choose it only when a dependency demands a v1-only feature. A hybrid configuration is also possible: the doc notes v2 can be mixed with “the legacy v1 multiple hierarchies in a fully backward compatible way,” with the one restriction that a given controller can be bound to only one hierarchy at a time. Migrating controllers between v1 and v2 dynamically is “strongly discouraged for production”; decide the layout before controllers are in use. For the full comparison and the historical reasons the unification happened, see cgroups v1 vs v2.
Production Notes
Nobody hand-builds the v2 tree — systemd owns it, as described under the systemd mapping above. Container runtimes (runc, crun) create a leaf cgroup per container and write the OCI spec’s resource limits into its *.max/*.weight files; Kubernetes arranges a QoS-class hierarchy on top, the subject of cgroups Integration (orchestration view).
A recurring real-world gotcha when writing low-level tooling is forgetting the top-down constraint: a program that creates a deep path and writes memory.max at the bottom fails unless every ancestor enabled memory in its subtree_control first — and the failure mode is not a permission error but ENOENT, because the limit file simply does not exist. The robust pattern is to walk from the root down, enabling each controller at every level before descending, and to treat ENOENT on a *.max write as “an ancestor has not enabled this controller” rather than “wrong path”.
What that looks like on a real machine, on the 7.1.13 test system:
$ stat -fc %T /sys/fs/cgroup
cgroup2fs # pure v2, no v1 hierarchies mounted
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc dmem
$ cat /sys/fs/cgroup/cgroup.subtree_control
cpuset cpu io memory hugetlb pids rdma misc dmem # everything enabled at the root
$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-ghostty-surface-transient-8741.scopeThree things to read from that. The root has every available controller enabled in subtree_control — the pattern the top-down constraint forces, since a controller unenabled at the root is unusable anywhere. The dmem controller is present because this is 7.1; at the v6.12 pin it does not exist. And /proc/self/cgroup shows the characteristic v2 single line 0::$PATH — one hierarchy, one path, in contrast to v1’s one line per controller — ending in a .scope, i.e. a leaf. Five levels of .slice above it hold no processes at all. That is the no-internal-process rule made visible: the tree is deep, budgets live in the middle, and every process is at the bottom.
A note on the sizing: this depth is not free. Each of those five levels is a psi_group and a set of per-subsystem state that the kernel walks on task migrations and PSI accounting. For deployments where that matters, cgroup.pressure = 0 on intermediate slices is the supported mitigation — see Pressure Stall Information.
See Also
- Control Groups Overview — the orientation note: core vs controllers, accounting vs enforcement, the four distribution models
- cgroups v1 vs v2 — why the single unified tree replaced v1’s many trees (the full rationale)
- cgroup Delegation and the No Internal Process Rule — the deep treatment of delegation and the constraint introduced here
- The cgroup v2 CPU Controller — a threaded controller in depth (
cpu.weight,cpu.max) - The Memory Cgroup memcg — a domain controller in depth (reclaim, OOM,
memory.max/memory.high) - cgroups Integration — how Kubernetes/kubelet builds a QoS cgroup tree on this design (orchestration view)
- Pressure Stall Information — the
*.pressurecore files present on every cgroup regardless of enabled controllers, andcgroup.pressure, the per-cgroup PSI off switch - The cgroup io Controller —
iois the renamedblkio, and the writeback unification is the headline benefit of the single tree - The cgroup pids Controller — a threaded controller, and therefore exempt from the no-internal-process rule
- The cgroup Freezer — the v1 controller that v2 absorbed into the core as
cgroup.freeze - cgroup BPF Programs — how
BPF_CGROUP_DEVICEreplaced the v1devicescontroller - Cgroup Namespaces — the second delegation method (
nsdelegate) and what a delegatee sees of the tree - systemd and cgroup Integration — the slice/scope/service mapping that builds this tree in practice
- Linux Containers and Isolation MOC — parent map; cgroups v2 is the “resource budget” half of a container