Почему при перечислении коллекции возникает исключение, а при циклическом просмотре ее элементов нет?

Я тестировал некоторые конструкции синхронизации и заметил кое-что, что меня смутило. Когда я перебирал коллекцию и одновременно писал в нее, возникало исключение (это было ожидаемо), но когда я просматривал коллекцию с помощью цикла for, этого не произошло. Кто-нибудь может это объяснить? Я думал, что список не позволяет читателю и писателю работать одновременно. Я ожидал, что цикл по коллекции будет демонстрировать то же поведение, что и использование перечислителя.

ОБНОВЛЕНИЕ. Это чисто академическое упражнение. Я понимаю, что перечисление списка плохо, если оно записывается одновременно. Я также понимаю, что мне нужна конструкция синхронизации. Мой вопрос снова был о том, почему одна операция выдает исключение, как и ожидалось, а другая нет.

Код ниже:

   class Program
   {
    private static List<string> _collection = new List<string>();
    static void Main(string[] args)
    {
        ThreadPool.QueueUserWorkItem(new WaitCallback(AddItems), null);
        System.Threading.Thread.Sleep(5000);
        ThreadPool.QueueUserWorkItem(new WaitCallback(DisplayItems), null);
        Console.ReadLine();
    }

    public static void AddItems(object state_)
    {
        for (int i = 1; i <= 50; i++)
        {
            _collection.Add(i.ToString());
            Console.WriteLine("Adding " + i);
            System.Threading.Thread.Sleep(150);
        }
    }

    public static void DisplayItems(object state_)
    {
        // This will not throw an exception
        //for (int i = 0; i < _collection.Count; i++)
        //{
        //    Console.WriteLine("Reading " + _collection[i]);
        //    System.Threading.Thread.Sleep(150);
        //}

        // This will throw an exception
        List<string>.Enumerator enumerator = _collection.GetEnumerator();
        while (enumerator.MoveNext())
        {
            string value = enumerator.Current;
            System.Threading.Thread.Sleep(150);
            Console.WriteLine("Reading " + value);
        }
    }
}

person adeel825    schedule 11.04.2009    source источник
comment
Почему имеет значение, используете ли вы цикл for или while? (Не пытаюсь быть ослом, просто интересно)   -  person Kredns    schedule 11.04.2009
comment
разница не между for и while. Вопрос в том, что в обоих случаях я читаю коллекцию во время записи в нее, так почему поведение отличается.   -  person adeel825    schedule 11.04.2009
comment
Пожалуйста, смотрите мой ответ ниже...   -  person Mitch Wheat    schedule 11.04.2009
comment
удалите Thread.Sleep из всех циклов for/while (включая цикл добавления). Вы оба видите разное поведение?   -  person Mitch Wheat    schedule 11.04.2009
comment
Даже после удаления вызовов сна из циклов поведение остается.   -  person adeel825    schedule 11.04.2009


Ответы (7)


Вы не можете изменить коллекцию во время ее перечисления. Это правило существует даже без учета вопросов потоковой передачи. Из MSDN:

Перечислитель остается действительным до тех пор, пока коллекция остается неизменной. Если в коллекцию вносятся изменения, такие как добавление, изменение или удаление элементов, перечислитель безвозвратно становится недействительным, и его поведение не определено.

Целочисленный цикл for на самом деле не является перечислителем. В большинстве сценариев это выполняет одно и то же. Однако интерфейс для IEnumerator гарантирует, что вы сможете выполнить итерацию по всей коллекции. Платформа обеспечивает это внутренним образом, создавая исключение, если вызов MoveNext происходит после изменения коллекции. Это исключение вызывается объектом перечислителя.

Целочисленный цикл for проходит только по списку чисел. Когда вы индексируете коллекцию целым числом, вы просто получаете элемент в этой позиции. Если что-то было вставлено или удалено из списка, вы можете пропустить элемент или запустить один и тот же элемент дважды. Это может быть полезно в определенных ситуациях, когда вам нужно изменить коллекцию во время ее обхода. Цикл for не имеет объекта перечислителя, который бы гарантировал выполнение контракта IEnumerator, поэтому исключение не выдается.

person Matt Brunell    schedule 11.04.2009
comment
это правильно, но на самом деле это не объясняет, ПОЧЕМУ опубликованный код выбрасывает. - person Mitch Wheat; 11.04.2009
comment
Похоже, код был разработан специально для нарушения IEnumerator с учетом длины вызовов Thread.Sleep(). Я понял вопрос больше как «Чем отличаются эти две итерационные стратегии?» - person Matt Brunell; 11.04.2009
comment
@Matt Brunell, у нас есть постоянная история ваших сообщений, поэтому вам не нужно приправлять свои ответы с помощью Update: или Edit:. В один клик я могу увидеть внесенные вами изменения, если захочу. Просто постарайтесь сделать свой пост как можно более понятным без псевдо-тегов обновления/редактирования. Хороший ответ. - person mmcdole; 11.04.2009

Чтобы ответить на ваш актуальный вопрос...

При перечислении вы получите IEnumerator, который привязан к состоянию списка, как это было, когда вы просили об этом. Дальнейшие операции выполняются с перечислителем (MoveNext, Current).

При использовании цикла for вы создаете последовательность вызовов if для получения определенного элемента по индексу. Нет внешнего контекста, такого как счетчик, который знает, что вы попали в цикл. Насколько известно коллекции, вы просите только один предмет. Поскольку коллекция никогда не выдавала счетчик, она не может знать, что причина, по которой вы запрашиваете элемент 0, затем элемент 1, затем элемент 2 и т. д., заключается в том, что вы ходите по списку.

Если вы возитесь со списком одновременно с его прохождением, вы в любом случае получите ошибки. При добавлении элементов цикл for может молча пропустить некоторые из них, в то время как цикл foreach вызовет исключение. При удалении элементов цикл for может выбросить индекс за пределы диапазона, если вам не повезет, но, вероятно, будет работать большую часть времени.

Но я думаю, вы все это понимаете, ваш вопрос был просто о том, почему два способа итерации ведут себя по-разному. Ответ на этот вопрос заключается в том, что состояние коллекции известно (для коллекции), когда вы вызываете GetEnumerator в одном случае и когда вы вызываете get_Item в другом случае.

person Darren Clark    schedule 11.04.2009

Разница в том, что когда вы говорите, что «перебираете коллекцию», вы на самом деле не перебираете коллекцию, вы перебираете целые числа от 1 до 50 и добавляете в коллекцию эти индексы. Это не влияет на тот факт, что числа от 1 до 50 все еще существуют.

Когда вы перечисляете список, вы перечисляете элементы, а не индексы. Поэтому, когда вы добавляете элементы во время перечисления, вы делаете перечисление недействительным. Он построен таким образом, чтобы предотвратить такие случаи, как то, что вы делаете, когда потенциально вы могли бы перечислить элемент 6 в списке одновременно с вставкой элемента в индекс 6, где вы могли бы потенциально перечислить старый или новый элемент, или какой-то неопределенное состояние.

Если вы хотите это сделать, ищите «поточно-безопасный» список, но будьте готовы одновременно иметь дело с неточностями чтения и записи :)

person Community    schedule 11.04.2009

Перечислитель становится недействительным после изменения списка. Если вы меняете список во время его перечисления, вам нужно будет немного переосмыслить свою стратегию.

Получите новый счетчик, когда вы начнете свою функцию отображения, и заблокируйте список, пока это происходит. В качестве альтернативы сделайте глубокую копию вашего списка в новый список _displayCollection и перечислите эту отдельную коллекцию, которая не будет записываться, кроме как заполняться до начала процесса отображения. Надеюсь это поможет.

person Chris Ballance    schedule 11.04.2009
comment
Это не полностью объясняет, почему опубликованный фрагмент кода не работает. - person Mitch Wheat; 11.04.2009
comment
Не проблема с кодом, как таковым. Это проблема непонимания того, как работают счетчики. Это можно исправить, заблокировав перечислитель во время отображения и получив новый перечислитель перед каждым отображением. - person Chris Ballance; 11.04.2009

Список имеет внутренний счетчик версий, который обновляется при изменении содержимого списка. Перечислитель отслеживает версию и выдает исключение, когда видит, что список изменился.

Когда вы просто зацикливаете список, ничто не отслеживает версию, поэтому ничто не улавливает, что список изменился.

Если вы измените список во время его зацикливания, вы можете получить нежелательные эффекты, от которых вас защищает перечислитель. Если вы, например, удаляете элемент из списка, не изменяя индекс цикла, чтобы он по-прежнему указывал на тот же элемент, вы можете пропустить элементы в цикле. Точно так же, если вы вставляете элементы без исправления индекса, вы можете повторять один и тот же элемент более одного раза.

person Guffa    schedule 11.04.2009

Вы не можете изменить коллекцию, ВО ВРЕМЯ ПЕРЕЧИСЛЕНИЯ через нее.

проблема в том, что вы начинаете перечислять, когда ваша коллекция НЕ ПОЛНА, и пытаетесь ПРОДОЛЖАТЬ ДОБАВЛЯТЬ элементы, ВО ВРЕМЯ ПЕРЕЧИСЛЕНИЯ

person roman m    schedule 11.04.2009

Код ошибочен: вы спите в течение 5 секунд, но не все элементы были добавлены в список. Это означает, что вы начинаете отображать элементы в одном потоке до того, как первый поток завершит добавление элементов в список, что приведет к изменению базовой коллекции и аннулированию перечислителя.

Удаление Thread.Sleep из кода добавления подчеркивает это:

public static void AddItems(object state_)
{     
   for (int i = 1; i <= 50; i++)      
   {        
       _collection.Add(i.ToString());      
       Console.WriteLine("Adding " + i);  
   }   
} 

Вместо того, чтобы спать, вы должны использовать механизм синхронизации, который ждет, пока первый поток завершит свою работу по добавлению элементов.

person Mitch Wheat    schedule 11.04.2009
comment
Это не имеет ничего общего с механизмом сна. Проблема связана с синхронизацией, как вы указали ниже фрагмента кода. (поэтому я удалил минус) - person Chris Ballance; 11.04.2009
comment
@Chris Ballance: если вы посмотрите на мой ответ, я объясню, почему опубликованный код работает так, как описано. Я четко указываю, почему перечислитель становится недействительным и выдает исключение. иногда ТАК сходит с ума! - person Mitch Wheat; 11.04.2009
comment
Кстати, я не утверждаю, что удаление Thread.Sleep() из цикла добавления — это исправление!! - person Mitch Wheat; 11.04.2009