- how to deal with blocking C# code on UI thread
- A) pretend it's not a problem (this we already have)
- B) move user C# code to web worker
- C) move all Mono to web worker
- D) like A) just move call of the C#
Main()
toJSWebWorker
- how to deal with blocking in synchronous JS calls from UI thread (like
onClick
callback)- D) pretend it's not a problem (this we already have)
- E) throw PNSE when synchronous JSExport is called on UI thread
- F) dispatch calls to synchronous JSExport to web worker and spin-wait on JS side of UI thread.
- how to implement JS interop between managed main thread and UI thread (DOM)
- G) put it out of scope for MT, manually implement what Blazor needs
- H) pure JS dispatch between threads, comlink style
- I) C/emscripten dispatch of infrastructure to marshal individual parameters
- J) C/emscripten dispatch of method binding and invoke, but marshal parameters on UI thread
- K) pure C# dispatch between threads
- how to implement JS interop on non-main web worker
- L) disable it for all non-main threads
- M) disable it for managed thread pool threads
- N) allow it only for threads created as dedicated resource
WebWorker
via new API - O) enables it on all workers (let user deal with JS state)
- how to dispatch calls to the right JS thread context
- P) via
SynchronizationContext
beforeJSImport
stub, synchronously, stack frames - Q) via
SynchronizationContext
insideJSImport
C# stub - R) via
emscripten_dispatch_to_thread_async
inside C code of ``
- P) via
- how to implement GC/dispose of
JSObject
proxies- S) per instance: synchronous dispatch the call to correct thread via
SynchronizationContext
- T) per instance: async schedule the cleanup
- at the detach of the thread. We already have
forceDisposeProxies
- could target managed thread be paused during GC ?
- S) per instance: synchronous dispatch the call to correct thread via
- where to instantiate initial user JS modules (like Blazor's)
- U) in the UI thread
- V) in the deputy/sidecar thread
- where to instantiate
JSHost.ImportAsync
modules- W) in the UI thread
- X) in the deputy/sidecar thread
- Y) allow it only for dedicated
JSWebWorker
threads - Z) disable it
- same for
JSHost.GlobalThis
,JSHost.DotnetInstance
- how to implement Blazor's
renderBatch
- a) keep as is, wrap it with GC pause, use legacy JS interop on UI thread
- b) extract some of the legacy JS interop into Blazor codebase
- c) switch to Blazor server mode. Web worker create the batch of bytes and UI thread apply it to DOM
- where to create HTTP+WS JS objects
- d) in the UI thread
- e) in the managed main thread
- f) in first calling
JSWebWorker
managed thread
- how to dispatch calls to HTTP+WS JS objects
- g) try to stick to the same thread via
ConfigureAwait(false)
.- doesn't really work.
Task
migrate too freely
- doesn't really work.
- h) via C#
SynchronizationContext
- i) via
emscripten_dispatch_to_thread_async
- j) via
postMessage
- k) same whatever we choose for
JSImport
- note there are some synchronous calls on WS
- g) try to stick to the same thread via
- where to create the emscripten instance
- l) could be on the UI thread
- m) could be on the "sidecar" thread
- where to start the Mono VM
- n) could be on the UI thread
- o) could be on the "sidecar" thread
- where to run the C# main entrypoint
- p) could be on the UI thread
- q) could be on the "deputy" or "sidecar" thread
- where to implement sync-to-async: crypto/DLL download/HTTP APIs/
- r) out of scope
- s) in the UI thread
- t) in a dedicated web worker
- z) in the sidecar or deputy
- where to marshal JSImport/JSExport parameters/return/exception
- u) could be only values types, proxies out of scope
- v) could be on UI thread (with deputy design and Mono there)
- w) could be on sidecar (with double proxies of parameters via comlink)
- x) could be on sidecar (with comlink calls per parameter)
- A,D,G,L,P,S,U,Y,a,f,h,l,n,p,v
- this is what we already have today
- it could deadlock or die,
- JS interop on threads requires lot of user code attention
- Keeps problems 1,2,3,4
- C,E,G,L,P,S,U,Z,c,d,h,m,o,q,u
- minimal effort, low risk, low capabilities
- move both emscripten and Mono VM sidecar thread
- no user code JS interop on any thread
- internal solutions for Blazor needs
- Ignores problems 1,2,3,4,5
- C,E,H,N,P,S,U,W+Y,c,e+f,h+k,m,o,q,w
- no C or managed code on UI thread
- this architectural clarity is major selling point for sidecar design
- no support for blocking sync JSExport calls from UI thread (callbacks)
- it will throw PNSE
- this will create double proxy for
Task
,JSObject
,Func<>
etc- difficult to GC, difficult to debug
- double marshaling of parameters
- Solves 1,2 for managed code.
- Avoids 1,2 for JS callback
- emscripten main loop stays responsive only when main managed thread is idle
- Solves 3,4,5
- C,F,H,N,P,S,U,W+Y,c,e+f,h+k,m,o,q,w
- no C or managed code on UI thread
- support for blocking sync JSExport calls from UI thread (callbacks)
- at blocking the UI is at least well isolated from runtime code
- it makes responsibility for sync call clear
- this will create double proxy for
Task
,JSObject
,Func<>
etc- difficult to GC, difficult to debug
- double marshaling of parameters
- Solves 1,2 for managed code
- unless there is sync
JSImport
->JSExport
call
- unless there is sync
- Ignores 1,2 for JS callback
- emscripten main loop stays responsive only when main managed thread is idle
- Solves 3,4,5
- B,F,K,N,Q,S/T,U,W,a/b/c,d+f,h,l,n,s/z,v
- this uses
JSSynchronizationContext
to dispatch calls to UI thread- this is "dirty" as compared to sidecar because some managed code is actually running on UI thread
- it needs to also use
SynchronizationContext
forJSExport
and callbacks, to dispatch to deputy.
- blazor render could be both legacy render or Blazor server style
- because we have both memory and mono on the UI thread
- Solves 1,2 for managed code
- unless there is sync
JSImport
->JSExport
call
- unless there is sync
- Ignores 1,2 for JS callback
- emscripten main loop could deadlock on sync JSExport
- Solves 3,4,5
- B,F,J,N,R,T,U,W,a/b/c,d+f,i,l,n,s,v
- is variation of (12)
- with emscripten dispatch and marshaling in UI thread
- this uses
emscripten_dispatch_to_thread_async
forcall_entry_point
,complete_task
,cwraps.mono_wasm_invoke_method_bound
,mono_wasm_invoke_bound_function
,mono_wasm_invoke_import
,call_delegate_method
to get to the UI thread. - it uses other
cwraps
locally on UI thread, likemono_wasm_new_root
,stringToMonoStringRoot
,malloc
,free
,create_task_callback_method
- it means that interop related managed runtime code is running on the UI thread, but not the user code.
- it means that parameter marshalling is fast (compared to sidecar)
- this deputy design is major selling point #2
- it still needs to enter GC barrier and so it could block UI for GC run shortly
- blazor render could be both legacy render or Blazor server style
- because we have both memory and mono on the UI thread
- Solves 1,2 for managed code
- unless there is sync
JSImport
->JSExport
call
- unless there is sync
- Ignores 1,2 for JS callback
- emscripten main loop could deadlock on sync JSExport
- Solves 3,4,5
- B,F,J,N,R,T,U,W,a/b/c,d+f,i,l,n,s,v
- is variation of (13)
- without support for synchronous JSExport
- Solves 1,2 for managed code
- emscripten main loop stays responsive
- unless there is sync
JSImport
->JSExport
call
- Avoids 2 for JS callback
- by throwing PNSE
- Solves 3,4,5
- 2 levels of indirection.
- benefit: blocking JSExport from UI thread doesn't block emscripten loop
- downside: complex and more resource intensive
- variation on (13) or (14) where we get rid of per-parameter calls to Mono
- benefit: get closer to purity of sidecar design without loosing perf
- this could be done later as purity optimization
- in this design the mono could be started on deputy thread
- this will keep UI responsive during startup
- UI would not be mono attached thread.
- See details
Related Net8 tracking dotnet/runtime#85592
- is interesting because it avoids cross-thread dispatch to UI
- including double dispatch in Blazor's
RendererSynchronizationContext
- including double dispatch in Blazor's
- avoids solving 1,2
- low level hacking of emscripten design assumptions
- keep both Mono and emscripten in the UI thread
- use
SynchronizationContext
to do the dispatch - make it easy and default to run any user code in deputy thread
- all Blazor events and callbacks like
onClick
to deputy - move SignalR to deputy
- move Blazor entry point to deputy
- all Blazor events and callbacks like
- hope that UI thread is mostly idle
- enable dynamic thread allocation
- throw exceptions in dev loop when UI thread does
lock
or.Wait
in user code
- this already works well in Net8
- when the developer is able to start dotnet in the worker himself and also handle all the messaging.
- there are known existing examples in the community
There are few downsides to them
- if we keep main managed thread and emscripten thread the same, pthreads can't be created dynamically
- we could upgrade it to design (15) and have extra thread for running managed
Main()
- we could upgrade it to design (15) and have extra thread for running managed
- we will have to implement extra layer of dispatch from UI to sidecar
- this could be pure JS via
postMessage
, which is slow and can't do spin-wait. - we could have
SharedArrayBuffer
for the messages, but we would have to implement (another?) marshaling.
- this could be pure JS via
- User code
- this is difficult and complex task which many will fail to do right
- it can't be user code for HTTP/WS clients because there is no direct call via Streams
- authors of 3rd party components would need to do it to hide complexity from users
- Roslyn generator: JSImport - if we make it responsible for the dispatch
- it needs to stay backward compatible with Net7, Net8 already generated code
- how to detect that there is new version of generated code ?
- it needs to do it via public C# API
- possibly new API
JSHost.Post
andJSHost.Send
- or
JSHost.InvokeInTargetSync
andJSHost.InvokeInTargetAsync
- possibly new API
- it needs to re-consider current
stackalloc
- probably by re-ordering Roslyn generated code of
__arg_return.ToManaged(out __retVal);
beforeJSFunctionBinding.InvokeJS
- probably by re-ordering Roslyn generated code of
- it needs to propagate exceptions
- it needs to stay backward compatible with Net7, Net8 already generated code
- Roslyn generator: JSExport - can't be used
- this is just the UI -> deputy dispatch, which is not C# code
- Mono/C/JS internal layer
- see
emscripten_dispatch_to_thread_async
below
- see
- when there is no extra code-gen flag
- for backward compatibility, dispatch handled by user
- assert that we are on
JSWebWorker
or main thread - assert all parameters affinity to current thread
- when generated with
JSImportAttribute.Affinity==UI
- dispatch to UI thread
- assert all parameters affinity to UI thread
- could be called from any thread, including thread pool
- there is no
JSSynchronizationContext
in deputy's UI, use emscripten. - emscripten can't block caller
- when generated with
JSImportAttribute.Affinity==ByParams
- dispatch to thread of parameters
- assert all parameters have same affinity
- could be called from any thread, including thread pool
- how to dispatch to UI in deputy design ?
- A) double dispatch, C# -> main, emscripten -> UI
- B) make whole dispatch emscripten only, implement blocking wait in C for emscripten sync calls.
- C) only allow sync call on non-UI target
- how to obtain
JSHandle
of function in the target thread ?- there are 2 dimensions: the thread and the method
- there are 2 steps:
- A) obtain existing
JSHandle
on next call (if available)- to avoid double dispatch, this needs to be accessible
- by any caller thread
- or by UI thread C code (not managed)
- to avoid double dispatch, this needs to be accessible
- B) if this is first call to the method on the target thread, create the target
JSHandle
by binding existing JS function- collecting the metadata is generated C# code
- therefore we need to get the metadata buffer on caller main thread: double dispatch
- store new
JSHandle
somewhere
- possible solution
assign
static
unique ID to the function on C# side during first call.- A) Call back to C# if the method was not bound yet (which thread ?).
- B) Keep the metadata buffer
- make
JSFunctionBinding
registration static (not thread-static)- never free the buffer
- pass the buffer on each call to the target
- late bind
JSHandle
- store the
JSHandle
on JS side (thread static) associated with method ID
- make
- TODO: double dispatch in Blazor
- when caller is UI, we need to dispatch back to managed thread
- preferably deputy or sidecar thread
- when caller is
JSWebWorker
,- we are probably on correct thread already
- when caller is callback from HTTP/WS we could dispatch to any managed thread
- callers are not from managed thread pool, by design. Because we don't want any JS code running there.
JSSynchronizationContext
- in deputy design- this would not work for dispatch to UI thread as it doesn't have sync context
- is implementation of
SynchronizationContext
installed to - managed thread with
JSWebWorker
- or main managed thread
- it has asynchronous
SynchronizationContext.Post
- it has synchronous
SynchronizationContext.Send
- can propagate caller stack frames
- can propagate exceptions from callee thread
- when the method is async
- we can schedule it asynchronously to the
JSWebWorker
or main thread - propagate result/exceptions via
TaskCompletionSource.SetException
from any managed thread
- we can schedule it asynchronously to the
- when the method is sync
- create internal
TaskCompletionSource
- we can schedule it asynchronously to the
JSWebWorker
or main thread - we could block-wait on
Task.Wait
until it's done. - return sync result
- create internal
- this would not work in sidecar design
- because UI is not managed thread there
emscripten_dispatch_to_thread_async
- in deputy design- can dispatch async call to C function on the timer loop of target pthread
- doesn't block and doesn't propagate results and exceptions
- from JS (UI) to C# managed main
- only necessary for deputy/sidecar, not for HTTP
- async
malloc
stack frame and do JS side of marshaling- re-order
marshal_task_to_js
beforeinvoke_method_and_handle_exception
- pre-create
JSHandle
of apromise_controller
- pass
JSHandle
instead of receiving it
- pre-create
- send the message via
emscripten_dispatch_to_thread_async
- return the promise immediately
- await until
mono_wasm_resolve_or_reject_promise
is sent back- this need to be also dispatched
- how could we make that dispatch same for HTTP cross-thread by
JSObject
affinity ?
- any errors in messaging will
abort()
- sync
- dispatch C function
- which will lift Atomic semaphore at the end
- spin-wait for semaphore
- stack-frame could stay on stack
- synchronously returning
null
Task?
- pass
slot.ElementType = MarshalerType.Discard;
? abort()
?resolve(null)
?reject(null)
?
- pass
- from C# to JS (UI)
- how to obtain JSHandle of function in the target thread ?
- async
- needs to deal with
stackalloc
in C# generated stub, by copying the buffer
- needs to deal with
- sync
- inside
JSFunctionBinding.InvokeJS
: - create internal
TaskCompletionSource
- use async dispatch above
- block-wait on
Task.Wait
until it's done.- !! this would not keep receiving JS loop events !!
- return sync result
- inside
- implementation calls
BindJSFunction
,mono_wasm_bind_js_function
- many out params, need to be sync call to UIBindCSFunction
,mono_wasm_bind_cs_function
- many out params, need to be sync call to UIReleaseCSOwnedObject
,mono_wasm_release_cs_owned_object
- async message to UIResolveOrRejectPromise
,mono_wasm_resolve_or_reject_promise
- async message to UIInvokeJSFunction
,mono_wasm_invoke_bound_function
- depending on signature, via FuncJS.ResMarshalerInvokeImport
,mono_wasm_invoke_import
- depending on signature, could be sync or async message to UIInstallWebWorkerInterop
,mono_wasm_install_js_worker_interop
- could become asyncUninstallWebWorkerInterop
,mono_wasm_uninstall_js_worker_interop
- could become asyncRegisterGCRoot
,mono_wasm_register_root
- could stay on deputyDeregisterGCRoot
,mono_wasm_deregister_root
- could stay on deputy- hybrid globalization, could probably stay on deputy
emscripten_sync_run_in_main_runtime_thread
- in deputy design- can run sync method in UI thread
- "comlink" - in sidecar design
- when the method is async
- extract GCHandle of the
TaskCompletionSource
- convert parameters to JS (sidecar context)
- using sidecar Mono
cwraps
for marshaling
- using sidecar Mono
- call UI thread via "comlink"
- will create comlink proxies
- capture JS result/exception from "comlink"
- use stored
TaskCompletionSource
to resolve theTask
on target thread
- extract GCHandle of the
- when the method is async
postMessage
- can send serializable message to web worker
- can transmit transferable objects
- doesn't block and doesn't propagate exceptions
- this is slow
- if we want to keep synchronous JS APIs to work on UI thread, we have to spin-wait
- we probably should have opt-in configuration flag for this
- making user responsible for the consequences
- at the moment emscripten implements spin-wait in wasm
- See pthread_cond_timedwait.c and __timedwait.c
- I was not able to confirm that they would call
emscripten_check_mailbox
during spin-wait - See also https://emscripten.org/docs/porting/pthreads.html
- in sidecar design - emscripten main is not running in UI thread
- it means it could still pump events and would not deadlock in Mono or managed code
- unless the sidecar thread is blocked, or CPU hogged, which could happen
- we need pure JS version of spin-wait and we have OK enough prototype
- in deputy design - emscripten main is running in UI thread
- but the UI thread is not running managed code
- it means it could still pump events and would not deadlock in Mono or managed code
- this deputy design is major selling point #1
- unless user code opts-in to call sync JSExport
- it could still deadlock if there is synchronous JSImport call to UI thread while UI thread is spin-waiting on it.
- this would be clearly user code mistake
- as compared to single threaded runtime, the major difference would be no synchronous callbacks.
- for example from DOM
onClick
. This is one of the reasons people prefer ST WASM over Blazor Server.
- for example from DOM
- Blazor
renderBatch
- currently
Blazor._internal.renderBatch
->MONO.getI16
,MONO.getI32
,MONO.getF32
,BINDING.js_string_to_mono_string
,BINDING.conv_string
,BINDING.unbox_mono_obj
- we could also RenderBatchWriter in the WASM
- some of them need Mono VM and GC barrier, but could be re-written with GC pause and only memory read
- currently
- Blazor's
IJSInProcessRuntime.Invoke
- this is C# -> JS direction- TODO: which implementation keeps this working ? Which worker is the target ?
- we could use Blazor Server style instead
- Blazor's
IJSUnmarshalledRuntime
- this is ICall
InvokeJS
- TODO: which implementation keeps this working ? Which worker is the target ?
- this is ICall
JSImport
used for startup, loading and embedding:INTERNAL.loadLazyAssembly
,INTERNAL.loadSatelliteAssemblies
,Blazor._internal.getApplicationEnvironment
,receiveHotReloadAsync
- all of them pass simple data types, no proxies
JSImport
used for calling user JS code:Blazor._internal.endInvokeDotNetFromJS
,Blazor._internal.invokeJSJson
,Blazor._internal.receiveByteArray
,Blazor._internal.getPersistedState
- TODO: which implementation keeps this working ? Which worker is the target ?
JSImport
used for logging:globalThis.console.debug
,globalThis.console.error
,globalThis.console.info
,globalThis.console.warn
,Blazor._internal.dotNetCriticalError
- probably could be any JS context
- it's not clear how to make this single-file
- because web workers need to start separate script(s) via
new Worker('./dotnet.js', {type: 'module'})
- we can start a WebWorker with a Blob, but that's not CSP friendly.
- when bundled together with user code, into
./my-react-application.js
we don't have way how tonew Worker('./dotnet.js')
anymore.
- emscripten uses
dotnet.native.worker.js
script. At the moment we don't have nice way how to customize what is inside of it. - for ST build we have JS API to replace our dynamic
import()
of our modules with provided instance of a module.- we would have to have some way how 3rd party code could become responsible for doing it also on web worker (which we start)
- what other JS frameworks do when they want to be webpack friendly and create web workers ?
- once we have have all managed threads outside of the UI thread
- we could synchronously wait for another thread to do async operations
- and use async API of subtle crypto
- once we have have all managed threads outside of the UI thread
- we could synchronously wait for another thread to do async operations
- to fetch another DLL which was not pre-downloaded
- Get rid of Mono GC boundary breach
- see dotnet/runtime#100411
- we already ship MT version of the runtime in the wasm-tools workload.
- It's enabled by
<WasmEnableThreads>true</WasmEnableThreads>
and it requires COOP HTTP headers. - It will serve extra file
dotnet.native.worker.js
. - This will also start in Blazor project, but UI rendering would not work.
- we have pre-allocated pool of browser Web Workers which are mapped to pthread dynamically.
- we can configure pthread to keep running after synchronous thread_main finished. That's necessary to run any async tasks involving JavaScript interop.
- legacy interop has problems with GC boundaries.
- JSImport & JSExport work
- There is private JSSynchronizationContext implementation which is too synchronous
- There is draft of public C# API for creating JSWebWorker with JS interop. It must be dedicated un-managed resource, because we could not cleanup JS state created by user code.
- There is MT version of HTTP & WS clients, which could be called from any thread but it's also too synchronous implementation.
- Many unit tests fail on MT dotnet/runtime#91536
- there are MT C# ref assemblies, which don't throw PNSE for MT build of the runtime for blocking APIs.