C# LazyCache 并发字典垃圾回收

Posted

技术标签:

【中文标题】C# LazyCache 并发字典垃圾回收【英文标题】:C# LazyCache concurrent dictionary garbage collection 【发布时间】:2020-05-20 15:53:37 【问题描述】:

在使用基于 Web 的 .Net(C#) 应用程序时遇到了一些问题。我正在使用LazyCache 库为属于同一公司的用户在用户会话中缓存频繁的 JSON 响应(一些在 80+KB 左右)。

我们需要做的一件事是跟踪特定公司的缓存键,因此当公司中的任何用户对正在缓存的项目进行变异更改时,我们需要清除该特定公司的这些项目的缓存用户在收到下一个请求时强制缓存立即重新填充。

我们选择LazyCache 库是因为我们希望在内存中执行此操作,而无需使用 Redis 等外部缓存源,因为我们没有大量使用。

我们在使用这种方法时遇到的一个问题是,我们需要在缓存的任何时候跟踪属于特定客户的所有缓存键。因此,当公司用户对相关资源进行任何变异更改时,我们需要使属于该公司的所有缓存键过期。

为了实现这一点,我们有一个所有 Web 控制器都可以访问的全局缓存。

private readonly IAppCache _cache = new CachingService();

protected IAppCache GetCache()

    return _cache;

使用此缓存的控制器的简化示例(请原谅任何拼写错误!)如下所示

[HttpGet]
[Route("customerId/accounts/users")]
public async Task<Users> GetUsers([Required]string customerId)

    var usersBusinessLogic = await _provider.GetUsersBusinessLogic(customerId)

    var newCacheKey= "GetUsers." + customerId;

    CacheUtil.StoreCacheKey(customerId,newCacheKey)

    return await GetCache().GetOrAddAsync(newCacheKey, () => usersBusinessLogic.GetUsers(), DateTimeOffset.Now.AddMinutes(10));

我们使用带有静态方法的 util 类和静态并发字典来存储缓存键 - 每个公司 (GUID) 可以有许多缓存键。

private static readonly ConcurrentDictionary<Guid, ConcurrentHashSet<string>> cacheKeys = new ConcurrentDictionary<Guid, ConcurrentHashSet<string>>();

public static void StoreCacheKey(Guid customerId, string newCacheKey)

    cacheKeys.AddOrUpdate(customerId, new ConcurrentHashSet<string>()  newCacheKey , (key, existingCacheKeys) =>
    
        existingCacheKeys.Add(newCacheKey);
        return existingCacheKeys;
    );

在同一个 util 类中,当我们需要删除特定公司的所有缓存键时,我们有一个类似于下面的方法(这是在其他控制器中进行变异更改时引起的)

public static void ClearCustomerCache(IAppCache cache, Guid customerId)

    var customerCacheKeys = new ConcurrentHashSet<string>();

    if (!cacheKeys.TryGetValue(customerId,out customerCacheKeys))
    
        return new ConcurrentHashSet<string>();
    


    foreach (var cacheKey in customerCacheKeys)
    
        cache.Remove(cacheKey);
    

    cacheKeys.TryRemove(customerId, out _);

我们最近遇到了性能问题,即我们的网络请求响应时间会随着时间的推移而显着变慢 - 我们看不到每秒请求数的显着变化。

查看垃圾收集指标,我们似乎注意到第 2 代堆大小和对象大小似乎在上升 - 我们没有看到内存被回收。

我们仍在调试中,但我想知道使用上述方法是否会导致我们看到的问题。我们想要线程安全,但是使用我们上面的并发字典是否会出现问题,即使我们删除了内存没有被释放的项目,从而导致过多的 Gen 2 收集。

我们还使用工作站垃圾收集模式,想象一下切换到服务器模式 GC 会帮助我们(我们的 IIS 服务器有 8 个处理器 + 16 GB 内存),但不确定切换是否能解决所有问题。

【问题讨论】:

您的ClearCustomerCache 没有多大意义。为什么void 方法会返回一个值?看起来它可能有竞争条件,如果两件事试图同时调用 ti,但从当前的“简化”代码中很难判断。 这是在 dotnetcore 还是 dotnetfw 上? 它的 .Net 框架 4.6.1 【参考方案1】:

大对象 (> 85k) 属于第 2 代大对象堆 (LOH),它们被固定在内存中。

    GC 扫描 LOH 并标记死对象 相邻的死对象合并到空闲内存中 LOH 压缩 进一步的分配只会尝试填充死对象留下的空洞

没有压缩,但只有重新分配可能会导致内存碎片。 长时间运行的服务器进程可以通过这个来完成 - 这并不罕见。 您可能会看到随着时间的推移会出现碎片。

服务器 GC 恰好是多线程的 - 我不希望它解决碎片问题。

您可以尝试分解大型对象 - 这对于您的应用程序可能不可行。

您可以在缓存清除后尝试设置LargeObjectHeapCompaction - 假设它不经常发生。

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

最终,我建议对堆进行分析以找出有效的方法。

【讨论】:

使用GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect(); 的方法你将如何在基于网络的应用程序中做到这一点?只需在设定的时间间隔内完成一项计划任务即可 我猜主要是静态 ConcurrentDictionary 存储我需要跟踪的缓存键有什么本质上的错误。我想知道即使在我清除它们之后,GC 是否也无法删除内存。用 null 强制替换相应的缓存键条目会比使用 cache.Remove() 更好 您可以按时间间隔安排任务。 ConcurrentDictionary 不是一个非常复杂的对象 - 这表示代码看起来容易受到竞争条件的影响。你应该重写它,使用TryRemove 而不是TryGetValue 是的,明白你的意思可以轻松使用public static void ClearCache(IAppCache cache, Guid customerId) if (allCustomerCacheKeys.TryRemove(IAppCache cache, Guid customerId, out var cacheKeys)) foreach (var cacheKey in cacheKeys) cache.Remove(cacheKey); 这就够了。如果您阅读了我的回答,我会解释为什么即使启动 GC,您也可能不会看到内存有任何减少。【参考方案2】:

您可能想要利用MemoryCacheEntryOptions 类的ExpirationTokens 属性。您还可以从 LazyCache.Providers.MemoryCacheProvider.GetOrCreateAsync 方法的委托中传递的 ICacheEntry 参数中使用它。例如:

Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory,
    int durationMilliseconds = Timeout.Infinite, string customerId = null)

    return GetMemoryCacheProvider().GetOrCreateAsync<T>(key, (options) =>
    
        if (durationMilliseconds != Timeout.Infinite)
        
            options.SetSlidingExpiration(TimeSpan.FromMilliseconds(durationMilliseconds));
        
        if (customerId != null)
        
            options.ExpirationTokens.Add(GetCustomerExpirationToken(customerId));
        
        return factory();
    );

现在GetCustomerExpirationToken 应该返回一个实现IChangeToken 接口的对象。事情变得有点复杂,但请耐心等待一分钟。 .NET 平台不提供适合这种情况的内置IChangeToken 实现,因为它主要关注文件系统观察程序。不过实现一个并不难:

class ChangeToken : IChangeToken, IDisposable

    private volatile bool _hasChanged;
    private readonly ConcurrentQueue<(Action<object>, object)>
        registeredCallbacks = new ConcurrentQueue<(Action<object>, object)>();

    public void SignalChanged()
    
        _hasChanged = true;
        while (registeredCallbacks.TryDequeue(out var entry))
        
            var (callback, state) = entry;
            callback?.Invoke(state);
        
    

    bool IChangeToken.HasChanged => _hasChanged;

    bool IChangeToken.ActiveChangeCallbacks => true;

    IDisposable IChangeToken.RegisterChangeCallback(Action<object> callback,
        object state)
    
        registeredCallbacks.Enqueue((callback, state));
        return this; // return null doesn't work
    

    void IDisposable.Dispose()   // It is called by the framework after each callback

这是IChangeToken 接口的一般实现,使用SignalChanged 方法手动激活。该信号将被传播到底层的MemoryCache 对象,该对象随后将使与此令牌关联的所有条目无效。

现在剩下要做的就是将这些令牌与客户相关联,并将它们存储在某个地方。我认为ConcurrentDictionary 应该足够了:

private static readonly ConcurrentDictionary<string, ChangeToken>
    CustomerChangeTokens = new ConcurrentDictionary<string, ChangeToken>();

private static ChangeToken GetCustomerExpirationToken(string customerId)

    return CustomerChangeTokens.GetOrAdd(customerId, _ => new ChangeToken());

最后,发出信号表明特定客户的所有条目都应失效所需的方法:

public static void SignalCustomerChanged(string customerId)

    if (CustomerChangeTokens.TryRemove(customerId, out var changeToken))
    
        changeToken.SignalChanged();
    

【讨论】:

以上是关于C# LazyCache 并发字典垃圾回收的主要内容,如果未能解决你的问题,请参考以下文章

C#关于垃圾回收 终结器IDispose的设计规范札记

浅析C#中的托管非托管堆栈与垃圾回收

浅析C#中的托管非托管堆栈与垃圾回收

记录JVM垃圾回收算法

G1垃圾回收器在并发场景调优

经典垃圾回收器