Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Blazor WebAssembly work on multithreaded runtime #54365

Open
4 tasks
SteveSandersonMS opened this issue Mar 5, 2024 · 11 comments
Open
4 tasks

Make Blazor WebAssembly work on multithreaded runtime #54365

SteveSandersonMS opened this issue Mar 5, 2024 · 11 comments
Assignees
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-wasm-threading triaged

Comments

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Mar 5, 2024

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.

@SteveSandersonMS SteveSandersonMS added the area-blazor Includes: Blazor, Razor Components label Mar 5, 2024
@pavelsavara
Copy link
Member

Regarding the non-blocking behavior of JS interop.

  • There are methods BeginInvokeDotNet and EndInvokeJS which 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 renderBatch JSImport to return Task and have the implementation return a promise should work just fine.
  • There is mono_wasm_gc_lock and mono_wasm_gc_unlock which 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 WebAssemblyDispatcher and replace it with RendererSynchronizationContextDispatcher same as on the server side.
  • we can install RendererSynchronizationContext and replace the JSSynchronizationContext which 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 ?

@Xyncgas
Copy link

Xyncgas commented Mar 9, 2024

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

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Mar 9, 2024

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.

@pavelsavara
Copy link
Member

pavelsavara commented Mar 12, 2024

Regarding blocking .Wait throwing 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:
dotnet/runtime#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.

@pavelsavara
Copy link
Member

pavelsavara commented Mar 15, 2024

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.WriteLine which all talk to UI thread

@tariqalsoahmed
Copy link

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

image

Would love to hear your thoughts!

@pavelsavara
Copy link
Member

Here is a demo how to start wasm single-threaded runtime on separate thread dotnet/runtime#95452

@tariqalsoahmed
Copy link

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.

@jirisykora83
Copy link

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.

@mkArtakMSFT
Copy link
Member

Related #58874

@MattParkerDev
Copy link

Change rendering to serialize renderbatches to JS instead of doing shared-memory reads

Instead of serializing render batches, could SharedArrayBuffers be used? They are available from the main thread and workers.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer

The caveat is that the window must be in a secure context.

Image

It may not be feasible to require everyone who wishes to use Blazor multi-threaded to apply these restrictions to their site.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-wasm-threading triaged
Projects
None yet
Development

No branches or pull requests

7 participants