Как исправить ошибку ‘Collection was modified; enumeration operation may not execute’ на WCF-сервере при изменении словаря во время перечисления?
Я разрабатываю WCF-сервер в виде службы Windows, который использует словарь для отслеживания подписчиков. Метод NotifySubscribers() вызывается примерно 800 раз в день для уведомления всех подписчиков о событиях данных. Когда клиент подписывается, его ID добавляется в словарь подписчиков, а когда он отписывается - удаляется.
Ошибка возникает, когда (или после) клиент отписывается. В следующий раз при вызове NotifySubscribers() цикл foreach() завершается с ошибкой ‘Collection was modified; enumeration operation may not execute’. Интересно, что когда отладчик подключен и клиент отписывается, код выполняется без проблем.
Вот мой код:
[ServiceBehavior(InstanceContextMode=InstanceContextMode.Single)]
public class SubscriptionServer : ISubscriptionServer
{
private static IDictionary<Guid, Subscriber> subscribers;
public SubscriptionServer()
{
subscribers = new Dictionary<Guid, Subscriber>();
}
public void NotifySubscribers(DataRecord sr)
{
foreach(Subscriber s in subscribers.Values)
{
try
{
s.Callback.SignalData(sr);
}
catch (Exception e)
{
DCS.WriteToApplicationLog(e.Message,
System.Diagnostics.EventLogEntryType.Error);
UnsubscribeEvent(s.ClientId);
}
}
}
public Guid SubscribeEvent(string clientDescription)
{
Subscriber subscriber = new Subscriber();
subscriber.Callback = OperationContext.Current.
GetCallbackChannel<IDCSCallback>();
subscribers.Add(subscriber.ClientId, subscriber);
return subscriber.ClientId;
}
public void UnsubscribeEvent(Guid clientId)
{
try
{
subscribers.Remove(clientId);
}
catch(Exception e)
{
System.Diagnostics.Debug.WriteLine("Unsubscribe Error " +
e.Message);
}
}
}
В чем проблема в этом коде? Нужно ли делать словарь потокобезопасным?
Ошибка “Коллекция была изменена; операция перечисления не может быть выполнена” в вашем WCF-сервере возникает из-за изменения словаря subscribers во время перечисления в методе NotifySubscribers(). Когда во время выполнения s.Callback.SignalData() выбрасывается исключение, ваш код вызывает UnsubscribeEvent(), который удаляет элементы из словаря, пока цикл foreach все еще активен. Кроме того, при использовании InstanceContextMode.Single несколько потоков могут одновременно обращаться к словарю, создавая состояния гонки, которые могут вызвать эту ошибку даже без исключений.
Содержание
- Понимание основной причины
- Проблемы потокобезопасности в вашей реализации
- Решение 1: Правильный механизм блокировки
- Решение 2: Использование ConcurrentDictionary
- Решение 3: Создание снимков перечисления
- Решение 4: Рефакторинг обработки исключений
- Лучшие практики для сервисов подписки WCF
- Заключение
Понимание основной причины
Фундаментальная проблема заключается в том, что коллекции C# не предназначены для изменения во время перечисления. Как объясняется в документации Microsoft, “перечислитель остается действительным до тех пор, пока коллекция остается неизменной”. Когда вы изменяете коллекцию во время итерации по ней, внутреннее состояние перечислителя повреждается, что приводит к InvalidOperationException.
В вашем конкретном случае проблема проявляется в двух сценариях:
-
Во время нормального выполнения: Когда
SignalData()выбрасывает исключение, вызываетсяUnsubscribeEvent(), который изменяет словарь, пока цикл foreach все еще активен. -
Во время состояний гонки: При использовании
InstanceContextMode.Singleнесколько запросов клиентов могут одновременно обращаться к экземпляру вашего сервиса. Один поток может перечислять словарь, в то время как другой добавляет или удаляет подписчиков, вызывая ошибку.
Почему отладчик скрывает проблему: При подключении отладчика выполнение значительно замедляется, что снижает вероятность возникновения состояний гонки. Поэтому ошибка не появляется при отладке.
Проблемы потокобезопасности в вашей реализации
Ваша текущая реализация имеет несколько проблем с потокобезопасностью:
private static IDictionary<Guid, Subscriber> subscribers;
Использование статического поля в сочетании с InstanceContextMode.Single создает общий ресурс, к которому обращаются несколько потоков. Стандартный класс Dictionary<TKey, TValue> не является потокобезопасным для любого одновременного доступа, включая чтение и запись.
Как подчеркивается в обсуждениях на Stack Overflow, “в общем случае вам не разрешено изменять коллекцию, пока перечисление ‘в полете’, независимо от того, является ли оно многопоточным или однопоточным”.
Сценарий состояния гонки:
- Поток 1 вызывает
NotifySubscribers()и начинает итерацию foreach - Поток 2 вызывает
UnsubscribeEvent()и удаляет подписчика - Поток 1 продолжает перечисление, но словарь уже был изменен
- Выбрасывается InvalidOperationException
Решение 1: Правильный механизм блокировки
Наиболее прямой подход - использование оператора lock для обеспечения эксклюзивного доступа к словарю во время перечисления и модификации:
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)]
public class SubscriptionServer : ISubscriptionServer
{
private static readonly object _lockObject = new object();
private static IDictionary<Guid, Subscriber> subscribers;
public SubscriptionServer()
{
subscribers = new Dictionary<Guid, Subscriber>();
}
public void NotifySubscribers(DataRecord sr)
{
// Создаем снимок значений для избежания блокировки во время обратного вызова
List<Subscriber> subscribersToNotify;
lock (_lockObject)
{
subscribersToNotify = new List<Subscriber>(subscribers.Values);
}
foreach(Subscriber s in subscribersToNotify)
{
try
{
s.Callback.SignalData(sr);
}
catch (Exception e)
{
DCS.WriteToApplicationLog(e.Message,
System.Diagnostics.EventLogEntryType.Error);
// Вызываем отмену подписки вне блокировки для предотвращения тупиков
UnsubscribeEvent(s.ClientId);
}
}
}
public Guid SubscribeEvent(string clientDescription)
{
lock (_lockObject)
{
Subscriber subscriber = new Subscriber();
subscriber.Callback = OperationContext.Current.
GetCallbackChannel<IDCSCallback>();
subscribers.Add(subscriber.ClientId, subscriber);
return subscriber.ClientId;
}
}
public void UnsubscribeEvent(Guid clientId)
{
lock (_lockObject)
{
try
{
subscribers.Remove(clientId);
}
catch(Exception e)
{
System.Diagnostics.Debug.WriteLine("Ошибка отмены подписки " +
e.Message);
}
}
}
}
Ключевые улучшения:
- Блокировка во время критических секций: Доступ к словарю защищен блокировкой
- Шаблон снимка: Создание копии значений перед перечислением для избежания блокировок во время обратных вызовов
- Предотвращение тупиков: Отмена подписки вызывается вне блокировки в обработчике исключений
Решение 2: Использование ConcurrentDictionary
Для лучшей производительности и более чистого кода используйте ConcurrentDictionary<TKey, TValue>, который специально разработан для потокобезопасных операций:
using System.Collections.Concurrent;
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)]
public class SubscriptionServer : ISubscriptionServer
{
private static readonly ConcurrentDictionary<Guid, Subscriber> subscribers;
static SubscriptionServer()
{
subscribers = new ConcurrentDictionary<Guid, Subscriber>();
}
public void NotifySubscribers(DataRecord sr)
{
// Используем ToList() для создания снимка, безопасного для перечисления
List<Subscriber> subscribersToNotify = subscribers.Values.ToList();
foreach(Subscriber s in subscribersToNotify)
{
try
{
s.Callback.SignalData(sr);
}
catch (Exception e)
{
DCS.WriteToApplicationLog(e.Message,
System.Diagnostics.EventLogEntryType.Error);
UnsubscribeEvent(s.ClientId);
}
}
}
public Guid SubscribeEvent(string clientDescription)
{
Subscriber subscriber = new Subscriber();
subscriber.Callback = OperationContext.Current.
GetCallbackChannel<IDCSCallback>();
subscribers.TryAdd(subscriber.ClientId, subscriber);
return subscriber.ClientId;
}
public void UnsubscribeEvent(Guid clientId)
{
subscribers.TryRemove(clientId, out _);
}
}
Важные замечания по ConcurrentDictionary:
- Не все операции потокобезопасны: Как показывают исследования, “методы расширения предполагают, что коллекция не изменяется в другом потоке”
- Используйте ToList() для безопасного перечисления: Свойство
Valuesсамо по себе потокобезопасно, но перечисление должно выполняться по снимку - TryAdd/TryRemove: Эти атомарные методы безопаснее, чем Add/Remove
Решение 3: Создание снимков перечисления
Другой подход - всегда работать со снимками словаря, гарантируя, что вы никогда не перечисляете потенциально изменяемую коллекцию:
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single)]
public class SubscriptionServer : ISubscriptionServer
{
private static readonly object _lockObject = new object();
private static IDictionary<Guid, Subscriber> subscribers;
public SubscriptionServer()
{
subscribers = new Dictionary<Guid, Subscriber>();
}
public void NotifySubscribers(DataRecord sr)
{
// Создаем полный снимок перед любой обработкой
Dictionary<Guid, Subscriber> snapshot;
lock (_lockObject)
{
snapshot = new Dictionary<Guid, Subscriber>(subscribers);
}
foreach(var kvp in snapshot)
{
try
{
kvp.Value.Callback.SignalData(sr);
}
catch (Exception e)
{
DCS.WriteToApplicationLog(e.Message,
System.Diagnostics.EventLogEntryType.Error);
// Безопасно изменять исходный словарь, так как мы работаем со снимком
UnsubscribeEvent(kvp.Key);
}
}
}
public Guid SubscribeEvent(string clientDescription)
{
lock (_lockObject)
{
Subscriber subscriber = new Subscriber();
subscriber.Callback = OperationContext.Current.
GetCallbackChannel<IDCSCallback>();
subscribers.Add(subscriber.ClientId, subscriber);
return subscriber.ClientId;
}
}
public void UnsubscribeEvent(Guid clientId)
{
lock (_lockObject)
{
subscribers.Remove(clientId);
}
}
}
Преимущества этого подхода:
- Полная изоляция: Снимок гарантирует работу с последовательным состоянием
- Безопасное изменение: Можно безопасно изменять исходный словарь во время перечисления
- Предсказуемое поведение: Полностью устраняет состояния гонки
Решение 4: Рефакторинг обработки исключений
Измените обработку исключений, чтобы избежать вызова UnsubscribeEvent() во время перечисления. Вместо этого собирайте неудачных подписчиков и удаляйте их afterward:
public void NotifySubscribers(DataRecord sr)
{
List<Guid> failedSubscribers = new List<Guid>();
foreach(Subscriber s in subscribers.Values.ToList()) // Используем ToList() для снимка
{
try
{
s.Callback.SignalData(sr);
}
catch (Exception e)
{
DCS.WriteToApplicationLog(e.Message,
System.Diagnostics.EventLogEntryType.Error);
failedSubscribers.Add(s.ClientId);
}
}
// Удаляем неудачных подписчиков после завершения перечисления
foreach(Guid clientId in failedSubscribers)
{
UnsubscribeEvent(clientId);
}
}
Преимущества:
- Четкое разделение: Перечисление и модификация - отдельные операции
- Снижение конкуренции блокировок: Только одна операция модификации в конце
- Более эффективно: Пакетное удаление неудачных подписчиков
Лучшие практики для сервисов подписки WCF
1. Рассмотрения режима контекста экземпляра
Хотя InstanceContextMode.Single работает для вашего сценария, рассмотрите альтернативы:
| InstanceContextMode | Плюсы | Минусы | Лучше всего для |
|---|---|---|---|
| Single | Общее состояние, эффективно для подписок | Проблемы потокобезопасности, единая точка отказа | Простые сервисы подписки |
| PerSession | Изолированные сессии, естественный жизненный цикл подписки | Более сложное управление состоянием | Подписки на основе сессий |
| PerCall | Без состояния, нет проблем с потокобезопасностью | Нет общего состояния, сложное отслеживание подписок | Бесштатные сервисы |
2. Надежность канала обратного вызова
Добавьте проверку работоспособности канала обратного вызова:
private bool IsCallbackValid(IDCSCallback callback)
{
try
{
// Проверяем, открыт ли канал
return callback != null &&
OperationContext.Current.Channel.State == CommunicationState.Opened;
}
catch
{
return false;
}
}
3. Управление памятью
Реализуйте периодическую очистку отключенных подписчиков:
private static void CleanupDisconnectedSubscribers()
{
List<Guid> toRemove = new List<Guid>();
lock (_lockObject)
{
foreach(var kvp in subscribers)
{
try
{
if (!IsCallbackValid(kvp.Value.Callback))
{
toRemove.Add(kvp.Key);
}
}
catch
{
toRemove.Add(kvp.Key);
}
}
foreach(Guid clientId in toRemove)
{
subscribers.Remove(clientId);
}
}
}
4. Оптимизация производительности
Для высокочастотных уведомлений (800 раз в день) рассмотрите:
- Пакетная обработка: Группируйте уведомления, когда это возможно
- Асинхронные обратные вызовы: Используйте async/await для предотвращения блокировок
- Пул соединений: Эффективно повторно используйте каналы обратного вызова
Заключение
Ошибка “Коллекция была изменена; операция перечисления не может быть выполнена” в вашем WCF-сервере возникает по двум основным причинам: изменение коллекций во время перечисления и неправильная обработка потоков в многопоточной среде WCF.
Ключевые выводы:
- Всегда избегайте изменения коллекций во время перечисления - Используйте снимки или разделяйте перечисление и модификацию
- Реализуйте правильную синхронизацию потоков - Используйте блокировки или ConcurrentDictionary для общих ресурсов
- Учитывайте модель потоков WCF - InstanceContextMode.Single требует тщательной обработки потокобезопасности
- Тестируйте при реальной нагрузке - Отладчики могут скрывать состояния гонки, которые появляются в продакшене
Рекомендуемое решение: Используйте ConcurrentDictionary с шаблоном ToList() для перечисления (Решение 2), так как оно обеспечивает лучший баланс производительности, безопасности и ясности кода для вашего сценария сервиса подписки. Этот подход устраняет ошибку перечисления, сохраняя потокобезопасность для 800 ежедневных уведомлений, которые обрабатывает ваш сервис.
Не забудьте реализовать правильную обработку ошибок и периодическую очистку отключенных клиентов для обеспечения долгосрочной надежности вашего сервиса подписки.