Skip to content

Avoid NOTE_ABSOLUTE timers for UPTIME/MONOTONIC on FreeBSD/OpenBSD.#943

Open
max-potapov wants to merge 1 commit into
swiftlang:mainfrom
max-potapov:freebsd-absolute-time-timers
Open

Avoid NOTE_ABSOLUTE timers for UPTIME/MONOTONIC on FreeBSD/OpenBSD.#943
max-potapov wants to merge 1 commit into
swiftlang:mainfrom
max-potapov:freebsd-absolute-time-timers

Conversation

@max-potapov
Copy link
Copy Markdown

@max-potapov max-potapov commented May 22, 2026

Motivation

libdispatch programs every EVFILT_TIMER with NOTE_ABSOLUTE on every
platform, computing an absolute deadline value via
_dispatch_time_now_cached and passing it to kqueue with
NOTE_NSECONDS | NOTE_ABSOLUTE (= NOTE_ABSTIME on BSD).

On FreeBSD / OpenBSD only the WALL clock works that way: kqueue with
NOTE_NSECONDS | NOTE_ABSTIME interprets the data as a
CLOCK_REALTIME nanosecond absolute deadline (FreeBSD subtracts the
boot offset internally; OpenBSD documents the realtime semantics
directly). That matches the value libdispatch already computes for
DISPATCH_CLOCK_WALL, so wall timers behave correctly and continue
to track wall-clock adjustments.

For DISPATCH_CLOCK_UPTIME / DISPATCH_CLOCK_MONOTONIC, libdispatch
hands kqueue a CLOCK_MONOTONIC nanosecond value that BSD kqueue
interprets as realtime — decades in the past after boot. kqueue
reports the timer as already-expired on every register, so the
dispatch event loop fires the timer block immediately, re-arms the
timer, fires it again, and so on. Observed on a long-running
HummingbirdCore HTTP server using Task.sleep as ~48 000 kevent()/s
with one CPU core pinned to ~100 % even with no real work pending.

PRs #879 and #931 corrected the NOTE_NSECONDS scaling for the
relative-time path on FreeBSD but did not touch the absolute-time
deadline computation this PR addresses.

Modifications

For os(FreeBSD) and os(OpenBSD), split the per-clock flag
selection so that NOTE_ABSOLUTE is kept for DISPATCH_CLOCK_WALL
(which works correctly on BSD) but dropped for
DISPATCH_CLOCK_UPTIME and DISPATCH_CLOCK_MONOTONIC. Introduce
DISPATCH_NOTE_ABSOLUTE_<kind> macros next to the existing
DISPATCH_NOTE_CLOCK_<kind> ones, and have
_dispatch_timer_index_to_fflags use them.

In _dispatch_event_loop_timer_arm, when running on BSD with a
non-WALL clock, convert the absolute deadline back to a relative
delay by subtracting the cached now. Using target -= now rather
than the simpler target = range.delay preserves any leeway already
folded into target by _dispatch_timers_force_max_leeway above
(range.leeway has been zeroed by that point, so an overwrite would
silently drop the addition and break LIBDISPATCH_TIMERS_FORCE_MAX_LEEWAY).

The change is guarded behind #if defined(__FreeBSD__) || defined(__OpenBSD__) and is a no-op on every other platform.

Result

Reproduced on FreeBSD 15.0-RELEASE-p9 with the official Apple Swift
FreeBSD preview toolchain (Swift 6.3-dev) by running the same Swift
binary
(HummingbirdCore HTTP server + a single periodic
Task.sleep(for: .seconds(3600)) loop, no real probes/work) against
both upstream and patched libdispatch.so via LD_LIBRARY_PATH:

=== upstream libdispatch ===
%CPU   RSS COMMAND
37.9 39736 /tmp/netwatch-exporter
syscall                     seconds   calls  errors
_umtx_op                3.999564140      16       0
kevent                  0.716818577  117827       0

=== patched libdispatch ===
%CPU   RSS COMMAND
 1.0 39820 /tmp/netwatch-exporter
syscall                     seconds   calls  errors
_umtx_op                3.999207012      16       0
kevent                  1.999276981      10       0

Same binary, same machine, same Swift toolchain — only
/opt/swift/lib/swift/freebsd/libdispatch.so swapped. kevent rate
drops by ~11 000×; CPU drops from one core pinned to ~idle.

In production (a Hummingbird-based Prometheus exporter on FreeBSD 15)
the same swap took the service from 70-98 % CPU down to 0.0 %.

Checks

  • Existing dispatch_timer* tests (dispatch_timer,
    dispatch_timer_short, dispatch_timer_timeout,
    dispatch_timer_set_time, dispatch_timer_bit31,
    dispatch_timer_bit63) pass on FreeBSD with both upstream
    and patched libdispatch. (They exercise the user-facing
    dispatch_after / DispatchSource APIs which complete before the
    busy loop can settle, so they don't independently detect the
    underlying tight-loop bug.)
  • Wall-clock semantics preserved on BSD:
    dispatch_after(dispatch_walltime(NULL, 2s)) returns at 2.001 s
    on the patched build, identical to upstream.
  • Uptime semantics preserved on BSD:
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2s)) returns
    at 2.001 s on the patched build.
  • All changes guarded behind #if defined(__FreeBSD__) || defined(__OpenBSD__); no behaviour change on Apple/Linux.
  • LIBDISPATCH_TIMERS_FORCE_MAX_LEEWAY semantics preserved —
    the relative-time conversion (target -= now) keeps any leeway
    already folded into target above.

Notes

  • No new failing-then-passing C unit test in this PR: the bug
    requires the Swift Concurrency cooperative executor + a concurrent
    NIO event loop to manifest, which is awkward to express against
    libdispatch's existing test suite without depending on the Swift
    runtime. The two smoke tests above (walltime / uptime
    dispatch_after) document the per-clock split and could be folded
    into tests/ if maintainers want them landed — happy to add as a
    follow-up commit.
  • A "deeper" root-cause fix would probably articulate the BSD kqueue
    NOTE_NSECONDS|NOTE_ABSTIME clock semantics in
    _dispatch_time_now_cached itself (so uptime/monotonic values get
    converted to realtime before being handed to kqueue). This PR
    intentionally takes the narrower route — sidestepping the absolute
    path for the two affected clocks — because it keeps the change
    small, self-contained, and lets users with broken
    Task.sleep-on-FreeBSD ship code today.

### Motivation:

libdispatch programs every EVFILT_TIMER with NOTE_ABSOLUTE on every
platform, computing an absolute deadline value via
`_dispatch_time_now_cached` and passing it to kqueue with
`NOTE_NSECONDS | NOTE_ABSOLUTE` (= `NOTE_ABSTIME` on BSD).

On FreeBSD/OpenBSD only the WALL clock works that way — kqueue
interprets the `NOTE_NSECONDS|NOTE_ABSTIME` value as CLOCK_REALTIME
nanoseconds, which matches what libdispatch computes for
`DISPATCH_CLOCK_WALL`.

For UPTIME / MONOTONIC, libdispatch passes a CLOCK_MONOTONIC
nanosecond value that BSD kqueue still interprets as realtime
(decades in the past after boot). kqueue reports the timer as
already-expired on every register, so the dispatch event loop fires
the timer block immediately, re-arms the timer, fires it again, and
so on. Observed on a long-running HummingbirdCore HTTP server using
`Task.sleep` as ~48 000 kevent()/s with one CPU core pinned to
~100 % even with no real work pending.

PRs swiftlang#879 and swiftlang#931 corrected the NOTE_NSECONDS scaling for the
relative-time path on FreeBSD but did not touch the absolute-time
deadline computation this PR addresses.

### Modifications:

For `os(FreeBSD)` and `os(OpenBSD)`, split the per-clock flag
selection so NOTE_ABSOLUTE is kept for DISPATCH_CLOCK_WALL (which
works correctly on BSD) but dropped for DISPATCH_CLOCK_UPTIME and
DISPATCH_CLOCK_MONOTONIC. Introduce `DISPATCH_NOTE_ABSOLUTE_<kind>`
macros next to the existing `DISPATCH_NOTE_CLOCK_<kind>` ones, and
have `_dispatch_timer_index_to_fflags` use them.

In `_dispatch_event_loop_timer_arm`, when running on BSD with a
non-WALL clock, convert the absolute deadline back to a relative
delay by subtracting the cached `now`. This preserves any leeway
already folded into `target` by `_dispatch_timers_force_max_leeway`
above (a simpler `target = range.delay` would silently drop that
addition because `range.leeway` has been zeroed by that point).

The change is guarded behind `#if defined(__FreeBSD__) ||
defined(__OpenBSD__)` and is a no-op on every other platform.

### Result:

Reproduced on FreeBSD 15.0-RELEASE-p9 with the official Apple Swift
FreeBSD preview toolchain (Swift 6.3-dev) by running the same Swift
binary (HummingbirdCore HTTP server + a single periodic
`Task.sleep(for: .seconds(3600))` loop, no real probes/work) against
both upstream and patched libdispatch.so via LD_LIBRARY_PATH:

```
=== upstream libdispatch ===
%CPU   RSS COMMAND
37.9 39736 /tmp/netwatch-exporter
syscall                     seconds   calls  errors
_umtx_op                3.999564140      16       0
kevent                  0.716818577  117827       0

=== patched libdispatch ===
%CPU   RSS COMMAND
 1.0 39820 /tmp/netwatch-exporter
syscall                     seconds   calls  errors
_umtx_op                3.999207012      16       0
kevent                  1.999276981      10       0
```

Same binary, same machine, same Swift toolchain — only
`/opt/swift/lib/swift/freebsd/libdispatch.so` swapped. kevent rate
drops by ~11 000×; CPU drops from one core pinned to ~idle.

In production (a Hummingbird-based Prometheus exporter on FreeBSD 15)
the same swap took the service from 70-98 % CPU down to 0.0 %.

Existing `dispatch_timer*` tests (`dispatch_timer`,
`dispatch_timer_short`, `dispatch_timer_timeout`,
`dispatch_timer_set_time`, `dispatch_timer_bit31`,
`dispatch_timer_bit63`) pass on FreeBSD with both upstream and
patched libdispatch — they exercise the user-facing dispatch_after /
DispatchSource APIs which complete before the busy loop can settle,
so they do not detect the underlying bug. Two smoke tests
documenting the per-clock split also pass cleanly on the patched
build:

```
$ ./dispatch_timer_walltime
dispatch_after(walltime + 2.0s) returned after 2.001s
PASS

$ ./dispatch_timer_uptime
uptime test: dispatch_after(NOW + 2s) returned after 2.001s
PASS
```

These two C-level reproducers are tiny and could be folded into the
existing tests/ directory if maintainers want them landed — happy to
add as a follow-up commit.
@max-potapov max-potapov force-pushed the freebsd-absolute-time-timers branch from caa5570 to 9c26068 Compare May 22, 2026 23:18
@max-potapov max-potapov changed the title Avoid NOTE_ABSOLUTE timers on FreeBSD/OpenBSD. Avoid NOTE_ABSOLUTE timers for UPTIME/MONOTONIC on FreeBSD/OpenBSD. May 22, 2026
@max-potapov max-potapov marked this pull request as ready for review May 22, 2026 23:24
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