Подтвердить что ты не робот

Когда кэшировать задачи?

Я смотрел Зен асинхронный: лучшие практики для лучшей производительности и Стивен Тууб начал говорить о кешировании задач, где вместо кэширования результатов заданий задаются задачи кэширования самих задач. Насколько я понял, начинать новую задачу для каждой работы дорого, и ее нужно как можно меньше свести к минимуму. Примерно в 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 = 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();
}

Мне трудно понять, как это на самом деле помогает больше, чем просто хранить результаты.

Означает ли это, что вы используете меньше Заданий для получения данных?

А также, как мы узнаем, когда кешировать задачи? Насколько я понимаю, если вы кэшируете не то место, вы просто получаете нагрузку накладные расходы и слишком сильно нажимаете на систему.

4b9b3361

Ответ 1

Предположим, вы разговариваете с удаленной службой, которая берет имя города и возвращает свои почтовые индексы. Служба удалена и находится под нагрузкой, поэтому мы говорим о методе с асинхронной подписью:

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

Поскольку службе требуется некоторое время для каждого запроса, мы хотели бы реализовать для него локальный кеш. Естественно, что в кеше также будет асинхронная подпись, возможно, даже реализация одного и того же интерфейса (см. "Фасад" ). Синхронная подпись нарушит наилучшую практику никогда не вызывать асинхронный код синхронно с .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. Но при этом он должен создать задачу для каждого кеша, вызвав Task.FromResult, и я думаю, что именно это пытается избежать Стивен Туб. Объект 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 flags TaskContinuationOptions.OnlyOnRanToCompletion и TaskContinuationOptions.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

Ответ 2

Мне трудно понять, как это на самом деле помогает больше, чем просто сохраняя результаты.

Когда метод помечен модификатором async, компилятор автоматически преобразует базовый метод в состояние-машину, как показывает Stephan в предыдущих слайдах. Это означает, что использование первого метода всегда вызывает создание Task.

Во втором примере заметим, что Стефан удалил модификатор async, а подпись метода теперь public static Task<string> GetContentsAsync(string url). Это означает, что ответственность за создание Task заключается в реализации метода, а не в компиляторе. Кэширование Task<string>, единственное "наказание" за создание Task (фактически, две задачи, так как ContinueWith также создаст их), это когда оно недоступно в кеше, а не вызовом метода foreach.

В этом конкретном примере IMO не должен повторно использовать сетевую операцию, которая уже выполняется при выполнении первой задачи, а просто уменьшить количество выделенных объектов Task.

как мы узнаем, когда кешировать задачи?

Подумайте о кешировании Task, как если бы это было что-то еще, и этот вопрос можно рассматривать с более широкой точки зрения: когда я должен что-то кэшировать? Ответ на этот вопрос очень широк, но я думаю, что наиболее распространенным случаем является то, что у вас есть дорогостоящая операция, которая находится по горячей ссылке вашего приложения. Должны ли вы всегда выполнять кеширование? точно нет. Накладные расходы на распределение состояния-машины обычно не учитываются. Если необходимо, профайл вашего приложения, а затем (и только потом) подумайте, будет ли кеширование использоваться в вашем конкретном случае использования.

Ответ 3

Соответствующее различие заключается в рассмотрении того, что происходит, когда метод вызывается несколько раз до того, как кеш был заполнен.

Если вы только кешируете результат, как это делается в первом фрагменте, тогда, если два (или три или пятьдесят) вызовов метода будут выполнены до того, как кто-либо из них закончит, все они начнут фактическую операцию сгенерировать результаты (в этом случае выполнить сетевой запрос). Итак, теперь у вас есть два, три, пятьдесят или любые сетевые запросы, которые вы делаете, все из которых собираются поместить свои результаты в кеш, когда они закончатся.

Когда вы кешируете задачу, а не результаты операции, если второй, третий или пятидесятый вызов выполняется этим методом после того, как кто-то другой начнет свой запрос, но до того, как какой-либо из этих запросов будет завершен, они всем будет дана одна и та же задача, представляющая собой одну сетевую операцию (или любую другую долговременную операцию). Это означает, что вы только отправляете один сетевой запрос или выполняете только одно дорогое вычисление, а не дублируете эту работу, когда у вас есть несколько запросов для одного и того же результата.

Кроме того, рассмотрите случай, когда один запрос отправляется, а когда он сделан на 95%, второй метод применяется к этому методу. В первом фрагменте, так как результата нет, он начнется с нуля и выполнит 100% работы. Второй фрагмент приведет к тому, что второй вызов будет передан Task, который будет выполнен на 95%, так что второй вызов будет получать его результат намного раньше, чем если бы он использовал первый подход, в дополнение к всей системе просто делая намного меньше работы.

В обоих случаях, если вы никогда не вызываете метод, когда нет кеша, а другой метод уже начал выполнять работу, то между этими двумя подходами нет существенной разницы.

Вы можете создать довольно простой воспроизводимый пример, чтобы продемонстрировать это поведение. Здесь у нас есть долгоиграющая игрушка и методы, которые либо кэшируют результат, либо кэшируют его Task. Когда мы запустим сразу 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, который представляет эту операцию. Поэтому, хотя это будет намного сложнее, чем с первым фрагментом, возможно, что работа над длительной работой будет выполняться дважды. Там должна быть более надежная блокировка вокруг проверки кеша, запуск новой операции, а затем заполнение кеша, чтобы этого не произошло. Если делать то, что долго выполняемая задача несколько раз в редких случаях просто теряла бы немного времени, то текущий код, вероятно, прекрасен, но если важно, чтобы операция никогда не выполнялась несколько раз (скажем, потому что она вызывает побочные эффекты), то текущий код не завершен.