remarks generate example c# caching async-await task-parallel-library task

c# - generate - ¿Cuándo almacenar en caché las tareas?



params comments c# (3)

Estaba viendo The zen of async: las mejores prácticas para obtener el mejor rendimiento y Stephen Toub comenzó a hablar sobre el almacenamiento en caché de tareas, donde en lugar de almacenar en caché los resultados de los trabajos de tareas, almacena las tareas en caché. Por lo que entiendo, comenzar una nueva tarea para cada trabajo es costoso y se debe minimizar al máximo. Alrededor de las 28:00 mostró este método:

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

Lo cual, a primera vista, parece un buen método pensado en el que almacenar en caché los resultados, no pensé en almacenar en caché el trabajo de obtener los contenidos.

Y de lo que mostró este método:

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

Tengo problemas para entender cómo esto realmente ayuda más que solo almacenar los resultados.

¿Esto significa que estás usando menos tareas para obtener los datos?

Y también, ¿cómo sabemos cuándo almacenar en caché las tareas? Por lo que entiendo, si está almacenando en el caché en el lugar equivocado, solo tiene que cargar un montón de sobrecarga y estresar demasiado el sistema.


Tengo problemas para entender cómo esto realmente ayuda más que solo almacenar los resultados.

Cuando un método está marcado con el modificador async , el compilador transformará automáticamente el método subyacente en una máquina de estado, como demuestra Stephan en las diapositivas anteriores. Esto significa que el uso del primer método siempre activará la creación de una Task .

En el segundo ejemplo, observe que Stephan eliminó el modificador async y la firma del método ahora es public static Task<string> GetContentsAsync(string url) . Esto ahora significa que la responsabilidad de crear la Task del implementador del método y no del compilador. Al almacenar en caché la Task<string> , la única "penalización" de crear la Task (en realidad, dos tareas, como ContinueWith también creará una) es cuando no está disponible en la memoria caché, y no para la llamada al método foreach.

En este ejemplo particular, IMO, no era para reutilizar la operación de red que ya está en marcha cuando se ejecuta la primera tarea, sino simplemente para reducir la cantidad de objetos de Task asignados.

¿cómo sabemos cuándo almacenar en caché las tareas?

Piense en guardar en caché una Task como si fuera otra cosa, y esta pregunta se puede ver desde una perspectiva más amplia: ¿ cuándo debería almacenar algo en la memoria caché? La respuesta a esta pregunta es amplia, pero creo que el caso de uso más común es cuando tiene una operación costosa que se encuentra en la vía de acceso de su aplicación. ¿Deberías estar siempre almacenando tareas en el caché? definitivamente no. La sobrecarga de la asignación de máquina de estado generalmente es poco útil. Si es necesario, perfile su aplicación, y luego (y solo entonces) piense si el almacenamiento en caché sería útil en su caso de uso particular.


La diferencia relevante es considerar qué ocurre cuando se llama al método varias veces antes de que se haya completado el caché.

Si solo almacena el resultado en caché, como se hace en el primer fragmento, si se realizan dos (o tres o cincuenta) llamadas antes de que cualquiera de ellos finalice, todos comenzarán la operación real para generar los resultados. (en este caso, realizar una solicitud de red). Así que ahora tiene dos, tres, cincuenta o cualquier solicitud de red que esté haciendo, y todos ellos colocarán sus resultados en la memoria caché cuando finalicen.

Cuando almacena en caché la tarea , en lugar de los resultados de la operación, si se realiza una segunda, tercera o quincuagésima llamada a este método después de que otra persona inicie su solicitud, pero antes de que se complete, todos van a recibir la misma tarea que representa esa operación de red (o cualquier operación de larga duración). Eso significa que solo está enviando una solicitud de red, o que solo realiza un cálculo caro, en lugar de duplicar ese trabajo cuando tiene varias solicitudes del mismo resultado.

Además, considere el caso en que se envía una solicitud, y cuando se ha completado en un 95%, se realiza una segunda llamada al método. En el primer fragmento, dado que no hay resultados, comenzará desde cero y realizará el 100% del trabajo. El segundo fragmento dará como resultado que a la segunda invocación se le entregue una Task que está completa al 95%, de modo que la segunda invocación obtendrá su resultado mucho antes de lo que lo haría si utilizara el primer enfoque, además de todo el sistema haciendo solo un mucho menos trabajo.

En ambos casos, si nunca llama al método cuando no hay caché, y otro método ya ha comenzado a hacer el trabajo, entonces no hay una diferencia significativa entre los dos enfoques.

Puede crear un ejemplo reproducible bastante simple para demostrar este comportamiento. Aquí tenemos una operación de juguete de larga ejecución y métodos que almacenan en caché el resultado o almacenan en caché la Task que devuelve. Cuando ejecutemos 5 de las operaciones de una vez, veremos que el almacenamiento en caché de resultados realiza la operación de ejecución larga 5 veces y el almacenamiento en caché de tareas lo realiza solo una vez.

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

Vale la pena señalar que la implementación específica del segundo enfoque que ha proporcionado tiene algunas propiedades notables. Es posible que el método sea llamado dos veces de tal manera que ambos inicien la operación de ejecución larga antes de que cualquiera de las tareas pueda finalizar el inicio de la operación y, por lo tanto, almacenar en caché la Task que representa esa operación. Entonces, si bien sería mucho más difícil que con el primer fragmento, es posible que la operación de ejecución prolongada se ejecute dos veces. Tendría que haber un bloqueo más sólido para verificar el caché, iniciar una nueva operación y luego llenar el caché, para evitar eso. Si hacer lo que sea que la tarea larga es varias veces en raras ocasiones simplemente estaría perdiendo un poco de tiempo, entonces el código actual probablemente sea correcto, pero si es importante que la operación nunca se realice varias veces (por ejemplo, porque causa efectos secundarios) ) entonces el código actual no está completo.


Supongamos que está hablando con un servicio remoto que toma el nombre de una ciudad y devuelve sus códigos postales. El servicio es remoto y está bajo carga, por lo que estamos hablando de un método con una firma asincrónica:

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

Como el servicio necesita un tiempo para cada solicitud, nos gustaría implementar un caché local para ello. Naturalmente, la memoria caché también tendrá una firma asíncrona que puede incluso implementar la misma interfaz (consulte el patrón Fachada). Una firma sincrónica rompería la mejor práctica de nunca llamar al código asíncrono de forma síncrona con .Wait (), .Result o similar. Al menos el caché debe dejar eso a la persona que llama.

Así que hagamos una primera iteración sobre esto:

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

Como puede ver, la memoria caché no almacena en caché los objetos Tarea, sino los valores devueltos de las colecciones ZipCode. Pero al hacerlo, tiene que construir una Tarea para cada golpe de caché llamando a Task.FromResult y creo que eso es exactamente lo que Stephen Toub intenta evitar. Un objeto Tarea viene con una sobrecarga especialmente para el recolector de basura porque no solo está creando basura, sino que también cada Tarea tiene un Finalizador que debe ser considerado por el tiempo de ejecución.

La única opción para evitar esto es colocar en caché todo el objeto 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; }); } }

Como puede ver, la creación de Tareas llamando a Task.FromResult se ha ido. Además, no es posible evitar esta creación de tareas cuando se utilizan las palabras clave async / await porque internamente crearán una tarea para devolver, independientemente de lo que su código haya almacenado en caché. Algo como:

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

no compilará

No se confunda con los indicadores ContinueWith de Stephen Toub TaskContinuationOptions.OnlyOnRanToCompletion y TaskContinuationOptions.ExecuteSynchronously . Son (solo) otra optimización del rendimiento que no está relacionada con el objetivo principal de las Tareas de almacenamiento en caché.

Al igual que con cada caché, debe considerar algún mecanismo que limpie la caché de vez en cuando y elimine las entradas que sean demasiado antiguas o no válidas. También podría implementar una política que limite el caché a n entradas y trate de almacenar en caché los elementos solicitados más introduciendo algunos conteos.

Hice algunos benchmarking con y sin almacenamiento en caché de tareas. Puede encontrar el código aquí http://pastebin.com/SEr2838A y los resultados se ven así en mi máquina (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