Идентификатор foreach и замыкания

79

В двух следующих отрывках, является ли первый безопасным или вы должны сделать второй?

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

Или вы должны копировать ссылку на новую переменную «local» на каждой итерации цикла?

var threads = new List<Thread>();
foreach (Foo f in ListOfFoo)
{      
    Thread thread = new Thread(() => f.DoSomething());
    threads.Add(thread);
    thread.Start();
}

-

var threads = new List<Thread>();
foreach (Foo f in ListOfFoo)
{      
    Foo f2 = f;
    Thread thread = new Thread(() => f2.DoSomething());
    threads.Add(thread);
    thread.Start();
}

Обновление: как указано в ответе Джона Скита, это не имеет никакого отношения к потокам.

xyz
источник
На самом деле я чувствую, что это связано с потоками, как если бы вы не использовали потоки, вы бы вызывали правильного делегата. В примере Джона Скита без многопоточности проблема в том, что есть 2 цикла. Здесь только один, так что проблем быть не должно ... если только вы не знаете точно, когда код будет выполнен (то есть, если вы используете многопоточность - ответ Марка Гравелла прекрасно это показывает).
user276648
возможный дубликат Access to Modified Closure (2)
nawfal
@ user276648 Не требует многопоточности. Чтобы добиться такого поведения, достаточно отложить выполнение делегатов до завершения цикла.
binki

Ответы:

103

Изменить: это все изменения в C # 5, с изменением того, где определена переменная (в глазах компилятора). Начиная с C # 5, они одинаковы .


До C # 5

Второй безопасен; первый нет.

С foreach, переменная объявляется вне цикла - т.е.

Foo f;
while(iterator.MoveNext())
{
     f = iterator.Current;
    // do something with f
}

Это означает, что существует только 1 fс точки зрения области закрытия, и потоки, скорее всего, могут запутаться, вызывая метод несколько раз в одних экземплярах, а в других - нет. Вы можете исправить это с помощью объявления второй переменной внутри цикла:

foreach(Foo f in ...) {
    Foo tmp = f;
    // do something with tmp
}

В этом случае tmpдля каждой области закрытия существует отдельная область, поэтому риск возникновения данной проблемы отсутствует.

Вот простое доказательство проблемы:

    static void Main()
    {
        int[] data = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        foreach (int i in data)
        {
            new Thread(() => Console.WriteLine(i)).Start();
        }
        Console.ReadLine();
    }

Выходы (произвольно):

1
3
4
4
5
7
7
8
9
9

Добавьте временную переменную, и она работает:

        foreach (int i in data)
        {
            int j = i;
            new Thread(() => Console.WriteLine(j)).Start();
        }

(каждый номер один раз, но порядок конечно не гарантируется)

Марк Гравелл
источник
Святая корова ... этот старый пост избавил меня от головной боли. Я всегда ожидал, что область видимости переменной foreach будет находиться внутри цикла. Это был один из главных опытов с WTF.
Крис,
На самом деле это считалось ошибкой в ​​цикле foreach и исправлено в компиляторе. (В отличие от цикла for, где переменная имеет единственный экземпляр для всего цикла.)
Ihar Bury
@Orlangur Я много лет напрямую разговаривал с Эриком, Мэдсом и Андерсом. Компилятор следовал спецификации, поэтому был прав. По спец сделал выбор. Просто: этот выбор был изменен.
Марк Гравелл
Этот ответ применим вплоть до C # 4, но не для более поздних версий: «В C # 5 переменная цикла foreach будет логически внутри цикла, и поэтому каждый раз замыкания будут закрывать новую копию переменной». ( Эрик Липперт )
Дуглас
@ Дуглас Да, я исправлял их по ходу дела, но это было обычным камнем преткновения, так что: осталось еще немало!
Марк Гравелл
37

Поп Каталин и Марк Гравелл верны. Все, что я хочу добавить, это ссылку на мою статью о замыканиях (в которой говорится как о Java, так и о C #). Просто подумал, что это может добавить немного ценности.

РЕДАКТИРОВАТЬ: Я думаю, стоит привести пример, в котором нет непредсказуемости потоковой передачи. Вот короткая, но полная программа, показывающая оба подхода. Список «плохих действий» распечатывается 10 десять раз; список "хороших действий" насчитывает от 0 до 9.

using System;
using System.Collections.Generic;

class Test
{
    static void Main() 
    {
        List<Action> badActions = new List<Action>();
        List<Action> goodActions = new List<Action>();
        for (int i=0; i < 10; i++)
        {
            int copy = i;
            badActions.Add(() => Console.WriteLine(i));
            goodActions.Add(() => Console.WriteLine(copy));
        }
        Console.WriteLine("Bad actions:");
        foreach (Action action in badActions)
        {
            action();
        }
        Console.WriteLine("Good actions:");
        foreach (Action action in goodActions)
        {
            action();
        }
    }
}
Джон Скит
источник
1
Спасибо - я добавил вопрос, чтобы сказать, что это не совсем про темы.
xyz
Это также было в одной из бесед, которую вы показываете
missaghi
Да, я, кажется, помню, что использовал там версию с потоками, и одно из предложений обратной связи заключалось в том, чтобы избегать потоков - яснее использовать пример, подобный приведенному выше.
Джон Скит,
Приятно знать, что видео смотрят :)
Джон Скит,
Даже понимание того, что переменная существует вне forцикла, меня сбивает с толку. Например, в вашем примере поведения закрытия, stackoverflow.com/a/428624/20774 , переменная существует за пределами закрытия, но привязывается правильно. Почему это другое?
Джеймс МакМахон
17

Вам необходимо использовать вариант 2, создание замыкания вокруг изменяющейся переменной будет использовать значение переменной при использовании переменной, а не во время создания замыкания.

Реализация анонимных методов на C # и ее последствия (часть 1)

Реализация анонимных методов на C # и ее последствия (часть 2)

Реализация анонимных методов на C # и ее последствия (часть 3)

Изменить: чтобы было понятно, в C # замыкания - это « лексические замыкания », означающие, что они захватывают не значение переменной, а саму переменную. Это означает, что при создании замыкания для изменяющейся переменной замыкание фактически является ссылкой на переменную, а не копией ее значения.

Edit2: добавлены ссылки на все сообщения в блоге, если кому-то интересно прочитать о внутренностях компилятора.

Поп-каталин
источник
Я думаю, это касается типов значений и ссылочных типов.
leppie
3

Это интересный вопрос, и кажется, что мы видели, как люди отвечали по-разному. У меня создалось впечатление, что второй путь будет единственным безопасным. Я приготовил очень быстрое доказательство:

class Foo
{
    private int _id;
    public Foo(int id)
    {
        _id = id;
    }
    public void DoSomething()
    {
        Console.WriteLine(string.Format("Thread: {0} Id: {1}", Thread.CurrentThread.ManagedThreadId, this._id));
    }
}
class Program
{
    static void Main(string[] args)
    {
        var ListOfFoo = new List<Foo>();
        ListOfFoo.Add(new Foo(1));
        ListOfFoo.Add(new Foo(2));
        ListOfFoo.Add(new Foo(3));
        ListOfFoo.Add(new Foo(4));


        var threads = new List<Thread>();
        foreach (Foo f in ListOfFoo)
        {
            Thread thread = new Thread(() => f.DoSomething());
            threads.Add(thread);
            thread.Start();
        }
    }
}

если вы запустите это, вы увидите, что вариант 1 определенно небезопасен.

Джош Берке
источник
1

В вашем случае вы можете избежать проблемы, не используя трюк с копированием, сопоставив ваш ListOfFooс последовательностью потоков:

var threads = ListOfFoo.Select(foo => new Thread(() => foo.DoSomething()));
foreach (var t in threads)
{
    t.Start();
}
Бен Джеймс
источник
-5
Foo f2 = f;

указывает на ту же ссылку, что и

f 

Так что ничего не потеряно и ничего не приобретено

Маце
источник
1
Это не волшебство. Он просто захватывает окружающую среду. Проблема здесь и с циклами for заключается в том, что переменная захвата изменяется (повторно назначается).
leppie
1
leppie: компилятор генерирует код за вас, и вообще непросто увидеть, что это за код. Это определение магии компилятора , если когда - либо был один.
Конрад Рудольф
3
@leppie: Я здесь с Конрадом. Компилятор производит впечатление волшебства, и хотя семантика четко определена, она не совсем понятна. Какая старая поговорка о том, что что-то непонятное, сравнимо с магией?
Джон Скит,
2
@Jon Skeet Вы имеете в виду, что «любая достаточно продвинутая технология неотличима от магии» en.wikipedia.org/wiki/Clarke%27s_three_laws :)
AwesomeTown
2
Это не указывает на ссылку. Это ссылка. Он указывает на тот же объект, но это другая ссылка.
mqp 04