Why a blocking call inside BackgroundService.ExecuteAsync is not blocking the main thread?

Viewed 2215

I'm implementing a Microsoft.Extensions.Hosting.BackgroundService in a Asp.Net Core Web API, which has a blocking call inside ExecuteAsync, but surprisingly (to me) it is actually not blocking my application and I'm wondering the reason.

Accordingly to the different versions of the source code of BackgroundService I could find, the method Task ExecuteAsync is called in a fire and forget fashion inside StartAync. Source bellow.

        public virtual Task StartAsync(CancellationToken cancellationToken)
        {
            // Store the task we're executing
            _executingTask = ExecuteAsync(_stoppingCts.Token);

            // If the task is completed then return it, this will bubble cancellation and failure to the caller
            if (_executingTask.IsCompleted)
            {
                return _executingTask;
            }

            // Otherwise it's running
            return Task.CompletedTask;
        }

From what I understand, the continuation of an await call (ie. whatever is bellow it) will be executed in the same SynchronizationContext that called this asynchronous method after the task returns. If this is true, then why isn't this continuation code (that has a blocking call) blocking my application?

To illustrate:

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            await Task.Yield();

            Foo(); // Foo blocks its executing thread until an I/O operation completes.
        }

As ExecutedAsync is never awaited, the continuation of the method (ie. the Foo call) will be executing in the same sync context that fired the ExecuteAsync task in the first place (which will be running in the main thread if I understood correctly).

I'm suspecting the Asp.Net runtime must have its own SynchronizationContext that actually executes async continuations in different threads or something like that.

Can anyone shed some light here?

4 Answers

According to what I know... I'll suggest you to read this first.

And now, I assume that you get the asp.net core itself has actually adopt the contextless approach.

Beside, When you build the host, usually by CreateHostBuilder(args).Build().Run();, that actually run the host, which is an instance of IHost interface, not the IHostedService, which BackgroundService abstract class inherited from.

Briefly, anything that inherited from BackgroundService base class can be consider a special object that got managed and execute by the host itself, and not running on the same level as the thread that got use by the host itself.

You can put an operation that block the thread on the your background service, but that's the thread handling your service, not the application thread itself.

UPDATE

As @underthevoid desire to inspect furthur on what i called "different level as the thread that got use by the host itself", allow me to clear some of my points here.

At first, CreateHostBuilder(args).Build().Run(); build an instance of WebHost, and execute it by calling the .Run(), which behind the screen, call to the Start() method as you can look at it here.

Then, magic happen on this line _hostedServiceExecutor = _applicationServices.GetRequiredService<HostedServiceExecutor>();, which will call StartAsync(cancellationToken) right after that.

Now, look at the HostedServiceExecutor, on the constructor, it get all instances of IHostedService that you registered along with your app, then execute them all, as you can saw briefly on the code.

Each of the IHostedService is now an object that execute their own Tasks, and has nothing to do with the Task that handle WebHost. They are all separate now.

And to what i mean different level as the thread that got use by the host itself, that i tried to point 2 things.

  • All the BackgroundService that you use got invoke by the WebHost, without it, they can do nothing.
  • They are running on separate task, therefore, separate thread (i don't think a thread can handle multiple task at the same time as the way they're implement the code).

And for how the task was execute, together nicely, look at @Stephen Cleary answered below, and believe him more than yourself, I have the same level of faith too.

By the way... this is also some nice couple written about BackgroundService, that i get quite a few things from them too.

Hope this will help!

Two points come into play: SynchronizationContext and TaskScheduler.

In Asp.Core the default SyncContext is null, allowing each task to run on whatever thread is available. As second the TaskScheduler comes into play. If it runs multiple tasks on the same thread one can block all the other tasks.

But the tasks running as background service are marked as LongRunning to the task scheduler and the task scheduler gives each long running task its own thread. Thus your background service can't block the asp core engine by a blocking thread call.

From what I understand, the continuation of an await call (ie. whatever is bellow it) will be executed in the same SynchronizationContext that called this asynchronous method after the task returns.

A bit of clarification: when await operates asynchronously, it captures the current "context" at that time (not what was the context at the beginning of the method call). This "context" is SynchronizationContext.Current, unless it is null, in which case the "context" is TaskScheduler.Current.

Note that TaskScheduler.Current is never null. If there isn't a custom TaskScheduler, then TaskScheduler.Current is the same as TaskScheduler.Default, which is a task scheduler that schedules work to the .NET thread pool. To put it more simply, if there's no custom SynchronizationContext and no custom TaskScheduler, then the continuations get queued to the thread pool.

... the same sync context ... (which will be running in the main thread if I understood correctly).

Synchronization contexts don't have a 1:1 relationship with threads. It's true that for GUI apps, a UI synchronization context will queue work to a specific UI thread. But in ASP.NET Classic (pre-Core), its synchronization context would queue work to the thread pool. Even in the UI world, it's possible to have multiple different synchronization contexts for the same UI thread (e.g., multi-windowed WPF apps used to do this, and still do AFAIK).

I'm suspecting the Asp.Net runtime must have its own SynchronizationContext that actually executes async continuations in different threads or something like that.

ASP.NET Core has no SynchronizationContext at all, so the default behavior applies: the continuations are queued to the thread pool.

the continuation of an await call (ie. whatever is bellow it) will be executed in the same SynchronizationContext that called this asynchronous method

Only when there is a SynchronizationContext. And then it depends on the specific Context, not all SynchronizationContexts have Thread affinity.

I think that SynchronizationContext.Current is null in asp.net, I am certain that it will not continue after an wait on the same thread.

Related