如何将异步函数传递给ConcurrentDictionary.GetOrAdd方法?

时间:2019-07-20 11:05:36

标签: c# multithreading .net-core async-await concurrentdictionary

这是一个与缓存和异步功能有关的问题。为了给我的问题提供一些背景信息,我将解释一下为什么我要通过缓存和异步函数来面对这个问题。

这几天,我重构了一段旧代码,目的是在给定用户ID的情况下提供一些用户信息。要解决这个问题,请想象这样的签名:

public interface IUserInfoRetriever 
{
  UserInfo GetUserInfo(Guid userId);
}

public class UserInfoRetriever : IUserInfoRetriever 
{
   // implementation is discussed below
}

UserInfoRetriever有一个ConcurrentDictionary<Guid, UserInfo>实例,该实例用作缓存(用户ID是键)。

第一次调用方法GetUserInfo时,将预先填充此内存高速缓存。当前这样做的方法需要在每次调用GetUserInfo时进行锁定,以检查缓存初始化是否已经完成。

释放锁定后,将调用ConcurrentDictionary的方法GetOrAdd,以获取请求的用户信息。传递给GetOrAdd的第二个参数(在缓存未命中的情况下使用的值工厂)是一个lambda表达式,该表达式调用能够提供用户信息的远程Web服务。由于 ConcurrentDictionary不支持异步调用,因此当前以阻止方式(使用Task<T>.Result)进行此调用。

这里有一些元代码可以更好地理解这种情况:

public class UserInfoRetriever : IUserInfoRetriever 
{
    private readonly ConcurrentDictionary<Guid, UserInfo> userCache = new ConcurrentDictionary<Guid, UserInfo>();
    private bool isCacheInitialized = false;
    private readonly object locker = new object();

    public UserInfo GetUserInfo(Guid userId) 
    {
        this.InitializeCache();

        return this.cache.GetOrAdd(userId, () => this.GetUserFromWebService(userId));
    }

    private void InitializeCache()
    {
        lock(this.locker)
        {
            if (this.isCacheInitialized)
            {
                return;
            }

            // read all the users available in the Users table of a database
            // put each user in the cache by using ConcurrentDictionary<Guid, UserInfo>.TryAdd()

            this.isCacheInitialized = true;
        }
    }

    private UserInfo GetUserFromWebService(Guid userId) 
    {
        // performs a call to a web service in a blocking fashion instead of using async methods of HttpClient class
        // this is due to the signature of ConcurrentDictionary<TKey,TValue>.GetOrAdd not supporting async functions as the second parameter
    }
}

这显然是一团糟。该类工作太多,可以通过使用框架类InitializeCache来替换方法Lazy<T>,以便摆脱锁和标志isCacheInitialized

现在是时候关闭入门部分并讨论真正的问题了。 这种情况下的难点是ConcurrentDictionary,它在GetOrAdd方法中不支持异步功能。这是我的问题的全部重点。

根据我的知识,有四种可能的方法:

  • 像当前实现一样依赖于阻塞调用。我不喜欢以阻塞方式进行http调用的想法。此外,不能保证提供给ConcurrentDictionary.GetOrAdd的回调仅被调用一次。我的Web服务调用是GET,因此是幂等的,但是在我看来,这并不理想。

  • 使用Task<UserInfo>代替UserInfo作为字典值。此技巧有助于解决缺少对异步功能的支持的问题。但是,这不能解决多次执行回调函数的问题(这是由于ConcurrentDictionary的实现方式所致)

  • 使用asp.net core memory cache代替ConcurrentDictionary。这样的优点是方法GetOrCreateAsync完全支持异步功能。不幸的是,不能保证回调仅被调用一次(有关更多信息,请参见here)。

  • 使用LazyCache的nuget包来解决这两个问题,因为它在GetOrAdd缓存方法中支持异步功能,并且可以保证异步回调被调用一次。我不知道是否有一种永久缓存项目的方法,但是我可以使用一个很长的绝对到期时间来模拟一个ConcurrentDictionary,其中项目永不过期。

您对ConcurrentDictionary建议什么方法?当用于提供缺失项的工厂方法是异步的时,除了上面列出的方法以外,还有其他方法可以与ConcurrentDictionary一起使用吗?

我的问题仅与管理缺乏对ConcurrentDictionary异步功能支持的问题有关。我正在寻找有关此问题的建议,因为有几种解决方法。我只是为了更清楚地解释整个重构场景,并为我的问题提供了一些背景信息。

更新2019年7月24日

对于感兴趣的人,这是我的最终实现:

public sealed class InMemoryUserCache : IUserCache, IDisposable
  {
    private readonly IBackEndUsersRepository _userRepository;
    private readonly Lazy<ConcurrentDictionary<Guid, Task<BackEndUserInfo>>> _cache;
    private readonly SemaphoreSlim _initializeCacheLocker;

    public InMemoryUserCache(IBackEndUsersRepository userRepository)
    {
      _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
      _cache = new Lazy<ConcurrentDictionary<Guid, Task<BackEndUserInfo>>>(
        InitializeCache,
        LazyThreadSafetyMode.PublicationOnly);
      _initializeCacheLocker = new SemaphoreSlim(2); // allows concurrency, but limit to 2 the number of threads that can concurrently initialize the lazy instance
    }

    public Task<BackEndUserInfo> GetOrAdd(
      Guid userId,
      Func<Guid, Task<BackEndUserInfo>> userInfoFactory)
    {
      if (userInfoFactory == null)
        throw new ArgumentNullException(nameof(userInfoFactory));

      return _cache.Value.GetOrAdd(userId, ToSafeUserInfoFactory(userInfoFactory));
    }

    private ConcurrentDictionary<Guid, Task<BackEndUserInfo>> InitializeCache()
    {
      _initializeCacheLocker.Wait();

      try
      {
        var cache = new ConcurrentDictionary<Guid, Task<BackEndUserInfo>>();

        foreach (var user in _userRepository.FindAll())
        {
          cache[user.Id] = Task.FromResult(user);
        }

        return cache;
      }
      finally
      {
        _initializeCacheLocker.Release();
      }
    }

    private Func<Guid, Task<BackEndUserInfo>> ToSafeUserInfoFactory(
      Func<Guid, Task<BackEndUserInfo>> userInfoFactory) =>
        userId => TryExecuteUserInfoFactory(userInfoFactory, userId);

    private async Task<BackEndUserInfo> TryExecuteUserInfoFactory(
      Func<Guid, Task<BackEndUserInfo>> userInfoFactory,
      Guid userId)
    {
      try
      {
        return await userInfoFactory(userId).ConfigureAwait(false);
      }
      catch (Exception)
      {
        _ = _cache.Value.TryRemove(userId, out var _);
        throw;
      }
    }

    public void Dispose()
    {
      _initializeCacheLocker?.Dispose();
    }
  }

2 个答案:

答案 0 :(得分:1)

GetOrAdd中支持异步回调似乎不是一个好主意。显然,回调正在完成大量的工作或状态更改,或者您使用的版本可以提前准备新项目……但是,这仍然是对并发集合的同步访问在谈论。

如果在回调完成之前访问ConcurrentDictionary的相同元素,应该怎么办?使用线程,每个线程最多可以对ConcurrentDictionary进行一个操作,除非您对重入进行了真正疯狂的操作。使用Tasks和continuation-passing-style,您甚至可以不做任何尝试而最终得到无限制的重叠Dictionary请求,甚至是无限制的递归。

考虑如果在第一个用户完成之前对同一用户进行了第二次缓存查找,应该怎么办。在您的情况下,两个查找可能应该从一个网络请求返回数据。为此,请将ConcurrentDictionary<UserId, User>更改为ConcurrentDictionary<UserId, Task<User>>,然后继续传递async函数作为回调。当异步代码被阻止时,它的Task将在Dictionary中注册,而不是在它完成时...。这允许后续查找await相同的正在进行中的{ {1}}。这是您的第二个要点,但我不确定您为什么得出这样的结论:它无法很好地处理多个查询。它不会100%防止重复查找导致独立的网络请求,但是竞争窗口要比当前实现的窗口小得多。

应对图书馆行为进行设计,以使正确的操作变得容易且直接的操作正确(有时称为“成功的秘诀”)。 Task会使逻辑推理变得更加复杂,而面对提供通用的预先构建的线程安全的集合类这一事实就不那么容易了。

如果您需要对共享集合进行长时间运行的元素初始化,那么通用选择不是一个好选择,并且您将需要构建针对特定用例进行优化的线程安全集合

答案 1 :(得分:-1)

我倾向于避免使用ConcurrentDictionary类,至少要等到对要实现的目标有了一个很好的了解之前。我从一个普通的(非并行)Dictionary和一个储物柜开始,然后在旅程结束时 if 我的受保护代码与ConcurrentDictionary可以保护的代码完全匹配类,然后 可以切换到并发类,以从减少的争用中获得额外的性能,该争用是由于其细化锁定实现而导致的。换句话说,ConcurrentDictionary在我看来不仅仅是性能优化工具,也不是提高可读性或可维护性的方法。

我的建议是确保http调用将发生一次,除非失败,在这种情况下,它将重复执行直到最终成功。在这种情况下,我最初尝试使用Lazy类将无法进行恢复。

public class UserInfoRetriever : IUserInfoRetriever
{
    private Task<Dictionary<Guid, UserInfo>> _cacheTask;
    private readonly object _locker = new object();

    public UserInfo GetUserInfo(Guid userId)
    {
        lock (_locker)
        {
            if (_cacheTask == null || _cacheTask.IsFaulted)
            {
                _cacheTask = GetAllUserInfoAsync();
            }
        }
        return _cacheTask.Result.TryGetValue(userId, out var v) ? v : null;
    }

    private async Task<Dictionary<Guid, UserInfo>> GetAllUserInfoAsync()
    {
        var dictionary = new Dictionary<Guid, UserInfo>();
        // Fetch data asynchronously and fill the dictionary
        return dictionary;
    }
}