Avatar ·

Ошибка "Collection was modified; enumeration operation may not execute" в C# при работе со словарём в WCF

Привет, коллеги! Пишу WCF-сервис, который обрабатывает запросы от нескольких клиентов одновременно. Внутри сервиса использую статический словарь `Dictionary` для кэширования данных. Когда клиенты шлют запросы, я иногда обновляю этот словарь (добавляю/удаляю элементы) и одновременно читаю его в цикле `foreach`. Внезапно ловлю исключение: "Collection was modified; enumeration operation may not execute". Вот упрощённый код: ```csharp public class MyService : IMyService { private static Dictionary _cache = new Dictionary(); public string GetData(int key) { // Иногда происходит добавление в словарь if (!_cache.ContainsKey(key)) { _cache.Add(key, FetchFromDatabase(key)); } // В другом месте, например, в фоновом методе очистки кэша // foreach (var item in _cache) { ... } — тут и летит исключение return _cache[key]; } private void CleanCache() { // Очистка устаревших записей foreach (var kvp in _cache) { if (IsExpired(kvp.Key)) { _cache.Remove(kvp.Key); // Исключение здесь! } } } } ``` Я понимаю, что проблема в одновременном изменении и переборе коллекции. Но как правильно организовать потокобезопасную работу со словарём в WCF? Пробовал `lock`, но сервис подвисает. Может, есть специальные коллекции? Заранее спасибо за помощь! <<>>

Почему возникает исключение?

Исключение 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 — это безопасно, производительно и просто.