cgroups v1 vs v2

A control group (cgroup) is a kernel mechanism that organizes processes into a hierarchy and meters out system resources — CPU time, memory, block-device bandwidth, process counts — along that hierarchy. The first design, cgroups v1, let userspace mount an arbitrary number of independent hierarchies, one per controller (a cpu tree, a separate memory tree, a separate blkio tree, and so on). This flexibility turned out to be a mistake: a task’s membership became a set of unrelated paths, controllers could not cooperate, threads could compete with their own parent cgroup, and “delegation” was ad hoc. cgroups v2 — the unified hierarchy — collapses all controllers onto one tree, where every process has exactly one cgroup membership, fixes the incoherent-cross-hierarchy-membership and internal-competition problems, defines a coherent delegation model, and is the only version on which new features such as Pressure Stall Information land. v2 was made official (marked stable) in Linux 4.5, released 13 March 2016 (per the Linux 4.5 changelog: “the unified hierarchy is considered stable, and it’s no longer hidden behind that developer flag. It can be mounted using the cgroup2 filesystem type”). As of the 6.12 / 6.18 long-term-support kernels v1 is not removed — it is labeled “legacy,” and its memory controller is gated behind the CONFIG_MEMCG_V1 build option which defaults to off (init/Kconfig, v6.12).

This note is the why v2 happened companion to cgroups v2 Unified Hierarchy (which describes v2’s structure in its own right) and Control Groups Overview (the general mechanism). It deliberately concentrates on the contrast: what v1 did, why each choice broke, and what v2 changed in response.

Mental Model — Many Trees vs One Tree

The single most important difference is topological. In v1 a process is a member of one cgroup per hierarchy, and there can be many hierarchies, so its membership is a vector of unrelated paths. In v2 there is exactly one hierarchy, so a process’s membership is a single path. Everything else — delegation, the no-internal-process rule, PSI — follows from that one decision.

flowchart TB
  subgraph V1["cgroups v1 — multiple independent hierarchies"]
    direction LR
    CPUH["cpu hierarchy<br/>/sys/fs/cgroup/cpu"]
    MEMH["memory hierarchy<br/>/sys/fs/cgroup/memory"]
    BLKH["blkio hierarchy<br/>/sys/fs/cgroup/blkio"]
    CPUH --> CA["/cpu/groupA"]
    MEMH --> MA["/memory/groupX"]
    BLKH --> BA["/blkio/groupQ"]
    TASK1["task PID 4242<br/>(in cpu:/groupA, mem:/groupX, blkio:/groupQ)"]
  end
  subgraph V2["cgroups v2 — single unified hierarchy"]
    direction TB
    ROOT2["/sys/fs/cgroup (cgroup2)"]
    ROOT2 --> SVC["/services"]
    SVC --> WEB["/services/web<br/>cpu+memory+io enabled"]
    TASK2["task PID 4242<br/>(0::/services/web — ONE path)"]
  end

The same machine, the two designs. What it shows: under v1 (top) the kernel mounts a separate filesystem tree for each controller, so task 4242’s membership is three independent paths that need not line up; under v2 (bottom) there is one tree and one path (0::/services/web), and the controllers cpu, memory, io are all enabled on that single node. The insight to take: v1’s flexibility — “put any controller on any tree” — is exactly what makes a task’s resource profile ambiguous and makes controllers unable to reason about each other; v2 trades that flexibility for a coherent, single-membership model. The 0:: prefix is v2’s signature in /proc/$PID/cgroup (cgroup-v2.rst §Processes).

The Four Problems v1 Had, and How v2 Fixed Each

The authoritative account is the “Issues with v1 and Rationales for v2” appendix (section R) of the kernel’s own cgroup-v2 documentation, written by cgroup maintainer Tejun Heo (cgroup-v2.rst). It names four families of problem. Each is worth walking through because they explain not just that v2 differs but why the differences are forced.

Problem 1 — Multiple hierarchies made membership unbounded and uncooperative

v1 “allowed an arbitrary number of hierarchies and each hierarchy could host any number of controllers” (cgroup-v2.rst, R-1). On paper that is maximal flexibility. In practice the doc lists several fatal consequences:

  • Utility controllers could live in only one tree. Because “there is only one instance of each controller,” a cross-cutting controller like the freezer (useful for any grouping of tasks) could be attached to only one hierarchy. You could not freeze the memory grouping and the cpu grouping independently.
  • Controllers could not be moved once populated. After a hierarchy had tasks in it, its controller set was effectively frozen, so misconfigurations were sticky.
  • Every controller on a tree was forced to share the same view. It was “not possible to vary the granularity depending on the specific controller” — yet, as the doc notes, what real configurations usually want is exactly differing granularity: “a given configuration might not care about how memory is distributed beyond a certain level while still wanting to control how CPU cycles are distributed.”
  • A thread’s membership “couldn’t be described in finite length.” With no cap on the number of hierarchies, the membership key was an unbounded list. This was “highly awkward to manipulate and led to addition of controllers which existed only to identify membership” (e.g. the net_cls/net_prio style of tagging), “which in turn exacerbated the original problem of proliferating number of hierarchies.”
  • Controllers could not cooperate. Because no controller could assume anything about the topology of any other controller’s tree, “each controller had to assume that all other controllers were attached to completely orthogonal hierarchies,” making cross-controller features “impossible, or at least very cumbersome.”

In practice, the doc observes, this drove everyone to “putting each controller on its own hierarchy” — which is not what the flexibility was for, and meant “userland ended up managing multiple similar hierarchies repeating the same steps on each hierarchy.”

v2’s fix: a single unified hierarchy (cgroup-v2.rst §Mounting: “Unlike v1, cgroup v2 has only single hierarchy”). All v2-capable controllers attach to that one tree and appear at the root. The desired differing granularity is recovered not by separate trees but by selectively enabling controllers per node: a parent enables a controller in its cgroup.subtree_control file only where it cares to distribute that resource. The doc’s example — A(cpu,memory) - B(memory) - C() / D() — shows A controlling both cpu and memory for B, while B controls only memory for C and D, so C/D “compete freely on CPU cycles but their division of memory available to B will be controlled.” One tree, per-controller granularity. The cgroup2 filesystem is mounted with mount -t cgroup2 none $MOUNT_POINT and carries the magic number 0x63677270 (“cgrp”).

Problem 2 — Thread granularity blurred the API boundary

v1 “allowed threads of a process to belong to different cgroups” (R-2). For service management — organizing whole processes into groups — that is meaningless; for in-process thread organization it requires the application’s own cooperation, because “in-process knowledge is available only to the process itself.” The deeper damage was that v1’s “ambiguously defined delegation model … got abused in combination with thread granularity,” effectively raising the cgroup filesystem “to the status of a syscall-like API exposed to lay programs.” But the cgroup interface is a terrible application API: to touch its own knobs a process must “extract the path on the target hierarchy from /proc/self/cgroup, construct the path by appending the name of the knob … open and then read and/or write to it” — “extremely clunky and unusual but also inherently racy,” with “no conventional way to define transaction across the required steps.” The result was kernel-internal knobs leaking out as de facto public APIs.

v2’s fix: the process is the default unit of organization, and the system-management surface is separated from any per-application thread API. v2 does still support thread granularity for a controlled subset of controllers — see the threaded-cgroup mechanism in cgroup Delegation and the No Internal Process Rule — but only inside an explicit threaded subtree with a single resource domain, not as the free-for-all v1 allowed. By default “all threads of a process belong to the same cgroup” (cgroup-v2.rst §Threads).

Problem 3 — Competition between inner nodes and threads was undefined

This is the subtle, load-bearing one. v1 let threads sit in any cgroup, including interior (non-leaf) ones. So a parent cgroup could contain both its own threads and child cgroups, and “threads belonging to a parent cgroup and its children cgroups competed for resources.” Two fundamentally different kinds of entity — a bare thread and a whole sub-tree — were thrown into the same scheduling/weighting contest “and there was no obvious way to settle it.” The doc catalogs the incompatible hacks each controller invented:

  • The cpu controller “considered threads and cgroups as equivalents and mapped nice levels to cgroup weights.” This broke when “children wanted to be allocated specific ratios of CPU cycles and the number of internal threads fluctuated — the ratios constantly changed as the number of competing entities fluctuated.”
  • The io controller “implicitly created a hidden leaf node for each cgroup to host the threads,” with a shadow copy of every knob prefixed leaf_. This added “an extra layer of nesting which wouldn’t be necessary otherwise, made the interface messy and significantly complicated the implementation.”
  • The memory controller simply “didn’t have a way to control what happened between internal tasks and child cgroups and the behavior was not clearly defined.”

“All the approaches were severely flawed and … made cgroup as a whole highly inconsistent.” The doc concludes this “needs to be addressed from cgroup core in a uniform way.”

v2’s fix: the No Internal Process Constraint — a non-root cgroup that has domain controllers enabled in its subtree_control may not contain processes of its own; processes live only on the leaves. That single rule eliminates the “thread vs subtree” competition by construction: when a controller looks at the part of the hierarchy where it is enabled, “processes are always only on the leaves.” The full mechanics, the root-cgroup exemption, and the threaded-cgroup escape hatch are the subject of cgroup Delegation and the No Internal Process Rule.

Problem 4 — Ad-hoc interfaces and an undefined delegation model

Beyond the three structural issues, v1 “grew without oversight and developed a large number of” inconsistent interface conventions, and — as noted under Problem 2 — its delegation story was never properly defined, which is precisely what let the thread-granularity abuse happen.

v2’s fix: standardized interface conventions (e.g. the 0::$PATH membership format, the requirement that all future changes be documented in the one cgroup-v2 doc) and an explicit, contained delegation model with two sanctioned forms — granting a user write access to a subtree, or the nsdelegate mount option that makes a cgroup namespace a delegation boundary. Again, see cgroup Delegation and the No Internal Process Rule.

What Was Explicitly Dropped

The cgroup-v2 doc enumerates the v1 core features that v2 deliberately removed (“Deprecated v1 Core Features”):

  • Multiple hierarchies, including named ones, are not supported. (The defining change.)
  • All v1 mount options are not supported.
  • The tasks file is removed; v2 uses cgroup.procs, which is not sorted.
  • cgroup.clone_children is removed.
  • /proc/cgroups is meaningless for v2 — use the root cgroup.controllers or cgroup.stat files instead.

These are not bugs to be ported; they are interface decisions v2 chose against. A tool written for v1 that reads /proc/cgroups or writes to tasks simply will not work against a v2 mount.

What v2 Adds That v1 Never Had

The contrast is not only “v2 fixed v1.” Several capabilities exist only on v2 because they depend on the unified model:

  • Pressure Stall Information (PSI) — the cpu.pressure, memory.pressure, and io.pressure files that quantify how long tasks in a cgroup were stalled waiting on a resource. PSI is a v2-only interface; it has no v1 equivalent and is the signal behind systemd-oomd and modern load-shedding.
  • A coherent memory.high/memory.max/memory.min/memory.low model with well-defined reclaim and out-of-memory (OOM) behavior, replacing v1’s single memory.limit_in_bytes hardlimit. The v2 memory.max reclaims and, if reclaim cannot meet the new limit, OOM-kills within the group, so a workload can be shrunk reliably. The kernel’s own v1-rationale section is explicit (cgroup-v2.rst §R-5-1 Memory): “Setting the original memory.limit_in_bytes below the current usage was subject to a race condition, where concurrent charges could cause the limit setting to fail. memory.max on the other hand will first set the limit to prevent new charges, and then reclaim and OOM kill until the new limit is met — or the task writing to memory.max is killed” (cgroup-v2.rst). v2 also replaces v1’s combined memory+swap accounting with real, independent control over swap space.
  • The freezer as a per-cgroup file (cgroup.freeze) usable on any node, instead of v1’s single-hierarchy freezer controller — see The cgroup Freezer.
  • Writeback-aware I/O accounting, where the memory and io controllers cooperate on dirty-page writeback — a cooperation v1’s orthogonal hierarchies made impossible (Problem 1).

Current Status — v1 Is Legacy, Not Gone (as of 6.12 / 6.18 LTS)

It is a common misconception that v1 has been removed. It has not, as of the 6.12 (2024-11-17) and 6.18 (2025-11-30) LTS kernels. The kernel cgroup-v2 doc states v2 supports “mixing v2 hierarchy with the legacy v1 multiple hierarchies in a fully backward compatible way,” and labels v1 “legacy” throughout while giving no removal date and no deprecation version for cgroup core v1.

What is changing is piecemeal deprecation of specific v1 controllers via build-time gating. The clearest evidence is the memory controller. In init/Kconfig at v6.12 the new option reads:

config MEMCG_V1
	bool "Legacy cgroup v1 memory controller"
	depends on MEMCG
	default n
	help
	  Legacy cgroup v1 memory controller which has been deprecated by
	  cgroup v2 implementation. The v1 is there for legacy applications
	  which haven't migrated to the new cgroup v2 interface yet. ...
	  Please note that feature set of the legacy memory controller is likely
	  going to shrink due to deprecation process. New deployments with v1
	  controller are highly discouraged.
	  Say N if unsure.

Line by line: bool "Legacy cgroup v1 memory controller" makes this a yes/no kernel build option labeled legacy; depends on MEMCG means it only appears if the memory controller is built at all; default n is the load-bearing fact — a kernel built with default config in the 6.12 era ships the v1 memory controller disabled unless a distribution explicitly turns it back on. The help text calls v1 “deprecated by cgroup v2,” warns its “feature set … is likely going to shrink,” and says “new deployments with v1 controller are highly discouraged.” This is the modern shape of the migration: not a hard removal, but the v1 controllers becoming opt-in and slowly hollowed out. This is unchanged in 6.18: init/Kconfig at v6.18 carries the identical config MEMCG_V1 … default n stanza (verified 2026-06-14). The cgroup core v1 remains “legacy” with no removal date in both LTS trees.

Migration Realities and Failure Modes

  • You cannot run a controller on both versions at once. A given controller is bound either to the v2 unified hierarchy or to a v1 hierarchy, never both. The kernel boot parameter cgroup_no_v1= exists precisely to pry controllers away from v1 — “system management software might still automount the v1 cgroup filesystem and so hijack all controllers during boot,” and cgroup_no_v1=all forces them to v2 (cgroup-v2.rst §Mounting).
  • “Hybrid” is real and messy. systemd has historically supported a hybrid mode (v2 for the unified name=systemd tree, v1 for resource controllers) selectable at build time via -Ddefault-hierarchy=. Upstream systemd switched its default to pure unified v2 in release 243 (-Ddefault-hierarchy=unified), after which most distributions followed (systemd 243 unified-default discussion, Ubuntu bug 1839292). The runtime override is the kernel command-line parameter systemd.unified_cgroup_hierarchy=1. Mixing modes is the source of the classic “my memory limit is ignored” bug: the limit was set on the wrong version’s hierarchy.
  • Kubernetes is v2-forward. The Kubernetes project supports cgroup v2 as the path forward; features that depend on PSI and the v2 memory QoS model require a v2 host, and a node booted with only v1 controllers falls back to coarser behavior. (This is orchestration detail owned by Kubernetes MOC — cross-link, do not duplicate.)

    Uncertain Kubernetes MOC, not the kernel. To resolve: check the current Kubernetes node-resource-management docs / KEP for MemoryQoS. uncertain

    Verify: the exact Kubernetes feature-gate name and graduation status for the v2 memory-QoS feature (I referred to it loosely as “the v2 memory QoS model”). Reason: not pinned to a Kubernetes primary source in this task — it is orchestration scope owned by

  • Tooling assumptions break. Anything that parses /proc/cgroups, writes to a tasks file, or expects a per-controller mount path (/sys/fs/cgroup/memory/...) is a v1 assumption. On a v2 system the single mount is /sys/fs/cgroup and membership is read from the 0:: line of /proc/$PID/cgroup.

When You Would Still Choose v1

Honestly, almost never for new work. The legitimate reasons are: (1) a legacy application or agent that hard-codes v1 paths/files and cannot be changed; (2) a controller feature that only ever existed in v1 and was intentionally not ported (rare, and shrinking); (3) running on a kernel or distribution old enough that v2 controllers you need were not yet present (v2’s cpu controller, for instance, did not ship in 4.5 — only memory and io did — and arrived in a later release). For everything else, v2 is the answer: it is the default across major distributions, it is required for PSI and modern memory QoS, and it is the only version receiving new development. See the decision note in cgroups v2 Unified Hierarchy and the cgroups v1 or v2? framework in the Linux Containers and Isolation MOC.

Resolving the MOC’s “v2 stable since” Flag

The Linux Containers and Isolation MOC carried an open uncertainty flag asking for “a precise ‘v2 stable since’ kernel version.” Resolved: cgroup v2 (the unified hierarchy, cgroup2 filesystem) was made official / stable in Linux 4.5, released 13 March 2016, per the Linux 4.5 changelog (“the unified hierarchy is considered stable, and it’s no longer hidden behind that developer flag”) and the cgroups(7) man page (“the new version (cgroups version 2) was eventually made official with the release of Linux 4.5”), corroborated by Phoronix’s 4.5 coverage. Note the nuance: the core unified hierarchy and the memory/io controllers were stable in 4.5; the cpu controller for v2 landed later. There is no dated removal/deprecation milestone for v1 core — only per-controller gating (CONFIG_MEMCG_V1, default n at 6.12).

See Also