2

Given the following code:

public static async Task TestTask()
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    await Task.Delay(1000);
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}

We see that the current thread is changed after the await. However, this code is not multi-threaded and instead it is meant to provide non-blocking IO (with reference to JavaScript's mechanism). But why does it happen? and since it happens, does it mean that anything that comes after the awaited line must consider thread-safety (for example adding to a HashSet) while that would not be needed before that awaited line?

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Arnold Zahrneinder
  • 4,788
  • 10
  • 40
  • 76
  • When code is awaited, it is entirely possible that the thread that was running before the await is different to the one afterwards, there is no need to consider 'thread safety', unless you are using thread based things (like CurrentThread or Thread.SetData etc). – Neil Jan 30 '22 at 18:15
  • @Neil: I am not creating a thread and I expect to see the exact thread id before and after the `await`. Async actually means let the cursor pass in the memory so that I can do other stuff on the same thread. But this is making me feel bad about C#. – Arnold Zahrneinder Jan 30 '22 at 18:20
  • 1
    Correct, YOU are not creating a thread, but there is a thread pool that async/await is using, and CLR is free to run your code on whatever thread from the thread pool is free, when your code executes. Your expectation is based on a false premise (c# is not like javascript). – Neil Jan 30 '22 at 18:24
  • 3
    If you want to preserve the same thread after the `await`, that thread must have a `SynchronizationContext`installed. Such context is installed automatically in WinForms/WPF apps. For console applications, see [here](https://stackoverflow.com/questions/52686862/why-the-default-synchronizationcontext-is-not-captured-in-a-console-app/). – Theodor Zoulias Jan 30 '22 at 18:25
  • @Neil: But Async is a concept, and that means non-blocking IO, when a programing language implements that by taking advantage of the thread-pool, it isn't very good. – Arnold Zahrneinder Jan 30 '22 at 18:26
  • 1
    @ArnoldZahrneinder _"But this is making me feel bad about C#."_ - why? _"does it mean that anything that comes after the awaited line must consider thread-safety"- your method is not executed in parallel (i.e. everything after the await will be invoked only after the await finishes.), so if you don't need synchronization before await you will not need it after. – Guru Stron Jan 30 '22 at 18:27
  • @ArnoldZahrneinder _"when a programing language implements that by taking advantage of the thread-pool, it isn't very good"_ - why? no thread is consumed while waiting for I/O - [there is no thread](https://blog.stephencleary.com/2013/11/there-is-no-thread.html) – Guru Stron Jan 30 '22 at 18:28
  • @GuruStron: But the thread ID becomes different! – Arnold Zahrneinder Jan 30 '22 at 18:29
  • @ArnoldZahrneinder and? it does not mean that thread is used while waiting. please read the linked article. When await starts (actually if operation is finished before the start of await - there will not be thread switching, `await Task.CompletedTask` will continue method execution in the same thread) "current" thread is returned to thread pool and when the I/O is completed runtime will invoke the continuation on the first available thread from pool (which makes much more sense than waiting for or blocking "current" thread until operation is finished). – Guru Stron Jan 30 '22 at 18:34
  • You should read about [SynchronizationContext](https://learn.microsoft.com/en-us/dotnet/api/system.threading.synchronizationcontext?view=net-6.0) and a console app does not have one (uses the default) and the UI frameworks (WinForms, WPF, …) do have. So your code will behave differently on different frameworks and with (WinForms, WPF, …) as you expect – Sir Rufo Jan 30 '22 at 18:34
  • @Neil btw the use of a `ThreadPool` thread is coincidental, and dependent on the implementation of the `Task.Delay` method. In general, *any* thread can be used for completing a task. See [here](https://dotnetfiddle.net/XP44GF) for an example. – Theodor Zoulias Jan 30 '22 at 18:35
  • @GuruStron ` it does not mean that thread is used while waiting.` How about after the `await`? it is indeed used at that time. – Arnold Zahrneinder Jan 30 '22 at 18:36
  • 1
    @ArnoldZahrneinder it is used to run your code AFTER the I/O has finished. You need some thread to run your code, and again - taking first free thread from pool makes much more sense than waiting for/blocking the one which started operation (at least in some contexts). – Guru Stron Jan 30 '22 at 18:37
  • @GuruStron: Correct but it should be the exact same thread where the code execution started in the first place. Now I understands what fearless concurrency that Rust is promoting means. This is indeed a Bad Design in C#. It should not work like that. – Arnold Zahrneinder Jan 30 '22 at 18:39
  • 1
    @ArnoldZahrneinder you should try your code inside a winforms app. – Sir Rufo Jan 30 '22 at 18:46
  • @ArnoldZahrneinder 1) _"it should be the exact same thread"_ - why???? For some stateless execution (i.e. no UI and so on) why do you need the same thread? One of the main goals of async - free threads while operation is waiting for async io so those threads can be used to perform some useful work. For example your thread is used to run some long CPU intensive work while you are waiting for I/O and wait is finished the CPU work is still in progress - does it make sense to wait for it? 2) There is not concurrency in your code, `TestTask` will not continue to run until the `await` has finished. – Guru Stron Jan 30 '22 at 18:47
  • @ArnoldZahrneinder here you can read the code, that bumps you to a new thread https://referencesource.microsoft.com/#mscorlib/system/threading/synchronizationcontext.cs,131 (because you are inside a console application) – Sir Rufo Jan 30 '22 at 18:47
  • @GuruStron: `One of the main goals of async - free threads while operation is waiting for async io so those threads can be used to perform some useful work` That is very wrong! Threading is supported at OS level (except for green threads), async on the other hand needs an executor implemented at language level! Async has nothing to do with threading. – Arnold Zahrneinder Jan 30 '22 at 18:49
  • @SirRufo: That is even more ridiculous! A language is a language, async is a language feature not a framework feature. – Arnold Zahrneinder Jan 30 '22 at 18:53
  • 2
    @ArnoldZahrneinder Calm down, use this https://github.com/StephenCleary/AsyncEx/wiki/AsyncContext and be happy - and the language behave as expected: no syncContext, await continues on another thread. thats it – Sir Rufo Jan 30 '22 at 18:56
  • @ArnoldZahrneinder TBH I do not see how your second sentence proves the first =) "Async" as concept - has nothing to do with threading, yes, code can be run asynchronously even in single threaded environments. That is why `TestTask` has no concurrency issues - everything after `await` will run only after code before `await` and only after `await` is finished. – Guru Stron Jan 30 '22 at 18:57
  • @ArnoldZahrneinder _"A language is a language, async is a language feature not a framework feature."_ yes, language is a language - basically bunch of concepts formulated and hopefully captured in some specification. The rest is implementation and it can be interconnected with the framework. – Guru Stron Jan 30 '22 at 18:59
  • 4
    @ArnoldZahrneinder [Rust Docs *"... any .await can potentially result in a switch to a new thread."*](https://rust-lang.github.io/async-book/03_async_await/01_chapter.html#awaiting-on-a-multithreaded-executor) – Sir Rufo Jan 30 '22 at 19:07
  • Saying "the original thread must be the one that carries on after the await" makes about as much sense as saying "when I run my api project, only threads whose ID is exactly divisible by 3 should service requests from clients". It's like working in an office and both you and your coworker know how to reload the printer equally well. You get the paper out but have no knife to open it, so you call for someone to bring you a knife. Then you need the bathroom and, while you're away, the knife arrives. Your coworker opens the paper with it and loads the printer. It doesn't have to be you! – Caius Jard Jan 30 '22 at 20:34
  • @SirRufo: Rust does not come with any default executor for async code. Because it is a system programing language and it actually does not know how to do it. So Async in rust without relying on other crates are actually blocking and they run sequentially without providing any concurrency. It depends on how the async library implements the executor in Rust. (Tokio for example). – Arnold Zahrneinder Jan 31 '22 at 01:40
  • @SirRufo: But generally speaking, the developer MUST have full control over what is going on. A language should not change the thread like that. In other words, it is my job to decide if I wanna attach the current operation to another thread or remain on the same thread. – Arnold Zahrneinder Jan 31 '22 at 01:44
  • @CaiusJard: `Saying "the original thread must be the one that carries on after the await" makes about as much sense as saying "when I run my api project, only threads whose ID is exactly divisible by 3 should service requests from clients"` No actually this does not make sense. When the concurrency level is extremely high and I want to make good balance between Async and Threads then I want to remain on the same thread. – Arnold Zahrneinder Jan 31 '22 at 01:48
  • What data is your thread dragging with it, that it refers to, that means it's the only one suited to doing the work? Why can your colleague not load the printer in exactly the same way? – Caius Jard Jan 31 '22 at 06:16
  • *the developer MUST have full control over what is going on* - you do. Read https://devblogs.microsoft.com/dotnet/configureawait-faq/ – Caius Jard Jan 31 '22 at 06:25
  • @CaiusJard: I do not want to block the current thread. SyncronizationContext cannot do this, if you tweak it then it will be equal to calling `Wait()` which is blocking and if you leave it as it is, it switches the thread. This link you provided is talking about that which is a waste of time to read. I didn't actually expect C# to be this silly! An asynchronous function must run on the calling thread, must remain on that, and must complete on that thread without changing that thread. If any language does not follow that, either its developers are too creative or too stupid. – Arnold Zahrneinder Jan 31 '22 at 06:46
  • Wow, this really blew up. @ArnoldZahrneinder This is the way async/await works in C#. If you don't like it or think it is broken, then make a suggestion to the C# committees and give an alternative that is better. Your misunderstanding of how async/await is implemented in C#, verses how other languages do it, is irrelevant. – Neil Jan 31 '22 at 12:51
  • *"I do not want to block the current thread."* -- Why? Blocking the current thread guarantees that you'll continue on the same thread after the completion of the task. Why don't you want to block it? What else do you want the current thread to do, while the task is running? – Theodor Zoulias Feb 01 '22 at 06:33
  • @TheodorZoulias: Well I already figured it all out and most comments were surprisingly misleading and wrong. For C# to be able to monitor the completion of tasks, specially when a task is awaited, it needs to create a new thread on which it can carry out this monitoring. When an `await` operation is then completed, since the `OnCompleted` method in the `TaskAwaiter` struct is called, which is on another thread, the action that it takes as an argument is then executed on the other thread resulting in a different thread id. – Arnold Zahrneinder Feb 03 '22 at 01:48
  • @TheodorZoulias: This behavior can NEVER be changed even by overriding the `SynchronizationContext`, but the task can be brought back to the main thread by suppressing the lamda, queueing it in a pool and then resume it on the main thread. Of course it will still be Non Blocking. – Arnold Zahrneinder Feb 03 '22 at 01:50
  • So the answer to my question *"why don't you want to block the current thread"* is, I guess, *"because I want it to serve a queue of lambdas"*. But if your thread has entered in a loop that waits and executes lambdas, it's now owned by this loop. Any code that follows the line that starts this loop, will be blocked until the loop completes. If you don't consider this to be "blocking a thread", then neither the [`AsyncContext.Run`](https://stackoverflow.com/a/68588200/11178549) blocks a thread. Because it does exactly the same thing. – Theodor Zoulias Feb 03 '22 at 04:00
  • So, why did you said earlier that *"tweaking a `SyncronizationContext` will be equal to calling `Wait()` which is blocking"*? – Theodor Zoulias Feb 03 '22 at 04:00
  • @TheodorZoulias: I make a mistake, it can be done in such a way that it would not block the operations started prior to its `handler` (which has to be implemented). Calling the handler then keeps the cursor position in place so it will not proceed to the other lines of code. – Arnold Zahrneinder Feb 03 '22 at 06:40
  • Let's go back to your original question, which is *"why the thread changes after the await"*. Now you know that the thread can stay the same by installing a `SynchronizationContext`, or event-loop, or whatever you want to call it, on the current thread. Do you think that Microsoft should have implemented the async-await infrastructure so that a `SynchronizationContext` would be always installed automatically, and silently, if it was not already present? – Theodor Zoulias Feb 03 '22 at 06:52
  • @TheodorZoulias: Yes I think there must be a default implementation. I actually did a survey (mostly mid-level developer) nobody actually knew about this behavior and exactly everyone was assuming that no separate thread is involved. Do you have any idea how many project out there could be flawed as the result of this? At least in the Async section of the documentation, Microsoft has to clarify this. – Arnold Zahrneinder Feb 03 '22 at 07:51
  • 1
    What kind of flaws do you have in mind? We are not talking about WPF/WinForms applications, because those have a `SynchronizationContext` installed automatically on the UI thread before showing the main window. We are talking only about Console apps, ASP.NET Core apps, Windows services etc. These apps are not dealing with thread-affine components. Could you give an example of a flaw that could be caused by the free-threading model of those apps? Bear in mind that installing a `SynchronizationContext` is not risk free. It increases the possibility of deadlocks by a lot. – Theodor Zoulias Feb 03 '22 at 08:23
  • @TheodorZoulias: Have you a ever tried writing a web server? or do you think all console application are simple applications printing `Hello World`? – Arnold Zahrneinder Feb 03 '22 at 09:11
  • Arnold no, I've never written a web server, nor I think that all console applications are simple. What's your point? – Theodor Zoulias Feb 03 '22 at 09:21
  • @TheodorZoulias: My point is that, when you are writing a complex system where the continuation thread is important to you (to be the same thread the code started) and you actually don't know what is going on behind the scene as the documentation did not clearly mention that, you encounter a bug and all of a sudden you discover that the language is doing so by design! So I think it has to be clarified some place instead of assuming that just because it is a console app, and not many people write complex apps nothing bad happens. – Arnold Zahrneinder Feb 03 '22 at 09:30
  • 1
    "*...where the continuation thread is important for you...*" -- Could you give an example where continuing on the same thread is important, in a console application that deals with non-thread-affine components? For example a `List` doesn't care if you `Add` from one thread and `Remove` from another, as long as the two actions are not concurrent, and a memory barrier is placed between the two actions (which is guaranteed by the async/await infrastructure). Please share a specific scenario where the free-threading model might result in an application flaw. – Theodor Zoulias Feb 03 '22 at 09:42
  • @TheodorZoulias: Please do not look at it from the perspective of concurrency only. Another case is very clear, imagine you share the same data between two threads (main thread and the continuation thread), generally and agnostic of the language, this leads to performance problems (let's not talk about if it is minor or major here). It will be more effective if the thread that owns the data continues handling the data in this case than to let another thread take over. – Arnold Zahrneinder Feb 03 '22 at 09:53
  • 1
    Arnold in .NET the data are not owned by threads. All data are shared by all threads. My expectation is that installing a `SynchronizationContext` in a console app is going to slow it down slightly, because of the synchronization overhead associated with passing the continuation action from the completion thread (usually a `ThreadPool` thread) to the main thread of the application. Btw I am still interested to learn how the default free-threaded model caused a flaw in an application of yours. – Theodor Zoulias Feb 03 '22 at 10:07
  • @TheodorZoulias: Imagine having two async methods `public async Task` which do nothing but to print a number in a for loop `if(i % 1000 == 0)`. both methods are exactly the same and you do not `await` anything inside of the two. Run them one after another without awaiting them like `Test(); Test2();`. You will notice that the second one runs after the first. Now, introduce `await` before the for loop like `await Task.Delay(1000)`. Now you will notice that both for loops are running concurrently! – Arnold Zahrneinder Feb 04 '22 at 07:10
  • @TheodorZoulias: This is a serious Flaw that this pattern can cause. But this is mainly due to the fact developers may not know about this issue due to bad documentation. – Arnold Zahrneinder Feb 04 '22 at 07:14
  • Arnold yes, if you start two asynchronous operations at once you introduce parallelism. The two operations might run code on two different threads at the same time. In the absence of a `SynchronizationContext`, the internal continuations of the two asynchronous operations are not synchronized. In case synchronization is needed, you have to synchronize them manually, with a `lock` or some other synchronization primitive. Honestly, I find it hardly surprising. On the other hand, it's a long time after I learned about this stuff, so I may have forgot the surprise-factor of my learning experience. – Theodor Zoulias Feb 04 '22 at 07:50
  • @TheodorZoulias: `if you start two asynchronous operations at once you introduce parallelism` That parallelism should only apply to memory/io bound operations and not CPU bound. A CPU bound operation indeed blocks a thread. But this is Async and Await not `Task.Run`. And I think you were speaking about a memory barrier making it needless of a lock. – Arnold Zahrneinder Feb 04 '22 at 07:56
  • Arnold many asynchronous operations do not interact with shared state, so there is no problem with using the CPU in parallel with other asynchronous operations. For example an asynchronous operation might create a local `List`, and `Add` things to it in a loop. Each asynchronous operation has its own `List`. Why should we force all `Add` operation of all local lists to be performed by a single thread? That's just inefficient. – Theodor Zoulias Feb 04 '22 at 08:17
  • It would be a different story if all asynchronous operations were filling a single shared list. In that case synchronization would be needed, with a `lock` being way more efficient than a `SynchronizationContext`. If, instead of `lock`, you funnel every `Add` of every asynchronous operation through a single thread, you might suffer from a [“thousands of paper cuts”](https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming#configure-context) situation. – Theodor Zoulias Feb 04 '22 at 08:17
  • @TheodorZoulias: You are right, and I am not trying to prove you wrong. All correct. My problem is that, Microsoft does not describe this behavior in their documentation (clearly) so many developer may not know about it and may think since Async is meant to be single threaded then no Lock would be needed. – Arnold Zahrneinder Feb 04 '22 at 08:22
  • 1
    @TheodorZoulias: By the way, it's been a long time since I last engaged in a technical argument with someone like you :). I really enjoyed it. I admit that I might become annoying sometimes, but thank you for having this chat with me. You are indeed an experienced developer who knows what he is talking about :) – Arnold Zahrneinder Feb 04 '22 at 08:26
  • 1
    Arnold thanks for the kind words! You might be right that the documentation is lacking. On the other hand I don't think that any amount of documentation would prevent new developers from falling occasionally in the pit of failure. The async-await technology is too powerful, offering many ways to shot yourself in the foot. Btw I should mention that I disagree with the advice offered in the link I posted above, about using `ConfigureAwait(false)` for the purpose of offloading work out of the UI thread. The correct way to offload work to the `ThreadPool` is the `Task.Run` method IMHO. – Theodor Zoulias Feb 04 '22 at 08:30
  • 1
    @TheodorZoulias: Finally we have come to a mutual agreement then :) – Arnold Zahrneinder Feb 04 '22 at 08:32

0 Answers0