Skip to content

feat: prepare shared code for cooperative single-threaded event backends - #953

Open
scottmarchant wants to merge 1 commit into
swiftlang:mainfrom
PassiveLogic:feat/prepare-cooperative-event-backend
Open

feat: prepare shared code for cooperative single-threaded event backends#953
scottmarchant wants to merge 1 commit into
swiftlang:mainfrom
PassiveLogic:feat/prepare-cooperative-event-backend

Conversation

@scottmarchant

Copy link
Copy Markdown

Summary

This PR prepares shared code for platforms that run libdispatch on one thread, with no worker threads. It adds two things: no-op hook macros around specific critical sections, and one shared main-queue drain function. It makes no functional change on any current platform. Linux builds and test results are identical before and after this change.

Context

WebAssembly (wasm32-wasip1) runs libdispatch on a single thread. That platform has no way to create a worker thread. A port for it therefore uses a cooperative event backend: when work is enqueued and nothing is running, the backend runs the work immediately, on the thread that enqueued it.

The series targets single-threaded WASI first. We validated the multi-threaded triple (wasm32-wasip1-threads) separately: there libdispatch takes the ordinary threaded shape, and the seams in this PR compile to no-ops, so this preparation serves both modes.

We built and tested that port as a series of small, independent changes. This PR is the first change in the series. Later changes add the WASI build system, the cooperative event backend, the Swift overlay, and file-descriptor and signal event sources. The later changes touch shared files only inside wasm-only blocks; they do not change code that other platforms compile.

You do not need the later changes to review this one. The two edits here are self-contained. Each is safe on its own, and each is required before a cooperative backend can exist.

What changes

1. Poke-defer hooks. The macros _dispatch_cooperative_pokes_defer() and _dispatch_cooperative_pokes_undefer() compile to ((void)0) on every current platform. A cooperative event backend defines the real versions and declares its functions when it arrives; this PR pre-stages no declarations. The hooks bracket each critical section that can enqueue work while it holds an internal lock. Each site carries a one-line comment; this list is the long form:

  • the dispatch_sync and dispatch_barrier_sync inline funnels: on the inline paths, the client callout runs with the queue's barrier lock held
  • both dispatch_async_and_wait funnels, including the private-data block entry that Swift's asyncAndWait(execute:) produces: the invoke can run inline with the acquired width or barrier held
  • _dispatch_barrier_trysync_or_async_f: the invoke runs with the barrier lock held
  • dispatch_once initializers: the initializer runs with the once gate held, and a drained item that re-enters the same dispatch_once would crash where threaded platforms simply wait
  • object dispose: dispose submits detached work (queue-specific destructor batches) while the object is partially torn down
  • the specifics-hash mutation in dispatch_queue_set_specific: the destructor push must not run the client destructor under dqsh_lock

On a threaded platform, a poke only wakes another worker, so the hooks change nothing. On a cooperative backend, the hooks defer the inline execution until the outermost section exits. Without the hooks, the enqueued work would run under the caller's lock. A program that is correct on every threaded platform would then deadlock.

2. A shared main-queue drain. _dispatch_main_queue_drain moves out of DISPATCH_COCOA_COMPAT into its own guard. The two runloop-only steps stay gated under DISPATCH_COCOA_COMPAT: the runloop-handle initialization, and the thread-QoS override propagation. A cooperative backend can then drain the thread-bound main queue through the same code the CFRunLoop callback uses. The alternative is a 63-line divergent copy, which we had, and which drifts. The new guard also names defined(__wasi__): that arm compiles nothing in this PR, and it is the seam the cooperative WASI backend in the next change compiles the shared drain through.

Why this is safe

  • The hook macros compile away on every current platform.
  • The sync funnel split (an _impl function plus a wrapper) is always-inline. The compiler folds it back together.
  • The hoisted drain compiles only where it compiled before. The compiled result on Darwin is unchanged, because both gated steps sit under a macro that is always true there.
  • A Linux container build shows main and this branch produce identical results: both pass the same 23 of 23 upstream tests.
  • The complete cooperative backend, built on these exact seams, passes 52 of 52 tests on the combined branch (feat: Full combined set of changes to add Wasm support to libDispatch PassiveLogic/swift-corelibs-libdispatch#3). That suite includes a regression test for each hook site.

What we ask from reviewers

The public macOS CMake build of this repository does not currently configure, so we cannot compile the DISPATCH_COCOA_COMPAT path ourselves. We ask a committer to trigger @swift-ci please test, and we ask for confirmation that the run compiles the DISPATCH_COCOA_COMPAT path. That coverage is the main risk this PR carries, and it is the reason we send this small change first.

Some platforms run libdispatch on one thread, with no worker threads.
An event backend for such a platform cannot wake a worker when work is
enqueued. Instead, a poke may run the enqueued work immediately, on the
thread that enqueued it. This commit prepares the shared code for that
execution model. It makes no functional change on any current platform.

Part 1: poke-defer hooks. The macros
_dispatch_cooperative_pokes_defer() and
_dispatch_cooperative_pokes_undefer() compile to ((void)0) unless a
cooperative event backend defines the real versions. The hooks bracket
each critical section that can enqueue work while it holds an internal
lock:

- the dispatch_sync and dispatch_barrier_sync inline funnels
- both dispatch_async_and_wait funnels
- _dispatch_barrier_trysync_or_async_f
- dispatch_once initializers (the once gate is held)
- object dispose (destructor batches are enqueued during teardown)
- the specifics-hash mutation in dispatch_queue_set_specific

On a threaded platform, a poke only wakes another worker, so the hooks
change nothing. On a cooperative backend, the hooks defer the inline
execution until the outermost section exits. Without them, enqueued
work would run under the caller's lock and deadlock.

Part 2: a shared main-queue drain. _dispatch_main_queue_drain moves out
of DISPATCH_COCOA_COMPAT into its own guard. The two runloop-only steps
(the runloop-handle initialization and the thread-QoS override
propagation) stay gated under DISPATCH_COCOA_COMPAT. A cooperative
backend can then drain the thread-bound main queue through the same
code the CFRunLoop callback uses, instead of a divergent copy. The
compiled result on Darwin is unchanged. Platforms that define neither
macro compile neither version, as before.

Co-authored-by: Krzysztof Rodak <krodak.konta@gmail.com>
@akmorrison

Copy link
Copy Markdown
Member

I’m not sure I understand why you’d need to bracket critical sections, I’m vaguely aware from the other pr that you want to run asyncs inline if you don’t hold any locks, but why? Is it a perf thing or a correctness thing? I’m guessing that there are cases where you can wait on future async work, and those go really poorly on a single threaded executor, and this is a way to try to patch around code written that does that?

e.g., if you async(q, ^{}) then sync(q, ^{}), that deadlocks if you don’t have worker threads, and inlining the first async removes the deadlock

however, if you sync(q1, ^{ async(q2, ^{}); sync(q2, ^{}); });, even under this scheme you’re going to deadlock.

my vote would be to crash code that can’t work on single threaded executors early instead of working around a subset of their bugs by doing weird ordering stuff, is that untenable for some reason? Like, replace every slow path futex lock with an abort.

@scottmarchant

Copy link
Copy Markdown
Author

Hi @akmorrison, thanks so much for the quick response! I'll post replies for each question below. Just some quick context, this PR is the first slice of a series. The complete port lives on our fork as a single reviewable branch, PassiveLogic/swift-corelibs-libdispatch#3, with the event backend, a 52-test suite, and the design records. The questions here are about the execution model of that backend, so I'll include link to its tests and docs where they answer a point directly.

We weren't sure if the libDispatch reviewer's preferred a single large PR or smaller PRs. We opted for a series of smaller ones. But if seeing the full context in a single PR would make the intentions easier, happy to bring everything into one PR for all of you.

@scottmarchant

Copy link
Copy Markdown
Author

I'm not sure I understand why you'd need to bracket critical sections

The brackets protect libdispatch's own locks, not user code. Each hooked site is an internal critical section: the sync funnels' barrier lock, the once gate, object dispose, and the specifics hash under dqsh_lock. With eager pokes and no brackets, a dispatch_once initializer that calls dispatch_async would run client code under an internal lock. That code is correct on every threaded platform. The hooks only defer those pokes until the section exits. The regression test for the sync and dispose sites explains the failure each bracket prevents: sync-nested-async.c#L21-L33.

@scottmarchant

Copy link
Copy Markdown
Author

Is it a perf thing or a correctness thing? I'm guessing that there are cases where you can wait on future async work, and those go really poorly on a single threaded executor, and this is a way to try to patch around code written that does that?

Correctness. The main hosts are wasm modules where the embedder calls exports, and dispatch_main() may never run. Nothing can wake a worker, and no later drain point is guaranteed. So a top-level poke runs the pending work on the spot. Work submitted from inside a running item defers to the outer drain. The port pins this as specified behavior, with the ordering rules and the group consequence documented in the test itself: eager-drain.c#L21-L37.

@scottmarchant

Copy link
Copy Markdown
Author

however, if you sync(q1, ^{ async(q2, ^{}); sync(q2, ^{}); });, even under this scheme you're going to deadlock.

This case does not deadlock in the port. The inner sync is contended, so it parks. A blocking wait issued from a caller-held section pumps: it drains the queued block on q2 first, then the sync acquires and runs. The suite pins the harder shape sync(qa){ async(qb){ sync(qa) } }: sync-nested-async.c#L56-L61. WAIT-PUMPING-AUDIT.md lists every pumping site and its two invariants: the waiter holds no internal locks while parked, and nested waits never pump.

@scottmarchant

scottmarchant commented Aug 26, 2026

Copy link
Copy Markdown
Author

my vote would be to crash code that can't work on single threaded executors early instead of working around a subset of their bugs by doing weird ordering stuff, is that untenable for some reason? Like, replace every slow path futex lock with an abort.

The port does crash early where progress is impossible, and the suite enforces it. An indefinite wait issued from inside a drained work item crashes at once: nested-wait.c#L29-L33 runs in crash mode and must print the diagnostic named in CMakeLists.txt#L54-L55. An indefinite wait that nothing can satisfy gets the same treatment, and so do fd-source failure modes that would otherwise hang or spin. The port never hangs and never spins silently.

Timed waits are the boundary of that policy. A timed wait keeps exact platform semantics: it consumes its full timeout and then reports timed out, and it must not return early just because there is one thread: blocking-waits.c#L21-L29. That holds even inside a drained work item, where the wait does not pump and does not crash. It sleeps to its own deadline and reports timeout: nested-timed-wait.c#L41-L48. So code with deadline and fallback logic behaves the same as on threaded platforms. We crash only where the requested semantics are impossible.

An abort on every slow-path lock would go further and abort your first example too. That sync takes the slow path because the queue is not empty, and on one thread that is normal for correct code. It would also abort the semaphore and group waits that timers and fd sources satisfy. The goal of the series is to run existing dependency code unmodified. The combined branch passes 52 of 52 tests, and 13 of those are unmodified upstream libdispatch tests.

This PR only adds the no-op seams. The pump-or-crash policy arrives with the event backend PR, where it is testable.

@scottmarchant

Copy link
Copy Markdown
Author

Hope this helps. Please let me know if I can answer any more questions or help with this. Our company is excited to help move Wasm support forward, and adding libDispatch support for Wasm would unlock compilation for quite a bit of code out there.

Feel free to reply here. Or if it is easier or faster, I'm also available as @Scott Marchant in the Swift Open Source slack group.

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.

2 participants