Skip to content

Threads that exit unjoined leak their stack and Worker while the main thread is blocked #27840

Open

Description

@typedrat

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:

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions