How to Fix: WASM threads don’t seem to share memory (if the memory isn’t imported)

6 min read

When WebAssembly threads appear to have separate memory, the bug is usually not in your thread code at all; it is in how the WASM memory is created and linked.

If a threaded WebAssembly module creates its own memory instead of using an imported shared memory, each worker or instance can end up with a different memory object. The result looks like broken thread sharing: one thread writes, another thread never sees the change. This issue is especially confusing because the code may compile and instantiate successfully while silently violating the assumptions required by shared linear memory.

Understanding the Root Cause

In WebAssembly, threads do not magically share state just because the module was compiled with thread support. They only share data when all participating execution contexts use the same shared WebAssembly.Memory object, backed by a SharedArrayBuffer.

The key detail is this: if the module does not import memory, then memory is typically defined internally by the module. In a browser or worker-based runtime, each instantiation can create a separate memory object. That means:

  • Main thread loads module instance A with memory A.
  • Worker loads module instance B with memory B.
  • Both instances run the same code, but they do not operate on the same bytes.

From the C or C++ side, this can feel wrong because native threads normally share one process address space. But in WebAssembly, sharing is not implied by thread creation alone; it is enforced through the module’s memory model and runtime wiring.

For WASM pthreads or similar threading models, the runtime expects a single shared memory to be passed into all workers. If the memory is internally defined rather than imported, the environment may have no reliable way to guarantee that every instance uses the exact same memory object.

This is why the issue often appears only when:

  • Threads are enabled
  • The module is instantiated in multiple workers
  • Memory is not imported
  • The runtime creates separate instances per worker

In short, the root cause is a mismatch between WebAssembly instance isolation and the requirement that threads operate on one shared linear memory.

Step-by-Step Solution

The fix is to compile and instantiate the module so that all thread participants use one imported shared memory.

1. Build the module with shared-memory support

Your toolchain must emit a module compatible with shared memory. The exact flags vary, but the common requirements are:

  • Enable atomics
  • Enable bulk memory if required by the toolchain
  • Enable pthread/thread support
  • Ensure memory is imported, not privately defined

For example, with Emscripten-like builds:

emcc main.cpp -O2 -pthread -sPTHREADS=1 -sIMPORTED_MEMORY=1 -o app.js

If you are using a lower-level LLVM/wasm-ld pipeline, make sure the linked module expects imported memory and is configured for shared memory semantics.

2. Create one shared WebAssembly.Memory in JavaScript

The memory must be explicitly created with shared: true.

const memory = new WebAssembly.Memory({
  initial: 256,
  maximum: 256,
  shared: true
});

The maximum is required for shared memory in most environments. Also note that sizing must be chosen carefully because some runtimes impose restrictions on growth for shared memories.

3. Import that same memory into every instance

Use the exact same memory object for the main thread and all workers.

const imports = {
  env: {
    memory
  }
};

const { instance } = await WebAssembly.instantiateStreaming(fetch('app.wasm'), imports);

In your worker:

self.onmessage = async (event) => {
  const memory = event.data.memory;
  const module = await WebAssembly.compileStreaming(fetch('app.wasm'));

  const imports = {
    env: {
      memory
    }
  };

  const instance = await WebAssembly.instantiate(module, imports);
  instance.exports.worker_entry();
};

Main thread posting the shared memory:

worker.postMessage({ memory });

4. Verify the module actually imports memory

This step is often skipped. Inspect the generated module to confirm memory is not defined internally.

wasm-objdump -x app.wasm

You want to see memory listed as an import, not solely under module-defined memory sections. If the module defines its own memory, your workers may instantiate separate memories.

5. Use atomics for synchronization

Even when memory is truly shared, unsynchronized reads and writes can still appear inconsistent. Shared memory does not remove the need for proper synchronization.

#include <atomic>
#include <cstdint>

std::atomic<int32_t> flag = 0;
int32_t shared_value = 0;

extern "C" void writer() {
  shared_value = 42;
  flag.store(1, std::memory_order_release);
}

extern "C" int reader() {
  while (flag.load(std::memory_order_acquire) == 0) {
  }
  return shared_value;
}

Without atomic synchronization, you can misdiagnose ordering bugs as memory-sharing bugs.

6. Make sure the runtime environment allows shared memory

In browsers, SharedArrayBuffer requires proper cross-origin isolation. If the page is not configured correctly, threading may fail entirely or fall back in unexpected ways.

Use the appropriate headers such as COOP and COEP on the server hosting your application. Refer to the relevant browser documentation through MDN Web Docs.

7. Minimal conceptual fix

If your current architecture is:

Main thread -> instantiate wasm
Worker A     -> instantiate wasm
Worker B     -> instantiate wasm

And each instantiation creates its own memory, change it to:

Main thread -> create shared WebAssembly.Memory
Main thread -> instantiate wasm with imported memory
Worker A     -> instantiate same wasm with same imported memory
Worker B     -> instantiate same wasm with same imported memory

That is the central fix for this bug.

Common Edge Cases

Memory is shared, but values still look stale

This usually means you have a synchronization problem, not a memory-import problem. Use atomics, mutexes, condition variables, or the thread runtime primitives provided by your toolchain.

The module compiles, but workers fail at instantiation

If the module requires shared memory, the imported memory must match the module’s declared limits and shared flag. A mismatch in initial, maximum, or shared can cause instantiation failure.

It works in one browser but not another

Browser support and deployment requirements for SharedArrayBuffer and WASM threads can differ. Verify that your target browsers support threaded WebAssembly and that your headers are configured correctly.

Memory growth causes unexpected behavior

Some environments place restrictions on growing shared memory. If your design depends on dynamic growth, confirm that your runtime and toolchain support it correctly. Many threaded builds are more stable with a fixed maximum.

The toolchain silently emits internal memory

Some flags interact in non-obvious ways. Always inspect the final .wasm binary instead of assuming the build settings produced imported memory.

Thread startup code is correct, but the wrong instance is called

If worker bootstrap logic instantiates a different module variant, or uses a cached non-threaded build, you may see behavior that mimics isolated memory. Confirm that every worker loads the same threaded artifact.

FAQ

Why do WASM threads need imported memory instead of module-defined memory?

Because threads need one shared memory object across all instances involved in execution. Imported memory lets the host create that object once and pass the same reference everywhere. Module-defined memory often leads to one memory per instance.

Can multiple WebAssembly instances share memory?

Yes. Multiple instances can share the same WebAssembly.Memory as long as it is created as shared and imported into each instance. This is the common model used by worker-based WASM threading runtimes.

How can I confirm that my bug is really caused by non-imported memory?

Check the binary with a tool such as wasm-objdump, verify that memory is imported, and log whether the same memory object is passed to every worker. If each instance owns a different memory, the threads are not actually sharing the same linear memory.

The practical takeaway is simple: for threaded WebAssembly, shared behavior depends on shared memory wiring. If memory is not imported and reused across all instances, your threads may be running correctly while still reading and writing completely different address spaces.

Leave a Reply

Your email address will not be published. Required fields are marked *