Observed Effect
When running a gtest test suite inside WASM on my codebase some tests seem to spuriously hang forever.
Environment
|
|
| emsdk |
6.0.5 (1db513782be24469589d7cb8a1f1834e9a33f271) |
| Target |
wasm64 (-m64), pthreads |
| Runtime |
Node.js v26.5.1, main() on the Node main thread (no PROXY_TO_PTHREAD) |
| Host |
Linux 6.8, x86-64, 32 cores |
Analysis of the root cause of the deadlock
Summary
Emscripten arms an Atomics.waitAsync on every pthread's struct and never cancels it. When the thread exits, the struct is freed but the async waiter stays registered on that address for the rest of the process. malloc hands the memory out again. musl wakes a contended lock with Atomics.notify(word, 1), waiters are woken in FIFO order, and the leftover async waiter is ahead of the blocked thread so it absorbs the notify. The notify reports that it woke a waiter, and the thread parked on the word is never released.
The mechanism
src/lib/libpthread.js, _emscripten_thread_mailbox_await:
var wait = Atomics.waitAsync(HEAP32, pthread_ptr / 4, pthread_ptr);
wait.value.then(checkMailbox);
This is how a thread gets told about mailbox messages without blocking. There is no way to cancel an Atomics.waitAsync. The promise only settles on a notify or a timeout, and no timeout is passed here. PThread.returnWorkerToPool frees the struct without ever notifying that address, so every thread that exits leaks exactly one permanently armed waiter.
On the other side, musl's unlock() (in pthread_cond_timedwait.c, which is what a contended std::condition_variable runs through) and pthread_mutex_unlock both wake with a count of 1:
static inline void unlock(volatile int *l) {
if (a_swap(l, 0) == 2) __wake(l, 1, 1); // -> Atomics.notify(l, 1)
}
Per spec, Atomics.notify wakes waiters in FIFO order and does not distinguish sync from async ones. The stale async waiter was registered first, so it is the one that gets woken, and it is counted in the return value. The real waiter stays parked forever.
Evidence
Instrumenting emscripten_futex_wait/emscripten_futex_wake with a ring buffer and a 2 s watchdog caught this in the original reproducer:
#54221 tid=694630 wait-enter a=2 b=2 waiter enters wait32(addr, expect 2), memory holds 2
#54222 tid=6cdd50 wake-call a=1 b=0 unlocker already stored 0, calls notify(addr, 1)
#54225 tid=6cdd50 wake-done a=1 b=0 notify RETURNED 1 — it claims it woke a waiter
#54247 tid=694630 wait-leave a=2 b=0 that waiter returns 2 = TIMED OUT, 2 s later
The address is not arbitrary. Logging every address passed to Atomics.waitAsync and comparing it with the stuck address:
=== STUCK tid=0x7073e0 addr=0x621710 expected=2 now=0 ===
stuck addr = 0x621710
occurrences in the waitAsync arm log: 1 (out of 62 distinct addresses)
The word the program deadlocks on is the struct of a thread that already exited. In the uninstrumented build it is consistently 0x21690, which is the same address as the lock of my data structure.
Reproducer
emcc -m64 -pthread -O2 -o dl.js RawFutexDeadlock.c && node dl.js
RawFutexDeadlock.c
#include <emscripten.h>
#include <emscripten/threading.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
static _Atomic int* word;
static _Atomic int parked = 0;
static _Atomic int woken = 0;
static void* nothing(void* arg) { return NULL; }
static void* waiter(void* arg) {
atomic_store(&parked, 1);
// This is what musl's lock() does when it finds the word at 2 ("locked, with
// waiters"): block until somebody stores something else and notifies.
__builtin_wasm_memory_atomic_wait32((int*)word, 2, -1 /* no timeout */);
atomic_store(&woken, 1);
return NULL;
}
int main(void) {
// 1. Run a thread to completion. Its struct is freed, but the async waiter
// Emscripten armed on it is still registered.
pthread_t dead;
pthread_create(&dead, NULL, nothing, NULL);
pthread_join(dead, NULL);
printf("thread ran and exited, its struct was at %p\n", (void*)dead);
// 2. Get that memory back from malloc and use a word inside it as a lock.
void* block = malloc(1024);
if ((uintptr_t)dead < (uintptr_t)block || (uintptr_t)dead >= (uintptr_t)block + 1024) {
printf("malloc returned %p, which does not cover the old struct -- inconclusive\n", block);
return 77;
}
word = (_Atomic int*)(void*)dead;
printf("malloc handed back %p; using %p as a lock word\n", block, (void*)word);
// 3. A thread takes the lock word and blocks on it.
atomic_store(word, 2);
pthread_t w;
pthread_create(&w, NULL, waiter, NULL);
while (!atomic_load(&parked)) {}
emscripten_thread_sleep(300); // let it actually reach the wait
// 4. Release the lock the way musl's unlock() does.
atomic_store(word, 0);
int n = __builtin_wasm_memory_atomic_notify((int*)word, 1);
printf("released the word and notified 1 waiter; notify reported %d woken\n", n);
emscripten_thread_sleep(2000);
if (atomic_load(&woken)) {
printf("waiter woke up -- no deadlock\n");
return 0;
}
printf("DEADLOCK: notify reported %d woken, but the parked thread is still blocked 2s later\n", n);
return 1;
}
Fix and workarounds
Release the waiter before the memory is recycled, in PThread.returnWorkerToPool:
Atomics.notify(HEAP32, pthread_ptr / 4); // add this
__emscripten_thread_free_data(pthread_ptr);
Observed Effect
When running a gtest test suite inside WASM on my codebase some tests seem to spuriously hang forever.
Environment
1db513782be24469589d7cb8a1f1834e9a33f271)-m64), pthreadsmain()on the Node main thread (noPROXY_TO_PTHREAD)Analysis of the root cause of the deadlock
Summary
Emscripten arms an
Atomics.waitAsyncon every pthread's struct and never cancels it. When the thread exits, the struct is freed but the async waiter stays registered on that address for the rest of the process.mallochands the memory out again. musl wakes a contended lock withAtomics.notify(word, 1), waiters are woken in FIFO order, and the leftover async waiter is ahead of the blocked thread so it absorbs the notify. The notify reports that it woke a waiter, and the thread parked on the word is never released.The mechanism
src/lib/libpthread.js,_emscripten_thread_mailbox_await:This is how a thread gets told about mailbox messages without blocking. There is no way to cancel an
Atomics.waitAsync. The promise only settles on a notify or a timeout, and no timeout is passed here.PThread.returnWorkerToPoolfrees the struct without ever notifying that address, so every thread that exits leaks exactly one permanently armed waiter.On the other side, musl's
unlock()(inpthread_cond_timedwait.c, which is what a contendedstd::condition_variableruns through) andpthread_mutex_unlockboth wake with a count of 1:Per spec,
Atomics.notifywakes waiters in FIFO order and does not distinguish sync from async ones. The stale async waiter was registered first, so it is the one that gets woken, and it is counted in the return value. The real waiter stays parked forever.Evidence
Instrumenting
emscripten_futex_wait/emscripten_futex_wakewith a ring buffer and a 2 s watchdog caught this in the original reproducer:The address is not arbitrary. Logging every address passed to
Atomics.waitAsyncand comparing it with the stuck address:The word the program deadlocks on is the struct of a thread that already exited. In the uninstrumented build it is consistently
0x21690, which is the same address as the lock of my data structure.Reproducer
emcc -m64 -pthread -O2 -o dl.js RawFutexDeadlock.c && node dl.jsRawFutexDeadlock.cFix and workarounds
Release the waiter before the memory is recycled, in
PThread.returnWorkerToPool: