Description
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
- We didn't have multithreading before
- 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 aTask
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 likeAsyncWebRenderer
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.