mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
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
348 lines
10 KiB
Go
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()
|
|
}
|