7

How does Task.Yield work under the hood in Mono/WASM runtime (which is used by Blazor WebAssembly)?

To clarify, I believe I have a good understanding of how Task.Yield works in .NET Framework and .NET Core. Mono implementation doesn't look much different, in a nutshell, it comes down to this:

static Task Yield() 
{
    var tcs = new TaskCompletionSource<bool>();
    System.Threading.ThreadPool.QueueUserWorkItem(_ => tcs.TrySetResult(true));
    return tcs.Task;
}

Surprisingly, this works in Blazor WebAssembly, too (try it online):

<label>Tick Count: @tickCount</label><br>

@code 
{
    int tickCount = System.Environment.TickCount;

    protected override void OnAfterRender(bool firstRender)
    {
        if (firstRender) CountAsync();
    }

    static Task Yield() 
    {
        var tcs = new TaskCompletionSource<bool>();
        System.Threading.ThreadPool.QueueUserWorkItem(_ => tcs.TrySetResult(true));
        return tcs.Task;
    }

    async void CountAsync() 
    {
        for (var i = 0; i < 10000; i++) 
        {
            await Yield();
            tickCount = System.Environment.TickCount;
            StateHasChanged();
        }
    }
}

Naturally, it all happens on the same event loop thread in the browser, so I wonder how it works on the lower level.

I suspect, it might be utilizing something like Emscripten's Asyncify, but eventually, does it use some sort of Web Platform API to schedule a continuation callback? And if so, which one exactly (like queueMicrotask, setTimout, Promise.resove().then, etc)?


Updated, I've just discovered that Thread.Sleep is implemented as well and it actually blocks the event loop thread

Curious about how that works on the WebAssembly level, too. With JavaScript, I can only think of a busy loop to simulate Thread.Sleep (as Atomics.wait is only available from web worker threads).

noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 1
    If I understand correctly the dotnet runtime is compiled to webassembly. I'm guessing that it has more to do with webassembly supporting multi threading: https://web.dev/webassembly-threads/ – mcbr Nov 24 '21 at 11:21
  • @mcbr thanks for the link! It led me to [an answer about `Thread.Sleep`](https://web.dev/asyncify/#:~:text=its%20default%20implementation%20of%20%22sleep%22), last but not least. – noseratio Nov 24 '21 at 11:47
  • 1
    As far as I know - blazor doesn't support web assembly multithreading yet (in reply to first comment) – Evk Nov 24 '21 at 11:51
  • @Evk, true AFAIK as well. Even if it used pthreads, imagine how expensive it'd be to do something like `await Task.Yield()`, if it worked in the same way as it does in .NET 6.x. They'd probably have to introduce `SynchonizationContext` for the main thread to mitigate that (currently, `SynchonizationContext.Current` is `null` in Blazor/WASM). – noseratio Nov 24 '21 at 11:56
  • @BrunoGarcia points out: https://github.com/mono/mono/issues/12453#issuecomment-744983773 – noseratio Nov 24 '21 at 22:34
  • Short answer: `YieldAwaitable Task.Yield() { return default; }`. And because `SyncContext.Current == null` the question is actually about how the ThreadPool works on WebAssembly. – H H Nov 26 '21 at 07:19
  • @HenkHolterman, yep that's essentially my question: *... does it use some sort of Web Platform API to schedule a continuation callback? And if so, which one exactly (like `queueMicrotask`, `setTimout`, `Promise.resove().then`, etc)?* – noseratio Nov 26 '21 at 08:08
  • 1
    I have a similar curiosity, but regarding how the Garbage Collector is implemented. Does it run as interleaved, high-level tasks in the thread pool or is it triggered as part of the new() operator execution? Does it have a stop-the-world phase that may block the event-loop thread for a considerable time every now and then? – BlueStrat Mar 16 '22 at 23:57

1 Answers1

9

It’s setTimeout. There is considerable indirection between that and QueueUserWorkItem, but this is where it bottoms out.

Most of the WebAssembly-specific machinery can be seen in PR 38029. The WebAssembly implementation of RequestWorkerThread calls a private method named QueueCallback, which is implemented in C code as mono_wasm_queue_tp_cb. This in invokes mono_threads_schedule_background_job, which in turn calls schedule_background_exec, which is implemented in TypeScript as:

export function schedule_background_exec(): void {
    ++pump_count;
    if (typeof globalThis.setTimeout === "function") {
        globalThis.setTimeout(pump_message, 0);
    }
}

The setTimeout callback eventually reaches ThreadPool.Callback, which invokes ThreadPoolWorkQueue.Dispatch.

The rest of it is not specific to Blazor at all, and can be studied by reading the source code of the ThreadPoolWorkQueue class. In short, ThreadPool.QueueUserWorkItem enqueues the callback in a ThreadPoolQueue. Enqueueing calls EnsureThreadRequested, which delegates to RequestWorkerThread, implemented as above. ThreadPoolWorkQueue.Dispatch causes some number of asynchronous tasks to be dequeued and executed; among them, the callback passed to QueueUserWorkItem should eventually appear.

user3840170
  • 26,597
  • 4
  • 30
  • 62
  • A great answer, tks! But geven it's `setTimeout`, could you explain a huge discrepancy I'm seeing when timing a loop of `await new Promise(r => setTimeout(r, 0))` with JS interop vs a loop of `await Task.Yield`? Is there a flaw in the test? https://blazorrepl.telerik.com/QlFFQLPF08dkYRbm30 – noseratio Nov 27 '21 at 11:09
  • `queueMicrotask` (as opposed to `setTimeout`) produces a much closer result: https://blazorrepl.telerik.com/QFbFGVFP10NWGSam57 – noseratio Nov 27 '21 at 11:11
  • 1
    I am unable to open any of the REPL links, so I cannot tell what you mean. But if you study the source code of `ThreadPoolWorkQueue.Dispatch`, you will notice there is some sophisticated scheduling involved as well, and one `setTimeout` may serve multiple queued .NET asynchronous tasks, which I would expect to be faster than having each `setTimeout` dispatch a single callback. – user3840170 Nov 27 '21 at 12:03
  • Odd repl links don't work. If you'd still like to try it, here's the gist: https://gist.github.com/noseratio/73f6cd2fb328387ace2a7761f0b0dadc. It's literrally 8000ms vs 20ms. Then just replace `setTimeout` with `queueMicrotask`, and it's about the same 20ms. – noseratio Nov 27 '21 at 12:46
  • 1
    Seems like it: `setTimeout` makes the browser process the event loop in between callbacks, but the .NET runtime can dispatch multiple asynchronous tasks in a single `setTimeout` callback (dequeuing them almost immediately after they are queued), thus avoiding the overhead of yielding to the event loop. (Also, browsers may perform throttling on `setTimeout` calls, which this batching avoids.) This produces an effect roughly equivalent to `queueMicrotask`. Although the timings you get are probably not very accurate, thanks to Spectre mitigations. – user3840170 Nov 27 '21 at 13:51