aboutsummaryrefslogtreecommitdiff
path: root/src/runtime/cgroup_linux_test.go
diff options
context:
space:
mode:
authorMichael Pratt <mpratt@google.com>2025-05-05 13:44:26 -0400
committerMichael Pratt <mpratt@google.com>2025-05-21 10:21:55 -0700
commite6dacf91ffb0a356aa692ab5c46411e2eef913f3 (patch)
treefc337b1d3fe594503468a6af639ed6459442ee2a /src/runtime/cgroup_linux_test.go
parentf12c66fbed546645389cf184b0e2ffd6ad9f78ec (diff)
downloadgo-e6dacf91ffb0a356aa692ab5c46411e2eef913f3.tar.xz
runtime: use cgroup CPU limit to set GOMAXPROCS
This CL adds two related features enabled by default via compatibility GODEBUGs containermaxprocs and updatemaxprocs. On Linux, containermaxprocs makes the Go runtime consider cgroup CPU bandwidth limits (quota/period) when setting GOMAXPROCS. If the cgroup limit is lower than the number of logical CPUs available, then the cgroup limit takes precedence. On all OSes, updatemaxprocs makes the Go runtime periodically recalculate the default GOMAXPROCS value and update GOMAXPROCS if it has changed. If GOMAXPROCS is set manually, this update does not occur. This is intended primarily to detect changes to cgroup limits, but it applies on all OSes because the CPU affinity mask can change as well. The runtime only considers the limit in the leaf cgroup (the one that actually contains the process), caching the CPU limit file descriptor(s), which are periodically reread for updates. This is a small departure from the original proposed design. It will not consider limits of parent cgroups (which may be lower than the leaf), and it will not detection cgroup migration after process start. We can consider changing this in the future, but the simpler approach is less invasive; less risk to packages that have some awareness of runtime internals. e.g., if the runtime periodically opens new files during execution, file descriptor leak detection is difficult to implement in a stable way. For #73193. Cq-Include-Trybots: luci.golang.try:gotip-linux-amd64-longtest Change-Id: I6a6a636c631c1ae577fb8254960377ba91c5dc98 Reviewed-on: https://go-review.googlesource.com/c/go/+/670497 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Michael Knyszek <mknyszek@google.com>
Diffstat (limited to 'src/runtime/cgroup_linux_test.go')
-rw-r--r--src/runtime/cgroup_linux_test.go325
1 files changed, 325 insertions, 0 deletions
diff --git a/src/runtime/cgroup_linux_test.go b/src/runtime/cgroup_linux_test.go
new file mode 100644
index 0000000000..0b060572b6
--- /dev/null
+++ b/src/runtime/cgroup_linux_test.go
@@ -0,0 +1,325 @@
+// Copyright 2025 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package runtime_test
+
+import (
+ "fmt"
+ "internal/cgrouptest"
+ "runtime"
+ "strings"
+ "syscall"
+ "testing"
+ "unsafe"
+)
+
+func mustHaveFourCPUs(t *testing.T) {
+ // If NumCPU is lower than the cgroup limit, GOMAXPROCS will use
+ // NumCPU.
+ //
+ // cgroup GOMAXPROCS also have a minimum of 2. We need some room above
+ // that to test interesting properies.
+ if runtime.NumCPU() < 4 {
+ t.Helper()
+ t.Skip("skipping test: fewer than 4 CPUs")
+ }
+}
+
+func TestCgroupGOMAXPROCS(t *testing.T) {
+ mustHaveFourCPUs(t)
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tests := []struct {
+ godebug int
+ want int
+ }{
+ // With containermaxprocs=1, GOMAXPROCS should use the cgroup
+ // limit.
+ {
+ godebug: 1,
+ want: 3,
+ },
+ // With containermaxprocs=0, it should be ignored.
+ {
+ godebug: 0,
+ want: runtime.NumCPU(),
+ },
+ }
+ for _, tc := range tests {
+ t.Run(fmt.Sprintf("containermaxprocs=%d", tc.godebug), func(t *testing.T) {
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ if err := c.SetCPUMax(300000, 100000); err != nil {
+ t.Fatalf("unable to set CPU limit: %v", err)
+ }
+
+ got := runBuiltTestProg(t, exe, "PrintGOMAXPROCS", fmt.Sprintf("GODEBUG=containermaxprocs=%d", tc.godebug))
+ want := fmt.Sprintf("%d\n", tc.want)
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+ })
+ }
+}
+
+// Without a cgroup limit, GOMAXPROCS uses NumCPU.
+func TestCgroupGOMAXPROCSNoLimit(t *testing.T) {
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ if err := c.SetCPUMax(-1, 100000); err != nil {
+ t.Fatalf("unable to set CPU limit: %v", err)
+ }
+
+ got := runBuiltTestProg(t, exe, "PrintGOMAXPROCS")
+ want := fmt.Sprintf("%d\n", runtime.NumCPU())
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+}
+
+// If the cgroup limit is higher than NumCPU, GOMAXPROCS uses NumCPU.
+func TestCgroupGOMAXPROCSHigherThanNumCPU(t *testing.T) {
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ if err := c.SetCPUMax(2*int64(runtime.NumCPU())*100000, 100000); err != nil {
+ t.Fatalf("unable to set CPU limit: %v", err)
+ }
+
+ got := runBuiltTestProg(t, exe, "PrintGOMAXPROCS")
+ want := fmt.Sprintf("%d\n", runtime.NumCPU())
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+}
+
+func TestCgroupGOMAXPROCSRound(t *testing.T) {
+ mustHaveFourCPUs(t)
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tests := []struct {
+ quota int64
+ want int
+ }{
+ // We always round the fractional component up.
+ {
+ quota: 200001,
+ want: 3,
+ },
+ {
+ quota: 250000,
+ want: 3,
+ },
+ {
+ quota: 299999,
+ want: 3,
+ },
+ // Anything less than two rounds up to a minimum of 2.
+ {
+ quota: 50000, // 0.5
+ want: 2,
+ },
+ {
+ quota: 100000,
+ want: 2,
+ },
+ {
+ quota: 150000,
+ want: 2,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(fmt.Sprintf("%d", tc.quota), func(t *testing.T) {
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ if err := c.SetCPUMax(tc.quota, 100000); err != nil {
+ t.Fatalf("unable to set CPU limit: %v", err)
+ }
+
+ got := runBuiltTestProg(t, exe, "PrintGOMAXPROCS")
+ want := fmt.Sprintf("%d\n", tc.want)
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+ })
+ }
+}
+
+// Environment variable takes precedence over defaults.
+func TestCgroupGOMAXPROCSEnvironment(t *testing.T) {
+ mustHaveFourCPUs(t)
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ if err := c.SetCPUMax(200000, 100000); err != nil {
+ t.Fatalf("unable to set CPU limit: %v", err)
+ }
+
+ got := runBuiltTestProg(t, exe, "PrintGOMAXPROCS", "GOMAXPROCS=3")
+ want := "3\n"
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+}
+
+// CPU affinity takes priority if lower than cgroup limit.
+func TestCgroupGOMAXPROCSSchedAffinity(t *testing.T) {
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ if err := c.SetCPUMax(300000, 100000); err != nil {
+ t.Fatalf("unable to set CPU limit: %v", err)
+ }
+
+ // CPU affinity is actually a per-thread attribute.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ const maxCPUs = 64 * 1024
+ var orig [maxCPUs / 8]byte
+ _, _, errno := syscall.Syscall6(syscall.SYS_SCHED_GETAFFINITY, 0, unsafe.Sizeof(orig), uintptr(unsafe.Pointer(&orig[0])), 0, 0, 0)
+ if errno != 0 {
+ t.Fatalf("unable to get CPU affinity: %v", errno)
+ }
+
+ // We're going to restrict to CPUs 0 and 1. Make sure those are already available.
+ if orig[0]&0b11 != 0b11 {
+ t.Skipf("skipping test: CPUs 0 and 1 not available")
+ }
+
+ var mask [maxCPUs / 8]byte
+ mask[0] = 0b11
+ _, _, errno = syscall.Syscall6(syscall.SYS_SCHED_SETAFFINITY, 0, unsafe.Sizeof(mask), uintptr(unsafe.Pointer(&mask[0])), 0, 0, 0)
+ if errno != 0 {
+ t.Fatalf("unable to set CPU affinity: %v", errno)
+ }
+ defer func() {
+ _, _, errno = syscall.Syscall6(syscall.SYS_SCHED_SETAFFINITY, 0, unsafe.Sizeof(orig), uintptr(unsafe.Pointer(&orig[0])), 0, 0, 0)
+ if errno != 0 {
+ t.Fatalf("unable to restore CPU affinity: %v", errno)
+ }
+ }()
+
+ got := runBuiltTestProg(t, exe, "PrintGOMAXPROCS")
+ want := "2\n"
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+}
+
+func TestCgroupGOMAXPROCSSetDefault(t *testing.T) {
+ mustHaveFourCPUs(t)
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tests := []struct {
+ godebug int
+ want int
+ }{
+ // With containermaxprocs=1, SetDefaultGOMAXPROCS should observe
+ // the cgroup limit.
+ {
+ godebug: 1,
+ want: 3,
+ },
+ // With containermaxprocs=0, it should be ignored.
+ {
+ godebug: 0,
+ want: runtime.NumCPU(),
+ },
+ }
+ for _, tc := range tests {
+ t.Run(fmt.Sprintf("containermaxprocs=%d", tc.godebug), func(t *testing.T) {
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ env := []string{
+ fmt.Sprintf("GO_TEST_CPU_MAX_PATH=%s", c.CPUMaxPath()),
+ "GO_TEST_CPU_MAX_QUOTA=300000",
+ fmt.Sprintf("GODEBUG=containermaxprocs=%d", tc.godebug),
+ }
+ got := runBuiltTestProg(t, exe, "SetLimitThenDefaultGOMAXPROCS", env...)
+ want := fmt.Sprintf("%d\n", tc.want)
+ if got != want {
+ t.Fatalf("output got %q want %q", got, want)
+ }
+ })
+ })
+ }
+}
+
+func TestCgroupGOMAXPROCSUpdate(t *testing.T) {
+ mustHaveFourCPUs(t)
+
+ if testing.Short() {
+ t.Skip("skipping test: long sleeps")
+ }
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ got := runBuiltTestProg(t, exe, "UpdateGOMAXPROCS", fmt.Sprintf("GO_TEST_CPU_MAX_PATH=%s", c.CPUMaxPath()))
+ if !strings.Contains(got, "OK") {
+ t.Fatalf("output got %q want OK", got)
+ }
+ })
+}
+
+func TestCgroupGOMAXPROCSDontUpdate(t *testing.T) {
+ mustHaveFourCPUs(t)
+
+ if testing.Short() {
+ t.Skip("skipping test: long sleeps")
+ }
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Two ways to disable updates: explicit GOMAXPROCS or GODEBUG for
+ // update feature.
+ for _, v := range []string{"GOMAXPROCS=4", "GODEBUG=updatemaxprocs=0"} {
+ t.Run(v, func(t *testing.T) {
+ cgrouptest.InCgroupV2(t, func(c *cgrouptest.CgroupV2) {
+ got := runBuiltTestProg(t, exe, "DontUpdateGOMAXPROCS",
+ fmt.Sprintf("GO_TEST_CPU_MAX_PATH=%s", c.CPUMaxPath()),
+ v)
+ if !strings.Contains(got, "OK") {
+ t.Fatalf("output got %q want OK", got)
+ }
+ })
+ })
+ }
+}