Почему фабрика в ConcurrentDictionary.GetOrAdd может сработать несколько раз?
ConcurrentDictionary удобно использовать, когда несколько потоков обращаются к одному словарю. Кажется, что GetOrAdd одновременно проверяет ключ и ровно один раз создаёт значение:
var profile = cache.GetOrAdd(id, key => LoadProfile(key));
В словаре действительно останется одно значение для ключа. Но это не означает, что LoadProfile обязательно вызовется только один раз.
Фабрика выполняется вне внутренней блокировки словаря. Если два потока одновременно не нашли ключ, оба могут начать загрузку:
var first = cache.GetOrAdd(id, LoadProfile);
var second = cache.GetOrAdd(id, LoadProfile);
Словарь сохранит один результат и вернёт его обоим вызовам, а результат другой загрузки окажется лишним. Поэтому в фабрику не стоит помещать побочные эффекты, например списание денег или отправку письма.
Если повторное вычисление недорого и безопасно, обычный GetOrAdd подходит. Для дорогой инициализации можно хранить в словаре Lazy:
var pending = cache.GetOrAdd(id, key =>
new Lazy(() => LoadProfile(key)));
var profile = pending.Value;
Здесь у разных потоков тоже могут создаться несколько объектов Lazy, но в словаре останется один. У него загрузка при обращении к Value выполнится один раз при стандартном режиме Lazy.
Не забывайте и про ошибки. По умолчанию Lazy запоминает исключение фабрики, а значит последующие обращения к тому же Value снова получат эту ошибку:
cache.TryRemove(id, out _);
Удалять запись для повтора нужно с учётом конкуренции с другими запросами.
➡️ C# Ready | #совет
ConcurrentDictionary удобно использовать, когда несколько потоков обращаются к одному словарю. Кажется, что GetOrAdd одновременно проверяет ключ и ровно один раз создаёт значение:
var profile = cache.GetOrAdd(id, key => LoadProfile(key));
В словаре действительно останется одно значение для ключа. Но это не означает, что LoadProfile обязательно вызовется только один раз.
Фабрика выполняется вне внутренней блокировки словаря. Если два потока одновременно не нашли ключ, оба могут начать загрузку:
var first = cache.GetOrAdd(id, LoadProfile);
var second = cache.GetOrAdd(id, LoadProfile);
Словарь сохранит один результат и вернёт его обоим вызовам, а результат другой загрузки окажется лишним. Поэтому в фабрику не стоит помещать побочные эффекты, например списание денег или отправку письма.
Если повторное вычисление недорого и безопасно, обычный GetOrAdd подходит. Для дорогой инициализации можно хранить в словаре Lazy:
var pending = cache.GetOrAdd(id, key =>
new Lazy(() => LoadProfile(key)));
var profile = pending.Value;
Здесь у разных потоков тоже могут создаться несколько объектов Lazy, но в словаре останется один. У него загрузка при обращении к Value выполнится один раз при стандартном режиме Lazy.
Не забывайте и про ошибки. По умолчанию Lazy запоминает исключение фабрики, а значит последующие обращения к тому же Value снова получат эту ошибку:
cache.TryRemove(id, out _);
Удалять запись для повтора нужно с учётом конкуренции с другими запросами.
➡️ C# Ready | #совет