Mise en cache des opérations asynchrones

Je recherche un moyen élégant de mettre en cache les résultats de mes opérations asynchrones.

J’ai d’abord eu une méthode synchrone comme ceci:

public Ssortingng GetStuff(Ssortingng url) { WebRequest request = WebRequest.Create(url); using (var response = request.GetResponse()) using (var sr = new StreamReader(response.GetResponseStream())) return sr.ReadToEnd(); } 

Puis je l’ai rendu asynchrone:

 public async Task GetStuffAsync(Ssortingng url) { WebRequest request = WebRequest.Create(url); using (var response = await request.GetResponseAsync()) using (var sr = new StreamReader(response.GetResponseStream())) return await sr.ReadToEndAsync(); } 

Ensuite, j’ai décidé de mettre en cache les résultats. Je n’ai donc pas besoin d’interroger aussi souvent à l’extérieur:

 ConcurrentDictionary _cache = new ConcurrentDictionary(); public async Task GetStuffAsync(Ssortingng url) { return _cache.GetOrAdd(url, await GetStuffInternalAsync(url)); } private async Task GetStuffInternalAsync(Ssortingng url) { WebRequest request = WebRequest.Create(url); using (var response = await request.GetResponseAsync()) using (var sr = new StreamReader(response.GetResponseStream())) return await sr.ReadToEndAsync(); } 

Ensuite, j’ai lu un article (o regardé une vidéo) sur les avantages de la mise en cache de la Task , car leur création coûte cher:

 ConcurrentDictionary<String, Task> _cache = new ConcurrentDictionary<String, Task>(); public Task GetStuffAsync(Ssortingng url) { return _cache.GetOrAdd(url, GetStuffInternalAsync(url)); } private async Task GetStuffInternalAsync(Ssortingng url) { WebRequest request = WebRequest.Create(url); using (var response = await request.GetResponseAsync()) using (var sr = new StreamReader(response.GetResponseStream())) return await sr.ReadToEndAsync(); } 

Et maintenant, le problème est que si la demande échoue (par exemple: un HTTP 401), le cache contiendra une Task échec Task et je devrai réinitialiser l’application car il sera impossible de renvoyer la demande.

Existe-t-il un moyen élégant d’utiliser ConcurrentDictionary pour ne mettre en cache que les tâches réussies tout en conservant le comportement atomique?

Tout d’abord, vos deux approches sont erronées, car elles ne vous épargnent aucune demande (bien que la seconde vous fasse au moins gagner du temps).

Votre premier code (celui avec await ) fait ceci:

  1. Faites la demande.
  2. Attendez que la demande soit complétée.
  3. S’il y avait déjà un résultat dans le cache, ignorez le résultat de la demande.

Votre deuxième code supprime l’étape 2, il est donc plus rapide, mais vous faites toujours beaucoup de demandes inutiles.

Ce que vous devriez faire à la place est d’utiliser la surcharge de GetOrAdd() qui prend un délégué :

 public Task GetStuffAsync(Ssortingng url) { return _cache.GetOrAdd(url, GetStuffInternalAsync); } 

Cela n’élimine pas complètement la possibilité que des demandes soient ignorées, mais cela les rend beaucoup moins probables. (Pour cela, vous pouvez essayer d’annuler les demandes dont vous savez qu’elles sont ignorées, mais je ne pense pas que cela en vaille la peine.)


Passons maintenant à votre question actuelle. Ce que je pense que vous devriez faire est d’utiliser la méthode AddOrUpdate() . Si la valeur n’y est pas encore, ajoutez-la. Si c’est le cas, remplacez-le s’il est défectueux:

 public Task GetStuffAsync(Ssortingng url) { return _cache.AddOrUpdate( url, GetStuffInternalAsync, (u, task) => { if (task.IsCanceled || task.IsFaulted) return GetStuffInternalAsync(u); return task; }); } 

En fait, il est raisonnable (et crucial en fonction de votre conception et de vos performances) de conserver ces tâches ayant échoué en tant que cache négatif . Sinon, si une url échoue toujours, son utilisation répétée annule l’utilisation d’un cache.

Ce dont vous avez besoin est un moyen d’effacer le cache de temps en temps. Le moyen le plus simple consiste à avoir un minuteur qui remplace l’instance ConcurrentDictionarry . La solution la plus robuste consiste à créer votre propre LruDictionary ou quelque chose de similaire.

J’ai créé un wrapper pour MemoryCache qui met en cache les objects Lazy> et fonctionne de manière à résoudre tous les problèmes suivants:

  • Aucune opération parallèle ou inutile pour obtenir une valeur ne sera démarrée. Plusieurs sites d’appels ou threads pourraient attendre la même valeur du cache.
  • Les tâches ayant échoué ne sont pas mises en cache. (Pas de cache négatif.)
  • Les utilisateurs de cache ne peuvent pas obtenir des résultats invalidés à partir du cache, même si la valeur est invalidée pendant une attente.

La solution est expliquée plus en détail dans mon blog et le code de travail complet est disponible sur GitHub .

Voici un moyen de mettre en cache les résultats d’opérations asynchrones qui garantit l’absence de mémoire cache.

Dans la réponse acceptée, si la même adresse URL est demandée plusieurs fois dans une boucle (selon le SynchronizationContext) ou à partir de plusieurs threads, la requête Web continuera à être envoyée jusqu’à ce qu’une réponse soit mise en cache, puis le cache commencera à recevoir utilisé.

La méthode ci-dessous crée un object SemaphoreSlim pour chaque clé unique. Cela empêchera la longue opération asynchrone de s’exécuter plusieurs fois pour la même clé tout en lui permettant de s’exécuter simultanément pour différentes clés. De toute évidence, les objects SemaphoreSlim sont omniprésents pour éviter les erreurs de cache, de sorte que cela ne vaut peut-être pas la peine, selon le cas d’utilisation. Mais si la garantie de l’absence de cache est importante, cela accomplit cette tâche.

 private readonly ConcurrentDictionary _keyLocks = new ConcurrentDictionary(); private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); public async Task GetSomethingAsync(ssortingng key) { ssortingng value; // get the semaphore specific to this key var keyLock = _keyLocks.GetOrAdd(key, x => new SemaphoreSlim(1)); await keyLock.WaitAsync(); try { // try to get value from cache if (!_cache.TryGetValue(key, out value)) { // if value isn't cached, get it the long way asynchronously value = await GetSomethingTheLongWayAsync(); // cache value _cache.TryAdd(key, value); } } finally { keyLock.Release(); } return value; } 

Edit: Comme @mtkachenko l’a mentionné dans les commentaires, une vérification supplémentaire du cache pourrait être effectuée au début de cette méthode pour potentiellement ignorer l’étape d’acquisition du verrou.

Ce travail pour moi:

 ObjectCache _cache = MemoryCache.Default; static object _lockObject = new object(); public Task GetAsync(ssortingng cacheKey, Func> func, TimeSpan? cacheExpiration = null) where T : class { var task = (T)_cache[cacheKey]; if (task != null) return task; lock (_lockObject) { task = (T)_cache[cacheKey](cacheKey); if (task != null) return task; task = func(); Set(cacheKey, task, cacheExpiration); task.ContinueWith(t => { if (t.Status != TaskStatus.RanToCompletion) _cache.Remove(cacheKey); }); } return task; } 

Une autre méthode simple consiste à étendre Lazy à AsyncLazy , comme AsyncLazy :

 public class AsyncLazy : Lazy> { public AsyncLazy(Func> taskFactory, LazyThreadSafetyMode mode) : base(() => Task.Factory.StartNew(() => taskFactory()).Unwrap(), mode) { } public TaskAwaiter GetAwaiter() { return Value.GetAwaiter(); } } 

Ensuite, vous pouvez faire ceci:

 private readonly ConcurrentDictionary> _cache = new ConcurrentDictionary>(); public async Task GetStuffAsync(ssortingng url) { return await _cache.GetOrAdd(url, new AsyncLazy( () => GetStuffInternalAsync(url), LazyThreadSafetyMode.ExecutionAndPublication)); }