Блокировка .NET или ConcurrentDictionary?

#.net #locking #concurrentdictionary

Вопрос:

Я пишу что-то вроде файлового кэша и обсуждаю, использовать ли блокировку или ConcurrentDictionary. Если несколько потоков запрашивают ключ, то у обычного словаря возникнут проблемы, если два потока попытаются записать в него, поэтому я попробовал ConcurrentDictionary. Теперь возникает вторичная проблема: как предотвратить повторное чтение файла (или более), когда каждый поток пытается получить файл. Я добавил пример кода, чтобы объяснить, что я имею в виду.

Вот версия с использованием блокировки и словаря

 class Program
{
    private static object locking = new object();
    private static Dictionary<string, byte[]> cache;
    
    static void Main(string[] args)
    {
        cache = new Dictionary<string, byte[]>();
        
        Task.Run(() =>
        {
            AddToCache("largefile", "largefile.bin");
        });

        Task.Run(() =>
        {
            AddToCache("largefile", "largefile.bin");
        });
    }
    
    static byte[] AddToCache(string key, string filename)
    {
        lock(locking)
        {
            if (cache.TryGetValue(key, out byte[] data))
            {
                Console.WriteLine("Found in cache");
                return data;
            }

            Console.WriteLine("Reading file into cache");
            data = File.ReadAllBytes(filename);
            cache[key] = data;
            return data;
        }
    }
}
 

Эта версия делает то, что ожидается, она защитит словарь от нескольких потоков и прочитает большой файл только ОДИН РАЗ.

Вот вторая версия, использующая ConcurrentDictionary:

 class Program
{
    private static ConcurrentDictionary<string, byte[]> cache;

    static void Main(string[] args)
    {
        cache = new ConcurrentDictionary<string, byte[]>();

        Task.Run(() =>
        {
            AddToCache("largefile", "largefile.bin");
        });

        Task.Run(() =>
        {
            AddToCache("largefile", "largefile.bin");
        });
    }

    static byte[] AddToCache(string key, string filename)
    {
        return cache.GetOrAdd(key, (s) => 
        {
            Console.WriteLine("Reading file into cache");
            return File.ReadAllBytes(filename); 
        });
    }
}
 

Эта версия защищает словарь, НО она дважды считывает большой файл, что не требуется. Я думаю, что делаю здесь что-то не так, но, не будучи знаком с GetOrAdd, я не уверен, что именно.

Первая версия выглядит нормально, но это сокращенная версия реального кода, и блокировка будет блокировать много кода. Вторая версия выглядит намного проще, но не предотвращает многократное чтение файла. Есть ли способ сделать это без блокировки большого количества кода или это единственный ответ?

Ответ №1:

Распространенный трюк заключается в использовании Lazy в качестве значения, ConcurrentDictionary чтобы вы могли сделать добавление части GetOrAdd потокобезопасным. В вашем случае это будет выглядеть примерно так:

 private static ConcurrentDictionary<string, Lazy<byte[]>> cache;

static byte[] AddToCache(string key, string filename) => cache
        .GetOrAdd(key, (s) =>
            new Lazy<byte[]>(() =>
            {
                Console.WriteLine("Reading file into cache");
                return File.ReadAllBytes(filename);
            }))
        .Value;
 

Недостатком такого подхода может быть отклоненное выполнение функции значения, но поскольку вы уже закрываете доступ к словарю, это не должно быть проблемой для вас.