How does asynchronous programming work with threads when using Thread.Sleep()?
Asked Answered
U

3

0

Presumptions/Prelude:

  1. In previous questions, we note that Thread.Sleep blocks threads see: When to use Task.Delay, when to use Thread.Sleep?.
  2. We also note that console apps have three threads: The main thread, the GC thread & the finalizer thread IIRC. All other threads are debugger threads.
  3. We know that async does not spin up new threads, and it instead runs on the synchronization context, "uses time on the thread only when the method is active". https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/task-asynchronous-programming-model

Setup:
In a sample console app, we can see that neither the sibling nor the parent code are affected by a call to Thread.Sleep, at least until the await is called (unknown if further).

var sw = new Stopwatch();
sw.Start();
Console.WriteLine($"{sw.Elapsed}");
var asyncTests = new AsyncTests();

var go1 = asyncTests.WriteWithSleep();
var go2 = asyncTests.WriteWithoutSleep();

await go1;
await go2;
sw.Stop();
Console.WriteLine($"{sw.Elapsed}");
        
Stopwatch sw1 = new Stopwatch();
public async Task WriteWithSleep()
{
    sw1.Start();
    await Task.Delay(1000);
    Console.WriteLine("Delayed 1 seconds");
    Console.WriteLine($"{sw1.Elapsed}");
    Thread.Sleep(9000);
    Console.WriteLine("Delayed 10 seconds");
    Console.WriteLine($"{sw1.Elapsed}");
    sw1.Stop();
}
public async Task WriteWithoutSleep()
{
    await Task.Delay(3000);
    Console.WriteLine("Delayed 3 second.");
    Console.WriteLine($"{sw1.Elapsed}");
    await Task.Delay(6000);
    Console.WriteLine("Delayed 9 seconds.");
    Console.WriteLine($"{sw1.Elapsed}");
}

Question: If the thread is blocked from execution during Thread.Sleep, how is it that it continues to process the parent and sibling? Some answer that it is background threads, but I see no evidence of multithreading background threads. What am I missing?

Ulane answered 17/5, 2022 at 19:34 Comment(2)
Task.Delay internally configures a timer which uses threadpool threads to fire its callback. See also learn.microsoft.com/en-us/dotnet/api/system.threading.timer You might log Thread.CurrentThread.ManagedThreadId to get an idea of what thread runs what. Note that none of your code actually runs on the main thread since you're using async Main.Retributive
What am I missing? - all .NET apps also have a built-in thread pool, which has a variable number of both worker threads and IOCP threads.Woadwaxen
F
2

I see no evidence of multithreading background threads. What am I missing?

Possibly you are looking in the wrong place, or using the wrong tools. There's a handy property that might be of use to you, in the form of Thread.CurrentThread.ManagedThreadId. According to the docs,

A thread's ManagedThreadId property value serves to uniquely identify that thread within its process.

The value of the ManagedThreadId property does not vary over time

This means that all code running on the same thread will always see the same ManagedThreadId value. If you sprinkle some extra WriteLines into your code, you'll be able to see that your tasks may run on several different threads during their lifetimes. It is even entirely possible for some async applications to have all their tasks run on the same thread, though you probably won't see that behaviour in your code under normal circumstances.

Here's some example output from my machine, not guaranteed to be the same on yours, nor is it necessarily going to be the same output on successive runs of the same application.

00:00:00.0000030
 * WriteWithSleep on thread 1 before await
 * WriteWithoutSleep on thread 1 before first await
 * WriteWithSleep on thread 4 after await
Delayed 1 seconds
00:00:01.0203244
 * WriteWithoutSleep on thread 5 after first await
Delayed 3 second.
00:00:03.0310891
 * WriteWithoutSleep on thread 6 after second await
Delayed 9 seconds.
00:00:09.0609263
Delayed 10 seconds
00:00:10.0257838
00:00:10.0898976

The business of running tasks on threads is handled by a TaskScheduler. You could write one that forces code to be single threaded, but that's not often a useful thing to do. The default scheduler uses a threadpool, and as such tasks can be run on a number of different threads.

Frenzy answered 18/5, 2022 at 15:49 Comment(0)
H
3

The Task.Delay method is implemented basically like this (simplified¹):

public static Task Delay(int millisecondsDelay)
{
    var tcs = new TaskCompletionSource();
    _ = new Timer(_ => tcs.SetResult(), null, millisecondsDelay, -1);
    return tcs.Task;
}

The Task is completed on the callback of a System.Threading.Timer component, and according to the documentation this callback is invoked on a ThreadPool thread:

The method does not execute on the thread that created the timer; it executes on a ThreadPool thread supplied by the system.

So when you await the task returned by the Task.Delay method, the continuation after the await runs on the ThreadPool. The ThreadPool typically has more than one threads available immediately on demand, so it's not difficult to introduce concurrency and parallelism if you create 2 tasks at once, like you do in your example. The main thread of a console application is not equipped with a SynchronizationContext by default, so there is no mechanism in place to prevent the observed concurrency.

¹ For demonstration purposes only. The Timer reference is not stored anywhere, so it might be garbage collected before the callback is invoked, resulting in the Task never completing.

Hague answered 17/5, 2022 at 20:5 Comment(2)
You can avoid that problem by capturing the timer variable in the callback lambda. (sharplab.io/…)Polish
@JeremyLakeman I just found an interesting GitHub issue about this.Hague
F
2

I see no evidence of multithreading background threads. What am I missing?

Possibly you are looking in the wrong place, or using the wrong tools. There's a handy property that might be of use to you, in the form of Thread.CurrentThread.ManagedThreadId. According to the docs,

A thread's ManagedThreadId property value serves to uniquely identify that thread within its process.

The value of the ManagedThreadId property does not vary over time

This means that all code running on the same thread will always see the same ManagedThreadId value. If you sprinkle some extra WriteLines into your code, you'll be able to see that your tasks may run on several different threads during their lifetimes. It is even entirely possible for some async applications to have all their tasks run on the same thread, though you probably won't see that behaviour in your code under normal circumstances.

Here's some example output from my machine, not guaranteed to be the same on yours, nor is it necessarily going to be the same output on successive runs of the same application.

00:00:00.0000030
 * WriteWithSleep on thread 1 before await
 * WriteWithoutSleep on thread 1 before first await
 * WriteWithSleep on thread 4 after await
Delayed 1 seconds
00:00:01.0203244
 * WriteWithoutSleep on thread 5 after first await
Delayed 3 second.
00:00:03.0310891
 * WriteWithoutSleep on thread 6 after second await
Delayed 9 seconds.
00:00:09.0609263
Delayed 10 seconds
00:00:10.0257838
00:00:10.0898976

The business of running tasks on threads is handled by a TaskScheduler. You could write one that forces code to be single threaded, but that's not often a useful thing to do. The default scheduler uses a threadpool, and as such tasks can be run on a number of different threads.

Frenzy answered 18/5, 2022 at 15:49 Comment(0)
U
0

I am not accepting my own answer, I will accept someone else's answer because they helped me figure this out. First, in the context of my question, I was using async Main. It was very hard to choose between Theodor's & Rook's answer. However, Rook's answer provided me with one thing that helped me fish: Thread.CurrentThread.ManagedThreadId

These are the results of my running code:

1 00:00:00.0000767
Not Delayed.
1 00:00:00.2988809
Delayed 1 second.
4 00:00:01.3392148
Delayed 3 second.
5 00:00:03.3716776
Delayed 9 seconds.
5 00:00:09.3838139
Delayed 10 seconds
4 00:00:10.3411050
4 00:00:10.5313519

I notice that there are 3 threads here, The initial thread (1) provides for the first calling method and part of the WriteWithSleep() until Task.Delay is initialized and later awaited. At the point that Task.Delay is brought back into Thread 1, everything is run on Thread 4 instead of Thread 1 for the main and the remainder of WriteWithSleep.
WriteWithoutSleep uses its own Thread(5).

So my error was believing that there were only 3 threads. I believed the answer to this question: https://mcmap.net/q/18194/-why-does-this-simple-net-console-app-have-so-many-threads#:~:text=You%20should%20only%20see%20three,see%20are%20debugger%2Drelated%20threads.

However, that question may not have been async, or may not have considered these additional worker threads from the threadpool.

Thank you all for your assistance in figuring out this question.

Ulane answered 19/5, 2022 at 16:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.