C
C#•4d ago
LastExceed

consolidating my understanding of `async`/`await` in C# and Rust

Please confirm or correct the following: 1. A Future represents a workload that has yet to be done 2. C# doesn't use Futures (at least not remotely as ubiquitous as Rust) 3. "Spawning" (e.g. via tokio::spawn(my_future)) begins (and ONLY begins) the execution of such a workload, and returns a Task that represents the execution 4. Calling an async C# function which returns a Task is analog to immediately spawning the future returned by an async Rust function 5. A Task calling await on another Task is analog (but NOT equivalent) to a Thread calling .Join() on another Thread (except that the join handle of a C# Thread cannot convey a result, so the analogy fails for Task<T>) 6. .ConfigureAwait(false) in C# is analog to using a multi-threaded runtime in Rust, and indicates that the workload may be executed not only concurrently, but also in parallel. This distinction is mostly relevant for context dependent workloads (e.g. some platforms restrict UI manipulation to the main thread), but also affects whether or not data races can occur 7. Exceptions thrown in async functions are automatically catched, stored in the returend Task object, and rethrown on await 8. calling an async void method is like calling an async Task and discarding the returned Task object, except that exceptions aren't catched, and can instead kill the entire thread
16 Replies
canton7
canton7•4d ago
I can't comment on the Rust analogues too closely, as it's been a while since I read about async in Rust.
C# doesn't use Futures (at least not remotely as ubiquitous as Rust)
Eeeh. Task represents an asynchronous operation which may or may not be complete yet, and it's used pretty ubiquitously in C#
A Task calling await on another Task is analog (but NOT equivalent) to a Thread calling .Join() on another Thread (except that the join handle of a C# Thread cannot convey a result, so the analogy fails for Task<T>)
I think that's stretching it, possibly in an unhelpful way. Calling await on a Task registers a continuation on that task -- a block of code which will be executed once the Task completes. That continuation may run on a different thread, which is where it would differ from a Thread.Join.
.ConfigureAwait(false) in C# is analog to using a multi-threaded runtime in Rust, and indicates that the workload may be executed not only concurrently, but also in parallel.
No, I think you've got the wrong end of the stick there. ConfigureAwait controls what thread the code after the await runs on. .NET has the concept of a SynchronizationContext. Some threads may have a way to send them messages (e.g. a UI thread has a message queue of UI events), and such threads can have an SC installed on them. An SC is an object saved as a thread-local variable which describes how to post messages to that thread's message queue. So a UI thread will have an SC which describes how to send messages to the UI thread's queue. By default, threads don't have an SC however (e.g. ThreadPool threads). Now, by default await looks to see whether the thread that runs the await has an SC on it, and if so it saves the SC. When the Task being awaited completes, the code after the await is then posted to the SC to run. (If there's no SC, the code after the await runs on the ThreadPool). ConfigureAwait changes the behaviour to always ignore the SC.
calling an async void method is like calling an async Task and discarding the returned Task object, except that exceptions aren't catched, and can instead kill the entire thread
They'll kill the entire process if uncaught. Confusingly Task means many things in .NET, as it was introduced before async/await. It used to represent a chuck of synchronous work which was to be performed on another thread (controlled by a TaskScheduler) and provide a way see what the result was and to run a continuation when that chunk of work was finished (which is why we have Task.Factory.StartNew, new Task(...), Task.Start, Task.ContinueWith, etc etc). Then along came async/await, and Task evolved slightly to also mean a chunk of not necessarily synchronous work which may or may not be complete yet, but doesn't necessary represent stuff happening currently on another thread, e.g. Task.Delay returns a Task which just completes after a period.
Thinker
Thinker•4d ago
Note that tasks aren't even necessarily asynchronous, you can use a TaskCompletionSource to create a task which is completed when some piece of code says it's completed. Tasks are, as Canton mentioned, just an operation which may or may not be complete yet.
LastExceed
LastExceedOP•4d ago
C# doesn't use Futures (at least not remotely as ubiquitous as Rust) Eeeh. Task represents an asynchronous operation which may or may not be complete yet, and it's used pretty ubiquitously in C#
I fully agree with what you say, and it doesn't conflict with my statement. note the distinction betweenTask and Futurein #3 (Rust has both)
Calling await on a Task registers a continuation on that task -- a block of code which will be executed once the Task completes
and
ConfigureAwait controls what thread the code after the await runs on
Oh wow, this is unexpected. Indeed i got this backwards. curiously, despite being the exact reverse of how i understood it so far, it works out to be just as coherent with the rest of my understanding as my previous understanding. interesting
They'll kill the entire process if uncaught.
this is not true. To demonstrate:
Console.WriteLine("Start");
Foo();
await Task.Delay(2000);
Console.WriteLine("End");

static async void Foo()
{
await Task.Delay(1000);
throw new Exception("oops");
}
Console.WriteLine("Start");
Foo();
await Task.Delay(2000);
Console.WriteLine("End");

static async void Foo()
{
await Task.Delay(1000);
throw new Exception("oops");
}
Output:
Start
Unhandled exception. System.Exception: oops
at Program.<<Main>$>g__Foo|0_0() in C:\Users\LastExceed\Documents\_dump\dev\repos\C#\Scratch\Scratch\Program.cs:line 13
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
End
Start
Unhandled exception. System.Exception: oops
at Program.<<Main>$>g__Foo|0_0() in C:\Users\LastExceed\Documents\_dump\dev\repos\C#\Scratch\Scratch\Program.cs:line 13
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__128_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
End
if the exception killed the process, then it wouldn't print End a full second later
Confusingly Task means many things in .NET, as it was introduced before async/await
actually this isn't confusing to me at all (despite being news to me). Quite the opposite even: Its elucidating! Task generalizing over any kind of execution (async / threaded / whatever) makes perfect sense to me, and covers@Thinker's point nicely thank you both. i learned a lot from this
canton7
canton7•4d ago
An uncaught exception on a thread does kill the process, I can promise you. I've been bitten by that many, many times. Task generalises over many types of things now, but it also has behaviour which is very much tied to its old fork/join threading model history. E.g. if Task were just a generalisation over an operation which might not have completed yet, why does new Task(() => ...).Start() run something on the thread pool?
LastExceed
LastExceedOP•4d ago
but then why is it still printing End ? uhh... counter question: why not? 😅
canton7
canton7•4d ago
Not sure: I don't think it's had time to die yet? If you replace the Task.Delay(2000 with a Console.ReadLine, you'll see the process terminate Why should something which is just a signal that an operation has completed know how to start work on a thread?
LastExceed
LastExceedOP•3d ago
huh. with ReadLine it indeed crashes, but with 5 seconds delay apparently. and as long as the rest of the program is able to finish within those 5 seconds i get exit code 0? this is really weird. i'll have to dig into this
canton7
canton7•3d ago
Yeah I'm not sure exactly what the process for terminating because of an uncaught threadpool exception is
LastExceed
LastExceedOP•3d ago
ah i see what you mean. then i guess they took an existing thing and retroactively generalized it?
canton7
canton7•3d ago
Yeah pretty much Other languages which started out with async/await have a primitive which is just a "pending work" / "I'm done" signal
Petris
Petris•3d ago
They did a breaking change with it in .net 4 And exceptions in async no longer kill the process
canton7
canton7•3d ago
That was whether an unobserved exception in a Task causes a problem, right?
Petris
Petris•3d ago
Yeah Now it just fires am event
canton7
canton7•3d ago
This business: https://learn.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/runtime/throwunobservedtaskexceptions-element#remarks That's nothing to do with whether an unhandled exception on a threadpool thread brings down the app That's whether an exception stored in a Task but which is never looked at brings down the app
Petris
Petris•3d ago
No This affects async void and such too
canton7
canton7•3d ago
I'm 98% sure that's wrong I.e. this has always brought down the process:
async void Foo() => throw new Exception();
Foo();
async void Foo() => throw new Exception();
Foo();
This used to bring down the process, but that was changed in .NET 4.5:
async Task FooAsync() => throw new Exception();
_ = FooAsync();
async Task FooAsync() => throw new Exception();
_ = FooAsync();

Did you find this page helpful?