Skip to content

reactor: handoffs from other threads share one eventfd write per loop pass, as posts already did - #387

Merged
MDA2AV merged 1 commit into
mainfrom
fix/96-wake-coalescing
Oct 23, 2026
Merged

MDA2AV merged 1 commit into
mainfrom
fix/96-wake-coalescing

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Fixes #96.

Only the post queue coalesced its eventfd wakes. Every other handoff from another thread wrote the eventfd once per item, a write(2) apiece:

  • a buffer return (EnqueueReturnQ, EnqueueReturnQIncremental)
  • a flush (EnqueueFlush)
  • a recycle (EnqueueRecycle)
  • a client op or timer (HandOff)

A handler that stays on the reactor never reaches any of them. One that leaves it, such as code after a ConfigureAwait(false) await or a ring client called from the thread pool, pays one per return, flush and op.

The post queue's flag is now the reactor's, _wakePending. Every handoff ends in Wake(), which writes the eventfd only when it is the one to set the flag. Each loop pass clears the flag first, before any queue is drained. That is the same Interlocked.Exchange that #321 put in DrainPostQ, moved earlier in the pass, so the reactor still pays one locked exchange per pass and nothing more. The ordering argument is unchanged from #321: every drain now comes after the clear, so a handoff that lands mid-drain either is drained or sets the flag again and wakes the next pass. Stop() still writes unconditionally, because it has to wake a parked loop whatever the flag says.

No new state: the post flag is renamed and moved. The diff in the core is +25/−13.

Tests

New, in AffinityTests:

  • affinity: handoffs from another thread share one eventfd write until the loop's next pass

It uses a reactor that never runs, whose wake is an eventfd of the test's own, so the counter it reads back is the number of writes. The test makes 100 of each of the six handoffs (return, incremental return, flush, recycle, client op, post), then a clear as a pass would, then two more handoffs.

five queues writing directly (main's producers) this PR
writes for the 600 handoffs 501 1
writes after the next pass's clear 1

Changed: the #321 race test drains the way a pass now does, ClearWakePending and then DrainPostQ, and reaches the flag under its new name.

No public API change.

All suites pass: Unit 102, E2E 312, Chaos 80, Http 88, Tls 169 (+14 skipped), File 5, Pg 7 (+11 skipped), Redis (6 skipped, 1 pending). The skips need servers this box doesn't run.

Measured

Setup:

  • 2 reactors on two P-cores, wrk -t4 -c64 on other cores.
  • Every round runs each build back to back, in rotated order, against origin/main.
  • A second worktree of main is the noise control.
  • Figures are the median of the per-round ratios.
  • write(2) per request comes from /proc/PID/io (syscw). The eventfd wakes are the only writes these servers make.
workload rounds write(2)/req, main → this PR req/s CPU/req control, main vs main
off-reactor handler 5 × 10 s 2.00 → 0.052 +7.2% −6.9% −1.8% / +0.1%
Tcp/Raw 10 × 10 s 0 → 0 −0.1% +0.6% −0.4% / +0.7%
Tcp/Hop (posts, already coalesced) 10 × 10 s 0 → 0 +0.6% −0.8% 0.0% / 0.0%

Every round of the off-reactor workload agreed, at +6.5% to +10.7% req/s. Its handler is Tcp/Raw's, except that after each read it resumes on the thread pool and stays there. Its buffer return and its flush therefore go through EnqueueReturnQ and EnqueueFlush, the path a handler takes when it awaits library code with ConfigureAwait(false):

RecvSnapshot snapshot = await conn.ReadAsync();
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
while (conn.TryGetItem(snapshot, out SpscRecvRing.Item item))
{
    if (item.HasBuffer) conn.ReturnBuffer(in item);
}
conn.Write(response);
await conn.FlushAsync().ConfigureAwait(false);

… pass, as posts already did

Only the post queue coalesced its wakes. A buffer return, a flush, a recycle or a client op handed
over from another thread each wrote the eventfd, a syscall per item for a handler that hops threads.

The post queue's flag is now the reactor's (_wakePending): every handoff wakes through Wake(), which
writes only when it is the one to set the flag, and each loop pass clears it first, before any queue
is drained - the exchange DrainPostQ made since #321, moved to the top of the pass, so the loop still
pays one per pass. Stop() still writes unconditionally: it must wake a parked loop whatever the flag
says.

Test (AffinityTests): on a reactor that never runs, whose wake is the test's own eventfd, 100 of each
of the six handoffs write it once, and after a pass's clear the next ones write it once more.
Confirmed both ways: with the five other queues writing directly again, the 600 handoffs make 501
writes. The post-vs-drain race test now drains the way a pass does: ClearWakePending, then DrainPostQ.
@MDA2AV
MDA2AV merged commit 1e5d920 into main Oct 23, 2026
1 check passed
@MDA2AV MDA2AV mentioned this pull request Oct 24, 2026
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.

coalesce eventfd wakes across all cross-thread queues (only _postQ coalesces today)

1 participant