You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A guest process gets at most 64 threads (MAX_THREADS in src/runtime/thread.h; the SP_EL1 slots are tracked in one uint64_t in src/runtime/thread.c). The 65th clone fails with EAGAIN. Linux allows far more, and Go programs reach this with ordinary work, because the Go runtime starts a new OS thread whenever a goroutine blocks in a syscall, independent of GOMAXPROCS.
gofmt over a few standard-library trees is enough:
scripts/elfuse-oci.py run --entrypoint /bin/sh \
mirror.gcr.io/library/golang:1.23-alpine@sha256:56e44a486dc12270a471259239c0005ae492be2f078c4f54567c0e3159e23e4b \
-c 'r=$(go env GOROOT); go version; for t in net runtime crypto; do gofmt -l $r/src/$t; done; gofmt -d $r/src/fmt' > /dev/null
It failed 5 of 30 runs:
runtime: failed to create new OS thread (have 65 already; errno=11)
runtime: may need to increase max user processes (ulimit -u)
fatal error: newosproc
With GOMAXPROCS=4 it passed 40 of 40 runs alone but still failed inside a full make bench-ci, so capping GOMAXPROCS is not a workaround.
This is why the go row of the real-workload ranking is left out of the Tier 3 benchmarks in #493.
The 64 is a Hypervisor.framework limit, not something elfuse picked. hv_vm_get_max_vcpu_count reports 64, and the 65th hv_vcpu_create in one VM fails with HV_NO_RESOURCES (0xfae94005). elfuse gives every guest thread its own vCPU, so MAX_THREADS and the uint64_t SP_EL1 bitmask only mirror that cap. Raising MAX_THREADS alone would move the failure from clone to hv_vcpu_create.
Probe: one VM, then 300 pthreads that each call hv_vcpu_create and stay parked.
max_vcpu_count=64
created=64
fail at 64: 0xfae94005 (236 times)
hv_vm_config has no setting that raises this. It offers only IPA size and IPA granule.
The demand is real Go behavior, not something elfuse adds. Peak threads= from GODEBUG=schedtrace=10 for the gofmt command above, one value per run:
Where
GOMAXPROCS
Peak threads
elfuse
15 (default, 15 host CPUs)
65, 65, 65 (the cap)
Linux 6.18 (QEMU, 4 vCPUs)
15
48, 126, 112
elfuse
4
13, 25
Linux 6.18
4
7, 24, 7
At GOMAXPROCS=4 the two are in the same range, so elfuse does not inflate the count. The Linux VM runs 15 Ps on 4 vCPUs, so a real 15-core Linux machine may peak lower.
Memory is not the limit. The EL1 stacks take 256 KiB at the top of the 2 MiB shim data block, and the shim globals take about 8.4 KiB at the bottom. For a guest to run more than 64 threads, a guest thread would have to stop owning a vCPU for its whole life, for example by taking a parked thread's registers off its vCPU while it blocks in a host syscall.
A possible way past the cap, as a design sketch. Nothing here is implemented or measured yet.
Go needs more than 64 threads because threads block in syscalls, not because more than 64 run guest code at once. So the target is narrower than a full N:M scheduler: at most 64 guest threads on a vCPU at a time, any number parked in a host syscall.
Hypervisor.framework binds a vCPU to the host thread that created it (per Apple's documentation, not tested here), so a guest thread cannot move to another vCPU itself. Its register state has to be saved and loaded by the thread that owns the target vCPU. That gives three roles:
Role
Count
Does
vCPU runner thread
at most 64, each owns one vCPU
loads a runnable guest context, runs hv_vcpu_run, handles fast syscalls inline as today
per-guest-thread host pthread
one per guest thread, no cap
runs that thread's blocking syscalls
saved guest context
one per guest thread
GPRs, FP/SIMD state, per-thread system registers
A blocking syscall (futex wait, blocking read/accept/poll, nanosleep) then goes: SVC exit on the runner, runner saves the context and hands the call to the thread's host pthread, runner loads the next runnable context; when the host call returns, the pthread stores the result and marks the context runnable for any free runner. Fast syscalls stay on the runner, so lat_syscall null should not move; the blocking path pays one host-thread handoff plus a register reload. With more than 64 runnable contexts the rest wait for a runner; time slicing through hv_vcpus_exit can come later.
What makes it a large change (counts from grep on current main):
current_thread is _Thread_local (src/runtime/thread.h) and has 152 uses; 25 _Thread_local objects in all, including the TLBI request in src/core/guest.c. Each one means "this guest thread" today and would have to be split into per-runner and per-context state.
src/syscall makes 182 hv_vcpu_* calls. A handler that runs on the blocking pthread cannot touch a vCPU and has to edit the saved context instead, so the blocking set has to be audited and the register-touching handlers (signal delivery, rt_sigreturn, clone, exec, ptrace) kept on the runner.
A signal to a parked thread edits its saved context; one to a running thread still needs a forced exit.
The SP_EL1 slot bitmask in src/runtime/thread.c and the shim_data slots become per-vCPU instead of per-thread.
A futex wake makes a context runnable instead of waking a pthread that owns a vCPU.
gdbstub and fork-state enumerate threads through vCPUs and would enumerate contexts instead.
Alternatives that do not work: raising MAX_THREADS moves the failure to hv_vcpu_create; one process cannot hold more than one VM; capping GOMAXPROCS still failed inside make bench-ci.
Suggested order: release the vCPU on futex wait only, with lat_syscall null and a futex ping-pong measured before and after; then extend it to blocking file and socket syscalls; then rerun the gofmt reproducer above at the default GOMAXPROCS, 30 runs. The futex step alone does not cover gofmt, which blocks in file I/O.
Activity
xalestar commentedon Nov 7, 2026
The 64 is a Hypervisor.framework limit, not something elfuse picked.
hv_vm_get_max_vcpu_countreports 64, and the 65thhv_vcpu_createin one VM fails withHV_NO_RESOURCES(0xfae94005). elfuse gives every guest thread its own vCPU, soMAX_THREADSand theuint64_tSP_EL1 bitmask only mirror that cap. RaisingMAX_THREADSalone would move the failure fromclonetohv_vcpu_create.Probe: one VM, then 300 pthreads that each call
hv_vcpu_createand stay parked.hv_vm_confighas no setting that raises this. It offers only IPA size and IPA granule.The demand is real Go behavior, not something elfuse adds. Peak
threads=fromGODEBUG=schedtrace=10for the gofmt command above, one value per run:At GOMAXPROCS=4 the two are in the same range, so elfuse does not inflate the count. The Linux VM runs 15 Ps on 4 vCPUs, so a real 15-core Linux machine may peak lower.
Memory is not the limit. The EL1 stacks take 256 KiB at the top of the 2 MiB shim data block, and the shim globals take about 8.4 KiB at the bottom. For a guest to run more than 64 threads, a guest thread would have to stop owning a vCPU for its whole life, for example by taking a parked thread's registers off its vCPU while it blocks in a host syscall.
xalestar commentedon Nov 8, 2026
A possible way past the cap, as a design sketch. Nothing here is implemented or measured yet.
Go needs more than 64 threads because threads block in syscalls, not because more than 64 run guest code at once. So the target is narrower than a full N:M scheduler: at most 64 guest threads on a vCPU at a time, any number parked in a host syscall.
Hypervisor.framework binds a vCPU to the host thread that created it (per Apple's documentation, not tested here), so a guest thread cannot move to another vCPU itself. Its register state has to be saved and loaded by the thread that owns the target vCPU. That gives three roles:
hv_vcpu_run, handles fast syscalls inline as todayA blocking syscall (futex wait, blocking read/accept/poll, nanosleep) then goes: SVC exit on the runner, runner saves the context and hands the call to the thread's host pthread, runner loads the next runnable context; when the host call returns, the pthread stores the result and marks the context runnable for any free runner. Fast syscalls stay on the runner, so
lat_syscall nullshould not move; the blocking path pays one host-thread handoff plus a register reload. With more than 64 runnable contexts the rest wait for a runner; time slicing throughhv_vcpus_exitcan come later.What makes it a large change (counts from grep on current main):
current_threadis_Thread_local(src/runtime/thread.h) and has 152 uses; 25_Thread_localobjects in all, including the TLBI request insrc/core/guest.c. Each one means "this guest thread" today and would have to be split into per-runner and per-context state.src/syscallmakes 182hv_vcpu_*calls. A handler that runs on the blocking pthread cannot touch a vCPU and has to edit the saved context instead, so the blocking set has to be audited and the register-touching handlers (signal delivery, rt_sigreturn, clone, exec, ptrace) kept on the runner.src/runtime/thread.cand theshim_dataslots become per-vCPU instead of per-thread.Alternatives that do not work: raising
MAX_THREADSmoves the failure tohv_vcpu_create; one process cannot hold more than one VM; cappingGOMAXPROCSstill failed insidemake bench-ci.Suggested order: release the vCPU on futex wait only, with
lat_syscall nulland a futex ping-pong measured before and after; then extend it to blocking file and socket syscalls; then rerun the gofmt reproducer above at the defaultGOMAXPROCS, 30 runs. The futex step alone does not cover gofmt, which blocks in file I/O.