Files
Shailend ChandandgVisor bot a43cece85a cgroup2fs: fix cpu controller accounting
The cpu controller tracks each task's CPU via a per-instance baseline
snapshot plus a cumulative total for exited tasks, but it never walked
the whole accounting domain when reporting cpu.stat and never reconciled
that state when a controller was disabled or when a task migrated within
a single controller's domain. This caused three accounting errors, each
covered by a new test:

  - Live tasks in descendant cgroups that had not enabled their own cpu
    controller were absent from the ancestor's cpu.stat until they
    exited, even though Linux aggregates descendant usage into the
    ancestor continuously.
  - Migrating a task between two cgroups sharing the same nearest cpu
    controller reset its baseline, discarding the pre-migration CPU.
  - Disabling +cpu discarded the controller's accumulated usage instead
    of reparenting it (Linux folds an offlined css's stats into parent).

Sum the whole domain (the controller's cgroup plus every descendant
without its own cpu controller, recursing into descendants that do) when
reading cpu.stat; leave the baseline untouched on same-instance
migration; and on disable, migrate the domain's tasks to the parent's
nearest cpu controller and fold the accumulated total into it.

PiperOrigin-RevId: 974821312
2026-09-01 18:35:58 -07:00

348 lines
10 KiB
Go

// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cgroup2fs
import (
"bytes"
"fmt"
"math"
"strconv"
"strings"
"gvisor.dev/gvisor/pkg/atomicbitops"
"gvisor.dev/gvisor/pkg/context"
"gvisor.dev/gvisor/pkg/errors/linuxerr"
"gvisor.dev/gvisor/pkg/sentry/kernel"
"gvisor.dev/gvisor/pkg/sentry/usage"
"gvisor.dev/gvisor/pkg/sentry/vfs"
"gvisor.dev/gvisor/pkg/usermem"
)
// +stateify savable
type cpu struct {
c *cgroup
parent *cpu
detached atomicbitops.Bool
mu cpuMutex `state:"nosave"`
// baselineCharges records the baseline CPU time snapshot for each task
// right when it entered or migrated into this cgroup (via enter or attach).
// When calculating usage for live tasks, subtracting this baseline ensures we
// only attribute CPU time burned while residing inside this cgroup.
// +checklocks:mu
baselineCharges map[*kernel.Task]usage.CPUStats
// usage is the cumulative CPU time used by past tasks in this cgroup. Note
// that this doesn't include usage by live tasks currently in the cgroup.
// +checklocks:mu
usage usage.CPUStats
// weight is the CPU weight representing the cpu.weight value.
// +checkatomic
weight atomicbitops.Int64
// maxUSec is the CPU quota limit representing the cpu.max quota.
// +checkatomic
maxUSec atomicbitops.Int64
// periodUSec is the CPU period limit representing the cpu.max period.
// +checkatomic
periodUSec atomicbitops.Int64
}
// canEnter implements controller.canEnter.
func (cc *cpu) canEnter(ctx context.Context, t *kernel.Task) bool { return true }
// cancelEnter implements controller.cancelEnter.
func (cc *cpu) cancelEnter(ctx context.Context, t *kernel.Task) {}
// enter implements controller.enter.
func (cc *cpu) enter(ctx context.Context, t *kernel.Task) {
cc.mu.Lock()
defer cc.mu.Unlock()
cc.baselineCharges[t] = t.CPUStats()
}
func (cc *cpu) commitTaskCharges(t *kernel.Task, charge usage.CPUStats) {
cc.mu.Lock()
outstandingCharge := charge.DifferenceSince(cc.baselineCharges[t])
cc.usage.Accumulate(outstandingCharge)
delete(cc.baselineCharges, t)
cc.mu.Unlock()
}
// exit implements controller.exit.
func (cc *cpu) exit(ctx context.Context, t *kernel.Task) {
cc.commitTaskCharges(t, t.CPUStats())
}
// canAttach implements controller.canAttach.
func (cc *cpu) canAttach(ctx context.Context, actx *attachCtx) bool { return true }
// cancelAttach implements controller.cancelAttach.
func (cc *cpu) cancelAttach(ctx context.Context, actx *attachCtx) {}
// attach implements controller.attach.
func (cc *cpu) attach(ctx context.Context, actx *attachCtx) {
for t := range actx.tasks {
var oldCPU *cpu
if oldNode := actx.oldNodes[t]; oldNode != nil {
if oldCtrl := oldNode.closestCtrls.Load()[kernel.Cgroup2CPU]; oldCtrl != nil {
oldCPU = oldCtrl.(*cpu)
}
}
migrateTaskCPU(t, oldCPU, cc)
}
}
// migrateTaskCPU commits the task's outstanding usage to oldCPU and rebaselines
// it in newCPU. If oldCPU == newCPU the task stays in the same domain and the
// baseline is left alone, since rebaselining would discard its usage so far.
func migrateTaskCPU(t *kernel.Task, oldCPU, newCPU *cpu) {
if oldCPU == newCPU {
return
}
charge := t.CPUStats()
if oldCPU != nil {
oldCPU.commitTaskCharges(t, charge)
}
if newCPU != nil {
newCPU.mu.Lock()
newCPU.baselineCharges[t] = charge
newCPU.mu.Unlock()
}
}
// +checklocksread:cc.c.fs.treeMu
// +checklocksread:cc.c.fs.tasksMu
func (cc *cpu) collectCPUStatsLocked(acc *usage.CPUStats) {
cc.accumulateDomainLocked(cc.c, acc)
cc.mu.Lock()
acc.Accumulate(cc.usage)
cc.mu.Unlock()
}
// accumulateDomainLocked adds the live usage of every task in cc's domain to acc:
// c and its descendants without their own cpu controller, attributed against cc's
// baselines. Descendants with their own controller are recursed into via it.
// +checklocksread:c.fs.treeMu
// +checklocksread:c.fs.tasksMu
func (cc *cpu) accumulateDomainLocked(c *cgroup, acc *usage.CPUStats) {
for t := range c.tasks {
charge := t.CPUStats()
cc.mu.Lock()
outstandingCharge := charge.DifferenceSince(cc.baselineCharges[t])
cc.mu.Unlock()
acc.Accumulate(outstandingCharge)
}
for child := range c.children {
// child shares c.fs, so c.fs.treeMu already guards child.ctrls.
if childCtrl := child.ctrls[kernel.Cgroup2CPU]; childCtrl != nil { // +checklocksforce: child shares c.fs locks
childCtrl.(*cpu).collectCPUStatsLocked(acc) // +checklocksforce: child shares c.fs locks
} else {
cc.accumulateDomainLocked(child, acc) // +checklocksforce: child shares c.fs locks
}
}
}
func (cc *cpu) collectCPUStats() usage.CPUStats {
cc.c.fs.treeMu.RLock()
defer cc.c.fs.treeMu.RUnlock()
cc.c.fs.tasksMu.RLock()
defer cc.c.fs.tasksMu.RUnlock()
var cs usage.CPUStats
cc.collectCPUStatsLocked(&cs)
return cs
}
// forEachCPUDomainTaskLocked calls fn for every task in c's domain: c and its
// descendants without their own cpu controller.
// +checklocksread:c.fs.treeMu
// +checklocksread:c.fs.tasksMu
func (c *cgroup) forEachCPUDomainTaskLocked(fn func(t *kernel.Task)) {
for t := range c.tasks {
fn(t)
}
for child := range c.children {
// child shares c.fs, so c.fs.treeMu already guards child.ctrls.
if child.ctrls[kernel.Cgroup2CPU] == nil { // +checklocksforce: child shares c.fs locks
child.forEachCPUDomainTaskLocked(fn) // +checklocksforce: child shares c.fs locks
}
}
}
// reparentCPUOnDisableLocked handles oldCPU being detached from c: it migrates
// c's domain tasks onto the parent's nearest cpu controller and folds oldCPU's
// accumulated usage into it, so no usage is lost.
// +checklocksread:c.fs.treeMu
func (c *cgroup) reparentCPUOnDisableLocked(oldCPU *cpu) {
var newCPU *cpu
if c.parent != nil {
if newCtrl := c.parent.closestCtrls.Load()[kernel.Cgroup2CPU]; newCtrl != nil {
newCPU = newCtrl.(*cpu)
}
}
c.fs.tasksMu.RLock()
c.forEachCPUDomainTaskLocked(func(t *kernel.Task) {
migrateTaskCPU(t, oldCPU, newCPU)
})
c.fs.tasksMu.RUnlock()
if newCPU == nil {
return
}
oldCPU.mu.Lock()
reparented := oldCPU.usage
oldCPU.usage = usage.CPUStats{}
oldCPU.mu.Unlock()
newCPU.mu.Lock()
newCPU.usage.Accumulate(reparented)
newCPU.mu.Unlock()
}
// interfaceFiles implements controller.interfaceFiles.
func (cc *cpu) interfaceFiles() []interfaceFile {
return []interfaceFile{
{name: "cpu.stat", source: &cpuStat{cc: cc}, perm: 0444, showAtRoot: true},
{name: "cpu.max", source: &cpuMax{cc: cc}, perm: 0644},
{name: "cpu.weight", source: &cpuWeight{cc: cc}, perm: 0644},
}
}
// interfaceFileNames implements controller.interfaceFileNames.
func (cc *cpu) interfaceFileNames() []string {
return []string{"cpu.stat", "cpu.max", "cpu.weight"}
}
// +stateify savable
type cpuStat struct {
cc *cpu
}
// Generate implements vfs.DynamicBytesSource.Generate.
func (cstat *cpuStat) Generate(ctx context.Context, buf *bytes.Buffer) error {
cs := cstat.cc.collectCPUStats()
usageUSec := (cs.UserTime + cs.SysTime).Microseconds()
userUSec := cs.UserTime.Microseconds()
sysUSec := cs.SysTime.Microseconds()
fmt.Fprintf(buf, "usage_usec %d\nuser_usec %d\nsystem_usec %d\nnice_usec 0\nnr_periods 0\nnr_throttled 0\nthrottled_usec 0\nnr_bursts 0\nburst_usec 0\n", usageUSec, userUSec, sysUSec)
return nil
}
// +stateify savable
type cpuMax struct {
cc *cpu
}
// Generate implements vfs.DynamicBytesSource.Generate.
func (cm *cpuMax) Generate(ctx context.Context, buf *bytes.Buffer) error {
quota := cm.cc.maxUSec.Load()
period := cm.cc.periodUSec.Load()
if quota == math.MaxInt64 {
fmt.Fprintf(buf, "max %d\n", period)
} else {
fmt.Fprintf(buf, "%d %d\n", quota, period)
}
return nil
}
// Write implements vfs.WritableDynamicBytesSource.Write.
//
// Note: Although cpu.max is writable and remembers the value, nothing is enforced.
func (cm *cpuMax) Write(ctx context.Context, _ *vfs.FileDescription, src usermem.IOSequence, offset int64) (int64, error) {
if src.NumBytes() > 1024 {
return 0, linuxerr.EINVAL
}
buf := make([]byte, src.NumBytes())
if _, err := src.CopyIn(ctx, buf); err != nil {
return 0, err
}
str := strings.TrimSpace(string(buf))
fields := strings.Fields(str)
if len(fields) < 1 || len(fields) > 2 {
return 0, linuxerr.EINVAL
}
var quota int64
if fields[0] == "max" {
quota = math.MaxInt64
} else {
val, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil || val <= 0 {
return 0, linuxerr.EINVAL
}
quota = val
}
var period int64
if len(fields) == 2 {
val, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil || val <= 0 {
return 0, linuxerr.EINVAL
}
period = val
} else {
period = cm.cc.periodUSec.Load()
}
cm.cc.maxUSec.Store(quota)
cm.cc.periodUSec.Store(period)
return int64(len(buf)), nil
}
// +stateify savable
type cpuWeight struct {
cc *cpu
}
// Generate implements vfs.DynamicBytesSource.Generate.
func (cw *cpuWeight) Generate(ctx context.Context, buf *bytes.Buffer) error {
fmt.Fprintf(buf, "%d\n", cw.cc.weight.Load())
return nil
}
// Write implements vfs.WritableDynamicBytesSource.Write.
//
// Note: Although cpu.weight is writable and remembers the value, nothing is enforced.
func (cw *cpuWeight) Write(ctx context.Context, _ *vfs.FileDescription, src usermem.IOSequence, offset int64) (int64, error) {
if src.NumBytes() > 1024 {
return 0, linuxerr.EINVAL
}
buf := make([]byte, src.NumBytes())
if _, err := src.CopyIn(ctx, buf); err != nil {
return 0, err
}
str := strings.TrimSpace(string(buf))
val, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return 0, linuxerr.EINVAL
}
if val < 1 || val > 10000 {
return 0, linuxerr.ERANGE
}
cw.cc.weight.Store(val)
return int64(len(buf)), nil
}
// detach implements controller.detach.
func (cc *cpu) detach() {
cc.detached.Store(true)
}
// isActive implements controller.isActive.
func (cc *cpu) isActive() bool {
return !cc.detached.Load()
}