Skip to content

fix: GOMAXPROCS auto-detection failure - #98

Merged
tgross merged 5 commits into
mainfrom
GOMAXPROCS-auto-detection-fails
Aug 13, 2026
Merged

fix: GOMAXPROCS auto-detection failure#98
tgross merged 5 commits into
mainfrom
GOMAXPROCS-auto-detection-fails

Conversation

@ritesh-harihar

@ritesh-harihar ritesh-harihar commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes: #82

Summary

exec2 tasks saw GOMAXPROCS equal to the total host CPU count rather than their allocated CPU quota. This caused over-threading, excessive cgroup throttling, and degraded throughput for any Go binary scheduled with a fractional CPU share (e.g. cpu = 500 on a 32-core host). The driver now injects the correct value before the task process starts.

Root cause

The Go runtime auto-detects GOMAXPROCS at startup by reading two kernel interfaces. Both are silently blocked by Landlock inside an exec2 task. The Landlock allow-list even with unveil_defaults = true does not include /sys/fs/cgroup or /sys/devices/system/cpu. So Go silently falls through to the host core count every time.

Fix
The fix injects GOMAXPROCS directly into the task environment in StartTask(), derived from the same bandwidth value already computed for writing to cpu.max. The formula uses integer ceiling division, so fractional shares round up to the next whole thread and reserved-core allocations (cores = N) produce an exact result because the per-core MHz cancels out.

The injection is skipped if the operator has already set GOMAXPROCS explicitly in the job's env block. Because the value arrives as an environment variable, the Go runtime reads it via os.Getenv during runtime.init with no file I/O, and the kernel-enforced cgroup limit and the Go thread pool size are now always derived from the same source.

Why integer ceiling, not float64?
bandwidth is uint64. Converting to float64 loses precision above 2^53. Integer ceiling division (n + d - 1) / d is exact for all representable values.

Data flow — how the value reaches the process

Screenshot 2026-08-07 at 12 48 38 PM

Job spec examples

Scenario A — fractional share (cpu = N MHz)

resources {
  cpu    = 500    # 500 MHz on a 3 GHz host → bandwidth=16666 → GOMAXPROCS=1
  memory = 256
}

Scenario B — whole core reservation (cores = N)

resources {
  cores  = 2     # 2 reserved cores → bandwidth=200_000 → GOMAXPROCS=2 (exact)
  memory = 512
}

Scenario C — verify inside the task

config {
  command = "printenv"
  args    = ["GOMAXPROCS"]
}

Testing

Details
job "gomaxprocs-test" {
  type = "batch"

  constraint {
    attribute = "${attr.kernel.name}"
    value     = "linux"
  }

  group "g" {
    reschedule {
      attempts  = 0
      unlimited = false
    }

    restart {
      attempts = 0
      mode     = "fail"
    }

    task "check" {
      driver = "exec2"

      config {
        command = "gomaxprocs-check"
      }

      resources {
        cpu    = 500
        memory = 64
      }
    }
  }
}

Result before:

riteshharihar@podman-dev:/tmp/gomaxprocs-test$ nomad job run /tmp/gomaxprocs-job.hcl
==> 2026-08-06T14:14:26+05:30: Monitoring evaluation "0ef9ff6c"
    2026-08-06T14:14:26+05:30: Evaluation triggered by job "gomaxprocs-test"
    2026-08-06T14:14:27+05:30: Allocation "4233757b" created: node "2dd2ca81", group "g"
    2026-08-06T14:14:27+05:30: Evaluation status changed: "pending" -> "complete"
==> 2026-08-06T14:14:27+05:30: Evaluation "0ef9ff6c" finished with status "complete"
riteshharihar@podman-dev:/tmp/gomaxprocs-test$ sleep 5 && nomad logs -job gomaxprocs-test check
GOMAXPROCS=14 NumCPU=14

Result After:

riteshharihar@podman-dev:/tmp/gomaxprocs-test$ nomad job run /tmp/gomaxprocs-job.hcl
==> 2026-08-06T14:18:05+05:30: Monitoring evaluation "f487422e"
    2026-08-06T14:18:05+05:30: Evaluation triggered by job "gomaxprocs-test"
    2026-08-06T14:18:06+05:30: Allocation "b6dce476" created: node "876a183c", group "g"
    2026-08-06T14:18:06+05:30: Evaluation status changed: "pending" -> "complete"
==> 2026-08-06T14:18:06+05:30: Evaluation "f487422e" finished with status "complete"
riteshharihar@podman-dev:/tmp/gomaxprocs-test$ sleep 5 && nomad logs -job gomaxprocs-test check
GOMAXPROCS=1 NumCPU=14
job "gomaxprocs-cores" {
  type = "batch"

  constraint {
    attribute = "${attr.kernel.name}"
    value     = "linux"
  }

  group "g" {
    reschedule {
      attempts  = 0
      unlimited = false
    }

    restart {
      attempts = 0
      mode     = "fail"
    }

    task "check" {
      driver = "exec2"

      config {
        command = "gomaxprocs-check"
      }

      resources {
        cores  = 1
        memory = 64
      }
    }
  }
}
EOF

Before Result:

riteshharihar@podman-dev:/tmp/gomaxprocs-test$ nomad job run /tmp/gomaxprocs-cores-job.hcl
==> 2026-08-07T13:25:11+05:30: Monitoring evaluation "c0ad6310"
    2026-08-07T13:25:11+05:30: Evaluation triggered by job "gomaxprocs-cores"
    2026-08-07T13:25:12+05:30: Allocation "efa53783" created: node "9a97a483", group "g"
    2026-08-07T13:25:12+05:30: Evaluation status changed: "pending" -> "complete"
==> 2026-08-07T13:25:12+05:30: Evaluation "c0ad6310" finished with status "complete"
riteshharihar@podman-dev:/tmp/gomaxprocs-test$ nomad logs -job gomaxprocs-cores check
GOMAXPROCS=1 NumCPU=1

After Result:

riteshharihar@podman-dev:/tmp/gomaxprocs-test$ nomad job run /tmp/gomaxprocs-cores-job.hcl
==> 2026-08-07T13:35:29+05:30: Monitoring evaluation "0f1f72f9"
    2026-08-07T13:35:29+05:30: Evaluation triggered by job "gomaxprocs-cores"
    2026-08-07T13:35:29+05:30: Allocation "5f0ccb66" created: node "0018293f", group "g"
    2026-08-07T13:35:30+05:30: Allocation "5f0ccb66" status changed: "pending" -> "running" (Tasks are running)
    2026-08-07T13:35:30+05:30: Evaluation status changed: "pending" -> "complete"
==> 2026-08-07T13:35:30+05:30: Evaluation "0f1f72f9" finished with status "complete"
riteshharihar@podman-dev:/tmp/gomaxprocs-test$ nomad logs -job gomaxprocs-cores check
GOMAXPROCS=1 NumCPU=1
  • If a change needs to be reverted, we will roll out an update to the code within 7 days.

Changes to Security Controls

Are there any changes to security controls (access controls, encryption, logging) in this pull request? If so, explain.

@ritesh-harihar
ritesh-harihar marked this pull request as ready for review August 7, 2026 10:02
@ritesh-harihar
ritesh-harihar requested a review from a team as a code owner August 7, 2026 10:02
@ritesh-harihar ritesh-harihar changed the title fix: GOMAXPROCS auto-detection fix: GOMAXPROCS auto-detection failure Aug 7, 2026

@tgross tgross left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than fixing this only for Go, shouldn't we fix this by changing the Landlock config? That way you can use this for anything. Ex. Java applications might want to read their own cgroup to learn how much memory they have allocated.

@ritesh-harihar

ritesh-harihar commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Rather than fixing this only for Go, shouldn't we fix this by changing the Landlock config? That way you can use this for anything. Ex. Java applications might want to read their own cgroup to learn how much memory they have allocated.

Adding /sys/fs/cgroup to the Landlock defaults would expose the entire cgroup hierarchy to every task. Landlock only grants access to a directory subtree, so every task would gain read access to every other task's memory.current, cpu.stat etc across all co-located allocations.

I thought this shouldn't happen that's why didn't touched landlock defaults.

Java applications might want to read their own cgroup to learn how much memory they have allocated.

If we want runtimes other than Go, to be able to read their own cgroup limits then we can introduce a new unveil_cgroup = true plugin flag in agent.hcl. Instead of exposing the entire /sys/fs/cgroup tree, the driver would append only the task's own cgroup scope path CpusetCgroupPath, which is already available in the driver at task start time. This would be safe, scoped to the task's own data only, and language-agnostic.

Let me know if this makes sense.

@tgross

tgross commented Aug 10, 2026

Copy link
Copy Markdown
Member

I thought this shouldn't happen that's why didn't touched landlock defaults.

Right, but can't we expose just the subtree for this PID?

@ritesh-harihar

Copy link
Copy Markdown
Collaborator Author

Right, but can't we expose just the subtree for this PID?

Yeah, but it cannot be done inside Landlock's setup phase as it has no knowledge of the task's runtime cgroup path and don’t know the actual cgroup path for a particular task.

The task-specific cgroup path is only available at StartTask() in the driver, which runs separately from the shim.

So unveil_cgroup will basically bridge this gap. The driver gets the CpusetCgroupPath at runtime and passes that specific path to the shim as an unveil path. This way, Landlock only exposes the cgroup directory for that task instead of giving access to the entire cgroup tree.

If this feels right i will start making the changes.

@tgross

tgross commented Aug 11, 2026

Copy link
Copy Markdown
Member

Should we even bother making this a configuration knob? What's the harm in exposing it (read-only!) to the task by default?

edit but otherwise, yeah, that seems like the right approach

@ritesh-harihar

ritesh-harihar commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Should we even bother making this a configuration knob? What's the harm in exposing it (read-only!) to the task by default?

edit but otherwise, yeah, that seems like the right approach

No, we don't need any config knobs here; this can be tied to unveil_defaults.

Exposing /proc read-only so runtimes (Go 1.25+, JVM) can read /proc/self/cgroup and /proc/self/mountinfo to discover their cgroup CPU and memory limits. unshare --mount-proc creates an isolated /proc scoped to the task's PID namespace. Without /proc unveiled, Landlock blocks both reads and Go never reaches cpu.max.

Go 1.24 and below never read cgroup so env-var injection is the fix for this(already present), and it gives GOMAXPROCS=1 for allocations where Go 1.25+'s native minimum is 2 .

Since Go checks the env-var first and skips all cgroup reads if set, the injection always wins for Go, the cgroup unveil is purely for Java and other runtimes.

If we want to drop the support for Go 1.24 we can remove this block of code?

if _, alreadySet := config.Env["GOMAXPROCS"]; !alreadySet {
gomaxprocs := max(1, int((bandwidth+99_999)/100_000))
config.Env["GOMAXPROCS"] = strconv.Itoa(gomaxprocs)
}

@tgross

tgross commented Aug 12, 2026

Copy link
Copy Markdown
Member

If we want to drop the support for Go 1.24 we can remove this block of code?

Go 1.24 is out of support itself. But I also have no idea why we'd be special-casing environment variables for Go in this driver at all. What makes Go special? Are there well-known environment variables that Haskell can use to set threadpools too? What about Luau? 😁 I think you get my point: we should be supplying a generic facility that well-behaved runtimes can use (cgroups), not special-casing environment variables for particular runtimes.

@ritesh-harihar

ritesh-harihar commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed. We should provide generic cgroup facility for all runtimes, rather than runtime-specific handling. Removed the GOMAXPROCS env var injection.

@ritesh-harihar
ritesh-harihar requested a review from tgross August 13, 2026 17:03

@tgross tgross left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@tgross
tgross merged commit e32a321 into main Aug 13, 2026
11 checks passed
@tgross
tgross deleted the GOMAXPROCS-auto-detection-fails branch August 13, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

exec2 driver: GOMAXPROCS auto-detection fails in Go 1.25 due to cgroup namespacing

2 participants