Search code examples
c#asp.net-coretaskbackground-service

BackgroundService implementation that prevents AspNetCore to start/stop correctly


I am using a BackgroundService object in an aspnet core application.

Regarding the way the operations that run in the ExecuteAsync method are implemented, the Aspnet core fails to initialize or stop correctly. Here is what I tried:

I implemented the abstract ExecuteAsync method the way it is explained in the documentation.

the pipeline variable is an IEnumerable<IPipeline> that is injected in the constructor.

public interface IPipeline {
    Task Start();
    Task Cycle();
    Task Stop();
}

...

protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    log.LogInformation($"Starting subsystems");

    foreach(var engine in pipeLine) {
        try {
            await engine.Start();
        }
        catch(Exception ex) {
            log.LogError(ex, $"{nameof(engine)} failed to start");
        }
    }

    log.LogInformation($"Runnning main loop");

    while(!stoppingToken.IsCancellationRequested) {
        foreach(var engine in pipeLine) {
            try {
                await engine.Cycle();
            }
            catch(Exception ex) {
                log.LogError(ex, $"{engine.GetType().Name} error in Cycle");
            }
        }
    }

    log.LogInformation($"Stopping subsystems");

    foreach(var engine in pipeLine) {
        try {
            await engine.Stop();
        }
        catch(Exception ex) {
            log.LogError(ex, $"{nameof(engine)} failed to stop");
        }
    }
}

Because of the current development state of the project, there are many "nop" Pipeline that contains an empty Cycle() operation that is implemented this way:

public async Task Cycle() {
    await Task.CompletedTask;
}

What I noticed is:

  • If at least one IPipeline object contains an actual asynchronous method (await Task.Delay(1)), then everything runs smoothly and I can stop the service gracefully using CTRL+C.

  • If all IPipeline objects contains await Task.CompletedTask;,

Then on one hand, aspnetcore fails to initialize correctly. I mean, there is no "Now listening on: http://[::]:10001 Application started. Press Ctrl+C to shut down." on the console.

On the other, when I hit CTRL+C, the console shows "Application is shutting down..." but the cycle loop continues to run as if the CancellationToken was never requested to stop.

So basically, if I change a single Pipeline object to this:

public async Task Cycle() {
    await Task.Delay(1);
}

Then everything is fine, and I dont understand why. Can someone explain me what I did not understood regarding Task processing ?


Solution

  • The simplest workaround is to add await Task.Yield(); as line one of ExecuteAsync.

    I am not an expert... but the "problem" is that all the code inside this ExecuteAsync actually running synchronously.

    If all the "cycles" return a Task that has completed synchronously (as Task.CompletedTask will be) then the while and therefore the ExecuteAsync method never "yield"s.

    The framework essentially does a foreach over the registered IHostedServices and calls StartAsync. If your service does not yield then the foreach gets blocked. So any other services (including the AspNetCore host) will not be started. As bootstrapping cannot finish, things like ctrl-C handling etc also never get setup.

    Putting await Task.Delay(1) in one of the cycles "releases" or "yields" the Task. This allows the host to "capture" the task and continue. Which allows the wiring up of Cancellation etc to happen.

    Putting Task.Yield() at the top of ExecuteAsync is just the more direct way of achieving this and means the cycles do not need to be aware of this "issue" at all.

    note: this is usually only really an issue in testing... 'cos why would you have a no-op cycle?

    note2: If you are likely to have "compute" cycles (ie they don't do any IO, database, queues etc) then switching to ValueTask will help with perf/allocations.