Ошибка "Collection was modified; enumeration operation may not execute" в C# при работе со словарём в WCF
Почему возникает исключение?
Исключение InvalidOperationException с сообщением "Collection was modified; enumeration operation may not execute" возникает, когда вы изменяете коллекцию (добавляете, удаляете или изменяете элементы) во время её перебора с помощью foreach или другого перечислителя (например, LINQ-запроса). В C# перечислители не поддерживают модификацию коллекции во время итерации — это защита от непредсказуемого поведения.
В вашем случае проблема усугубляется многопоточностью: один поток (например, обработчик запроса WCF) добавляет элемент в _cache, а другой поток (фоновый метод очистки) одновременно перебирает его через foreach и удаляет элементы. Это типичная гонка данных (race condition).
Простое, но неэффективное решение: lock
Вы уже пробовали lock, но он может вызывать подвисания, если блокировка удерживается слишком долго (например, при долгом вызове FetchFromDatabase внутри блокировки). Вот правильный способ использования lock:
public class MyService : IMyService
{
private static readonly object _lock = new object();
private static Dictionary _cache = new Dictionary();
public string GetData(int key)
{
lock (_lock)
{
if (!_cache.ContainsKey(key))
{
_cache.Add(key, FetchFromDatabase(key));
}
return _cache[key];
}
}
private void CleanCache()
{
// Собираем ключи для удаления внутри блокировки
List<int> keysToRemove;
lock (_lock)
{
keysToRemove = _cache.Where(kvp => IsExpired(kvp.Key))
.Select(kvp => kvp.Key)
.ToList();
}
// Удаляем элементы без блокировки (или с повторной блокировкой)
foreach (var key in keysToRemove)
{
lock (_lock)
{
_cache.Remove(key);
}
}
}
}
Недостаток: блокировка lock снижает производительность при высокой нагрузке, особенно если вызов FetchFromDatabase выполняется внутри блокировки. В вашем коде он там и находится — это плохая практика.
Лучшее решение: ConcurrentDictionary
В .NET есть потокобезопасная коллекция ConcurrentDictionary<TKey, TValue> из пространства имён System.Collections.Concurrent. Она специально спроектирована для многопоточного доступа и не требует явных блокировок.
using System.Collections.Concurrent;
public class MyService : IMyService
{
private static ConcurrentDictionary<int, string> _cache = new ConcurrentDictionary<int, string>();
public string GetData(int key)
{
// GetOrAdd — атомарная операция: если ключ отсутствует, добавляет значение
return _cache.GetOrAdd(key, k => FetchFromDatabase(k));
}
private void CleanCache()
{
// Перебор ConcurrentDictionary безопасен, но удаление внутри итерации — нет!
// Поэтому собираем ключи для удаления
var keysToRemove = _cache.Where(kvp => IsExpired(kvp.Key))
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
{
_cache.TryRemove(key, out _);
}
}
}
Преимущества:
GetOrAdd— атомарная операция, не требует блокировки вручную.- Внутренняя блокировка на уровне сегментов (concurrency level), что даёт высокую производительность.
- Не нужно заботиться о синхронизации чтения и записи.
Важно: даже с ConcurrentDictionary нельзя изменять коллекцию внутри foreach (например, вызывать Remove). Всегда сначала собирайте ключи для удаления, а потом удаляйте в отдельном цикле.
Для вашего WCF-сервиса
Используйте ConcurrentDictionary — это стандартный и правильный подход. Если вам нужна ещё более высокая производительность, рассмотрите ImmutableDictionary (неизменяемый словарь), но его пересоздание при каждом изменении может быть дорогим. Для большинства сценариев ConcurrentDictionary — идеальный выбор.
Дополнительный совет: в WCF-сервисах избегайте статических коллекций, если они не требуются. Рассмотрите использование MemoryCache из System.Runtime.Caching — он уже потокобезопасен и имеет встроенные механизмы очистки.
Альтернативный подход: копирование коллекции перед итерацией
Если вы не хотите менять тип коллекции на ConcurrentDictionary (например, из-за легаси-кода), можно применить простой трюк: перед перебором создайте копию коллекции с помощью ToList() или ToArray(). Это гарантирует, что итерация идёт по неизменяемому снимку, а изменения можно делать в оригинале.
private void CleanCache()
{
// Создаём копию ключей и значений
var snapshot = _cache.ToList(); // или .ToArray()
foreach (var kvp in snapshot)
{
if (IsExpired(kvp.Key))
{
lock (_cache) // всё равно нужна синхронизация
{
_cache.Remove(kvp.Key);
}
}
}
}
Важно: этот метод не решает проблему многопоточности полностью. Если один поток добавляет элемент в оригинальный словарь, а другой поток удаляет, всё равно могут быть гонки. Поэтому копирование лучше комбинировать с lock или использовать для однопоточных сценариев. Для WCF с многопоточностью это не лучший вариант, но как временное решение — сойдёт.
Также можно использовать ReaderWriterLockSlim для более тонкого контроля блокировок, но это сложнее и часто избыточно.
Итог: для продакшн-кода в WCF выбирайте ConcurrentDictionary — это безопасно, производительно и просто.