Skip to content

[Proxying] Do not allocate while holding mutex - #28186

Open
tlively wants to merge 5 commits into
mainfrom
proxying-no-alloc-under-lock
Open

tlively wants to merge 5 commits into
mainfrom
proxying-no-alloc-under-lock

Conversation

@tlively

@tlively tlively commented Nov 6, 2026

Copy link
Copy Markdown
Member

Previously, do_proxy held q->mutex across get_or_add_tasks_for_thread, which called realloc and malloc. If an allocation contended on the malloc lock, it could call emscripten_futex_wait -> _emscripten_yield -> emscripten_proxy_execute_queue, attempting to re-lock q->mutex on the same thread (or blocking another thread trying to execute the queue).

PR #24565 worked around the same-thread deadlock for the system proxying queue by setting system_queue_in_use = true during do_proxy and skipping emscripten_proxy_execute_queue when set. However, that left non-system queues (including user queues and _dlopen_proxying_queue) vulnerable to both same-thread and cross-thread deadlocks. Skipping emscripten_proxy_execute_queue during a contended malloc inside emscripten_futex_wait could also consume NOTIFY_BIT without executing the system queue, causing subsequent waits to hang due to a lost wakeup.

Fix both issues by moving q->mutex acquisition into get_or_add_tasks_for_thread and performing all allocations and frees outside the lock, re-checking whether another thread (or re-entrant execution) added the task queue or grew the array while the lock was released. This allows system_queue_in_use to be reverted back to executing_system_queue as a pure recursion guard in emscripten_proxy_execute_queue.

Previously, `do_proxy` held `q->mutex` across `get_or_add_tasks_for_thread`, which called realloc and malloc. If an allocation contended on the malloc lock, it could call `emscripten_futex_wait` -> `_emscripten_yield` -> `emscripten_proxy_execute_queue`, attempting to re-lock `q->mutex` on the same thread (or blocking another thread trying to execute the queue).

PR #24565 worked around the same-thread deadlock for the system proxying queue by setting `system_queue_in_use = true` during `do_proxy` and skipping `emscripten_proxy_execute_queue` when set. However, that left non-system queues (including user queues and `_dlopen_proxying_queue`) vulnerable to both same-thread and cross-thread deadlocks. Skipping `emscripten_proxy_execute_queue` during a contended `malloc` inside `emscripten_futex_wait` could also consume `NOTIFY_BIT` without executing the system queue, causing subsequent waits to hang due to a lost wakeup.

Fix both issues by moving `q->mutex` acquisition into `get_or_add_tasks_for_thread` and performing all allocations and frees outside the lock, re-checking whether another thread (or re-entrant execution) added the task queue or grew the array while the lock was released. This allows `system_queue_in_use` to be reverted back to `executing_system_queue` as a pure recursion guard in `emscripten_proxy_execute_queue`.

@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 the pthread proxying queue implementation in system/lib/pthread/proxying.c to prevent deadlocks during memory allocation. Specifically, it modifies get_or_add_tasks_for_thread to perform allocations outside of the queue's mutex lock, preventing re-entrant locking issues when malloc or realloc triggers queue execution. Additionally, the deadlock test suite in test/pthread/test_pthread_proxy_deadlock.c has been significantly expanded and parameterized in test/test_core.py to cover multiple deadlock and lost-wakeup scenarios. There are no review comments, so I have no feedback to provide.

static bool do_proxy(em_proxying_queue* q, pthread_t target_thread, task t) {
assert(q != NULL);
pthread_mutex_lock(&q->mutex);
bool is_system_queue = q == &system_proxying_queue;

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.

Can we keep is_system_queue.. even though it only has a single use? That keeps the diff smaller and make this condition easier to read ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

Comment thread test/test_core.py Resolved
Comment thread test/test_core.py Outdated
def test_pthread_proxy_deadlock(self):
self.do_runf('pthread/test_pthread_proxy_deadlock.c')
@parameterized({
'system_queue': (['-DTEST_SYSTEM_QUEUE'],),

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.

I like to always keep the first variant names with the empty string. Is there one of these that can be considered the "default" mode?

That way one can always run the test as names in the source code (e.g. ./test/runner core0.test_pthread_proxy_deadlock actually works).

@tlively tlively Nov 6, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, we can make the first one the default.

return tasks;
}

static _Thread_local bool executing_system_queue = false;

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.

So executing_system_queue is like the old system_queue_in_use but a more narrow meaning?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, and in fact this is just going back to the name and functionality we had before #24565.

static _Thread_local bool wait_for_worker_in_malloc = false;
static _Atomic int in_malloc = 0;
static _Atomic int worker_proxied = 0;
static _Atomic int task_done = 0;

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.

Drop all these static keywords if you can.

pthread_t worker;
int rc = pthread_create(&worker, NULL, lost_wakeup_worker_main, &main_thread);
assert(rc == 0);
while (!worker_proxied) {

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.

Can you use a pthread_barrier_wait here instead, or would that interfere with the test?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Using pthread_barrier_wait here would interfere with the test: when the first thread to reach pthread_barrier_wait waits for the other thread, it calls emscripten_futex_wait, which calls _emscripten_yield -> emscripten_proxy_execute_queue and consumes NOTIFY_BIT (executing task) before the main thread reaches emscripten_proxy_async(..., worker, nop, NULL). Added a comment explaining why this spins.

@tlively
tlively enabled auto-merge (squash) November 6, 2026 22:55
pthread_mutex_unlock(&q->mutex);
if (tasks != NULL) {
return tasks;
}

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.

this if (get_tasks_for_thread) return tasks; pattern also seem to occur at the start of the loop below? Perhaps we somehow avoid duplicating up here?

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.

2 participants