aspnetcore
aspnetcore copied to clipboard
Make Blazor WebAssembly work on multithreaded runtime
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
UpdateDisplayAsyncreturns aTaskthat 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
WebRenderersubclass called something likeAsyncWebRendererthat 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.
Regarding the non-blocking behavior of JS interop.
- There are methods
BeginInvokeDotNetandEndInvokeJSwhich have synchronous signature, but the runtime is hacked to treat them as fire-and-forget async messages to deputy thread. If you rename them hack will break. We don't have public API attribute to express that yet. - Making
renderBatchJSImport to returnTaskand have the implementation return a promise should work just fine. - There is
mono_wasm_gc_lockandmono_wasm_gc_unlockwhich you call on UI thread (not deputy thread). This is not ideal because it's making the UI involved in GC stop-the-world. - I wonder if you can offload the DOM event handlers to thread pool rather than deputy thread.
Regarding blocking .Wait throwing PNSE on "deputy" thread
- Let's try to push thru the problems if possible. We could give up bit later.
- Runtime would probably bring more scenarios. To throw on similar blocking operations which we didn't cover yet.
- At the same time, I'm actively thinking on how to soften the limitation for deputy thread. No conclusion so far.
In last month or so, we switched the implementation of JS interop dispatch from JSSynchronizationContext to emscripten internal queue. Therefore
- we could drop
WebAssemblyDispatcherand replace it withRendererSynchronizationContextDispatchersame as on the server side. - we can install
RendererSynchronizationContextand replace theJSSynchronizationContextwhich is installed by default on main thread.
I'm not clear how you would ship 2 different flavors of the components, if you start using #ifdef ?
Change rendering to serialize renderbatches to JS instead of doing shared-memory reads
Are you really going to enable multi-threading at the cost of performance, serialization round-trip can potentially greatly reduce the amount of components displayed on screen before it's unresponsive to users, I saw UI frameworks using blazor having their VirtualDOM and render their VirtualDOM in a for loop recognizing the elements in the VirtualDOM and creating a component for every elements, these framework allows dev to compose and generate a large number of components, potentially every button has multiple components and every cell in a calender has multiple components and that is adding up to a lot components for blazor to render quickly, if blazor is sacrificing a lot performance in large quantity of components being rendered, I worry it would be cutting off an entire line of UI frameworks that compose the UI their way to use .NET through blazor and that's a crowd of audience
Are you really going to enable multi-threading at the cost of performance
No, we're not open to sacrificing any performance from the single-threaded build. This was mentioned in the issue description:
Do the above without changing things for the single-threaded WebAssembly flavour ... It's OK to have significant behavioral and performance changes when people opt into multithreading, but not when they don't.
Regarding blocking
.Waitthrowing PNSE on "deputy" thread * Let's try to push thru the problems if possible. We could give up bit later.
This shows how many scenarios would have to be avoided if we don't allow blocking .Wait https://github.com/dotnet/runtime/pull/98802/files
It seems that's too many.
At the same time, I'm actively thinking on how to soften the limitation for deputy thread. No conclusion so far.
This enables blocking .Wait on deputy (main) thread when running async code:
https://github.com/dotnet/runtime/pull/99422
Our team conclusion so far is, that we wish to disable synchronous [JSExport] on MT to avoid broad class of deadlocks.
That would be different PR.
it would be good to make following [JSExport]s async and return Task
public static string? InvokeDotNet
public static void UpdateRootComponentsCore
private static void ReceiveByteArrayFromJS
So that we could disable support for synchronous JSExport completely.
If we are unable to do that, please be aware that any managed code inside of those calls will
- throw PNSE on blocking
.Wait - on any virtual FS access
- creating new thread
- and also on
Console.WriteLinewhich all talk to UI thread
Hi @SteveSandersonMS and team,
Our production Blazor app is working and performing nearly perfect! I'm sure Blazor WASM multi-threading is difficult due to violating single-threaded assumptions.
As a short-term solution ... Have you considered starting Blazor on a single non-blocking background thread?
SSR is working beautifully... only prob is Total Blocking Time (TBT) from Blazor.start() blocking UI thread.
https://eatinglove.com/recipe-boost/1096211/The-Easiest-Beef-Pho
Presumably Blazor could start() on a background thread, then marshal to UI thread when WASM state is completely loaded? At worse DOM flickers but TBT should be reduced to under a second for normal scenarios. While Blazor background thread loads, DOM simply behaves as normal HTML page. This might be favorable performance profile for any app optimized for SSR.
https://pagespeed.web.dev/analysis/https-eatinglove-com-recipe-boost-1096211-The-Easiest-Beef-Pho/ct3g2rbt3f?hl=en&form_factor=desktop
Would love to hear your thoughts!
Here is a demo how to start wasm single-threaded runtime on separate thread https://github.com/dotnet/runtime/issues/95452
Here is a demo how to start wasm single-threaded runtime on separate thread dotnet/runtime#95452
has anyone demo calling Blazor.start() from Web Worker separate thread? Not sure it's even possible but great performance enhancement.
Here is a demo how to start wasm single-threaded runtime on separate thread dotnet/runtime#95452
has anyone demo calling Blazor.start() from Web Worker separate thread? Not sure it's even possible but great performance enhancement.
I don't think it will work because i believe you cannot access DOM directly from Web Worker. They may try make "proxy" to streamline changes (possibly use same technology as for server-side interactive mode?). But hard to say how fast it would be. And it would be nice, BUT when they actually introduce multi-thread support for blazor this mode becomes obsolved (?). Also, this change would only demo because it will break many nugget packages.