【问题标题】:ValueTask method with unavoidable async await calls?具有不可避免的异步等待调用的 ValueTask 方法?
【发布时间】:2021-08-26 21:27:46
【问题描述】:

我目前有以下异步方法:

private SomeObject _someObject = null;
public async Task<SomeObject> GetObjectAsync()
{
    await sslim.WaitAsync();
    if (_someObject == null)
    {
        _someObject = await InitializeSomeObjectAsync(); //starts calls to alot of async methods
    }
    sslim.Release();
    return _someObject;
}

如果上面的代码是热路径并且被多次调用,改用ValueTask是否安全/可以?

private SomeObject _someObject = null;
public async ValueTask<SomeObject> GetObjectAsync()
{
    await sslim.WaitAsync();
    if (_someObject == null)
    {
        _someObject = await InitializeSomeObjectAsync(); //starts calls to a lot of async methods
    }
    sslim.Release();
    return _someObject;
}

我不确定的是sslim.WaitAsync 锁定调用,它总是会导致代码路径永远不会完全同步(即使_someObject 已经被初始化),这与使用ValueTask 是相反的可以同步执行的路径?

另一个想法,也许将SemaphoreSlim 调用更改为同步版本是否有意义?

private SomeObject _someObject = null;
public async ValueTask<SomeObject> GetObjectAsync()
{
    sslim.Wait();
    if (_someObject == null)
    {
        _someObject = await InitializeSomeObjectAsync(); //starts calls to a lot of async methods
    }
    sslim.Release();
    return _someObject;
}

我计划对上述变体执行一些基准测试,但只是想从更了解哪个选项可以考虑的人那里获得一些反馈。

【问题讨论】:

  • 您的目标是提高性能还是减少分配?顺便说一句,我希望在您的实际代码库中,您在 finally 块中调用 sslim.Release();

标签: c# asynchronous .net-core synchronization valuetask


【解决方案1】:

我不确定的是 sslim.WaitAsync 锁定调用,它总是会导致代码路径永远不会完全同步

我不确定为什么会这样。异步方法可能同步运行,如果信号量可用,我希望SemaphoreSlim.WaitAsync 同步获取信号量。

这与将 ValueTask 用于可能同步执行的路径相反?

即使异步完成,使用ValueTask&lt;T&gt; 也可以让您的代码避免为每次调用分配Task&lt;T&gt;。如果它同步完成,它甚至效率更高,但即使它始终是异步的,您也会获得一些效率优势。(参见 cmets)

如果它异步完成,ValueTask&lt;T&gt; 将不得不进行一些分配。如果您选择加入这些分配(DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS on .NET 5[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] on .NET 6),则可以合并这些分配。

我目前有以下异步方法

您可能对AsyncLazy&lt;T&gt; (with AsyncLazyFlags.RetryOnFailure) 感兴趣。它使用Task&lt;T&gt;,但一旦初始化成功完成,它就不再分配(总是返回相同的Task&lt;T&gt; 实例)。

【讨论】:

  • “即使异步完成,使用ValueTask&lt;T&gt; 也可以让您的代码避免为每次调用分配Task&lt;T&gt;。” 这是真的吗?我的理解是,如果sslim.WaitAsync() Task 没有在await 点完成,GetObjectAsync() 将返回一个ValueTask&lt;SomeObject&gt;,它将包装信号量返回的不完整的Task。所以在这种情况下我期望的是通常的分配加上开销。
  • 我的回答是错误的。返回的 ValueTask 实际上包装了状态机中的任务(不是从WaitAsync 返回的任务)。这是真的,除非应用程序选择加入 ValueTask 池,在这种情况下,它可能有(摊销的)零分配。
【解决方案2】:

我做了一个 DIY 基准来衡量从 Task&lt;T&gt; 切换到 ValueTask&lt;T&gt; 的效果,涉及性能和分配。作为起点,我使用了以下方法:

async Task<object> TaskOne()
{
    await Task.Yield();
    return new object();
}

我在一个紧密的循环中连续调用并等待这个方法一秒钟,然后测量发生了多少循环,总共分配了多少字节。然后我对具有ValueTask&lt;object&gt; 结果的变体做了同样的事情,最后我从两个变体中省略了await Task.Yield(); 行,以查看同步完成将如何影响测量。这是完整的基准测试:

using System;
using System.Threading;
using System.Threading.Tasks;

public static class Program
{
    static async Task Main()
    {
        await TestAsync("Using Task<object>", true, TaskLoop);
        await TestAsync("Using ValueTask<object>", true, ValueTaskLoop);
        await TestAsync("Using Task<object>", false, TaskLoop);
        await TestAsync("Using ValueTask<object>", false, ValueTaskLoop);
    }

    static async Task TestAsync(string title, bool asynchronous,
        Func<bool, CancellationToken, Task<int>> loop)
    {
        GC.Collect();
        long mem0 = GC.GetTotalAllocatedBytes(true);
        var cts = new CancellationTokenSource(1000);
        int count = await loop(asynchronous, cts.Token);
        long mem1 = GC.GetTotalAllocatedBytes(true);
        Console.WriteLine($"{title} - " + 
            (asynchronous ? "Asynchronous" : "Synchronous") + " completion");
        Console.WriteLine($"- Loops: {count:#,0}");
        Console.WriteLine($"- Allocations: {mem1 - mem0:#,0} bytes");
        double perLoop = (mem1 - mem0) / (double)count;
        Console.WriteLine($"- Allocations per loop: {perLoop:#,0} bytes");
        Console.WriteLine();
    }

    static async Task<object> TaskOne(bool asynchronous)
    {
        if (asynchronous) await Task.Yield();
        return new object();
    }

    static async ValueTask<object> ValueTaskOne(bool asynchronous)
    {
        if (asynchronous) await Task.Yield();
        return new object();
    }

    static async Task<int> TaskLoop(bool asynchronous, CancellationToken token)
    {
        int count = 0;
        while (!token.IsCancellationRequested)
        {
            var result = await TaskOne(asynchronous);
            count++;
            if (result == null) break; // Make sure that the result is not optimized out
        }
        return count;
    }

    static async Task<int> ValueTaskLoop(bool asynchronous, CancellationToken token)
    {
        int count = 0;
        while (!token.IsCancellationRequested)
        {
            var result = await ValueTaskOne(asynchronous);
            count++;
            if (result == null) break; // Make sure that the result is not optimized out
        }
        return count;
    }
}

Try it on Fiddle.

我在我的 PC 上得到了这些结果(.NET 5、C# 9、发布版本,没有附加调试器):

Using Task<object> - Asynchronous completion
- Loops: 448,628
- Allocations: 61,034,784 bytes
- Allocations per loop: 136 bytes

Using ValueTask<object> - Asynchronous completion
- Loops: 416,055
- Allocations: 59,919,520 bytes
- Allocations per loop: 144 bytes

Using Task<object> - Synchronous completion
- Loops: 8,450,945
- Allocations: 811,290,792 bytes
- Allocations per loop: 96 bytes

Using ValueTask<object> - Synchronous completion
- Loops: 8,806,701
- Allocations: 211,360,896 bytes
- Allocations per loop: 24 bytes

我在 Fiddle 服务器上得到的结果有点不同。它可能在调试版本上运行:

Using Task<object> - Asynchronous completion
- Loops: 667,918
- Allocations: 106,889,024 bytes
- Allocations per loop: 160 bytes

Using ValueTask<object> - Asynchronous completion
- Loops: 637,380
- Allocations: 107,084,176 bytes
- Allocations per loop: 168 bytes

Using Task<object> - Synchronous completion
- Loops: 10,128,652
- Allocations: 1,377,497,176 bytes
- Allocations per loop: 136 bytes

Using ValueTask<object> - Synchronous completion
- Loops: 9,850,096
- Allocations: 709,207,232 bytes
- Allocations per loop: 72 bytes

我的结论是,从Task&lt;T&gt; 切换到ValueTask&lt;T&gt; 在大多数调用返回完成的任务时是非常有利的,如果所有调用都返回不完整的任务,则稍微不利。对于您的特定用例(保护缓存值的初始化),我认为值得进行切换,但不要期望从中获得巨大的性能提升。可能有更好的方法来改进您的缓存机制,这些方法不仅可以提供更好的性能,而且在大量使用的情况下也可以减少争用。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-11-09
    相关资源
    最近更新 更多