Skip to content

Read the loop time without a syscall on epoll and io_uring when libc is linked - #256

Open
endel wants to merge 1 commit into
mitchellh:mainfrom
endel:libc-clock
Open

endel wants to merge 1 commit into
mitchellh:mainfrom
endel:libc-clock

Conversation

@endel

@endel endel commented Oct 23, 2026

Copy link
Copy Markdown

On Linux with libc linked, every update_now() on epoll and io_uring is a clock_gettime syscall, where it should be a vDSO call.

Both backends call std.os.linux.clock_gettime, which uses the vDSO once std.os.linux.getauxval(AT_SYSINFO_EHDR) finds it. On Zig 0.17.0 that getauxval reads an auxiliary-vector pointer that only Zig's own _start records, so under libc's startup it finds nothing and every read falls back to the syscall (ziglang/zig#36203, fixed after 0.17.0). That covers every program using the C API, and libxev's own tests.

kqueue already reads posix.system.clock_gettime: libc's when libc is linked, and std.os.linux's otherwise, where Zig's startup does record the vector. This does the same in epoll and io_uring.

We hit this in a game server on quic-zig (Linux aarch64 in a VM, epoll, glibc linked, 512 clients). perf stat counted 1,584 clock_gettime syscalls a second, all from update_now, against 950 epoll_pwait: a quarter of the process's syscalls. With this change update_now makes none.

Test

"epoll, io_uring: loop time is read without a syscall where libc's clock is": a thread installs a seccomp filter, on itself alone, that fails clock_gettime syscalls with EPERM. It then calls update_now() on an epoll loop and an io_uring loop, and checks that each one's time lies between two libc reads taken around the calls. It is skipped if the filter can't tell the two paths apart (libc's read refused too, or std.os.linux's not refused), and runs only where libc is linked, which is how zig build test builds on Linux.

Reverting either backend's change makes it fail. io_uring's had a second problem: it passed std.os.linux's raw return value to posix.errno, which under libc only treats -1 as a failure, so a failed read was taken as a success and the uninitialized timespec stored. With posix.system the two agree again.

  • Linux (aarch64, epoll and io_uring): 144 passed, 3 skipped
  • macOS (aarch64, kqueue): zig build test, 59 passed (the test is Linux-only)
  • zig build -Demit-example -Demit-bench, and zig build for every target in the CI matrix

CI on main fails until #250 moves the workflow to Zig 0.17, so this PR's checks will fail the same way until then.

🤖 Generated with Claude Code

https://claude.ai/code/session_015YcSvZdqwMncezgbb64ihg

std.os.linux.clock_gettime finds the vDSO through an auxiliary vector
that Zig 0.17.0 records only in its own startup code, so under libc's
every update_now was a clock_gettime syscall. kqueue already reads
posix.system's clock.

Assisted-by: Claude Opus 5.5
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.

1 participant