perf(worker): unpark awaited control-frame writers from the winning drain pass
WriteAsync enqueued its frame and then contended unconditionally for the write lock, so a caller that lost the race stayed in WaitAsync until the winning drainer released — even though that winner writes, flushes, and completes the loser's control frame at the control-to-event class boundary, part-way through its pass. The boundary flush made the delivery point honest; the awaited task was still charged for the whole event backlog it had just been flushed ahead of. WriteAsync now awaits its own frame's completion racing the lock acquisition. Completion first: the caller returns at its frame's delivery point and the outstanding acquisition is detached, not dropped — a continuation drains whatever is queued and releases, so the lock is never acquired and silently held and a frame enqueued between the previous drainer's last dequeue and its release is still written. Lock first: drain as before. Cancellation keeps the WRK-22 tombstone semantics exactly, and a wait cancelled after the caller has already detached releases nothing (SemaphoreSlim hands no count to a wait it cancels), so no count leaks and no queued frame is stranded. A token that fires after the frame's completion won the race changes nothing — the frame was delivered. WriteBatchAsync deliberately keeps the plain wait-then-drain shape: its last completion resolves at the end-of-pass flush anyway. Three tests: the latency win (a control caller returning while the winning WriteBatchAsync event burst is demonstrably still blocked mid-pass), a mixed-priority concurrency soak pinning exactly-once writes and a single drainer, and the cancel-after-detach corner (a wrongly released count would surface as the drainer's own Release throwing SemaphoreFullException). edited on macOS, windev verification pending (plan Task 11). Verified here by compiling and running WorkerFrameWriter plus the writer suite against net10.0 in a scratch harness: 31/31 pass, and the two behaviour-pinning tests fail against the pre-change parked implementation.
This commit is contained in:
@@ -171,13 +171,33 @@ oversized event) surfaces from the batch's awaited completions as that frame's
|
||||
`WorkerFrameProtocolException`; the remaining completions are still observed
|
||||
so none faults unobserved.
|
||||
|
||||
The completion is the frame's delivery point, not necessarily the instant its
|
||||
caller returns. A caller that loses the race for the write lock only observes
|
||||
its own completion after the winning drainer releases the lock, so its return
|
||||
remains bounded by that drain pass even though its control frame was flushed
|
||||
and completed at the class boundary inside it. The boundary flush is what
|
||||
makes the delivery point honest; unparking a lock-race loser from the winner's
|
||||
pass would be a separate change to the enqueue-then-contend shape.
|
||||
The completion is the frame's delivery point, and a `WriteAsync` caller now
|
||||
returns at it. The boundary flush alone only made the delivery point honest:
|
||||
a caller that lost the race for the write lock still sat in the lock wait
|
||||
until the winning drainer released it, so its awaited task was charged for the
|
||||
whole event backlog its control frame had just been flushed ahead of. To close
|
||||
that, `WriteAsync` awaits its own frame's completion *racing* the lock
|
||||
acquisition instead of the acquisition alone. Whichever settles first decides:
|
||||
|
||||
- **Completion first** — the winning drainer wrote and flushed this frame at
|
||||
the class boundary, so the caller returns immediately. The lock acquisition
|
||||
it leaves outstanding is *detached*, not dropped: a continuation drains
|
||||
whatever is queued and then releases, so the lock is never acquired and
|
||||
silently held, and a frame enqueued between the previous drainer's last
|
||||
dequeue and its release is still written by someone. Draining an empty queue
|
||||
is a no-op, so the common case is acquire-nothing-release.
|
||||
- **Lock first** — the caller drains the pass itself, exactly as before.
|
||||
- **Cancellation** — the wait ends without the lock (`SemaphoreSlim` hands no
|
||||
count to a wait it cancels, so the detached continuation releases nothing on
|
||||
that path) and the tombstone rules below apply unchanged. A token that fires
|
||||
*after* the frame's completion won the race changes nothing: the frame was
|
||||
delivered, and the caller returns normally.
|
||||
|
||||
`WriteBatchAsync` deliberately keeps the plain wait-then-drain shape. A batch
|
||||
caller's result is its whole set of completions and the last of those resolves
|
||||
at the end-of-pass flush — the instant before the drainer releases the lock —
|
||||
so racing the acquisition would buy it nothing while adding one detached
|
||||
acquisition per call.
|
||||
|
||||
Cancellation of a `WriteAsync`/`WriteBatchAsync` call that is still waiting
|
||||
for the write lock when its token fires tombstones the queued frame: the
|
||||
|
||||
Reference in New Issue
Block a user