Skip to content

Free a side module's file data once all threads have loaded it - #28007

Open
rustyconover wants to merge 1 commit into
emscripten-core:mainfrom
rustyconover:dylink-free-file-data
Open

rustyconover wants to merge 1 commit into
emscripten-core:mainfrom
rustyconover:dylink-free-file-data

Conversation

@rustyconover

@rustyconover rustyconover commented Oct 17, 2026 •

Copy link
Copy Markdown

In a -pthread MAIN_MODULE program, every library loaded with dlopen keeps a full copy of its file in the heap for the rest of the program's life. Loading 50 MB of side modules costs 50 MB of heap that never comes back. This PR frees that copy once no thread can need it.

Results

  • A DuckDB-wasm based app (Haybarn): its threaded build loads 6 extensions (55 MB of side modules) in Chrome, then runs a multi-threaded query. Total memory, measured with performance.measureUserAgentSpecificMemory(), drops from 386.3 MB to 316.7 MB, a saving of 70 MB (18%). All 4 runs gave the same result, and load and query times are unchanged.
  • The new test loads an 8 MB side module. Heap use after dlopen grows by 8,194 KB on main and by less than 1 MB with this PR.
  • Threads started after a dlopen, on a worker that already existed, now reuse the compiled module. Before, each one copied the file data out of the heap (HEAP8.slice) and compiled the module again.

The 70 MB comes from two effects together. The heap copies are freed, so the next library's file data reuses that space; WebAssembly.Memory itself doesn't shrink. And the new query threads no longer each copy and compile every module.

What changes

  • load_library_done frees dso->file_data when _emscripten_dlsync_threads reports that every thread has the module. This resolves the TODO there.
  • The run message sent to a pool worker now carries sharedModules, so threads started after the dlopen instantiate the already-compiled module instead of reading the file data.
  • _emscripten_dlsync_threads now returns whether all threads synced.
  • The file data is also freed when dlopen fails. Its pointer is now cleared after a short read(), so that buffer can't be freed twice.

Why it's safe

When the main runtime thread calls dlopen, _emscripten_dlsync_threads proxies a sync to each running thread and blocks until the sync is done. Threads that are blocked, or spawned but not yet running, handle that sync from futex_wait or before their entry point. So once it returns successfully, every thread that existed at the time of the dlopen has loaded the module.

The only other threads that could read file_data are ones started later on a pool worker created before the dlopen. That includes threads created by other pthreads through CMD_SPAWN_THREAD. The run message now gives them sharedModules. The main thread registers the module in sharedModules before load_library_done runs, and only the main thread runs spawnThread, so every later run message includes the module.

Two cases still keep the file data, as today:

  • dlopen called on a thread other than the main runtime thread, which includes PROXY_TO_PTHREAD. The main thread catches up asynchronously, so a thread spawned in the meantime could still read the file data. Collecting the promises only after the main thread has caught up would make this case safe too. I've left that for a follow-up.
  • A thread failed to load the module. It may retry, so the data is kept. To tell that apart from a thread that has exited, _emscripten_proxy_dlsync now returns success for a thread it can't reach.

Cost: sharedModules is cloned into each run message. Compiled modules are shared rather than copied when cloned. If this matters for programs that create many threads, sending only the modules a worker hasn't seen yet would keep the message smaller.

Testing

New test: core.test_pthread_dlopen_file_data. It runs in each core suite, and checks:

  • heap use after dlopen doesn't grow by the size of the side module.
  • a thread that is running during the dlopen can call into the module afterwards.
  • a thread created by that thread after the dlopen can call into the module (CMD_SPAWN_THREAD).
  • a thread the main thread starts after the dlopen can call into the module. It runs on a pool worker that existed before the dlopen.

The side module is embedded under a path that doesn't exist on the host, and is unlinked after the dlopen, so the late threads can't fall back to reading the file. The test fails on main because of the heap growth. It also fails if the run message doesn't carry sharedModules, because the late threads then can't load the module.

The pthread, dylink, dlfcn, dlopen, dlsym, main_module and side_module selectors pass across core0, core2, wasm64, core_2gb and other: 1,160 tests, 65 skipped. core2.test_pthread_dlopen_many, which is marked flaky (#18887), passed 10 runs out of 10. tools/maint/gen_sig_info.py reproduces the libsigs.js change.

The test-browser-chrome-2gb and test-browser-chrome-wasm64-4gb failures (test_sdl_quit) also happen on main right now.

🤖 Generated with Claude Code

In threaded builds dlopen reads the side module's file into linear memory
(dso->file_data) so that other threads can load it, and never frees it, so
every loaded library keeps a copy of its file in the heap.

When the main runtime thread calls dlopen, _emscripten_dlsync_threads
blocks until every running thread has loaded the module.  The only threads
that could still need the file data are ones started later on workers
created before the dlopen.  spawnThread now passes sharedModules in the
run message, so those threads instantiate the compiled module instead,
and load_library_done frees the file data.

_emscripten_dlsync_threads now returns whether that holds.  It doesn't
when dlopen was called on another thread (the main thread catches up
asynchronously, so a thread spawned meanwhile could still read the file
data) or when a thread failed to load the module (it may retry), and the
file data is kept as before.

Also free the file data when dlopen fails, and clear the pointer after a
short read so that it isn't freed twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

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.

1 participant