0

I've searched a lot of information surrounding this topic and I understand the general premises of:

  • Await is a handing off of control from the callee backer to the caller
  • Most Modern I/O doesn't use real threading in underlying architecture
  • Most async methods do not explicitly spin up their own threads (i.e. Web Requests)

The last bullet in particular is what I want to discuss. To future-proof this let's use an example as a medium for explanation. Let's assume this is the code block:

public async Task<int> LongOperationWithAnInt32ResultAsync(string input)
{
    /// Section A
    _ = int.TryParse(input, out var parsedInt)
    parsedInt = SyncOperationWithAnInt32Result(parsedInt);
    
    /// Section B
    await MyCustomTaskThatIWantAwaited();

    /// Section C
    return parsedInt;
}


private Task MyCustomTaskThatIWantAwaited()
{
    /// Section D
    AnotherSyncOperationWithVoidResult();

    /// Section E
    return Task.CompletedTask;
}

The method LongOperationWithAnInt32ResultAsync(string) will perform synchronously even though this is not the intended effect.

This is because when the caller enters the callee at Section B, the code from Section D and Section E are executed immediately and are not awaited. This behavior is changed if, Section D is removed and, Section E was "return Task.Run(() => AnotherSyncOperationWithVoidResult())" instead. In this new Section E, the awaitable being tracked becomes the thread from Task.Run (wrapped with the returned Task).

If you replace Section B with "await Task.Delay(10000);" or "await FunctionalWebRequestAsync();" it works as intended. However, to my knowledge, neither of these internally generate a thread to be followed - so what exactly is being awaited?

I've accepted the main answer because it really helped me understand my misconception on Task functionality, but please also refer to my answer as well. It may be what you're looking for.

  • 2
    There are so many misunderstandings here I'm not going try to pick them apart. `await something...` will either continue execution immediately on the same thread, or a continuation callback will be registered. When the task actually completes, the callback will be executed. That's all there is to it. Awaitable I/O will use OS features like I/O Completion Ports to continue execution on a thread pool (see https://blog.stephencleary.com/2013/11/there-is-no-thread.html). – Jeremy Lakeman Jun 24 '22 at 04:27
  • @JeremyLakeman my wording may have been unclear. I've read the article you've linked during my research and it states that drivers cannot block IRP and must perform the operation on their own and raise an interrupt when ready. This is exactly what I want too, the "interrupt" is Task.IsCompleted and I'm asking how to not immediately raise that interrupt while performing my own Task. –  Jun 24 '22 at 05:26
  • @JeremyLakeman I want to inform the awaiter that I am not ready, but for my own Task. For example, say that I want to mark myself as complete iff (<- intentional) a designated value isn't null. How would I go about that in C#? –  Jun 24 '22 at 05:29
  • You can use a `TaskCompletionSource` and `.SetResult` whenever you are ready. – Jeremy Lakeman Jun 24 '22 at 07:05
  • @JeremyLakeman as you said I fundamentally misunderstood Tasks, I was attempting to use them as delegates. My answer below shows what I was actually trying to communicate. –  Jun 24 '22 at 15:14

2 Answers2

1

so what exactly is being awaited?

Nothing is being awaited. Await means asynchronous wait. For a wait to be asynchronous, the awaitable (the Task) should not be completed at the await point. In your case the awaitable is already completed (the IsCompleted property of the TaskAwaiter returns true), so the async state machine grabs immediately its result and proceeds with the next line as usual. There is no reason to pack the current state of the machine, invoke the OnCompleted method of the awaiter, and hand back an incomplete Task to the caller.

If you want to offload specific parts of an asynchronous method to the ThreadPool, the recommended way is to wrap these parts in Task.Run. Example:

public async Task<int> LongOperationWithAnInt32ResultAsync(string input)
{
    /// Section A
    _ = int.TryParse(input, out var parsedInt)
    parsedInt = await Task.Run(() => SyncOperationWithAnInt32Result(parsedInt));

    /// Section B
    await Task.Run(async () => await MyCustomTaskThatIWantAwaited());

    /// Section C
    return parsedInt;
}

If you like the idea of controlling imperatively the thread where the code is running, there is a SwitchTo extension method available in the Microsoft.VisualStudio.Threading package. Usage example:

await TaskScheduler.Default.SwitchTo(); // Switch to the ThreadPool

The opinion of the experts is to avoid this approach, and stick with the Task.Run.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Very appreciated, exactly the answer I was looking for. But I have another question to build on this, so say I wanted to make MyCustomTaskThatIWantAwaited() actually awaitable and not completed immediately, what would I have to do to tell the caller "I'm still not completed, check back next frame" without offloading to a different thread? –  Jun 24 '22 at 05:13
  • Just to get the general gist, may we use an example. Say I wanted to wait for a variable to not be null before considering myself completed, how would I inform the caller awaiting me of that? –  Jun 24 '22 at 05:19
  • @dreamstep I don't think that you can do that. At least not an a "nice" way. You might have to resort in a loop that checks the variable in each iteration, and includes an `await Task.Delay(XXX)` for good measure. Make sure that you understand the concept of visibility when reading variables that might be changed from other threads. These variables must be accessed with `volatile` semantics. You can learn more about the C# memory model in [this](https://docs.microsoft.com/en-us/archive/msdn-magazine/2012/december/csharp-the-csharp-memory-model-in-theory-and-practice) article by Igor Ostrovsky. – Theodor Zoulias Jun 24 '22 at 06:21
  • @dreamstep btw what you are asking indicates that you might be dealing with an architectural problem. Threads and tasks are supposed to communicate through signaling, not through changed variables. You could consider asking a new question about this issue. You might get broader advices, about how you could improve the architecture of your app. – Theodor Zoulias Jun 24 '22 at 06:30
  • 1
    I had a fundamental misunderstanding about Tasks, I thought they could be used as delegates. That's why my wording and context was wrong. The way I'm using the Tasks doesn't require a scan of internal variables. You were a huge help, much appreciated and have a great day. –  Jun 24 '22 at 15:05
0

Huge thanks to @TheodorZoulias for his explanation and answer as it was critical in me reaching this point.

My misconception was simple, Task (or Task{T}) cannot be used as a delegate. Task is a class through and through, meaning that if you define this:

public Task DoSomeReallyLongWork()
{
    SyncTaskThatIsReallyLong();
    AnotherSyncTaskThatIsReallyLong();
    /* perform as much work as needed here */
    
    return Task.CompletedTask;
}

This will run synchronously and ONLY synchronously. The thing actually being awaited is the Task.CompletedTask object that you returned, nothing else. And since the Task is already completed, the internal awaiter is also marked as completed.

This means that though the intention may have been to wrap multiple methods within a Task and then execute/await it, what's actually happening is that the methods are executing synchronously like any other call and then a completed Task is being returned.

If you want multiple methods to be awaited, this is done by making a new Task Object. Using our previous example:

public async Task DoSomeReallyLongWorkAsync()
{
    /// Task.Run does not necessarily run on a separate thread
    /// this is up to the scheduler (usually the .NET scheduler)
    await Task.Run(LongSyncTasksWrapped);
}

public void LongSyncTasksWrapped()
{
    SyncTaskThatIsReallyLong();
    AnotherSyncTaskThatIsReallyLong();
    /* perform as much work as needed here */
    
    return;
}

There may be instances where you want cold Tasks (task that haven't been started yet) and then run them when needed. Using the previous example, this would be done by:

public async Task DoSomeReallyLongWorkAsync()
{
    var coldTask = new Task(LongSyncTasksWrapped);

    /// Must call .Start() whenever you want the Task to
    /// actually start. Await will not start the Task, its
    /// just an asynchronous form of .Wait()
    coldTask.Start();

    /// coldTask was considered "hot" from .Start()
    /// await is waiting a hot task.
    await coldTask;
}

public void LongSyncTasksWrapped()
{
    SyncTaskThatIsReallyLong();
    AnotherSyncTaskThatIsReallyLong();
    /* perform as much work as needed here */
    
    return;
}

This answers the question of what's being awaited, its the Task's awaiter that is internally generated by the class.

  • 1
    Regarding the first `DoSomeReallyLongWorkAsync` method, you might find this an interesting reading: [Should I expose asynchronous wrappers for synchronous methods?](https://devblogs.microsoft.com/pfxteam/should-i-expose-asynchronous-wrappers-for-synchronous-methods/) Regarding the idea of creating cold `Task`s and starting them with `Start`, you should also configure the `scheduler` argument (explanation [here](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2008 "Do not create tasks without passing a TaskScheduler")). – Theodor Zoulias Jun 24 '22 at 19:36
  • 1
    Also an awaiter is not semantically the same with an awaitable. The awaiter is the component that performs the awaiting on behalf of the awaitable. Technically awaiter is any object that has `IsCompleted`/`OnCompleted`/`GetResult` members (with specific signatures), and awaitable is any object with a `GetAwaiter` method that returns an awaiter object (according to the aforementioned criteria). It is possible though for an object to be both an awaitable and an awaiter, in which case the `GetAwaiter` might return itself. – Theodor Zoulias Jun 24 '22 at 19:38