Conversation
A backtrace cannot find where an async task is stuck after the fact: when a future returns Poll::Pending its poll stack unwinds, and the await chain lives on in the compiler-generated state machine, which no unwinder can walk. Shuttle worked around this by capturing eagerly inside its own leaf futures, so a user future that returns Pending on its own terms reported nothing but the spawn or block_on call site. The hook with the right timing is the waker. A future returning Pending is obliged to arrange a wakeup, and the ordinary way is cx.waker().clone() -- which runs inside the future's own poll, on the live stack, through a vtable we own. Capture there and it works for arbitrary user futures, not just ours. Add the await_backtrace module: note_waker_clone is called from the waker vtable's clone, take_captured is called by the driver loops once poll has returned Pending. Two guards keep it honest. PollGuard marks the extent of a driver-loop poll, so clones made by the executor itself are not mistaken for await sites. InternalBlockOnGuard marks the block_on that BatchSemaphore::acquire_blocking uses to implement the synchronous Mutex and RwLock: a task parked there keeps its whole call chain on its coroutine stack, so it is captured lazily on deadlock instead. That is the hot path, and skipping it is what keeps the flag cheap. This subsumes the special-case capture in Acquire::poll, which is removed: that site clones the waker, so the generic hook already covers it. Returning None from take_captured when no clone happened is deliberate. A future may skip the clone when it already holds an equivalent waker; the deadlock handler then falls back to its own capture rather than printing a stale backtrace from an earlier, unrelated park. Deadlock reports now name the exact user frame for all four shapes: synchronous primitives, spawned futures, block_on in a thread task, and arbitrary user futures with no Shuttle primitive involved. Cost on the 4-thread mutex workload with the flag set: 975ms -> ~32ms, indistinguishable from the flag being off. Async debug runs get more expensive, because the hook fires on every waker clone within a poll rather than once per Pending: the future:: test module goes from 535s to 1154s with the flag set. Capturing only the first clone per poll would roughly halve that, but would pick an arbitrary branch under select!.
The await-site machinery was unconditional: PollGuard ran on every poll and take_captured did a thread-local RefCell borrow on every Poll::Pending, whether or not SHUTTLE_CAPTURE_BACKTRACE was set. Only the capture itself was behind the flag. That put work on the path every async test pays, which is the same path #322 had just finished clearing. Read backtrace_enabled() once outside each driver loop and skip the guard and the take entirely when it is false. Also inline note_waker_clone so the flag check on the waker-clone path collapses to a load and a branch. Measured on the counter async benches with the flag unset, against unmodified main: before pct-narrow -2.1% (ns), random-narrow +0.80% (p=0.00), pct-wide -0.56% (ns), random-wide +3.70% (p=0.01) after pct-narrow -3.51% (p=0.00), random-narrow +1.53% (ns), pct-wide -2.08% (p=0.00), random-wide -1.18% (p=0.00) So the regression is gone, and three of four are now slightly ahead of main: gating also skips the ExecutionState::with that main performed unconditionally on every Pending poll in JoinHandle::poll, and the Acquire::poll site this series removes outright. With the flag set, behaviour is unchanged: all four blocking shapes still resolve to the user's own frame, and the mutex workload still costs 30.2ms against 29.6ms with the flag unset.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #352 — review/merge that one first. This PR is based on
lazy-deadlock-backtraces; retarget it tomainonce that lands.A backtrace fundamentally cannot find where an async task is stuck after the fact: when a future returns
Poll::Pendingitspollstack unwinds, and the await chain lives on in the compiler-generated state machine, which no unwinder can walk.Shuttle worked around this by capturing eagerly inside its own leaf futures (
Acquire::poll,JoinHandle::poll). That covers Shuttle primitives and nothing else — a user future that returnsPendingon its own terms reported nothing but thespawnorblock_oncall site.How
The hook with the right timing is the waker. A future returning
Pendingis contractually obliged to arrange a wakeup, and the ordinary way iscx.waker().clone()— which runs inside the future's ownpoll, on the live stack, through a vtable we own. Capture there and it works for arbitrary user futures.New
await_backtracemodule:note_waker_clone()— called from the waker vtable'sclonetake_captured()— called by the driver loops oncepollhas returnedPendingPollGuard— marks the extent of a driver-looppoll, so clones made by the executor itself (e.g.Task::waker()) are not mistaken for await sitesInternalBlockOnGuard— marks theblock_onthatBatchSemaphore::acquire_blockinguses to implement the synchronousMutex/RwLock. A task parked there keeps its whole call chain on its coroutine stack, so it is captured lazily on deadlock instead. This is the hot path, and skipping it is what keeps the flag cheap.This subsumes and removes the special-case capture in
Acquire::poll— that site clones the waker, so the generic hook already covers it.take_captured()returningNonewhen no clone happened is deliberate: a future may skip the clone when it already holds an equivalent waker, and falling back to the lazy path is better than printing a stale backtrace from an earlier, unrelated park.Results
Deadlock reports now name the exact user frame, with line numbers, for all four shapes:
user_fn_locksuser_sem_inner,user_sem_outerblock_onin a thread taskuser_sem_inner,user_sem_outerMyFuture::poll,user_arb_inner,user_arb_outerExample — a hand-written future awaiting something never produced (Shuttle-internal frames trimmed for readability):
Before this change, that task reported only frame 4.
Cost
4-thread mutex workload; 5 samples, same machine and run:
SHUTTLE_CAPTURE_BACKTRACE=1mainSo synchronous blocking now pays essentially nothing for the flag.
Async debug runs get more expensive, and I want that explicit: the hook fires on every waker clone within a poll rather than once per
Pending. With the flag set, thefuture::test module goes from 362 s to 626 s (16.6 s with the flag unset).I chose fidelity over speed — keeping the last clone's stack is correct under
select!/join!, where several children clone in one poll. Capturing only the first clone per poll would roughly halve the cost but pick an arbitrary branch. Happy to flip that if reviewers prefer.With the flag unset there is no regression. The second commit on this branch makes that true: the first version left
PollGuardandtake_capturedoutside the flag check, which cost ~1-4% on the default async path.counter asyncbenches with the flag unset, against unmodifiedmain:Three of four now come out slightly ahead of
main, because gating also skips theExecutionState::withthatmainperforms unconditionally on everyPendingpoll inJoinHandle::poll, plus theAcquire::pollsite this series removes outright.Testing
cargo fmt --check,cargo clippy --workspace --all-targets,cargo docall cleancargo test --release -p shuttle: 426 passed, 0 failedfuture::(81) andbasic::(356)Known limitation
A future that returns
Pendingwithout cloning the waker (skipping it via awill_wakecheck when it already holds an equivalent one) is still invisible. It falls back to the lazy path, which for a future-polling task yields the bare poll loop.Follow-up, not in this PR
format_for_deadlockuses{:#?}, which selects std'sDebug: one long{ fn: .., file: .., line: .. }record per frame, wrapped inSome(, with absolute paths. Switching to{}(std'sDisplay) gives the numberedat file:lineform thatRUST_BACKTRACE=1prints and shrinks the message ~7%. Independent of the capture mechanism, so I left it out.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.