C# asynchronous tasks training (turn-based simulation) I know embarassingly little about asynchronous programming in C#, so decided to start catching up. It would help me a lot if anyone checked this basic example I've created.
Assumption: we're creating a simulation where the time passes turn by turn with predefined minimal interval, e.g. 1 second. However, if any important calculations during the turn would take longer than this interval, next turn should not begin until they're done.
I would be grateful if you could tell me if there any visible issues, code smells, or if it can be optimized. I've read bad things about using Task.Run, but I don't really know how to tackle it better.
public static class Program
{
    static void Main()
    {
        var gameTurnsController = new GameTurnsController();
        var cts = new CancellationTokenSource();

        try
        {
            // Asynchronously wait for input to send cancellation token
            Task.Run(() =&gt;
            {
                Console.ReadKey(true);
                cts.Cancel();
            });

            gameTurnsController.PassTurns(TimeSpan.FromSeconds(1), cts.Token).Wait(cts.Token);
        }
        // It smells of controlling the execution flow through exception. Isn't there a better way to handle cancellation?
        catch (OperationCanceledException)
        {
            Console.WriteLine(Environment.NewLine + &quot;Cancelled.&quot;);
            Environment.ExitCode = 0;
        }
        
        Console.WriteLine(&quot;Execution finished.&quot;);
    }
}

public class GameTurnsController
{
    public async Task PassTurns(TimeSpan interval, CancellationToken cancellationToken)
    {
        while (true)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                return;
            }

            // Wait until both delay and calculation is finished
            await Task.WhenAll(Task.Delay(interval, cancellationToken), LongRunningOperation(cancellationToken));
        }
    }

    // expensive method example
    private async Task&lt;long&gt; LongRunningOperation(CancellationToken cancellationToken)
    {
        return await Task.Run(() =&gt;
        {
            long result;
            // ...
            // expensive math operation that returns long value
            // ...
            return result;
        }, cancellationToken);
    }
}

In the above code I've removed most of the logs and stopwatches for clarity.
The Wait call is tricky.

It throws by default AggregateException not OperationCanceledException.
If you provide the cancellationToken to the Wait then it will throw an OperationCanceledException.
The biggest problem with this approach is that it cancels only the Wait not the underlying task.

Please read this article for more details.



So, if we switch to async main then the application will not stop after user cancellation:
static async Task Main()
{
    var gameTurnsController = new GameTurnsController();
    var userRequestedCancellation = new CancellationTokenSource();

    try
    {
        _ = Task.Run(() =&gt;
        {
            Console.WriteLine(&quot;Please press any key to cancel&quot;);
            Console.ReadKey(true);
            userRequestedCancellation.Cancel();
        });

        await gameTurnsController.PassTurns(TimeSpan.FromSeconds(1), userRequestedCancellation.Token);
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine(Environment.NewLine + &quot;Cancelled.&quot;);
        Environment.ExitCode = 0;
    }

    Console.WriteLine(&quot;Execution finished.&quot;);
}


Because the underlying LongRunningOperation will not terminate.

(Let me use Thread.Sleep(5000) to simulate complex CPU bound operation)



private async Task&lt;long&gt; LongRunningOperation(CancellationToken cancellationToken)
{
    return await Task.Run(() =&gt;
    {
        long result;
        Thread.Sleep(5000);
        return result;
    }, cancellationToken);
}


If I run this app and I press any key then it will not cancel the LongRunningOperation method that's why the output will be the Execution finished..

How to fix it?
By passing all the way long the cancellationToken:
private async Task&lt;long&gt; LongRunningOperation(CancellationToken cancellationToken)
{
    var job = Task.Run(() =&gt; LongRunningOperationInternal(cancellationToken), cancellationToken);
    return await job;
}

private long LongRunningOperationInternal(CancellationToken cancellationToken)
{
    long result = 1;
    for (int i = 0; i &lt; 5; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();
        Thread.Sleep(1000); 
    }

    return result;
}

If need to do graceful cancellation then consider to use the following pattern:
if(cancellationToken.IsCancellationRequested)
{
  //TODO: clean up whatever is needed

  cancellationToken.ThrowIfCancellationRequested();
}

TL;DR: You need to check the cancellationToken at milestones/checkpoints to be able to truly cancel the whole application.

Might be an interesting read: How to can non-cancelable async operations