await await vs Unwrap()
Asked Answered
S

4

47

Given a method such as

public async Task<Task> ActionAsync()
{
    ...
}

What is the difference between

await await ActionAsync();

and

await ActionAsync().Unwrap();

if any.

Solemnize answered 15/1, 2016 at 17:32 Comment(3)
await ActionAsync().Unwrap(); is definitely easier to read between the two. That's about where the differences end.Gunslinger
Add benchmarks to my asnwer.Sitnik
@DrewKennedy I'm not sure if it is, once you get over the initial confusion what double-await is, it's sort of like a special keyword meaning a specific thingIronsides
S
77

Unwrap() creates a new task instance that represent whole operation on each call. In contrast to await task created in such a way is differ from original inner task. See the Unwrap() docs, and consider the following code:

private async static Task Foo()
{
    Task<Task<int>> barMarker = Bar();

    Task<int> awaitedMarker = await barMarker;
    Task<int> unwrappedMarker = barMarker.Unwrap();

    Console.WriteLine(Object.ReferenceEquals(originalMarker, awaitedMarker));
    Console.WriteLine(Object.ReferenceEquals(originalMarker, unwrappedMarker));
}

private static Task<int> originalMarker;
private static Task<Task<int>> Bar()
{
    originalMarker = Task.Run(() => 1);;
    return originalMarker.ContinueWith((m) => m);
}

Output is:

True
False

Update with benchmark for .NET 4.5.1: I tested both versions, and it turns out that version with double await is better in terms of memory usage. I used Visual Studio 2013 memory profiler. Test includes 100000 calls of each version.

x64:

╔══════════════════╦═══════════════════════╦═════════════════╗
║ Version          ║ Inclusive Allocations ║ Inclusive Bytes ║
╠══════════════════╬═══════════════════════╬═════════════════╣
║ await await      ║ 761                   ║ 30568           ║
║ await + Unwrap() ║ 100633                ║ 8025408         ║
╚══════════════════╩═══════════════════════╩═════════════════╝

x86:

╔══════════════════╦═══════════════════════╦═════════════════╗
║ Version          ║ Inclusive Allocations ║ Inclusive Bytes ║
╠══════════════════╬═══════════════════════╬═════════════════╣
║ await await      ║ 683                   ║ 16943           ║
║ await + Unwrap() ║ 100481                ║ 4809732         ║
╚══════════════════╩═══════════════════════╩═════════════════╝
Sitnik answered 16/1, 2016 at 21:25 Comment(1)
I really appreciate the detailed answer, code samples, and even benchmarks! Very awesome.Solemnize
S
13

There won't be any functional difference.

Slowworm answered 15/1, 2016 at 17:38 Comment(5)
thanks for confirming, I'll accept soon. In the meantime, any reference for this statement?Solemnize
@Solemnize What reference do you need? await unwraps a task. That's what it does. They're equal by definition.Slowworm
@Solemnize Unwrap returns a new task that represents the inner task of a Task<Task>. It will be able to return such a task virtually immediately; it will not be a blocking operation. If the tasks returned by either weren't identical, then it would be a bug in one of the two processes.Slowworm
@Slowworm when would you ever want to run a Task that runs a Task?Mccallion
@DavidKlempfner It comes up a lot when using ContinueWIth, as it doesn't have overloads assuming the continuation is itself asynchronous, so you're forced to unwrap it. It also comes up with other operations with the same problem, namely they are given an operation and don't consider the case it could be asynchronous. Also with WhenAny which provides a task of tasks by design.Slowworm
D
5

I run the unmodified sample from above and came to a different result (both are true, so they are equivalent. I tested that on .NET SDK 6.0.300 but it should hold for all).

Then I improved the code slightly to use recommended best practices for async await and verified my findings:

public static class Program
{
    public static async Task Main()
    {
        await Run().ConfigureAwait(false);
    }

    public async static Task Run()
    {
        Task<Task<int>> barMarker = GetTaskOfTask();

        Task<int> awaitedMarker = await barMarker.ConfigureAwait(false);
        Task<int> unwrappedMarker = barMarker.Unwrap();

        Out(ReferenceEquals(_originalMarker, awaitedMarker));
        Out(ReferenceEquals(_originalMarker, unwrappedMarker));
    }

    private static Task<int> _originalMarker = Task.Run(() => 1);
    private static Task<Task<int>> GetTaskOfTask()
    {
        return _originalMarker.ContinueWith((m) => m, TaskScheduler.Default);
    }

    private static void Out(object t)
    {
        Console.WriteLine(t);
        Debug.WriteLine(t);
    }
}

Output is:

True
True

Then I benchmarked the code:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19044.1706 (21H2)
AMD Ryzen 7 3700X, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.300
  [Host]               : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
  .NET 5.0             : .NET 5.0.17 (5.0.1722.21314), X64 RyuJIT
  .NET 6.0             : .NET 6.0.5 (6.0.522.21309), X64 RyuJIT
  .NET Core 3.0        : .NET Core 3.1.25 (CoreCLR 4.700.22.21202, CoreFX 4.700.22.21303), X64 RyuJIT
  .NET Framework 4.6.1 : .NET Framework 4.8 (4.8.4510.0), X64 RyuJIT
  .NET Framework 4.7.2 : .NET Framework 4.8 (4.8.4510.0), X64 RyuJIT
  .NET Framework 4.8   : .NET Framework 4.8 (4.8.4510.0), X64 RyuJIT
  CoreRT 3.0           : .NET 6.0.0-rc.1.21420.1, X64 AOT


```
|     Method |                  Job |              Runtime |       Mean |    Error |    StdDev | Ratio | RatioSD |  Gen 0 |  Gen 1 | Allocated |
|----------- |--------------------- |--------------------- |-----------:|---------:|----------:|------:|--------:|-------:|-------:|----------:|
|AsynsUnwrap |             .NET 5.0 |             .NET 5.0 | 1,462.5 ns |  5.07 ns |   4.74 ns |  1.02 |    0.01 | 0.0458 |      - |     386 B |
|AsynsUnwrap |             .NET 6.0 |             .NET 6.0 | 1,435.2 ns |  6.71 ns |   6.27 ns |  1.00 |    0.00 | 0.0458 |      - |     385 B |
|AsynsUnwrap |        .NET Core 3.0 |        .NET Core 3.0 | 1,539.0 ns |  2.09 ns |   1.96 ns |  1.07 |    0.00 | 0.0458 |      - |     386 B |
|AsynsUnwrap | .NET Framework 4.6.1 | .NET Framework 4.6.1 | 2,286.3 ns |  5.33 ns |   4.98 ns |  1.59 |    0.01 | 0.0839 | 0.0038 |     546 B |
|AsynsUnwrap | .NET Framework 4.7.2 | .NET Framework 4.7.2 | 2,267.3 ns |  6.66 ns |   5.90 ns |  1.58 |    0.01 | 0.0839 | 0.0038 |     546 B |
|AsynsUnwrap |   .NET Framework 4.8 |   .NET Framework 4.8 | 2,307.6 ns |  9.04 ns |   8.45 ns |  1.61 |    0.01 | 0.0839 | 0.0038 |     546 B |
|AsynsUnwrap |           CoreRT 3.0 |           CoreRT 3.0 |   413.2 ns |  3.78 ns |   3.54 ns |  0.29 |    0.00 | 0.0467 |      - |     391 B |
|            |                      |                      |            |          |           |       |         |        |        |           |
| AsyncAsync |             .NET 5.0 |             .NET 5.0 | 1,496.7 ns |  1.20 ns |   1.00 ns |  1.08 |    0.01 | 0.0381 |      - |     332 B |
| AsyncAsync |             .NET 6.0 |             .NET 6.0 | 1,391.5 ns |  8.25 ns |   7.72 ns |  1.00 |    0.00 | 0.0381 |      - |     332 B |
| AsyncAsync |        .NET Core 3.0 |        .NET Core 3.0 | 1,508.5 ns | 36.04 ns | 104.55 ns |  1.07 |    0.11 | 0.0381 |      - |     332 B |
| AsyncAsync | .NET Framework 4.6.1 | .NET Framework 4.6.1 | 2,179.8 ns | 11.64 ns |  10.89 ns |  1.57 |    0.01 | 0.0725 | 0.0038 |     483 B |
| AsyncAsync | .NET Framework 4.7.2 | .NET Framework 4.7.2 | 2,213.6 ns |  8.31 ns |   7.37 ns |  1.59 |    0.01 | 0.0725 | 0.0038 |     483 B |
| AsyncAsync |   .NET Framework 4.8 |   .NET Framework 4.8 | 2,195.1 ns |  9.87 ns |   9.23 ns |  1.58 |    0.01 | 0.0725 | 0.0038 |     483 B |
| AsyncAsync |           CoreRT 3.0 |           CoreRT 3.0 |   380.3 ns |  4.55 ns |   4.03 ns |  0.27 |    0.00 | 0.0401 |      - |     336 B |

My measurements vary drastically from the accepted answer:

  • The speed difference between async-async and async-Unwrap is very small, we're talking nanoseconds.
  • async-async has a memory advantage over async-Unwrap which is also very small.

Talking about the framework .Net Framework performs around 60% slower and consumes around 30% more memory than .Net Core 3/.Net 5/.Net 6 for this code. It also shows up in Gen1, so the garbage collector is stressed higher on the full framework. Unfortunately BenchmarkDotNet supports up to .NET Framework 4.6.1, so I could not compare my findings against .Net Framework 4.5.1.

conclusion: If you're for minimizing the memory footprint because this is critical in your case you might want to use await await. In any other case async-Unwrap wins as the code is more explicit and its easier to read. (More recent versions of .NET perform faster and more memory efficient as well.)

Demers answered 8/6, 2022 at 11:33 Comment(7)
.NET 6 is .NET Core 6. All .NET Framework versions below 4.6.2 have reached End-Of-Life (that's beyond end of support). No supported Windows OS comes with anything less than 4.6.2 anywayCornucopia
@PanagiotisKanavos To not confuse developers any further and to make the path explicit Microsoft has decided that versions 5, and later are called .NET $Version, not .NET Core $Version, as they are both the successor of the .Net Framework (final version: 4.8) and .Net Core (final version 3).Demers
I know what they did. This has been discussed dozens of times already. They added any missing APIs to .NET Core and changed the marketing name. .NET 5 is still .NET Core 5. You can't just upgrade a .NET 4.8 version to 5. You can't run a .NET 4.8 application on a machine that only has .NET Core 5 installed. You'll have to migrate the project, recompile and solve any breaking changes, and finally deploy to .NET CoreCornucopia
Yes, you cannot just upgrade from 4.8 to 5 without issues. Some APIs have been dropped and you might be stuck with the Full Framework. However, I benchmarked the frameworks, I did not tell to upgrade. But if you are lucky and can upgrade some improvements come for free.Demers
Note that Unwrap will only return a reference to the inner task if the outer task has run to completion at the time you call Unwrap, as an optimization. If the outer task isn't completed yet it couldn't possibly return a reference equal task (as that task possibly doesn't exist yet). Of course, you really shouldn't be writing code that cares about task references like that, only that the two tasks have the same result and complete at (about) the same time.Slowworm
You are right. The sample code on this question only deals with short running and likely already completed tasks. It would be worth repeating the same test with tasks that run a little bit longer.Demers
I attempted to swap two unwraps for double await. Resulted in deadlock for me.Changeover
O
0

Regarding the claim that Unwrap requires the inner task instance to be created and the outer task to complete, so the outer task can return the inner task reference. I've tried the following code (.Net 6), and it seems Unwrap is able to return before the inner task instance is created.

Does anybody know if this is a limitation that existed but was removed in .Net 6?

var nestedTask = Task.Run<Task>(() =>
{
    Task.Delay(TimeSpan.FromSeconds(10)).Wait();

    var innerTask = Task.Run(() =>
    {
        Task.Delay(TimeSpan.FromSeconds(10)).Wait();

        Console.WriteLine("Inner task about to complete...");
    });

    Console.WriteLine("Outer task about to complete...");

    return innerTask;
});

var innerTask = nestedTask.Unwrap();

Console.WriteLine("Inner task obtained");

innerTask.Wait();

Console.WriteLine("Inner task completed");

/* output:

   Inner task obtained
   Outer task about to complete...
   Inner task about to complete...
   Inner task completed

 */
Overtire answered 22/2 at 8:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.