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

Add WaitAsync functionality to Background Service #74789

Merged
merged 4 commits into from
Sep 2, 2022

Conversation

Nick-Stanton
Copy link
Member

Resolves problem in #36380 where Microsoft.Extensions.Hosting.Abstractions\src\BackgroundService.cs can create Tasks that appear likely to never complete. New functionality in .NET 6.0 allowed this to be cleaned up with Task.WaitAsync. Since this API does not exist in all configurations that the Extensions libraries target, the new desired behavior is simulated by a fix proposed by @Zenexer in versions without it.

The nested ConfigureAwait() calls don't look pretty, but they were required in order to be compliant with CA2007.

@ghost
Copy link

ghost commented Aug 30, 2022

Tagging subscribers to this area: @dotnet/area-extensions-hosting
See info in area-owners.md if you want to be subscribed.

Issue Details

Resolves problem in #36380 where Microsoft.Extensions.Hosting.Abstractions\src\BackgroundService.cs can create Tasks that appear likely to never complete. New functionality in .NET 6.0 allowed this to be cleaned up with Task.WaitAsync. Since this API does not exist in all configurations that the Extensions libraries target, the new desired behavior is simulated by a fix proposed by @Zenexer in versions without it.

The nested ConfigureAwait() calls don't look pretty, but they were required in order to be compliant with CA2007.

Author: Nick-Stanton
Assignees: -
Labels:

area-Extensions-Hosting

Milestone: -

@tarekgh
Copy link
Member

tarekgh commented Aug 30, 2022

Do we have a test exercise the affected functionality (especially on non net core)?

CC @stephentoub if interested to take a quick look at the change.

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like @tarekgh mentioned, it would be good to ensure we have a test that does something like:

  1. Run a BackgroundService
  2. Calls StopAsync on the service
  3. Stopping the service will cancel the ExecuteAsync method, which should be either ignored or continue processing for longer than the shutdown timeout
  4. The shutdown timeout should kick in and the processing in StopAsync here (that you are changing) should be canceled and the host should continue shutting down.

@@ -74,7 +74,13 @@ public virtual async Task StopAsync(CancellationToken cancellationToken)
finally
{
// Wait until the task completes or the stop token triggers
await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
#if NETCOREAPP
await _executeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test are failing off of line 78. Here's a stack trace of some of the fails.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were never actually awaiting the _executeTask before. Before we were just awaiting Task.WhenAny which successfully returns when either of those tasks completes (if that underlying Task is successful or not).

Basically we need to ignore that the task is getting cancelled. The first way I can think of to do that is to:

try
{
    await _executeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
    // ignore cancellation exceptions since we are stopping the service and the cancellation is expected
}

But maybe there is a cleaner way to have have exception processing in the "happy path".

Another option is to not use the new API and instead unconditionally use the TaskCompletionSource approach even on NETCOREAPP.

Copy link
Contributor

@Zenexer Zenexer Aug 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were never actually awaiting the _executeTask before.

The intention was to await _executeTask, though; the original code had a bug (#36380).

Edit: Looking at the source for WaitAsync, I'm fairly certain the NETCOREAPP and !NETCOREAPP paths are equivalent, other than the fact that the code for WaitAsync seems suboptimal (bool type param instead of object, inconsistent formatting--and why is it wrapped in a Timer? Is that important?)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention was to await _executeTask, though

Not really. The intention is to signal to the _executeTask that it should be stopped and wait for it to be stopped (or to timeout). If an exception is thrown by the _executeTask, we don't care about throwing it here - which is what await does. Exceptions coming from the ExecuteTask are handled here (which also ignores Cancellation exceptions):

private async Task TryExecuteBackgroundServiceAsync(BackgroundService backgroundService)
{
// backgroundService.ExecuteTask may not be set (e.g. if the derived class doesn't call base.StartAsync)
Task? backgroundTask = backgroundService.ExecuteTask;
if (backgroundTask == null)
{
return;
}
try
{
await backgroundTask.ConfigureAwait(false);
}
catch (Exception ex)
{
// When the host is being stopped, it cancels the background services.
// This isn't an error condition, so don't log it as an error.
if (_stopCalled && backgroundTask.IsCanceled && ex is OperationCanceledException)
{
return;
}
_logger.BackgroundServiceFaulted(ex);
if (_options.BackgroundServiceExceptionBehavior == BackgroundServiceExceptionBehavior.StopHost)
{
_logger.BackgroundServiceStoppingHost(ex);
_applicationLifetime.StopApplication();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the behavior of BackgroundService handling has changed significantly from what it was when I opened #36380. @Nick-Stanton, you should keep in mind that my original suggestion was for .NET Core 3.0, and the behavior has changed since then. My suggestion is no longer valid with the current behavior. Line 82 in your current version is also erroneous with the current behavior: it shouldn't await twice; that was only valid when BackgroundService didn't expose _executeTask (the ExecuteTask property didn't exist).

Copy link
Contributor

@Zenexer Zenexer Aug 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the !NETCOREAPP behavior, this is actually a bit of a mess: if _executeTask is returned, it must not be awaited, but if tcs.Task is returned, it must be awaited (otherwise, the memory leak in #36380 returns). See line 82 below.

@Nick-Stanton Nick-Stanton marked this pull request as ready for review August 30, 2022 21:53
await Task.WhenAny(_executeTask, tcs.Task).ConfigureAwait(false);
#endif
}
catch (OperationCanceledException) { }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a quick comment here explaining why we are ignoring this exception.

#else
var tcs = new TaskCompletionSource<object>();
using CancellationTokenRegistration registration = cancellationToken.Register(s => ((TaskCompletionSource<object>)s).SetCanceled(), tcs);
await Task.WhenAny(_executeTask, tcs.Task).ConfigureAwait(false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await Task.WhenAny(_executeTask, tcs.Task).ConfigureAwait(false);
await (await Task.WhenAny(_executeTask, tcs.Task).ConfigureAwait(false)).ConfigureAwait(false).;

I think since we are catching OperationCanceledException, we should also be awaiting the returned Task from awaiting Task.WhenAny here.

@@ -74,7 +74,17 @@ public virtual async Task StopAsync(CancellationToken cancellationToken)
finally
{
// Wait until the task completes or the stop token triggers
await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
try
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stephentoub - do you have any opinion on this approach? The options I see are:

  1. The current code.
  2. Explicitly not awaiting the Task returned from awaiting Task.WhenAny, which will ignore the OperationCanceledException - because we never await the Task that got canceled.
var tcs = new TaskCompletionSource<object>();
using CancellationTokenRegistration registration = cancellationToken.Register(s => ((TaskCompletionSource<object>)s).SetCanceled(), tcs);
await Task.WhenAny(_executeTask, tcs.Task).ConfigureAwait(false);

I'm not 100% sure which is the best option. I thought the current proposed approach was more explicit as to what is happening: An OperationCanceledException is being thrown, and we are explicitly ignoring it.

Performance here isn't a major concern because these BackgroundServices don't get Start/Stopped very often (typically just at the beginning and end of the process), so I didn't think the exception handling would make a perf difference.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spoke with @stephentoub offline. His recommendation was to keep it simple, and just use option (2) above on all TFMs. I also recommend adding a comment that we explicitly aren't awaiting the _executeTask here, just the Task.WhenAny because cancelling the _executeTask will always throw an OperationCanceledException and we are just ignoring that exception.

Futher, @stephentoub showed me that in "normal" hosting scenarios, we don't actually have a leak with the current code. The Task created from Task.Delay(Timeout.Infinite, cancellationToken) will be available for GC once the cancellationToken passed in goes away. This is because Timeout.Infinite is special cased to not create a Timer:

if (millisecondsDelay != Timeout.UnsignedInfinite) // no need to create the timer if it's an infinite timeout
{
_timer = new TimerQueueTimer(s_timerCallback, this, millisecondsDelay, Timeout.UnsignedInfinite, flowExecutionContext: false);
if (IsCompleted)
{
// Handle rare race condition where the timer fires prior to our having stored it into the field, in which case
// the timer won't have been cleaned up appropriately. This call to close might race with the Cleanup call to Close,
// but Close is thread-safe and will be a nop if it's already been closed.
_timer.Close();
}
}

Instead, the CancellationToken and the Task from Task.WhenAny are the things holding onto the Task. The latter will be available for GC once this method completes. So the only question is the lifetime of the CancellationToken.

In normal hosting scenarios, StopAsync gets called with a CancellationToken from a CancellationTokenSource that is disposed of after all the hosted services are stopped:

using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken))
{
CancellationToken token = linkedCts.Token;
// Trigger IHostApplicationLifetime.ApplicationStopping
_applicationLifetime.StopApplication();
IList<Exception> exceptions = new List<Exception>();
if (_hostedServices != null) // Started?
{
foreach (IHostedService hostedService in _hostedServices.Reverse())
{
try
{
await hostedService.StopAsync(token).ConfigureAwait(false);

In that case, there won't be a leak.

However, since this API is public, taking a fix here is the right thing to do since anyone can call this method with any CancellationToken - for example one that is long-lived.

Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for fixing this!

await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken)).ConfigureAwait(false);
var tcs = new TaskCompletionSource<object>();
using CancellationTokenRegistration registration = cancellationToken.Register(s => ((TaskCompletionSource<object>)s!).SetCanceled(), tcs);
// Do not await the _executeTask because cancelling it will throw an OperationCanceledException which we are explicitly ignoring
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we usually spell canceling (and canceled and canceller) with 1 L rather than 2. Cancellation is the only variant for which we use 2.

@Nick-Stanton
Copy link
Member Author

Failures are tracked by #74952 and are unrelated to this change. Merging

@Nick-Stanton Nick-Stanton merged commit 2f64bc7 into dotnet:main Sep 2, 2022
@Nick-Stanton Nick-Stanton deleted the fix36380 branch September 2, 2022 16:35
@ghost ghost locked as resolved and limited conversation to collaborators Oct 2, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants