Skip to content

Sync threads in parallel when dlopen runs on the main runtime thread - #28176

Open
rustyconover wants to merge 2 commits into
emscripten-core:mainfrom
rustyconover:dlopen-parallel-sync
Open

rustyconover wants to merge 2 commits into
emscripten-core:mainfrom
rustyconover:dlopen-parallel-sync

Conversation

@rustyconover

@rustyconover rustyconover commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #28047. Without that fix, the new test hangs in about 5% of stress runs (73 of 1,500), so it can flake in CI until #28047 lands.

Problem

When dlopen runs on the main runtime thread, _emscripten_dlsync_threads waits for each thread in turn. The threads load the new side module one after another, so dlopen gets slower with every running thread, and a thread that is slow to yield holds up all the others.

Change

  • Parallel sync: ask every thread to sync, then wait for all of them. dlsyncThreadsAsync already works this way for dlopen on a worker, but its await can't be used on this path, which has to block.
  • Guard in proxying.c: do_proxy can allocate with the queue's mutex held, and a contended malloc can yield into emscripten_proxy_execute_queue, which takes the same mutex again. The code already guards against this for the system queue; this covers every queue. The existing async path has the same exposure.

Results

Time to LOAD six extensions in a DuckDB-wasm based app (Haybarn), threaded build, median of 4–10 runs. These used a backport to 6.0.13 that includes #28047. Startup time didn't change.

Setup Before After Change
Linux arm64, 48 cores, Chromium, extensions prefetched 1,169 ms 632 ms −46%
Same, pinned to 4 cores 1,251 ms 716 ms −43%
Same as first row, no prefetch 2,094 ms 1,260 ms −40%
Apple Silicon Mac, Chrome, real network, prefetched 350 ms 221 ms −37%
Apple Silicon Mac, Firefox, real network, prefetched 895 ms 651 ms −27%

Testing

These ran on aarch64 Linux with Node 24.19.0, and all but the mode run below were on main at 7c2d477, with #28047 applied:

  • New test: test_pthread_dlopen_parallel_sync has two threads that don't yield waiting on a third that must sync first, so it hangs on the old loop. An earlier version of the test was checked against the old loop and did hang there.
  • Stress: 0 hangs in 3,000 runs of the new test. The 6.0.13 backport also had 0 in 3,000.
  • Modes: the new test, test_pthread_dlopen and Release the task queue lock while running cancel handlers #28047's test_pthread_dlopen_exit pass in core0, core2, corez, asan, lsan, wasm64 and core_2gb. This exact branch, without Release the task queue lock while running cancel handlers #28047 and on current main, passes the first two in core0, core2, corez, wasm64 and core_2gb.
  • Sweep: across the dylink, dlopen and pthread tests, 10 of 993 tests fail. All 10 also fail on unmodified main (test_pthread_wait_suspending* and test_pthread_join_and_asyncify in core2 and asan).
  • Guard: random stress never hit this deadlock. With a yield forced at that point in get_or_add_tasks_for_thread, 5 of 100 runs hung without the guard and 0 of 100 with it. That injection can't be committed, so there's no regression test for the guard. If you'd rather avoid allocating under the lock, or see this as its own PR, I can do either.

Not run locally: the browser test suite.

Known limitation

If memory runs out and the completion can't be sent back to the main thread, dlsync_pending is never decremented and the wait hangs. The old synchronous path didn't allocate at that point. The async path doesn't appear to resolve its promise in that case either.

Conflicts

This overlaps #28007 and #28056 on the same lines. Whichever lands second needs a mechanical rebase.

🤖 Generated with Claude Code

_emscripten_dlsync_threads waited for each thread in turn, so the threads
loaded a new side module one after another and a thread that was slow to
yield held up all the others. Ask every thread to sync first, then wait for
all of them, as dlsyncThreadsAsync already does for dlopen on a worker.

The completion callbacks add traffic on _dlopen_proxying_queue. do_proxy can
allocate with that queue's mutex held, and a contended malloc can yield into
emscripten_proxy_execute_queue, which takes the mutex again. Guard every
queue against that, not only the system queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea of doing this in parallel seems perfectly fine.

The changes to proxying.c might need some deeper scrutiny though since that subsystem is very fragile/important/suble. @tlively will likely need to be involved the review of that.

@rustyconover
rustyconover marked this pull request as ready for review November 5, 2026 18:21

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors thread synchronization during dlopen to occur in parallel rather than sequentially, preventing potential deadlocks when some threads are busy. It replaces _emscripten_proxy_dlsync with _emscripten_proxy_dlsync_post and _emscripten_proxy_dlsync_wait, and addresses a potential deadlock in proxying.c during memory allocation with a queue's mutex held. A new test is added to verify parallel synchronization. Feedback on the new test file recommends explicitly including <emscripten.h> for emscripten_get_now() and removing the unused done variable.

Comment on lines +7 to +13
#include <assert.h>
#include <dlfcn.h>
#include <emscripten/em_asm.h>
#include <emscripten/threading.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function emscripten_get_now() is used in exiting_thread but <emscripten.h> is not explicitly included. Although it might be transitively included by other headers, it is best practice to explicitly include <emscripten.h> to ensure the declaration is always available and to prevent compilation issues if transitive headers change.

#include <assert.h>
#include <dlfcn.h>
#include <emscripten.h>
#include <emscripten/em_asm.h>
#include <emscripten/threading.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

Comment on lines +15 to +17
_Atomic int loading;
_Atomic int loaded_on_thread;
_Atomic int done;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The done variable is declared but never used in this test file. It should be removed to keep the code clean and avoid compiler warnings about unused variables.

_Atomic int loading;
_Atomic int loaded_on_thread;

Comment on lines +118 to +120
// Adding a task can allocate with the queue's mutex held, and a contended
// malloc can get here from emscripten_yield. Taking the mutex again would
// deadlock this thread, so leave the queue to be executed later.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately delaying the queue execution to later can result in missed wakeups and new deadlocks, so we'll need a different solution here. I will work on this and upload a PR today.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robust deadlock fix at #28186.

This branch has not been deployed

No deployments
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.

3 participants