Execution of ContinueWith when the antecedent Task is in canceled state and usage of async delegate inside of ContinueWith

Viewed 264

The code discussed here is written in C# and executed with .netcore 3.1

I have the following piece of code, which starts a workload in the background without awaiting for it to complete (fire and forget):

public void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    // some background work is started in a fire and forget manner
    _ = Task.Run(async () => 
    {
        
        try 
        {
            // here I perform my background work. Regardless of the outcome resource must be released as soon as possible
            // I want that cancellation requests coming from the provided cancellation token are correctly listened by this code
            // So, I pass the cancellation token everywhere
            
            await Task.Delay(1500, token);
        }
        finally 
        {
            // here I need to release the resource. Releasing this resource is important and must be done as soon as possible
            await resource.DisposeAsync();
        }       
    }, token);
}

There are three important points:

  • the background work is started in a fire and forget manner. I'm not interested in awaiting its completion
  • the provided cancellation token is important and the background work must listed to incoming cancellation requests
  • the provided resource (IAsyncDisposable) must be released as soon as possible, regardless of the outcome of the background work. In order to release the resource a call to DisposeAsync is required.

The problem with this code is that the cancellation token is passed to Task.Run invokation. If the token is canceled before the execution of the async delegate starts, the async delegate is never executed and so the finally block is never executed. By doing so the requirement of releasing the IAsyncDisposable resource is not met (basically, DisposeAsync is never called).

The simplest way to solve this issue is not providing the cancellation token when Task.Run is invoked. That way, the async delegate is always executed and so the finally block is executed too. The code inside the async delegate listens to cancellation requests, so the requirement of cancel the execution is met too:

public void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    // some background work is started in a fire and forget manner
    _ = Task.Run(async () => 
    {
        
        try 
        {
            // here I perform my background work. Regardless of the outcome resource must be released as soon as possible
            // I want that cancellation requests coming from the provided cancellation token are correctly listened by this code
            // So, I pass the cancellation token everywhere
            
            await Task.Delay(1500, token);
        }
        finally 
        {
            // here I need to release the resource. Releasing this resource is important and must be done as soon as possible
            await resource.DisposeAsync();
        }       
    }, CancellationToken.None);
}

I'm asking myself whether the release of the IAsyncDisposable resource should, instead, be delegated to a continuation task. The code refactored by using this approach is the following:

public void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    // some background work is started in a fire and forget manner
    _ = Task.Run(async () => 
    {
        // here I perform my background work. Regardless of the outcome resource must be released as soon as possible
        // I want that cancellation requests coming from the provided cancellation token are correctly listened by this code
        // So, I pass the cancellation token everywhere
        
        await Task.Delay(1500, token);
    }, 
    token).ContinueWith(async _ => 
    {
        // release the IAsyncDisposable resource here, afte the completion of the antecedent task and regardless
        // of the antecedent task actual state
        await resource.DisposeAsync();
    });
}

I'm not really familiar with ContinueWith gotchas, so my questions are the following:

  1. do I have the guarantee that the continuation is always executed, even if the cancellation token is canceled before the execution of the antecedent task starts ?
  2. is there any issue in providing an async delegate to the invokation of ContinueWith ? Is the execution of the async delegate fully completed as expected ?
  3. What is the best approach ? Passing CancellationToken.None to the invokation of Task.Run, or relying on the continuation by using ContinueWith ?

IMPORTANT NOTE: I know that using Task.Run is not the best approach in a server application (more on that can be found here), so there are probably much better ways of designing my overall architecture. I posted this question to better understanding the actual behavior of ContinueWith, because I'm not really familiar with its usage (in modern .NET code it is largely replaced by the usage of async await).

2 Answers

You could consider using the await using statement, that handles the asynchronous disposal of the resource automatically:

public async void StartBackgroundWork(IAsyncDisposable resource, CancellationToken token)
{
    await using var _ = resource;
    try
    {
        await Task.Run(async () => 
        {
            await Task.Delay(1500, token);
        }, token);
    } catch (OperationCanceledException) { }
}

I also converted your fire-and-forget task to an async void (aka fire-and-crash) method. In case the unthinkable happens and your code has a bug, instead of the app continue running with an unobserved exception having occurred, resulting possibly to corrupted application state, the whole app will crash, forcing you to fix the bug ASAP.

But honestly creating a disposable resource in one method and disposing it in another is a smelly design. Ideally the method that created the resource should be responsible for disposing it finally.

I think Theodor has a great answer; I'm just going to answer some of your other questions:

do I have the guarantee that the continuation is always executed, even if the cancellation token is canceled before the execution of the antecedent task starts ?

ContinueWith will execute its delegate even of the antecedent task is already completed. In this specific case, there is no "guarantee" simply because of the nature of fire-and-forget.

is there any issue in providing an async delegate to the invokation of ContinueWith ?

ContinueWith is not async-aware, so the return type of ContinueWith is surprising for most developers. Since your code discards the return type, that's not a concern here.

Is the execution of the async delegate fully completed as expected ?

In this case, most likely, but it really depends on what "expected" means. Like all other fire-and-forget code, you can't guarantee completion. ContinueWith has an additional wrinkle: it executes its delegate using a TaskScheduler, and the default TaskScheduler is not TaskScheduler.Default but is actually TaskScheduler.Current. So I always recommend passing an explicit TaskScheduler for clarity if you really need to use ContinueWith.

What is the best approach ? Passing CancellationToken.None to the invokation of Task.Run, or relying on the continuation by using ContinueWith ?

Just drop the second argument to Task.Run.

I'll go further than that: Task.Run probably shouldn't even take a CancellationToken. I have yet to see a scenario where it's useful. I suspect the CancellationToken part of the API was copied from TaskFactory.StartNew (where it is rarely useful), but since Task.Run always uses TaskScheduler.Default, providing a CancellationToken is not useful in practice.

P.S. I recently wrote a short series on the proper solution for fire-and-forget on ASP.NET.

Related