何时缓存任务?

31

我正在观看异步之禅:最佳性能的最佳实践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();
}

我很难理解这种方法如何有助于不仅仅是存储结果。

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

另外,我们如何知道何时缓存任务?据我所知,如果您在错误的位置进行缓存,只会增加大量开销并过度压力系统。


1
是的,您使用更少的任务实例来获取数据。至于其余部分,与缓存任何其他内容真的没有什么特别之处。任务并不等同于线程。 - Luaan
如果有人感兴趣,视频现在可以在这里观看:https://www.youtube.com/watch?v=zjLWWz2YnyQ - Alex from Jitbit
3个回答

10
我很难理解这种方法如何比仅存储结果更有用。
当一个方法被标记为 “async” 时,编译器会自动将底层方法转换成状态机,正如Stephan在前面的幻灯片中所演示的那样。这意味着使用第一个方法将始终触发创建一个任务 (Task)。
在第二个示例中,请注意Stephan删除了 “async” 修饰符,并且该方法的签名现在是“public static Task GetContentsAsync(string url)” 。这意味着创建任务 (Task) 的责任在于方法的实现者而不是编译器。通过缓存 Task, 创建任务 (Task) 的唯一 "惩罚" 是当它在缓存中不可用时,而不是对于每个方法调用。
在这个特定的例子中,我认为旨在重复使用已经正在执行的网络操作的任务 (task),而只是减少分配任务对象的数量。
我们如何知道何时缓存任务 (Task)?
将缓存任务 (Task) 视为任何其他事物,可以从更广泛的角度来看待这个问题: 什么时候应该缓存某些东西?这个问题的答案是广泛的,但我认为最常见的用例是当您的应用程序的热点存在昂贵的操作时。您是否应该始终缓存任务 (Task)?肯定不是。状态机分配的开销通常可以忽略不计。如果需要,对您的应用程序进行分析,然后(仅在必要时)考虑在特定用例中使用缓存。

将执行数据库调用的任务缓存起来是个好主意吗?比如说,如果我有一个异步的获取方法,它接受一个表达式,并且这个方法在很多情况下都使用了同一个表达式,那么缓存是否是个好主意呢? - Nikola.Lukovic
1
如果表达式最终创建了一个委托,那么缓存该委托是明智的。关于异步获取,这取决于该调用的开销以及您进行调用的频率。如果您的应用程序告诉您它是瓶颈,则它是缓存的好选择。 - Yuval Itzchakov
这个问题要求比较缓存Task和缓存异步操作结果之间的差异。但是,本帖并没有以任何方式回答这个问题。使用async关键字的方法与不使用该关键字的异步方法之间的区别并不是这个问题所涉及的内容。它们两个实现在这方面的差异并不相关于它们具有的语义行为,而这正是被问及的内容。 - Servy
@Servy并不完全是这样。虽然表面上问题似乎是关于:“我应该缓存异步操作的结果还是Task”,但示例是指Stephan在他的视频中所做的比较,其中很明显他的意图是减少Task分配。当然,缓存Task而不是原始结果也有好处,也许我应该详细说明一下。 - Yuval Itzchakov
@Servy 这两种实现方式的不同之处与它们拥有的语义行为无关,而这正是被询问的。 我不同意这个陈述,因为我认为它实际上忽略了问题的要点。但你可以有自己的看法。 - Yuval Itzchakov

8

假设你正在与一个远程服务交互,该服务需要输入一个城市名并返回其邮政编码。由于服务是远程的且负载较高,因此我们要使用异步方法进行交互:

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;
        });
    }
}

从上面的代码可以看出,缓存并不会缓存任务对象,而是ZipCode集合的返回值。但这样做就需要为每个缓存命中构建一个Task对象,并且我认为这正是Stephen Toub想要避免的。Task对象带有开销,特别是对于垃圾回收器来说,因为你不仅创建垃圾,每个Task还有一个Finalizer需要运行时考虑。

唯一的解决办法是缓存整个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关键字时,无论您的代码缓存了什么内容,它们都会在内部创建一个任务返回,因此无法避免这种任务的创建。类似于下面的代码:

    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所迷惑。它们是(仅仅)另一种性能优化,与缓存任务的主要目标无关。

就像每一个缓存一样,你应该考虑一些机制来定期清理缓存,并删除过时或无效的条目。你也可以实现一个策略,将缓存限制为n个条目,并尝试通过引入计数来缓存最常请求的项目。

我进行了一些有关任务缓存的基准测试。你可以在这里找到代码 http://pastebin.com/SEr2838A,在我的机器上,结果看起来像这样(带有.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

1
在OP的代码中,当缓存任务时,它会在开始任务时将任务添加到缓存中。而在你的代码中,只有当任务完成时才将任务添加到队列中。这在操作语义上有一个巨大的区别;这也是为什么OP的第二个片段通常比他的第一个片段更可取的主要原因。在你的情况下,你的两个片段之间没有实质性的区别。使用FromResult创建一个Task的"成本"是微不足道的;与其他长时间运行的操作相比,它根本不相关。 - Servy
2
你完全错了重点。Stephen Toub的视频正是关于避免分配任务对象的问题。请在评判他人之前先理解问题。我会在我的答案中添加一些基准来使Stephen Toub的陈述更清晰。 - Thomas Zeman
这不是代理模式而是外观模式吗? - Divisadero
@Divisadero,实际上它更常被称为装饰器模式。装饰器可以被认为是包装器/代理的更严格版本,因为它将逻辑委托给具有相同接口的对象来实现自身,而不是委托给某个随机对象。这样自然更具可组合性。是的,facade是一种完全不同的模式,用于通过公开新接口来组合/简化功能。 - julealgon
对于缓存,通常会序列化缓存对象,以便从缓存中检索的对象的后续更改不会影响缓存项。我尝试使用System.Text.Json的序列化程序序列化Task<T>,但无法反序列化它(因为Task没有无参数构造函数)。您对此有什么想法吗?请参见此SO帖子。 - DrGriff

3
相关的区别在于考虑在缓存被填充之前多次调用方法时会发生什么。
如果只缓存结果,就像第一个片段中所做的那样,那么如果在任何一个操作完成之前进行了两个(或三个、五十个)对该方法的调用,它们都将开始实际操作以生成结果(在本例中执行网络请求)。因此,现在你有两个、三个、五十个或更多的网络请求正在进行,它们都将在完成后将其结果放入缓存中。
当你缓存任务而不是操作的结果时,如果在其他人开始请求但在这些请求完成之前对该方法进行第二次、第三次或第五十次调用,它们都将被赋予表示该一个网络操作(或其他长时间运行的操作)的相同任务。这意味着你只发送一个网络请求或只执行一次昂贵的计算,而不是在对同一结果进行多个请求时重复这项工作。
另外,考虑这样一种情况:发送了一个请求,在其完成95%的工作时,对该方法进行了第二次调用。在第一个片段中,由于没有结果,它将从头开始并完成100%的工作。第二个片段将导致第二次调用得到一个已经完成95%的任务,因此第二次调用将比使用第一种方法更快地得到其结果,此外整个系统只需要做更少的工作。
在这两种情况下,如果你从不在没有缓存时调用该方法,并且另一个方法已经开始做这项工作,则这两种方法之间没有实质性的区别。
你可以创建一个相当简单的可重现示例来演示这种行为。在这里,我们有一个玩具长时间运行的操作和缓存结果或缓存它返回的任务的方法。当我们同时启动5个操作时,你会发现结果缓存执行了5次长时间运行的操作,而任务缓存只执行了一次。
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")));
    }
}

值得注意的是,你提供的第二种方法的具体实现有一些值得注意的属性。可能会以这样的方式调用该方法两次,以使得两个任务在任何一个任务可以完成操作“开始”之前都会“开始”长时间运行的操作,并因此缓存代表该操作的Task。因此,尽管与第一个片段相比要难得多,但仍然有可能运行长时间的操作两次。为了防止这种情况发生,需要在检查缓存、启动新操作并填充缓存时进行更强大的锁定。如果在偶尔多次执行长时间的任务只是浪费一点时间,则当前代码可能还可以,但如果重要的是永远不要执行操作(例如,因为它会引起副作用),则当前代码就不完整了。

1
第二个片段将导致第二次调用被交付一个已完成95%的任务。这是怎么做到的?任务的缓存仅在原始任务完成后通过其继续运行一次完成,并非在任务启动后直接进行缓存。 - Yuval Itzchakov
另外,你所提供的是危险的。假设任务出现故障,那么怎么办?你正在缓存一个尚未完成且可能失败的“任务”,并且一遍又一遍地提供该任务。 - Yuval Itzchakov
1
@Servy,坦白说,写答案并投票而没有观看OP所引用的视频是不礼貌的。你的实现完全错过了重点,即“如何实现某个缓存策略”,而是“为什么和何时重用任务对象”,而且它有严重的缺陷:它也缓存了失败的任务 - 不应该公开显示在SO上。请您好心撤销您的投票和答案,直到您完全理解了问题。我会添加一些基准测试来详细说明Stephen Toub的观点。 - Thomas Zeman
3
公平地说,缓存失败的任务是否危险取决于应用程序。无论如何,您都需要一些逻辑来清除“过期”的缓存条目,因此在重试之前缓存失败的任务一段时间是许多情况下完全有效的方法,也是我之前使用过的一种模式。 - Mike Marynowski
2
还有一种选择是立即删除故障任务,这样重试尝试实际上会重试,而不是将相同的故障任务发送回来,但您仍然每次只能获得1个真正的请求以进行并发请求。对于相同故障资源的多个并发请求,直到它们重试,它们都将返回相同的故障任务。 - Mike Marynowski

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接