Repository navigation
Release the task queue lock while running cancel handlers - #28047
Conversation
aeedef9 to
8b60e22
Compare
There was a problem hiding this comment.
Code Review
This pull request updates em_task_queue_cancel in system/lib/pthread/em_task_queue.c to unlock the queue's mutex before executing the task's cancel handler and lock it again afterward, preventing potential deadlocks if the handler blocks. There are no review comments, so I have no feedback to provide.
|
@sbc100 Release build:
Build with -fsanitize=address -sASSERTIONS=2
|
|
Thanks @clementperon for tracking down this flakiness and fixing it. @tlively does the fix looks reasonable? |
|
Seems good directionally, but Gemini found a problem. If there is a re-entrant Gemini suggests this additional test for that problem (edit: new test that does not depend on proxying queue internals): #include <assert.h>
#include <dlfcn.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdio.h>
#define NUM_ITERATIONS 100
static pthread_barrier_t barrier;
static _Atomic bool stop = false;
// Worker thread that repeatedly starts and immediately exits while dlopen()s occur
static void* exiting_worker(void* arg) {
pthread_barrier_wait(&barrier);
// Thread exits immediately while dynamic link synchronization calls are in-flight
return NULL;
}
// Loader thread that continuously dlopen()s libraries to generate pending dlsync tasks
static void* loader_thread(void* arg) {
pthread_barrier_wait(&barrier);
while (!stop) {
void* handle = dlopen("libside.so", RTLD_NOW | RTLD_GLOBAL);
assert(handle && "dlopen failed");
dlclose(handle);
}
return NULL;
}
int main() {
pthread_barrier_init(&barrier, NULL, 2);
pthread_t loader;
pthread_create(&loader, NULL, loader_thread, NULL);
for (int i = 0; i < NUM_ITERATIONS; ++i) {
pthread_t worker;
pthread_create(&worker, NULL, exiting_worker, NULL);
pthread_join(worker, NULL);
}
stop = true;
pthread_join(loader, NULL);
pthread_barrier_destroy(&barrier);
printf("PASSED\n");
return 0;
}Side module (libside.c): int side_func() {
return 42;
}AI explanation of the test (I haven't vetted it):
|
|
I deep dive into this and indeed it can happen, seems that a blocking cancellation handler can re-enter the dlopen queue and execute the remaining task. |
tlively
left a comment
There was a problem hiding this comment.
Can you add the test as well?
|
@tlively fine for you ? |
em_task_queue_cancel ran cancel handlers with the queue lock held. A handler can block, and a blocked thread processes its dlopen queue, which can be the queue being cancelled: a thread exiting during dlopen() then deadlocks on its own lock. em_task_queue_execute already unlocks around tasks. See emscripten-core#18887.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
956f02c to
5bfd001
Compare
|
@tlively is anything still needed here? We have a change that depends on this one (#28176), so here's some extra data. With #28176's parallel sync on One caveat: The codesize expectations in this PR also conflict with current |
tlively
left a comment
There was a problem hiding this comment.
Thanks for the ping. Yes, this LGTM.
|
(We can land it after the code size tests are updated) |
- Print `done` to signal success, following emscripten-core#26926. - Rename NUM_ITERATIONS to NUM_SIDE_MODULES (and `iterations` to `num_libs` in the runner) since it is the number of libsideN.so copies, and document why the worker and loader loop counts must match. - Pass EXIT_RUNTIME and PROXY_TO_PTHREAD via `cflags=`. - Drop top-level `static` keywords, following emscripten-core#27873. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the codesize expectation conflicts: main only changed the JS sizes (emscripten-core#28141) and this branch only changed the wasm sizes, so take the JS sizes from main, the wasm sizes from this branch, and recompute the totals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
27d8571 to
90ad2fc
Compare
The previous version kept only one short-lived thread alive at a time and almost never hit the bug. Instead, keep several threads creating and exiting threads while main dlopens the side modules, so that threads regularly exit with dlsync tasks still queued. Without the fix this aborts with "pthread mutex deadlock detected" in em_task_queue_execute in roughly 30% of runs; with the fix it passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@rustyconover I confirm that this test rarely trigger the issue I got a hit around 1/1600. So it's too weak to be reliable. New one is ~29/100 @sbc100 I have push your remarks but I had to force push to rewrite some of my emails. Sorry about that. |
|
@sbc100 about moving the test to stress_test, I'd rather keep it in test_core. The new version reproduces the deadlock in a single run (~27% at -O0), so it doesn't need the stress harness, and test_core runs it in every configuration on each CI run. It also relies on the dylink helpers (build_dlfcn_lib/prep_dlfcn_main) that only exist in test_core, and it sits alongside the related test_pthread_dlopen* tests there. |
| } | ||
|
|
||
| void em_task_queue_cancel(em_task_queue* queue) { | ||
| queue->processing = 1; |
There was a problem hiding this comment.
processing is documented as only accessed on the target thread. I assume that em_task_queue_cancel is only ever called the target thread?
Should we assert(pthread_self() == queue->thread) here? Or is that overkill?
em_task_queue_cancelruns cancel handlers with the queue lock held. A handler can block (cancel_ctxproxies back to the sender), and a blocked thread processes its dlopen queue from_emscripten_yield. When that is the queue being cancelled, a thread exiting duringdlopen()deadlocks on its own lock:Related to #18887.
em_task_queue_executealready releases the lock around each task; this does the same for cancel handlers, and marks the queue as processing during cancellation so a re-entrantem_task_queue_executecannot run the remaining tasks instead of cancelling them.test_pthread_dlopen_exitkeeps several threads creating and exiting threads while maindlopen()s 100 side modules. 100 runs each:-O0-O2