Identyfikator foreach i zamknięcia

79

Czy w dwóch kolejnych fragmentach pierwszy z nich jest bezpieczny, czy też musisz zrobić drugi?

Przez bezpieczne rozumiem, czy każdy wątek gwarantuje wywołanie metody na Foo z tej samej iteracji pętli, w której został utworzony wątek?

A może musisz skopiować odniesienie do nowej zmiennej „lokalnej” do każdej iteracji pętli?

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();
}

Aktualizacja: Jak wskazano w odpowiedzi Jona Skeeta, nie ma to nic wspólnego z wątkami.

xyz
źródło
Właściwie czuję, że ma to związek z wątkami, tak jakbyś nie używał wątków, powinieneś zadzwonić do właściwego delegata. W próbce Jona Skeeta bez wątków problem polega na tym, że są 2 pętle. Tutaj jest tylko jeden, więc nie powinno być problemu ... chyba że nie wiesz dokładnie, kiedy kod zostanie wykonany (czyli jeśli używasz wątków - odpowiedź Marca Gravella doskonale to pokazuje).
user276648
możliwy duplikat Access to Modified Closure (2)
nawfal
@ user276648 Nie wymaga wątków. Odroczenie wykonania delegatów do czasu zakończenia pętli wystarczy, aby uzyskać takie zachowanie.
binki

Odpowiedzi:

103

Edycja: to wszystko zmienia się w C # 5, ze zmianą miejsca zdefiniowania zmiennej (w oczach kompilatora). Począwszy od C # 5 są one takie same .


Przed C # 5

Drugi jest bezpieczny; pierwszy nie jest.

W foreachprzypadku zmiennej deklarowana jest poza pętlą - tj

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

Oznacza to, że istnieje tylko 1 fpod względem zakresu zamknięcia, a wątki mogą być bardzo zdezorientowane - wywołując metodę wiele razy w niektórych instancjach, a wcale nie w innych. Możesz to naprawić za pomocą drugiej deklaracji zmiennej w środku pętli:

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

To następnie ma oddzielny tmp zakres w każdym zamknięciu, więc nie ma ryzyka tego problemu.

Oto prosty dowód na problem:

    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();
    }

Wyjścia (losowo):

1
3
4
4
5
7
7
8
9
9

Dodaj zmienną tymczasową i działa:

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

(każdy numer raz, ale oczywiście kolejność nie jest gwarantowana)

Marc Gravell
źródło
Święta krowa ... ten stary post uratował mi wiele bólu głowy. Zawsze oczekiwałem, że zmienna foreach będzie objęta zakresem wewnątrz pętli. To było jedno z głównych doświadczeń WTF.
chris
Faktycznie uznano to za błąd w pętli foreach i naprawiono w kompilatorze. (W przeciwieństwie do pętli, w którym zmienna jedną instancję całej pętli.)
Ihar Bury
@Orlangur Od lat prowadzę bezpośrednie rozmowy z Ericiem, Madsem i Andersem. Kompilator postępował zgodnie ze specyfikacją, więc miał rację. Spec dokonał wyboru. Po prostu: ten wybór został zmieniony.
Marc Gravell
Ta odpowiedź dotyczy języka C # 4, ale nie dla nowszych wersji: „W języku C # 5 zmienna pętli foreach będzie logicznie znajdowała się wewnątrz pętli, dlatego zamknięcia będą za każdym razem zamykać nową kopię zmiennej”. ( Eric Lippert )
Douglas
@Douglas aye, poprawiałem je na bieżąco, ale to była częsta przeszkoda, więc: jest jeszcze sporo do zrobienia!
Marc Gravell
37

Odpowiedzi Pop Catalina i Marca Gravella są poprawne. Chcę tylko dodać link do mojego artykułu o zamknięciach (który mówi zarówno o Javie, jak i C #). Pomyślałem, że może to dodać trochę wartości.

EDYCJA: Myślę, że warto podać przykład, który nie ma nieprzewidywalności wątków. Oto krótki, ale kompletny program pokazujący oba podejścia. Lista „złych działań” jest drukowana 10 razy; lista „dobrych działań” liczy od 0 do 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();
        }
    }
}
Jon Skeet
źródło
1
Dzięki - dodałem pytanie, aby powiedzieć, że tak naprawdę nie dotyczy wątków.
xyz
Było to również w jednym z wykładów, które masz na wideo na swojej stronie csharpindepth.com/Talks.aspx
missaghi
Tak, wydaje mi się, że pamiętam, że użyłem tam wersji z wątkami, a jedną z sugestii opinii było unikanie wątków - wyraźniej jest użyć przykładu takiego jak ten powyżej.
Jon Skeet
Miło jednak wiedzieć, że filmy są oglądane :)
Jon Skeet
Nawet zrozumienie, że zmienna istnieje poza forpętlą, to zachowanie jest dla mnie mylące. Na przykład w Twoim przykładzie zachowania zamknięcia, stackoverflow.com/a/428624/20774 , zmienna istnieje poza zamknięciem, ale wiąże się poprawnie. Dlaczego jest inaczej?
James McMahon
17

Musisz użyć opcji 2, utworzenie zamknięcia wokół zmieniającej się zmiennej spowoduje użycie wartości zmiennej, gdy zmienna jest używana, a nie w czasie tworzenia zamknięcia.

Implementacja metod anonimowych w C # i jej konsekwencje (część 1)

Implementacja metod anonimowych w C # i jej konsekwencje (część 2)

Implementacja metod anonimowych w C # i jej konsekwencje (część 3)

Edycja: aby było jasne, w C # domknięcia są „ zamknięciami leksykalnymi ”, co oznacza, że ​​nie przechwytują wartości zmiennej, ale samą zmienną. Oznacza to, że podczas tworzenia domknięcia zmiennej zmiennej zamknięcie jest w rzeczywistości odniesieniem do zmiennej, a nie kopią jej wartości.

Edit2: dodane linki do wszystkich postów na blogu, jeśli ktoś jest zainteresowany czytaniem o wewnętrznych funkcjach kompilatora.

Pop Catalin
źródło
Myślę, że dotyczy to typów wartości i referencji.
leppie
3

To interesujące pytanie i wydaje się, że widzieliśmy, jak ludzie odpowiadają na wiele różnych sposobów. Miałem wrażenie, że druga droga będzie jedyną bezpieczną. Przygotowałem naprawdę szybki dowód:

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();
        }
    }
}

jeśli to uruchomisz, zobaczysz, że opcja 1 na pewno nie jest bezpieczna.

JoshBerke
źródło
1

W twoim przypadku możesz uniknąć problemu bez korzystania ze sztuczki kopiowania, mapując swój ListOfFoodo sekwencji wątków:

var threads = ListOfFoo.Select(foo => new Thread(() => foo.DoSomething()));
foreach (var t in threads)
{
    t.Start();
}
Ben James
źródło
-5
Foo f2 = f;

wskazuje na to samo odniesienie co

f 

Więc nic straconego i nic nie zyskało ...

Matze
źródło
1
To nie jest magia. Po prostu oddaje środowisko. Problem w tym miejscu oraz w przypadku pętli for polega na tym, że zmienna przechwytywania zostaje zmutowana (ponownie przypisana).
leppie
1
leppie: kompilator generuje kod dla Ciebie i ogólnie nie jest łatwo zobaczyć, jaki to dokładnie kod. To definicja kompilator magii, jeśli kiedykolwiek tam był.
Konrad Rudolph
3
@leppie: Jestem tutaj z Konradem. Długość, którą kompilator ma wydawać się magiczna, i chociaż semantyka jest jasno zdefiniowana, nie jest dobrze zrozumiana. Jakie jest stare powiedzenie o czymś, co nie jest dobrze zrozumiane i można je porównać z magią?
Jon Skeet
2
@Jon Skeet Czy masz na myśli, że „każda wystarczająco zaawansowana technologia jest nie do odróżnienia od magii” en.wikipedia.org/wiki/Clarke%27s_three_laws :)
AwesomeTown
2
Nie wskazuje na odniesienie. To jest odniesienie. Wskazuje na ten sam obiekt, ale jest to inne odniesienie.
mqp