Avatar ·

Ошибка "Collection was modified; enumeration operation may not execute" в C#: как исправить?

Привет, коллеги! Я новичок в C# и WCF, и столкнулся со странной ошибкой: "Collection was modified; enumeration operation may not execute". У меня есть WCF-сервис, который обрабатывает заказы. Внутри сервиса я использую `ConcurrentDictionary` для хранения активных заказов. Периодически я запускаю фоновый поток, который проверяет статусы заказов и удаляет завершённые. Вот упрощённый код: ```csharp public class OrderService { private ConcurrentDictionary _orders = new ConcurrentDictionary(); public void ProcessOrders() { // Фоновый поток для очистки Task.Run(() => { while (true) { foreach (var kvp in _orders) { if (kvp.Value.Status == OrderStatus.Completed) { _orders.TryRemove(kvp.Key, out _); } } Thread.Sleep(1000); } }); // Основной поток добавляет заказы for (int i = 0; i < 100; i++) { _orders.TryAdd(i, new Order { Status = OrderStatus.Active }); Thread.Sleep(10); } } } ``` Когда я запускаю сервис, через несколько секунд падает исключение. Я думал, что `ConcurrentDictionary` потокобезопасен, но ошибка всё равно возникает. Почему это происходит и как правильно организовать удаление элементов из коллекции во время её обхода? Заранее спасибо за помощь! <<>>

Причина ошибки

Ошибка возникает из-за того, что вы изменяете коллекцию (удаляете элементы) во время её перебора с помощью foreach. Хотя ConcurrentDictionary является потокобезопасным для отдельных операций (добавление, удаление, чтение), он не гарантирует безопасность при итерации, если коллекция модифицируется в том же или другом потоке. В вашем случае фоновый поток удаляет элементы, пока основной поток добавляет новые — это нарушает работу итератора.

Решение: используйте snapshot-итерацию

Чтобы избежать ошибки, нужно создать копию коллекции (snapshot) перед итерацией. Это гарантирует, что вы перебираете фиксированный набор элементов, а модификации происходят с оригиналом. Вот исправленный код:

public class OrderService
{
    private ConcurrentDictionary<int, Order> _orders = new ConcurrentDictionary<int, Order>();

    public void ProcessOrders()
    {
        Task.Run(() =>
        {
            while (true)
            {
                // Создаём snapshot ключей для безопасной итерации
                var keysToRemove = _orders.Keys
                    .Where(key => _orders.TryGetValue(key, out var order) && 
                                  order.Status == OrderStatus.Completed)
                    .ToList();

                // Удаляем найденные элементы
                foreach (var key in keysToRemove)
                {
                    _orders.TryRemove(key, out _);
                }

                Thread.Sleep(1000);
            }
        });

        for (int i = 0; i < 100; i++)
        {
            _orders.TryAdd(i, new Order { Status = OrderStatus.Active });
            Thread.Sleep(10);
        }
    }
}

Почему это работает?

  • Snapshot через ToList(): метод Keys возвращает коллекцию ключей, а ToList() создаёт её копию в памяти. Теперь вы перебираете копию, а не оригинальный словарь.
  • Проверка статуса: после создания snapshot мы проверяем статус каждого заказа через TryGetValue — это потокобезопасно.
  • Удаление вне итерации: удаление происходит после того, как мы закончили перебор snapshot, поэтому итератор не ломается.

Альтернативные подходы

Если вам нужно удалять элементы прямо во время итерации (например, в цикле while), используйте:

  • Ручной обход с индексатором: для списков можно использовать for с обратным порядком.
  • Блокировка: оберните итерацию и модификацию в lock (но это снизит производительность при высокой нагрузке).
  • Использование ImmutableDictionary: создавайте новую коллекцию при каждом изменении (подходит для редких обновлений).

Дополнительные советы

  • В WCF-сервисах избегайте длительных фоновых задач в экземплярах службы — используйте отдельный сервис или IHostedService.
  • Для проверки статусов заказов рассмотрите использование таймеров (System.Threading.Timer) вместо бесконечного цикла с Thread.Sleep.
  • Логируйте исключения: ConcurrentDictionary может генерировать ArgumentException при дублировании ключей, хотя TryAdd этого избегает.
<<>>

Использование блокировки (lock) для синхронизации

Если snapshot-итерация не подходит (например, коллекция очень большая, и вы не хотите тратить память на копию), можно использовать явную синхронизацию через lock. Однако это снижает преимущества ConcurrentDictionary — все потоки будут ждать освобождения блокировки.

private readonly object _lock = new object();

public void ProcessOrders()
{
    Task.Run(() =>
    {
        while (true)
        {
            lock (_lock)
            {
                foreach (var kvp in _orders)
                {
                    if (kvp.Value.Status == OrderStatus.Completed)
                    {
                        _orders.TryRemove(kvp.Key, out _);
                    }
                }
            }
            Thread.Sleep(1000);
        }
    });

    for (int i = 0; i < 100; i++)
    {
        lock (_lock)
        {
            _orders.TryAdd(i, new Order { Status = OrderStatus.Active });
        }
        Thread.Sleep(10);
    }
}

Когда это оправдано?

  • Коллекция относительно небольшая (до тысяч элементов).
  • Операции добавления/удаления редкие (например, раз в секунду).
  • Вам критична консистентность данных, а не производительность.

Недостаток: при высокой нагрузке (частые вызовы из разных потоков) блокировки могут стать узким местом и снизить пропускную способность сервиса.