Skip to content

runtime: futex waits never sleep on freebsd/arm64 because sys_umtx_op sign-extends val #81849

Open

Description

@mateuszchrobok

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 runtime

Then 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
done

What 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.waitsema semasleep shown 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] to b94017e2 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) to 01416603 lwu a2, 20(sp).

Other ports

  • freebsd/riscv64: very likely affected, not tested. The wrapper has the same MOVW val+12(FP), A2, which is lw/c.lwsp and sign-extends on RV64. The FreeBSD riscv64 kernel also copies a0..a7 unchanged into the u_long argument, and do_wait() is shared code. Two things limit this conclusion. First, riscv64 only reaches m.waitsema semasleep since go1.25.0: CL 658456 made the spinbit mutex unconditional (except on wasm), while in go1.24 riscv64 was not in haveXchg8 and 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 MOVL zero-extends.
  • freebsd/386 and freebsd/arm: not affected, because they are 32-bit and u_long is 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·futex uses the same MOVW val+12(FP), R2. Linux declares the futex val as u32, and the syscall wrapper truncates it.
  • Other freebsd/arm64 wrappers: we checked the other MOVW argument loads. The only other one where the kernel type is 64-bit is mmap's off (uint32 loaded into an off_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.

Activity

  1. rhysh commented on Sep 29, 2026

    @rhysh
    Contributor

    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

  2. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.OS-FreeBSDcompiler/runtimeIssues related to the Go compiler and/or runtime.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions