Repository navigation
runtime: futex waits never sleep on freebsd/arm64 because sys_umtx_op sign-extends val #81849
Description
Go version
go1.26.8. runtime·sys_umtx_op is unchanged in go1.27.1 and on master at 38f24c5c (2026-09-29). Judging by the source, every release since go1.24.0 is affected (see "Since when" below).
Output of go env in your module/workspace
The test binaries were cross-compiled. These are their build settings, taken from go version -m:
go1.26.8
build -buildmode=exe
build -compiler=gc
build -trimpath=true
build CGO_ENABLED=0
build GOARCH=arm64
build GOOS=freebsd
build GOARM64=v8.0
What operating system and processor architecture are you using?
FreeBSD 16.0-CURRENT on arm64, built from source on 2026-09-24, with the ULE scheduler and HZ=1000. The board is a Pine64 RockPro64 (Rockchip RK3399: 2x Cortex-A72 + 4x Cortex-A53). The kernel code involved (do_wait() in sys/kern/kern_umtx.c) is the same in releng/13.0, 14.0 and 15.0.
What did you do?
We first saw this in a daemon on freebsd/arm64 that is pinned to one CPU with GOMAXPROCS=2 and restarts a child process. In 4 of 6 restarts, the thread whose wait6 had just returned for the exited child got no CPU for 1.1-3.7 s. During that time, other threads of the same process made about 1.5 M system calls a second on that CPU with no increase in context switches. ktrace showed every M, sysmon included, looping on:
_umtx_op(<&m.waitsema>, 0xf<UMTX_OP_WAIT_UINT_PRIVATE>, 0xffffffffffffffff, 0x18, 0) = 0
Each call returned 0 at once.
Here is a minimal standalone reproducer. There are two modes:
idle: a goroutine sleeps 2 ms and then makes a system call.pp: goroutine pairs play channel ping-pong.
Both run on one CPU with GOMAXPROCS=2.
main.go
// umtxspin: shows runtime-internal futex waits spinning on freebsd/arm64.
//
// Mode "idle": one goroutine sleeps 2 ms and then makes a system call, in a
// loop (all Ps idle, sysmon sleeping on its note; entering the system call
// wakes sysmon while sched.lock is held). Mode "pp": channel ping-pong
// between goroutine pairs (contended runtime channel locks and wake-ups).
// Run it on one CPU with GOMAXPROCS=2:
//
// cpuset -l 1 env GOMAXPROCS=2 ./umtxspin -mode pp -d 15s
//
// It prints the latency of the 2 ms sleeps, the process's CPU time and its
// context switches.
package main
import (
"flag"
"fmt"
"os"
"runtime"
"sort"
"sync/atomic"
"syscall"
"time"
)
func main() {
d := flag.Duration("d", 10*time.Second, "duration")
mode := flag.String("mode", "idle", "idle | pp")
pairs := flag.Int("pairs", 4, "ping-pong pairs (pp)")
flag.Parse()
fmt.Printf("pid %d GOMAXPROCS %d NumCPU %d mode %s d %v\n", os.Getpid(), runtime.GOMAXPROCS(0), runtime.NumCPU(), *mode, *d)
var r0 syscall.Rusage
_ = syscall.Getrusage(syscall.RUSAGE_SELF, &r0)
t0 := time.Now()
var lat []time.Duration
var ops atomic.Int64
switch *mode {
case "idle":
var st syscall.Stat_t
for time.Since(t0) < *d {
s := time.Now()
time.Sleep(2 * time.Millisecond)
_ = syscall.Stat("/", &st)
lat = append(lat, time.Since(s))
}
case "pp":
stop := make(chan struct{})
for i := 0; i < *pairs; i++ {
a, b := make(chan int), make(chan int)
go func() {
for {
select {
case <-stop:
return
case a <- 1:
}
<-b
ops.Add(1)
}
}()
go func() {
for {
select {
case <-stop:
return
case v := <-a:
b <- v
}
}
}()
}
for time.Since(t0) < *d {
s := time.Now()
time.Sleep(2 * time.Millisecond)
lat = append(lat, time.Since(s))
}
close(stop)
}
el := time.Since(t0)
var r1 syscall.Rusage
_ = syscall.Getrusage(syscall.RUSAGE_SELF, &r1)
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
q := func(p float64) time.Duration { return lat[int(p*float64(len(lat)-1))] }
tv := func(t syscall.Timeval) float64 { return float64(t.Sec) + float64(t.Usec)/1e6 }
over := 0
for _, l := range lat {
if l > 50*time.Millisecond {
over++
}
}
fmt.Printf("elapsed %.2f s iterations %d (2 ms sleep each) p50 %v p99 %v p999 %v max %v >50ms %d pp-ops %d\n",
el.Seconds(), len(lat), q(0.5), q(0.99), q(0.999), lat[len(lat)-1], over, ops.Load())
fmt.Printf("utime %.2f s stime %.2f s (%.0f%% of one CPU) nvcsw %d nivcsw %d\n",
tv(r1.Utime)-tv(r0.Utime), tv(r1.Stime)-tv(r0.Stime), 100*(tv(r1.Utime)-tv(r0.Utime)+tv(r1.Stime)-tv(r0.Stime))/el.Seconds(),
r1.Nvcsw-r0.Nvcsw, r1.Nivcsw-r0.Nivcsw)
}We built it twice: with the stock go1.26.8 runtime, and with the one-line fix below applied to src/runtime/sys_freebsd_arm64.s in a copy of go1.26.8's GOROOT. With a normal GOROOT, go build -overlay also works, but an overlay cannot replace files in a toolchain that lives in the module cache.
GOOS=freebsd GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -o umtxspin_stock .
GOOS=freebsd GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -o umtxspin_fix . # with the patched runtimeThen we ran it on the board (as root):
for m in idle pp; do for b in stock fix; do
cpuset -l 1 env GOMAXPROCS=2 ./umtxspin_$b -mode $m -d 15s
done; done
for b in stock fix; do
cpuset -l 1 env GOMAXPROCS=2 ktrace -t c -f k_$b.ktr ./umtxspin_$b -mode idle -d 3s
kdump -H -T -f k_$b.ktr > k_$b.kdump
grep -c 'UMTX_OP_WAIT_UINT_PRIVATE>,0xffffffffffffffff' k_$b.kdump # sign-extended val
grep -c 'UMTX_OP_WAIT_UINT_PRIVATE>,0xffffffff,' k_$b.kdump # zero-extended val
doneWhat did you see happen?
The results below are from 15 s runs on CPU 1 with GOMAXPROCS=2. The only difference between "stock" and "fix" is the one instruction in runtime·sys_umtx_op.
| mode | runtime | 2 ms sleep: p50 / p99 / p99.9 / max | sleeps > 50 ms | ping-pong round trips | utime / stime | nvcsw / nivcsw |
|---|---|---|---|---|---|---|
| pp (4 pairs) | stock | 2.00 / 70.9 / 394.7 / 568.9 ms | 36 | 2,172,580 | 10.17 s / 4.69 s | 1225 / 2117 |
| pp (4 pairs) | fix | 2.00 / 22.0 / 98.1 / 120.3 ms | 22 | 3,608,179 (+66 %) | 14.79 s / 0.03 s | 2041 / 2864 |
| idle | stock | 2.15 / 3.39 / 11.26 / 96.3 ms | 2 | - | 0.24 s / 0.36 s | 18522 / 320 |
| idle | fix | 2.15 / 3.31 / 7.25 / 13.7 ms | 0 | - | 0.18 s / 0.16 s | 16478 / 52 |
Under ktrace -t c, the idle mode ran for 3 s:
| runtime | syscalls (top) | UMTX_OP_WAIT_UINT_PRIVATE with val 0xffffffffffffffff |
with val 0xffffffff |
|---|---|---|---|
| stock | 7947 _umtx_op, 2077 nanosleep, 1246 kevent, 1245 fstatat, 87 mmap, 76 sched_yield | 3591. These came in 5 bursts; the longest was 1348 calls in 47 ms, all returning 0 | 0 |
| fix | 4715 _umtx_op, 2090 nanosleep, 1282 kevent, 1281 fstatat, 121 sched_yield, 87 mmap | 0 | 1. It slept until the matching WAKE |
The stock trace looks like this. The same thread and address repeat, each call returns 0 immediately, and a sched_yield precedes each burst. That sched_yield is lock2's osyield() step before semasleep(-1).
10974 101910 umtxspin_stock 1790669710.647332668 CALL _umtx_op(0xcf23e530,0xf<UMTX_OP_WAIT_UINT_PRIVATE>,0xffffffffffffffff,0x18,0)
10974 101910 umtxspin_stock 1790669710.647350168 RET _umtx_op 0
10974 101910 umtxspin_stock 1790669710.647367085 CALL _umtx_op(0xcf23e530,0xf<UMTX_OP_WAIT_UINT_PRIVATE>,0xffffffffffffffff,0x18,0)
10974 101910 umtxspin_stock 1790669710.647384293 RET _umtx_op 0
10974 101910 umtxspin_stock 1790669710.647402084 CALL _umtx_op(0xcf23e530,0xf<UMTX_OP_WAIT_UINT_PRIVATE>,0xffffffffffffffff,0x18,0)
10974 101910 umtxspin_stock 1790669710.647420459 RET _umtx_op 0
With the fix, the same wait carries 0xffffffff and actually sleeps until another thread wakes it:
10992 101761 umtxspin_fix 1790669716.751420225 CALL _umtx_op(0x1ab9c8,0xf<UMTX_OP_WAIT_UINT_PRIVATE>,0xffffffff,0x18,0)
10992 101913 umtxspin_fix 1790669716.751449100 CALL _umtx_op(0x1ab9c8,0x10<UMTX_OP_WAKE_PRIVATE>,0x1,0,0)
10992 101913 umtxspin_fix 1790669716.751471558 RET _umtx_op 0
10992 101761 umtxspin_fix 1790669716.751491683 RET _umtx_op 0
Tracing slows each spin iteration to about 35 µs. Untraced, the loop runs at roughly 1.5 M calls/s, measured with vmstat in the original application.
What did you expect to see?
We expected futexsleep(addr, val, -1) with *addr == val to block in the kernel until futexwakeup, as it does on freebsd/amd64. We did not expect bursts of immediate _umtx_op returns, and we expected system time near zero for the ping-pong case.
Cause
runtime·sys_umtx_op in sys_freebsd_arm64.s loads its uint32 val argument like this:
MOVW val+12(FP), R2
On arm64, a MOVW load is LDRSW, which sign-extends to 64 bits. The stock binary contains b98017e2 = ldrsw x2, [sp, #20].
FreeBSD declares the argument as u_long (syscalls.master: int _umtx_op(void *obj, int op, u_long val, void *uaddr1, void *uaddr2)), and the arm64 kernel copies x0..x7 into the argument array unchanged. For UMTX_OP_WAIT_UINT and UMTX_OP_WAIT_UINT_PRIVATE, __umtx_op_wait_uint_private() calls do_wait(td, uap->obj, uap->val, tm_p, 1, 1) with compat32 = 1 (L3979). do_wait() then zero-extends the 32-bit word it reads and compares that with the full 64-bit val:
static int
do_wait(struct thread *td, void *addr, u_long id,
struct _umtx_time *timeout, int compat32, int is_private)
{
u_long tmp;
uint32_t tmp32;
...
} else {
error = fueword32(addr, &tmp32);
if (error == 0)
tmp = tmp32;
...
}
umtxq_lock(&uq->uq_key);
if (error == 0) {
if (tmp == id)
error = umtxq_sleep(uq, "uwait", ...);
...
return (error); /* 0 when the value "differs" */So whenever val has bit 31 set, the kernel sees 0x00000000ffffffff != 0xffffffffffffffff, returns 0 without sleeping, and the runtime treats the return as a spurious wake-up.
Since the spinbit mutex landed, that is the normal case for semasleep on futex platforms (lock_futex.go):
for v := atomic.Xadd(&mp.waitsema, -1); ; v = atomic.Load(&mp.waitsema) {
if int32(v) >= 0 {
return 0
}
futexsleep(&mp.waitsema, v, ns) // v == 0xffffffff
...
}lock2 (lock_spinbit.go) calls semasleep(-1) after its spin phase. Every M that has to wait for a contended runtime mutex therefore busy-loops in and out of the kernel until the holder's semawakeup, instead of sleeping. Futex waits with small values, such as notes (val 0), are unaffected, which is why everything still works functionally and the tests pass.
The damage is worst when the waiter and the lock holder share a CPU. Our reading of the application's kernel stacks is as follows. The holder was preempted with sched.lock held; in our case this happened on the return from exitsyscall's wake-up of sysmon. The waiter was sysmon, which had been sleeping and so had the better ULE priority, and it spun on the CPU until the scheduler demoted it. That produced the 1.1-3.7 s stalls.
Since when
- The sign-extending load has been there since the freebsd/arm64 port was added (CL 198544, Go 1.14).
- It was harmless until Go 1.24. Before that, the futex lock waited with values 0/1/2 and notes with 0, and none of those has bit 31 set.
- CL 622996 ("runtime: allow futex OSes to use sema-based mutex", runtime: improve scaling of lock2 #68578) added the
m.waitsemasemasleepshown above. In go1.24.0 the spinbit mutex is on by default on arm64 (SpinbitMutex: haveXchg8). - By source inspection, then, freebsd/arm64 has been affected since go1.24.0. We measured go1.26.8 only.
Proposed fix
Zero-extend with MOVWU, as the amd64 version effectively already does. There, MOVL val+12(FP), DX writes a 32-bit register, which clears the upper 32 bits (sys_freebsd_amd64.s#L52).
--- a/src/runtime/sys_freebsd_arm64.s
+++ b/src/runtime/sys_freebsd_arm64.s
@@ -52,7 +52,7 @@
TEXT runtime·sys_umtx_op(SB),NOSPLIT,$0
MOVD addr+0(FP), R0
MOVW mode+8(FP), R1
- MOVW val+12(FP), R2
+ MOVWU val+12(FP), R2
MOVD uaddr1+16(FP), R3
MOVD ut+24(FP), R4
MOVD $SYS__umtx_op, R8
--- a/src/runtime/sys_freebsd_riscv64.s
+++ b/src/runtime/sys_freebsd_riscv64.s
@@ -51,7 +51,7 @@
TEXT runtime·sys_umtx_op(SB),NOSPLIT,$0
MOV addr+0(FP), A0
MOVW mode+8(FP), A1
- MOVW val+12(FP), A2
+ MOVWU val+12(FP), A2
MOV uaddr1+16(FP), A3
MOV ut+24(FP), A4
MOV $SYS__umtx_op, T0- arm64: tested on hardware (the "fix" rows above). The instruction changes from
b98017e2 ldrsw x2, [sp, #20]tob94017e2 ldr w2, [sp, #20], which zero-extends. - riscv64: we have not run it (see below). It builds, and the instruction changes from
5246 c.lwsp a2, 20(sp)(sign-extending) to01416603 lwu a2, 20(sp).
Other ports
- freebsd/riscv64: very likely affected, not tested. The wrapper has the same
MOVW val+12(FP), A2, which islw/c.lwspand sign-extends on RV64. The FreeBSD riscv64 kernel also copiesa0..a7unchanged into theu_longargument, anddo_wait()is shared code. Two things limit this conclusion. First, riscv64 only reachesm.waitsemasemasleepsince go1.25.0: CL 658456 made the spinbit mutex unconditional (except on wasm), while in go1.24 riscv64 was not inhaveXchg8and still used the tristate lock. Second, we have no riscv64 hardware, and the port is currently marked broken (build: freebsd/riscv64 port is broken #76475). - freebsd/amd64: not affected, because
MOVLzero-extends. - freebsd/386 and freebsd/arm: not affected, because they are 32-bit and
u_longis 32 bits. The same holds for 32-bit binaries on a 64-bit kernel, where the compat32 syscall layer passes 32-bit values. - linux/arm64: not affected, even though
runtime·futexuses the sameMOVW val+12(FP), R2. Linux declares the futexvalasu32, and the syscall wrapper truncates it. - Other freebsd/arm64 wrappers: we checked the other
MOVWargument loads. The only other one where the kernel type is 64-bit ismmap'soff(uint32loaded into anoff_t). The runtime always passes 0 there, so it is harmless.
A regression test could call futexsleep(&v, 0x80000000, 50e6) with v == 0x80000000 (through an export_test.go hook) and check that it waits about 50 ms instead of returning at once. We have not written that test.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity
rhysh commentedon Sep 29, 2026
So the 64-bit freebsd kernel has native support for 64-bit waits, and when the program asks it to wait on a 32 bit value it loads only those 32 bits from memory (zeroing the upper 32 bits of its copy) but compares against the full 64 bits of the register.
But because the program loaded the register via a sign-extending move (arm64's MOVW rather than MOVWU), it ends up with a mismatch if the upper 32 bits of the register have become ones rather than matching the zeros in the memory that the kernel loaded.
CC @golang/freebsd