Почему перечисление через коллекцию вызывает исключение, а циклический просмотр ее элементов не
Я тестировал некоторые конструкции синхронизации и заметил кое-что, что меня смутило. Когда я одновременно перечислял коллекцию во время записи в нее, она выдавала исключение (это ожидалось), но когда я просматривал коллекцию, используя цикл 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);
}
}
}
7 ответов
Вы не можете изменить коллекцию при перечислении. Это правило существует даже без учета проблем с потоками. Из MSDN:
Перечислитель остается действительным до тех пор, пока коллекция остается неизменной. Если в коллекцию вносятся изменения, такие как добавление, изменение или удаление элементов, перечислитель безвозвратно аннулируется и его поведение не определено.
Циклический цикл for на самом деле не является перечислителем. В большинстве сценариев это совершает одно и то же. Тем не менее, интерфейс для IEnumerator гарантирует, что вы можете перебирать всю коллекцию. Платформа реализует это внутренне, вызывая исключение, если вызов MoveNext происходит после изменения коллекции. Это исключение выдается объектом перечислителя.
Целочисленный цикл for проходит только через список чисел. Когда вы индексируете коллекцию по целому числу, вы просто получаете элемент в этой позиции. Если что-то было вставлено или удалено из списка, вы можете пропустить элемент или запустить один и тот же элемент дважды. Это может быть полезно в определенных ситуациях, когда вам нужно изменить коллекцию при ее обходе. Цикл for не имеет объекта-перечислителя для гарантии контракта IEnumerator, поэтому исключение не выдается.
Чтобы ответить на ваш актуальный вопрос...
При перечислении вы получите IEnumerator, который привязан к состоянию списка, как это было, когда вы его просили. Дальнейшие операции выполняются на счетчике (MoveNext, Current).
При использовании цикла for вы выполняете последовательность вызовов, чтобы получить определенный элемент по индексу. Нет внешнего контекста, такого как перечислитель, который знает, что вы находитесь в цикле. Как известно всей коллекции, вы запрашиваете только один предмет. Поскольку коллекция никогда не передавала перечислитель, у нее нет возможности узнать, что причина, по которой вы запрашиваете элемент 0, затем элемент 1, затем элемент 2 и т. Д., Заключается в том, что вы просматриваете список.
Если вы гадите со списком в то же время, как ходите по нему, вы получите ошибки в любом случае. Если вы добавляете элементы, цикл for может пропустить некоторые молча, а цикл foreach сработает. Если удалить элементы, то цикл for может выкинуть индекс из диапазона, если вам не повезло, но, вероятно, будет работать большую часть времени.
Но я думаю, что вы понимаете все это, ваш вопрос был просто, почему два способа итерации вели себя по-разному. Ответ на это - состояние коллекции известно (коллекции), когда вы вызываете GetEnumerator в одном случае, и когда вы вызываете get_Item в другом случае.
В списке есть внутренний счетчик версий, который обновляется при изменении содержимого списка. Перечислитель отслеживает версию и выдает исключение, когда видит, что список изменился.
Когда вы просто зацикливаете список, нет ничего, что отслеживает версию, поэтому нет ничего, что ловит, что список изменился.
Если вы измените список во время зацикливания, вы можете получить нежелательные эффекты, от которых перечислитель защищает вас. Например, если вы удаляете элемент из списка без изменения индекса цикла, чтобы он все еще указывал на тот же элемент, вы можете пропустить элементы в цикле. Аналогично, если вы вставляете элементы без исправления индекса, вы можете повторять один и тот же элемент более одного раза.
Перечислитель становится недействительным после изменения списка. Если вы изменяете список при перечислении по списку, вам необходимо немного переосмыслить свою стратегию.
Получить новый перечислитель, когда вы начнете свою функцию отображения и заблокировать список, пока это происходит. В качестве альтернативы, сделайте глубокую копию своего списка в новый список _displayCollection и перечислите его через эту отдельную коллекцию, в которую не будут записываться, кроме как заполненные до начала процесса отображения. Надеюсь это поможет.
Разница в том, что когда вы говорите, что "циклически проходите коллекцию", вы фактически не просматриваете коллекцию, вы перебираете целые числа от 1 до 50 и добавляете в коллекцию эти индексы. Это не влияет на тот факт, что числа от 1 до 50 все еще существуют.
Когда вы перечисляете список, вы перечисляете элементы, а не индексы. Поэтому, когда вы добавляете элементы при перечислении, вы лишаете законной силы перечисление. Он построен таким образом, чтобы предотвратить случаи, подобные тому, что вы делаете, когда потенциально вы могли бы перечислить элемент 6 в списке одновременно со вставкой элемента в индекс 6, где вы могли бы перечислить потенциально старый или новый элемент, или некоторые неопределенное состояние.
Ищите "потокобезопасный" список, если вы хотите это сделать, но будьте готовы справиться с неточностями чтения + записи одновременно:)
Вы не можете изменить коллекцию при ее перечислении.
проблема в том, что вы начинаете перечислять, пока ваша коллекция НЕ ПОЛНА, и пытаетесь ПРОДЕЛАТЬ ДОБАВЛЕНИЕ ПОСЛЕДОВАТЕЛЬНОСТИ ПРИ ПЕРЕЧИСЛЕНИИ
Код неисправен, потому что вы спите в течение 5 секунд, но не все элементы были добавлены в список. Это означает, что вы начинаете отображать элементы в одном потоке до того, как первый поток завершит добавление элементов в список, что приведет к изменению базовой коллекции и аннулированию перечислителя.
Удаление Thread.Sleep из кода добавления подчеркивает это:
public static void AddItems(object state_)
{
for (int i = 1; i <= 50; i++)
{
_collection.Add(i.ToString());
Console.WriteLine("Adding " + i);
}
}
Вместо того, чтобы спать, вы должны использовать механизм синхронизации, который ожидает, пока первый поток завершит свою работу по добавлению элементов.