Repository navigation
Threads that exit unjoined leak their stack and Worker while the main thread is blocked #27840
Description
A thread that exits without being joined on the main runtime thread (a detached thread, or one that another thread joins) only gets its memory freed and its Worker returned to the pool when the main thread's event loop handles a CMD_CLEANUP_THREAD message. A main thread that blocks instead (in emscripten_futex_wait(), pthread_cond_wait(), a pthread_join() on something else, or a loop that waits for work on a futex as a native-style event loop does) never handles it. Each such thread then keeps its whole block (stack, TLS and struct pthread), and its Worker stays out of the pool, so the next pthread_create() starts a new Worker. A program with a thread pool whose workers exit after an idle timeout leaks a stack and a Worker per thread until it runs out of memory.
This is the case described in #16763 ("recycling detached threads requires the main thread to be running its event loop"). The workarounds given there (join every thread on the main thread, PROXY_TO_PTHREAD, a main loop) don't fit code that blocks by design. The blocked main thread already wakes for, and runs, work that other threads proxy to it, so the cleanup could take the same route.
Version of emscripten/emsdk:
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 6.0.11-git (e81f85424d09439b4af1f2066ca8a2408878c2e5)
clang version 24.0.0git (https:/github.com/llvm/llvm-project 1f5222198a9100743929084e575efae874b1b2fb)
main at e81f854 with the toolchain from test/emsdk_version.txt (c366a3c2), Node.js v24.19.0, x86_64 Linux.
Test case (detached.c):
#include <emscripten/threading.h>
#include <malloc.h>
#include <pthread.h>
#include <stdio.h>
static void *nothing(void *arg) { return NULL; }
/* Blocks for ms without returning to the event loop. A futex wait can end
* early, on a notification, so keep waiting until the time is up. */
static void block(double ms) {
static int never;
double end = emscripten_get_now() + ms;
for (double left = ms; left > 0; left = end - emscripten_get_now())
emscripten_futex_wait(&never, 0, left);
}
int main(void) {
for (int round = 1; round <= 5; round++) {
for (int i = 0; i < 4; i++) {
pthread_t thread;
pthread_create(&thread, NULL, nothing, NULL);
pthread_detach(thread);
}
block(200);
printf("round %d: %zu bytes in use\n", round, mallinfo().uordblks);
}
return 0;
}Failing command line in full:
$ emcc -O2 -pthread -sPTHREAD_POOL_SIZE=4 detached.c -o detached.js && node detached.js
round 1: 299240 bytes in use
round 2: 596872 bytes in use
round 3: 894504 bytes in use
round 4: 1192136 bytes in use
round 5: 1489768 bytes in use
Every round leaks four threads' blocks (64 KiB stacks here), and every round after the first starts four new Workers. Each thread had long exited: the main thread was blocked for 200 ms after creating them.
Where:
- A thread that exits detached calls
_emscripten_thread_cleanup()on itself (pthread_create.c#L376-L377), andpthread_join()calls it for the joined thread (pthread_join.c#L48). - Off the main thread, that only posts
CMD_CLEANUP_THREADto it (libpthread.js#L616-L626), which only its event loop handles (libpthread.js#L318-L323). - A blocked main thread does call
_emscripten_yield()from everyemscripten_futex_wait()(emscripten_futex_wait.c#L208), which runs the calls other threads proxied to it (emscripten_yield.c#L48), but the cleanup isn't one of them.
I have a fix ready and will send a PR: a thread that ends unjoined is queued for cleanup and wakes the main thread, which drains the queue whenever it processes its queued calls (so inside any blocking wait), while still sending the event-loop message for a main thread that is idle there. dlopen()'s thread sync holds these cleanups until it finishes, so it never touches a freed struct pthread.
Activity