【问题标题】:What are best practices / good patterns for managing cached async data?管理缓存的异步数据的最佳实践/良好模式是什么?
【发布时间】:2021-07-01 16:01:31
【问题描述】:

我正在重写一个旧应用程序,我正在尝试使用async 来加速它。

旧代码是这样做的:

var value1 = getValue("key1");
var value2 = getValue("key2");
var value3 = getValue("key3");

getValue 函数在字典中管理自己的缓存,执行如下操作:

object getValue(string key) {
  if (cache.ContainsKey(key)) return cache[key];
  var value = callSomeHttpEndPointsAndCalculateTheValue(key);
  cache.Add(key, value);
  return value;
}

如果我每次拨打getValueasync 和awaitgetValue,那么一切正常。但它并不比旧版本快,因为一切都像以前一样同步运行。

如果我删除await(好吧,如果我推迟它,但这不是这个问题的重点),我终于可以并行运行缓慢的东西。但是,如果在第一次调用完成之前对getValue("key1") 执行第二次调用,我最终会执行两次相同的慢速调用,并且一切都比旧版本慢,因为它没有利用缓存。

有没有像await("key1") 这样的东西,只有在之前与"key1" 的通话仍在等待时才会等待?

编辑(跟进评论)

“加快速度”是指响应速度更快。

例如,当用户在下拉列表中选择材料时,我想更新其他下拉列表中的可用厚度或颜色列表以及其他 UI 元素中的其他材料属性。有时这会触发一系列事件,需要多次使用相同的getValue("key")。

例如当材质发生变化时,可能会调用几个函数:updateThicknesses()、updateHoleOffsets()、updateMaxWindLoad()、updateMaxHoleDistances()等。每个函数从 UI 元素中读取值并决定是否执行它自己的慢速计算独立于其他函数。每个函数可能需要几次 h​​ttp 调用来计算一些参数,而其中一些参数可能需要多个函数。

旧的实现是按顺序调用函数,所以第二个函数在处理第一个函数时会利用缓存的一些值。用户第一次会看到界面的每个部分在 5-6 秒内按顺序更新,接下来的时间会非常快,除非新值需要一些新的 http 端点调用。

新的异步实现同时调用所有函数,因此每个函数最终都会调用相同的 http 端点,因为它们的结果尚未缓存。

【问题讨论】:

  • "我正在尝试使用 async 来加速它" 通常 async/await 用于使应用程序更具响应性或可扩展性,而不是更快。您能否展示一下您打算如何使用异步版本的getValue 方法(getValueAsync?)来加快应用程序的速度?
  • 免费下载Task-based Asynchronous Pattern Stephen Toub 的小书。那里有一个 AsyncCache 实现。
  • 您可能对AsyncLazy 之类的内容感兴趣。
  • 一个简单的方法是缓存任务对象而不是它们的值。
  • 您可以将ConcurrentDictionary<K,V> 与接受异步委托的GetOrAddAsync 实现一起使用(Func<TKey, Task<TValue>>)。这个方法有一些实现here.

标签: c# design-patterns async-await


【解决方案1】:

一个简单的方法是缓存任务而不是值,这样你就可以等待一个挂起的任务和一个已经完成的任务来获取值。

如果多个并行任务都尝试使用相同的键获取值,则只有第一个会分拆任务,其他任务将等待相同的任务。

这是一个简单的实现:

private Dictionary<string, Task<object>> cache = new();
public Task<object> getValueAsync(string key)
{
    lock (cache)
    {
        if (!cache.TryGetValue(key, out var result))
            cache[key] = result = callSomeHttpEndPointsAndCalculateTheValueAsync(key);

        return result;
    }
}

根据 cmets 判断,可能不应该使用以下示例。

由于已经提到了 [ConcurrentDictionary](),所以这里有一个使用它的版本。
private ConcurrentDictionary<string, Task<object>> cache = new();
public Task<object> getValueAsync(string key)
{
    return cache.GetOrAdd(key, k => callSomeHttpEndPointsAndCalculateTheValueAsync(k));
}

该方法似乎更简单,仅此一项就可能是切换到它的理由,但根据我的经验,ConcurrentDictionary 和其他 ConcurrentXXX 集合似乎有它们的利基用途,并且似乎更加笨拙,因此对于基本的东西。

【讨论】:

  • 缓存字典不需要是ConcurrentDictionary,因为它只被这个函数使用,不存在被意外访问/修改的风险,对吧?
  • 没有。我的意思是,改用 ConcurrentDictionary 并没有什么坏处,但是我尝试过的所有性能测量似乎都表明,对于简单的东西,基本锁定方法的性能更好。如果您愿意,我可以使用该 ConcurrentDictionary 发布一个版本,您可以同时尝试它们。
  • 我是 C# 的新手,我理解它的方式,如果 cache 变量仅由一个函数使用并且该函数没有 await,那么它可以被认为是线程安全的。这个假设是错误的吗? (这应该是另一个帖子吗?)
  • @stenci 仅从一种方法调用它不是问题,重要的是它是否曾同时从多个线程调用。一个调用方法可能会或可能不会同时从多个线程调用。
  • GetOrAdd 不保证每个键只调用一次委托,而这正是问题要求此缓存实现的属性。
猜你喜欢
  • 2010-12-18
  • 2019-09-27
  • 1970-01-01
  • 1970-01-01
  • 2018-02-14
  • 1970-01-01
  • 2013-12-05
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多