什么时候缓存任务?

When to cache Tasks?

我在看 The zen of async: Best practices for best performance and Stephen Toub 开始谈论任务缓存,在这里缓存任务本身而不是缓存任务作业的结果。据我所知,为每项工作开始一项新任务是昂贵的,应该尽可能地减少它。在 28:00 左右,他展示了这个方法:

private static ConcurrentDictionary<string, string> s_urlToContents;

public static async Task<string> GetContentsAsync(string url)
{
    string contents;
    if(!s_urlToContents.TryGetValue(url, out contents))
    {
        var response = await new HttpClient().GetAsync(url);
        contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
        s_urlToContents.TryAdd(url, contents);
    }
    return contents;
}

乍一看,这似乎是一种经过深思熟虑的缓存结果的好方法,但我没想过缓存获取内容的工作。

然后他展示了这个方法:

private static ConcurrentDictionary<string, Task<string>> s_urlToContents;

public static Task<string> GetContentsAsync(string url)
{
    Task<string> contents;
    if(!s_urlToContents.TryGetValue(url, out contents))
    {
        contents = GetContentsAsync(url);
        contents.ContinueWith(t => s_urlToContents.TryAdd(url, t); },
        TaskContinuationOptions.OnlyOnRanToCompletion |
        TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
    }
    return contents;
}

private static async Task<string> GetContentsAsync(string url)
{
    var response = await new HttpClient().GetAsync(url);
    return response.EnsureSuccessStatusCode().Content.ReadAsString();
}

我很难理解这实际上比存储结果更有帮助。

这是否意味着您使用更少的任务来获取数据?

另外,我们如何知道何时缓存任务?据我所知,如果你在错误的地方缓存,你只会得到大量的开销并给系统带来太多压力

I have trouble understanding how this actually helps more than just storing the results.

当方法用 async 修饰符标记时,编译器会自动将底层方法转换为状态机,正如 Stephan 在之前的幻灯片中所演示的那样。这意味着使用第一种方法将始终触发创建 Task.

在第二个示例中,注意 Stephan 删除了 async 修饰符,方法的签名现在是 public static Task<string> GetContentsAsync(string url)。这意味着创建 Task 的责任在于方法的实现者而不是编译器。通过缓存 Task<string>,创建 Task 的唯一 "penalty" (实际上,两个任务,因为 ContinueWith 也会创建一个)是当它在缓存中不可用时,而不是foreach 方法调用。

在这个特定的例子中,IMO 并没有重新使用第一个任务执行时已经在进行的网络操作,它只是为了减少分配的 Task 对象的数量。

how do we know when to cache tasks?

想想缓存一个 Task 就好像它是其他任何东西一样,这个问题可以从更广泛的角度来看:我什么时候应该缓存一些东西? 的这个问题的答案很广泛,但我认为最常见的用例是当你有一个昂贵的操作在你的应用程序的热路径上时。您是否应该总是 缓存任务?当然不。状态机分配的开销通常可以忽略不计。如果需要,分析您的应用程序,然后(并且只有那时)考虑缓存是否适用于您的特定用例。

假设您正在与远程服务对话,该服务使用城市名称和 return 其邮政编码。该服务是远程的且处于负载状态,因此我们正在与具有异步签名的方法对话:

interface IZipCodeService
{
    Task<ICollection<ZipCode>> GetZipCodesAsync(string cityName);
}

由于服务对每个请求都需要一段时间,我们希望为其实施本地缓存。自然地,缓存也将具有异步签名,甚至可能实现相同的接口(请参阅 Facade 模式)。同步签名会打破从不使用 .Wait()、.Result 或类似方法同步调用异步代码的最佳实践。至少缓存应该把它留给调用者。

所以让我们对此进行第一次迭代:

class ZipCodeCache : IZipCodeService
{
    private readonly IZipCodeService realService;
    private readonly ConcurrentDictionary<string, ICollection<ZipCode>> zipCache = new ConcurrentDictionary<string, ICollection<ZipCode>>();

    public ZipCodeCache(IZipCodeService realService)
    {
        this.realService = realService;
    }

    public Task<ICollection<ZipCode>> GetZipCodesAsync(string cityName)
    {
        ICollection<ZipCode> zipCodes;
        if (zipCache.TryGetValue(cityName, out zipCodes))
        {
            // Already in cache. Returning cached value
            return Task.FromResult(zipCodes);
        }
        return this.realService.GetZipCodesAsync(cityName).ContinueWith((task) =>
        {
            this.zipCache.TryAdd(cityName, task.Result);
            return task.Result;
        });
    }
}

如您所见,缓存不缓存 Task 对象,而是缓存 ZipCode 集合的 returned 值。但是通过这样做,它必须通过调用 Task.FromResult 为每个缓存命中构造一个任务,我认为这正是 Stephen Toub 试图避免的。 Task 对象会带来开销,尤其是对于垃圾收集器而言,因为您不仅在创建垃圾,而且每个 Task 都有一个运行时需要考虑的终结器。

解决此问题的唯一方法是缓存整个 Task 对象:

class ZipCodeCache2 : IZipCodeService
{
    private readonly IZipCodeService realService;
    private readonly ConcurrentDictionary<string, Task<ICollection<ZipCode>>> zipCache = new ConcurrentDictionary<string, Task<ICollection<ZipCode>>>();

    public ZipCodeCache2(IZipCodeService realService)
    {
        this.realService = realService;
    }

    public Task<ICollection<ZipCode>> GetZipCodesAsync(string cityName)
    {
        Task<ICollection<ZipCode>> zipCodes;
        if (zipCache.TryGetValue(cityName, out zipCodes))
        {
            return zipCodes;
        }
        return this.realService.GetZipCodesAsync(cityName).ContinueWith((task) =>
        {
            this.zipCache.TryAdd(cityName, task);
            return task.Result;
        });
    }
}

如您所见,通过调用 Task.FromResult 创建任务已经消失。此外,使用 async/await 关键字时无法避免创建此任务,因为无论您的代码缓存了什么,它们都会在内部创建一个 return 的任务。类似于:

    public async Task<ICollection<ZipCode>> GetZipCodesAsync(string cityName)
    {
        Task<ICollection<ZipCode>> zipCodes;
        if (zipCache.TryGetValue(cityName, out zipCodes))
        {
            return zipCodes;
        }

不会编译。

不要被 Stephen Toub 的 ContinueWith 标记 TaskContinuationOptions.OnlyOnRanToCompletionTaskContinuationOptions.ExecuteSynchronously 搞糊涂了。它们(只是)另一个性能优化,与缓存任务的主要 objective 无关。

与每个缓存一样,您应该考虑一些不时清理缓存并删除太旧或无效条目的机制。您还可以实施一项策略,将缓存限制为 n 个条目,并尝试通过引入一些计数来缓存请求最多的项目。

我在使用和不使用任务缓存的情况下进行了一些基准测试。您可以在此处找到代码 http://pastebin.com/SEr2838A,结果在我的机器上看起来像这样(w/ .NET4.6)

Caching ZipCodes: 00:00:04.6653104
Gen0: 3560 Gen1: 0 Gen2: 0
Caching Tasks: 00:00:03.9452951
Gen0: 1017 Gen1: 0 Gen2: 0

相关的区别是考虑在填充缓存之前多次调用该方法时会发生什么。

如果您只缓存结果,就像在第一个片段中所做的那样,那么如果在其中任何一个完成之前对该方法进行了两次(或三次,或五十次)调用,它们都会启动实际的生成结果的操作(在本例中,执行网络请求)。因此,您现在有两个、三个、五十个或您正在发出的任何网络请求,所有这些请求都将在完成时将其结果放入缓存中。

当您缓存 任务,而不是操作的结果时,如果在其他人开始他们的请求后对该方法进行第二次、第三次或第五十次调用,但在这些请求中的任何一个完成之前,它们都将被赋予相同的任务,代表 one 网络操作(或任何 long-运行ning 操作) .这意味着您只会发送一个网络请求,或者只会执行一次昂贵的计算,而不是在您对同一结果有多个请求时重复该工作。

另外,请考虑发送一个请求的情况,当它完成 95% 时,将对该方法进行第二次调用。在第一个片段中,由于没有结果,它将从头开始并完成 100% 的工作。第二个片段将导致第二次调用被传递 Task ,完成了 95%,因此第二次调用将比使用第一种方法更快地获得结果,除了整个系统只做了 很多 的工作。

在这两种情况下,如果您在没有缓存时从未调用该方法,而另一个方法已经开始执行该工作,那么这两种方法之间没有有意义的区别。

您可以创建一个相当简单的可重现示例来演示此行为。这里我们有一个玩具 long 运行ning 操作,以及缓存结果或缓存 Task it returns 的方法。当我们同时触发 5 个操作时,您会看到结果缓存执行了 5 次长 运行ning 操作,而任务缓存只执行了一次。

public class AsynchronousCachingSample
{
    private static async Task<string> SomeLongRunningOperation()
    {
        Console.WriteLine("I'm starting a long running operation");
        await Task.Delay(1000);
        return "Result";
    }

    private static ConcurrentDictionary<string, string> resultCache =
        new ConcurrentDictionary<string, string>();
    private static async Task<string> CacheResult(string key)
    {
        string output;
        if (!resultCache.TryGetValue(key, out output))
        {
            output = await SomeLongRunningOperation();
            resultCache.TryAdd(key, output);
        }
        return output;
    }

    private static ConcurrentDictionary<string, Task<string>> taskCache =
        new ConcurrentDictionary<string, Task<string>>();
    private static Task<string> CacheTask(string key)
    {
        Task<string> output;
        if (!taskCache.TryGetValue(key, out output))
        {
            output = SomeLongRunningOperation();
            taskCache.TryAdd(key, output);
        }
        return output;
    }

    public static async Task Test()
    {
        int repetitions = 5;
        Console.WriteLine("Using result caching:");
        await Task.WhenAll(Enumerable.Repeat(false, repetitions)
              .Select(_ => CacheResult("Foo")));

        Console.WriteLine("Using task caching:");
        await Task.WhenAll(Enumerable.Repeat(false, repetitions)
              .Select(_ => CacheTask("Foo")));
    }
}

值得注意的是,您提供的第二种方法的具体实现有一些值得注意的特性。该方法有可能以这样的方式被调用两次,即它们都将 start 长 运行ning 操作,然后任一任务可以完成 starting 操作,因此缓存表示该操作的 Task。因此,虽然它 比第一个代码片段 难,但它 可能的 运行ning 操作是是 运行 两次。为了防止这种情况发生,需要围绕检查缓存、开始新操作然后填充缓存进行更强大的锁定。如果在极少数情况下多次执行长 运行ning 任务只会浪费一点时间,那么当前代码可能没问题,但如果重要的是操作 never 执行多次(比如,因为它会产生副作用)那么当前代码不完整。