【问题标题】:MemoryCache Thread Safety, Is Locking Necessary?MemoryCache 线程安全,是否需要加锁?
【发布时间】:2013-12-07 14:59:27
【问题描述】:

首先,让我把它扔在那里,我知道下面的代码不是线程安全的(更正:可能是)。我正在努力寻找一种实现,并且我实际上可以在测试中失败。我现在正在重构一个大型 WCF 项目,该项目需要缓存一些(大部分)静态数据并从 SQL 数据库中填充。它需要每天至少过期和“刷新”一次,这就是我使用 MemoryCache 的原因。

我知道下面的代码不应该是线程安全的,但我不能让它在重负载下失败并且使事情复杂化,谷歌搜索显示了两种方式的实现(有和没有锁以及是否有必要的争论。

在多线程环境中了解 MemoryCache 的人能否让我明确知道我是否需要在适当的地方锁定,以便在检索期间不会抛出删除调用(很少调用但它是必需的)/重新繁殖。

public class MemoryCacheService : IMemoryCacheService
{
    private const string PunctuationMapCacheKey = "punctuationMaps";
    private static readonly ObjectCache Cache;
    private readonly IAdoNet _adoNet;

    static MemoryCacheService()
    {
        Cache = MemoryCache.Default;
    }

    public MemoryCacheService(IAdoNet adoNet)
    {
        _adoNet = adoNet;
    }

    public void ClearPunctuationMaps()
    {
        Cache.Remove(PunctuationMapCacheKey);
    }

    public IEnumerable GetPunctuationMaps()
    {
        if (Cache.Contains(PunctuationMapCacheKey))
        {
            return (IEnumerable) Cache.Get(PunctuationMapCacheKey);
        }

        var punctuationMaps = GetPunctuationMappings();

        if (punctuationMaps == null)
        {
            throw new ApplicationException("Unable to retrieve punctuation mappings from the database.");
        }

        if (punctuationMaps.Cast<IPunctuationMapDto>().Any(p => p.UntaggedValue == null || p.TaggedValue == null))
        {
            throw new ApplicationException("Null values detected in Untagged or Tagged punctuation mappings.");
        }

        // Store data in the cache
        var cacheItemPolicy = new CacheItemPolicy
        {
            AbsoluteExpiration = DateTime.Now.AddDays(1.0)
        };

        Cache.AddOrGetExisting(PunctuationMapCacheKey, punctuationMaps, cacheItemPolicy);

        return punctuationMaps;
    }

    //Go oldschool ADO.NET to break the dependency on the entity framework and need to inject the database handler to populate cache
    private IEnumerable GetPunctuationMappings()
    {
        var table = _adoNet.ExecuteSelectCommand("SELECT [id], [TaggedValue],[UntaggedValue] FROM [dbo].[PunctuationMapper]", CommandType.Text);
        if (table != null && table.Rows.Count != 0)
        {
            return AutoMapper.Mapper.DynamicMap<IDataReader, IEnumerable<PunctuationMapDto>>(table.CreateDataReader());
        }

        return null;
    }
}

【问题讨论】:

  • ObjectCache 是线程安全的,我认为您的课程不会失败。 msdn.microsoft.com/en-us/library/… 你可能会同时访问数据库,但这只会使用比需要更多的 CPU。
  • 虽然 ObjectCache 是线程安全的,但它的实现可能不是。因此,MemoryCache 问题。

标签: c# multithreading wcf memorycache


【解决方案1】:

MS 提供的默认MemoryCache 是完全线程安全的。从MemoryCache 派生的任何自定义实现可能不是线程安全的。如果您使用开箱即用的普通MemoryCache,它是线程安全的。浏览我的开源分布式缓存解决方案的源代码,看看我是如何使用它的(MemCache.cs):

https://github.com/haneytron/dache/blob/master/Dache.CacheHost/Storage/MemCache.cs

【讨论】:

  • 大卫,只是为了确认一下,在我上面的非常简单的示例类中,如果另一个线程正在调用 Get(),对 .Remove() 的调用实际上是线程安全的?我想我应该只使用反射器并深入挖掘,但那里有很多相互矛盾的信息。
  • 它是线程安全的,但容易出现竞争条件...如果您的 Get 发生在您的 Remove 之前,则数据将在 Get 上返回。如果删除首先发生,则不会。这很像数据库上的脏读。
  • 值得一提(正如我在下面的另一个答案中评论的那样),dotnet core 实现目前NOT 完全是线程安全的。特别是GetOrCreate 方法。 github上有一个issue
  • 使用控制台运行线程时,内存缓存是否会抛出异常“Out of Memory”?
【解决方案2】:

虽然 MemoryCache 确实像其他答案所指定的那样是线程安全的,但它确实存在一个常见的多线程问题 - 如果 2 个线程同时尝试从(或检查 Contains)缓存中访问 Get,那么两者都会错过缓存,两者最终都会生成结果,然后都将结果添加到缓存中。

这通常是不可取的——第二个线程应该等待第一个线程完成并使用它的结果,而不是生成两次结果。

这就是我写LazyCache 的原因之一——MemoryCache 上的一个友好的包装器可以解决这些问题。它也可以在Nuget 上找到。

【讨论】:

  • "它确实有一个常见的多线程问题" 这就是为什么你应该使用像AddOrGetExisting 这样的原子方法,而不是围绕Contains 实现自定义逻辑。 MemoryCache 的AddOrGetExisting 方法是原子的和线程安全的 referencesource.microsoft.com/System.Runtime.Caching/R/…
  • 是的 AddOrGetExisting 是线程安全的。但它假定您已经拥有对将被添加到缓存中的对象的引用。通常你不想要 AddOrGetExisting 你想要 "GetExistingOrGenerateThisAndCacheIt" 这是 LazyCache 给你的。
  • 是的,同意“如果您还没有对象”这一点
  • AddOrGetExisting 实际上不是原子的。请看github.com/dotnet/runtime/issues/36499
  • @Andrew 你从 .NET Core 引用了AddOrCreate。我在谈论AddOrGetExisting,甚至链接到其中包含lock 的源代码
【解决方案3】:

正如其他人所说,MemoryCache 确实是线程安全的。但是,存储在其中的数据的线程安全性完全取决于您对它的使用。

从他很棒的post 中引用Reed Copsey 关于并发和ConcurrentDictionary&lt;TKey, TValue&gt; 类型。这当然适用于此。

如果两个线程同时调用这个[GetOrAdd],就可以很容易地构造两个TValue实例。

您可以想象,如果 TValue 的构建成本很高,这将特别糟糕。

要解决这个问题,您可以非常轻松地利用Lazy&lt;T&gt;,巧合的是,它的构建成本非常低。这样做可以确保如果我们进入多线程情况,我们只会构建Lazy&lt;T&gt; 的多个实例(这很便宜)。

GetOrAdd()GetOrCreate()MemoryCache 的情况下)将向所有线程返回相同的单数Lazy&lt;T&gt;Lazy&lt;T&gt; 的“额外”实例被简单地丢弃。

由于 Lazy&lt;T&gt; 在调用 .Value 之前不会执行任何操作,因此只会构造对象的一个​​实例。

现在有一些代码!下面是实现上述内容的IMemoryCache 的扩展方法。它根据int seconds 方法参数任意设置SlidingExpiration。但这完全可以根据您的需要进行定制。

注意这是特定于 .netcore2.0 应用程序

public static T GetOrAdd<T>(this IMemoryCache cache, string key, int seconds, Func<T> factory)
{
    return cache.GetOrCreate<T>(key, entry => new Lazy<T>(() =>
    {
        entry.SlidingExpiration = TimeSpan.FromSeconds(seconds);

        return factory.Invoke();
    }).Value);
}

打电话:

IMemoryCache cache;
var result = cache.GetOrAdd("someKey", 60, () => new object());

要异步执行这一切,我建议使用 Stephen Toub's 优秀的 AsyncLazy&lt;T&gt; 实现,可在 MSDN 上的 article 中找到。它结合了内置的惰性初始化器Lazy&lt;T&gt; 和承诺Task&lt;T&gt;

public class AsyncLazy<T> : Lazy<Task<T>>
{
    public AsyncLazy(Func<T> valueFactory) :
        base(() => Task.Factory.StartNew(valueFactory))
    { }
    public AsyncLazy(Func<Task<T>> taskFactory) :
        base(() => Task.Factory.StartNew(() => taskFactory()).Unwrap())
    { }
}   

现在是 GetOrAdd() 的异步版本:

public static Task<T> GetOrAddAsync<T>(this IMemoryCache cache, string key, int seconds, Func<Task<T>> taskFactory)
{
    return cache.GetOrCreateAsync<T>(key, async entry => await new AsyncLazy<T>(async () =>
    { 
        entry.SlidingExpiration = TimeSpan.FromSeconds(seconds);

        return await taskFactory.Invoke();
    }).Value);
}

最后,调用:

IMemoryCache cache;
var result = await cache.GetOrAddAsync("someKey", 60, async () => new object());

【讨论】:

  • 我试过了,它似乎不起作用(dot net core 2.0)。每个 GetOrCreate 都会创建一个新的 Lazy 实例,并使用新的 Lazy 更新缓存,因此,Value get 被多次评估\创建(在多线程环境中)。
  • 从外观上看,.netcore 2.0 MemoryCache.GetOrCreate 不像 ConcurrentDictionary 那样是线程安全的
  • 可能是一个愚蠢的问题,但您确定这是多次创建的 Value 而不是 Lazy?如果是,您是如何验证的?
  • 我使用了一个工厂函数,该函数在使用时打印到屏幕上 + 生成一个随机数,并启动 10 个线程,所有线程都尝试使用相同的密钥和该工厂来GetOrCreate。结果,当与内存缓存一起使用时,工厂被使用了 10 次(看到打印)+ 每次 GetOrCreate 返回不同的值!我使用ConcurrentDicionary 进行了相同的测试,发现工厂只使用了一次,并且总是得到相同的值。我在github上找到了一个已关闭的issue,我只是在那里写了一条评论说应该重新打开它
  • 这一切都正确,并且类似于 LazyCache 中为您处理此代码的实现。此外,它还有一些额外的锁定技巧,以确保您的委托在所有场景中只运行一次,并且其他功能(如回调、设置大小和取消令牌)仍然有效。 github.com/alastairtree/lazycache
【解决方案4】:

查看此链接:http://msdn.microsoft.com/en-us/library/system.runtime.caching.memorycache(v=vs.110).aspx

转到页面的最底部(或搜索文本“线程安全”)。

你会看到:

^ 线程安全

这种类型是线程安全的。

【讨论】:

  • 根据个人经验,我很久以前就不再相信 MSDN 对“线程安全”的定义了。这是一个很好的阅读:link
  • 那篇文章与我上面提供的链接略有不同。区别非常重要,因为我提供的链接没有对线程安全声明提供任何警告。我也有使用MemoryCache.Default 的个人经验,在非常大的容量(每分钟数百万次缓存命中)中没有线程问题。
  • 我认为他们的意思是读写操作原子发生。简单地说,当线程 A 尝试读取当前值时,它总是读取完成的写入操作,而不是在将数据写入内存的过程中由任何线程。当线程 A 尝试写入内存时,任何线程都无法进行干预。这是我的理解,但有很多关于此的问题/文章并不能得出如下的完整结论。 stackoverflow.com/questions/3137931/msdn-what-is-thread-safety
【解决方案5】:

刚刚上传示例库以解决 .Net 2.0 的问题。

看看这个 repo:

RedisLazyCache

我正在使用 Redis 缓存,但如果缺少连接字符串,它也可以进行故障转移或仅使用 Memorycache。

它基于 LazyCache 库,可在多线程尝试加载和保存数据的事件中保证单次执行回调,特别是如果回调执行起来非常昂贵。

【讨论】:

  • 请仅分享答案,其他信息可以作为评论分享
  • @WaelAbbas。我试过了,但似乎我首先需要 50 个声望。 :D。虽然这不是对 OP 问题的直接答案(可以通过是/否来回答并解释原因),但我的回答是针对否答案的可能解决方案。
【解决方案6】:

正如@AmitE 在@pimbrouwers 的回答中提到的那样,他的示例没有像这里演示的那样工作:

class Program
{
    static async Task Main(string[] args)
    {
        var cache = new MemoryCache(new MemoryCacheOptions());

        var tasks = new List<Task>();
        var counter = 0;

        for (int i = 0; i < 10; i++)
        {
            var loc = i;
            tasks.Add(Task.Run(() =>
            {
                var x = GetOrAdd(cache, "test", TimeSpan.FromMinutes(1), () => Interlocked.Increment(ref counter));
                Console.WriteLine($"Interation {loc} got {x}");
            }));
        }

        await Task.WhenAll(tasks);
        Console.WriteLine("Total value creations: " + counter);
        Console.ReadKey();
    }

    public static T GetOrAdd<T>(IMemoryCache cache, string key, TimeSpan expiration, Func<T> valueFactory)
    {
        return cache.GetOrCreate(key, entry =>
        {
            entry.SetSlidingExpiration(expiration);
            return new Lazy<T>(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication);
        }).Value;
    }
}

输出:

Interation 6 got 8
Interation 7 got 6
Interation 2 got 3
Interation 3 got 2
Interation 4 got 10
Interation 8 got 9
Interation 5 got 4
Interation 9 got 1
Interation 1 got 5
Interation 0 got 7
Total value creations: 10

似乎GetOrCreate 总是返回创建的条目。幸运的是,这很容易解决:

public static T GetOrSetValueSafe<T>(IMemoryCache cache, string key, TimeSpan expiration,
    Func<T> valueFactory)
{
    if (cache.TryGetValue(key, out Lazy<T> cachedValue))
        return cachedValue.Value;

    cache.GetOrCreate(key, entry =>
    {
        entry.SetSlidingExpiration(expiration);
        return new Lazy<T>(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication);
    });

    return cache.Get<Lazy<T>>(key).Value;
}

按预期工作:

Interation 4 got 1
Interation 9 got 1
Interation 1 got 1
Interation 8 got 1
Interation 0 got 1
Interation 6 got 1
Interation 7 got 1
Interation 2 got 1
Interation 5 got 1
Interation 3 got 1
Total value creations: 1

【讨论】:

  • 这也不起作用。多试几次就会发现,有时候值并不总是1。
【解决方案7】:

缓存是线程安全的,但正如其他人所说,如果从多种类型调用,GetOrAdd 可能会调用多种类型的函数。

这是我对此的最小修复

private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1);

await _cacheLock.WaitAsync();
var data = await _cache.GetOrCreateAsync(key, entry => ...);
_cacheLock.Release();

【讨论】:

  • 我认为这是一个不错的解决方案,但如果我有多种方法更改不同的缓存,如果我使用锁,它们将在不需要的情况下被锁定!在这种情况下我们应该有多个_cacheLock,我认为如果cachelock也可以有一个Key会更好!
  • 有很多方法可以解决这个问题,一种是泛型,每个 MyCache 实例的信号量都是唯一的。然后就可以注册 AddSingleton(typeof(IMyCache), typeof(MyCache));
  • 如果您需要调用其他瞬态类型,我可能不会将整个缓存设为单例,这可能会导致麻烦。所以也许有一个信号量存储 ICacheLock 这是单例
  • 这个版本的问题是,如果你有两个不同的东西要同时缓存,那么你必须等待第一个完成生成,然后才能检查第二个的缓存。如果密钥不同,它们能够同时检查缓存(并生成)会更有效。 LazyCache 使用 Lazy 和条带锁定的组合来确保您的项目尽可能快地缓存,并且每个键只生成一次。见github.com/alastairtree/lazycache
猜你喜欢
  • 2016-05-08
  • 1970-01-01
  • 2021-12-19
  • 2010-11-06
  • 2016-11-14
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多