0

Basically, why does switching out the Thread.Sleep() line for the await Task.Delay() line cause the output order to reverse?

var cts = new CancellationTokenSource();

var task = Task.Run(async () =>
{
    try
    {
        await Task.Delay(TimeSpan.FromSeconds(30), cts.Token);
    }
    catch (TaskCanceledException) { }
    Thread.Sleep(TimeSpan.FromSeconds(3)); // output 1 2
    //await Task.Delay(TimeSpan.FromSeconds(3)); // outputs 2 1
    Console.WriteLine("1");
});

Thread.Sleep(TimeSpan.FromSeconds(3));

cts.Cancel();
Console.WriteLine("2");

await task;

.NET Fiddle: https://dotnetfiddle.net/nqX5LP

Chris Yungmann
  • 1,079
  • 6
  • 14

4 Answers4

3

CancellationTokenSource.Cancel executes the cancellation callbacks synchronously. This includes async method continuations because await uses TaskContinuationOptions.ExecuteSynchronously (as described on my blog). Which execute synchronously except when they don't.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
2

I think this is an interesting question and highlights the difference between Tasks and Threads. See here for a good description: What is the difference between task and thread?

The highlight is that the app runs on a Thread. Tasks need a thread to execute, but not every task needs its own thread. The behavior you're noticing is due to each task likely being run on the same thread.

Why would that have an impact?

If you look at the documentation for Thread.Sleep() you'll see the note that it

Suspends the current thread for the specified amount of time (https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.sleep?view=net-6.0).

So in the case of using Thread.Sleep() inside the task the flow looks like the following:

  1. Start task
  2. await Delay for 30 seconds
  3. Sleep for 3 seconds (blocks entire thread, including task)
  4. Cancel token
  5. Cancel token exception thrown and handled
  6. Task sleeps for 3 seconds (blocks entire thread, including outer task)
  7. Task finishes sleep and outputs 1
  8. Main task outputs 2
  9. await task which has already completed
  10. fin

When using Task.Delay, which is a "sleep" that only blocks the task, not the executing thread:

  1. Start task
  2. await delay for 30 seconds
  3. Main task sleeps for 3 seconds (blocking entire thread, including task)
  4. Cancel token
  5. Cancel token exception thrown and handled
  6. await delay for 3 seconds (gives control back to main task)
  7. Output 2
  8. await task
  9. Output 1
  10. fin
emagers
  • 841
  • 7
  • 13
  • I think this does a good job of answering the question. Will wait and see if any other answers come in. I was surprised that (1) `CancellationTokenSource.Cancel` causes the "after cancellation" code to synchronously execute on the same thread (I linked a question that expands on this point) and (2) if it encounters an async/await construct while doing so, it won't wait for the continuation code to execute before continuing itself – Chris Yungmann Apr 13 '22 at 18:46
0

Stephen Cleary's answer gives the theoretical explanation to this interesting question. My suggestion from a practical standpoint is to output in your experiments the current time and thread-id. It helps a lot at understanding what's going on:

var cts = new CancellationTokenSource();
Print("1");
var task = Task.Run(async () =>
{
    Print("2");
    try { await Task.Delay(Timeout.Infinite, cts.Token); }
    catch (OperationCanceledException) { }
    Print("4");
    Thread.Sleep(1000);
    Print("5");
});
Thread.Sleep(1000);
Print("3");
cts.Cancel();
Print("6");
await task;

...where Print is this helper method:

static void Print(object value)
{
    Console.WriteLine($@"{DateTime.Now:HH:mm:ss.fff} [{Thread.CurrentThread
        .ManagedThreadId}] > {value}");
}

Output:

03:11:03.310 [1] > 1
03:11:03.356 [4] > 2
03:11:04.360 [1] > 3
03:11:04.403 [1] > 4
03:11:05.414 [1] > 5
03:11:05.416 [1] > 6

Try it on Fiddle.

As you can see, the continuation after awaiting the Task.Delay runs on the thread #1, which is the main thread of the console application. It is the same thread that you invoked the cts.Cancel on.

If you anticipate that the canceling of the CancellationTokenSource might hijack the current thread for longer than it's desirable, you can offload the canceling to a ThreadPool thread like this:

await Task.Run(() => cts.Cancel());
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
0

What Stephen Cleary said.

From CancellationTokenSource.Cancel Method

Any callbacks or cancelable operations registered with the CancellationToken will be executed. Callbacks will be executed synchronously in LIFO order.

I find that adding CurrentThread.ManagedThreadId to the output to see what threads do which work - while not bulletproof - often uncovers mysteries. I think this simple technique works very well in this specific case:

With Thread.Sleep you can see that all the work is done by the same thread:

1:Main>Before sleep
1:Main>After sleep
1:Task>Before extra sleep
1:Task>1
1:Main>2

With Task.Delay you can see that the work after Delay is done by another worker:

1:Main>Before sleep
1:Main>After sleep
1:Task>Before extra sleep
1:Main>2
4:Task>1
tymtam
  • 31,798
  • 8
  • 86
  • 126