Skip to content

Make Blazor WebAssembly work on multithreaded runtime #54365

@SteveSandersonMS

Description

@SteveSandersonMS

Currently, Blazor WebAssembly's internals make use of the historical JS/WASM single-threadedness guarantees as a performance optimization. For example:

  • Render batches are communicated to JS via zero-copy shared memory rather than by serialization
  • Events triggered from JS are always processed synchronously by .NET, so there's no need to queue them

At first glance, it appears to work with <WasmEnableThreads>true</WasmEnableThreads>, but that's a mirage: it does not guarantee correctness in that mode, because the core single-threadedness assumption is violated. For example, JS might raise event 2 before event 1 finished processing, and by the time event 1 is done, there might not even be an event 2 handler any more and then you have an error.

Why we're only just encountering this now

  1. We didn't have multithreading before
  2. Even to the extent that we did have an early preview of multithreading, it worked by running .NET on the browser UI thread, so we could define that as the sync context and then all the same assumptions would remain valid within that sync context. But now .NET WebAssembly has just moved to the new "deputy thread" multithreading model (i.e., all .NET code is on a background worker), these assumptions are no longer valid (e.g., because the UI thread's JS can no longer communicate with .NET in a synchronous, blocking manner).

Ensuring correctness

We've already solved all these problems in our other hosting environments, Server and WebView, because they've always been innately asynchronous from the beginning. We would need to generalize/port all these mechanisms so they are shared with or copied by the WebAssembly renderer, JS interop, eventing system, etc.:

  • Change rendering to serialize renderbatches to JS instead of doing shared-memory reads
  • Change the rendering flow so that the renderer's UpdateDisplayAsync returns a Task that doesn't complete until the JS side sends back an explicit acknowledgement of that renderbatch
  • Review JS interop to make sure any low-level assumptions we've made about synchrony during the internals of message passing are replaced by async assumptions
  • Do the above without changing things for the single-threaded WebAssembly flavour, since (1) it would be breaking if for example synchronous UI updates became async, and (2) those perf optimizations are there for a reason, and we don't want to make rendering many times more expensive in cases where you'd be serializing huge strings, for example. It's OK to have significant behavioral and performance changes when people opt into multithreading, but not when they don't.

Approach

As a broad strategy, I think we can:

  • Factor out some new WebRenderer subclass called something like AsyncWebRenderer that holds the common logic around serializing renderbatches and accepting the async renderbatch acknowledgements from JS. This can then be used by all of Server, WebView, and multithreaded-WebAssembly.
  • Use our WebView implementation as the reference for how a complete, async-safe rendering and eventing model should work, because this was more recently implemented than Server and is generally simpler and cleaner. There's not a huge amount of code in it, really. If some extra work is needed to queue event notifications, for example, we should see that clearly from the WebView code.

I suspect it's not necessary to have two different builds of Microsoft.AspNetCore.Components.WebAssembly. The total amount of code that should differ between the threaded and not-threaded won't be very much. I expect we can just have two different Renderer subclasses and some if/else branches in the JS code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions