Die foreach-Kennung und die Verschlüsse

79

Ist in den beiden folgenden Ausschnitten der erste sicher oder müssen Sie den zweiten ausführen?

Mit sicher meine ich, ist garantiert, dass jeder Thread die Methode auf dem Foo aus derselben Schleifeniteration aufruft, in der der Thread erstellt wurde?

Oder müssen Sie den Verweis auf eine neue Variable "local" in jede Iteration der Schleife kopieren?

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

Update: Wie in Jon Skeets Antwort ausgeführt, hat dies nichts spezielles mit Threading zu tun.

xyz
quelle
Eigentlich habe ich das Gefühl, dass es mit Threading zu tun hat, als ob Sie kein Threading verwenden würden, würden Sie den richtigen Delegierten anrufen. In Jon Skeets Beispiel ohne Threading besteht das Problem darin, dass es 2 Schleifen gibt. Hier gibt es nur einen, also sollte es kein Problem geben ... es sei denn, Sie wissen nicht genau, wann der Code ausgeführt wird (dh wenn Sie Threading verwenden - Marc Gravells Antwort zeigt dies perfekt).
user276648
Mögliches Duplikat von Access to Modified Closure (2)
Nawfal
@ user276648 Es ist kein Threading erforderlich. Es würde ausreichen, die Ausführung der Delegaten bis nach der Schleife zu verschieben, um dieses Verhalten zu erhalten.
Binki

Antworten:

103

Bearbeiten: Dies alles ändert sich in C # 5, mit einer Änderung an der Stelle, an der die Variable definiert ist (in den Augen des Compilers). Ab C # 5 sind sie gleich .


Vor C # 5

Der zweite ist sicher; das erste ist nicht.

Mit foreachwird die Variable außerhalb der Schleife deklariert - dh

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

Dies bedeutet, dass es nur 1 fin Bezug auf den Schließungsbereich gibt und die Threads sehr wahrscheinlich verwirrt werden - die Methode wird in einigen Fällen mehrmals aufgerufen und in anderen überhaupt nicht. Sie können dies mit einer zweiten Variablendeklaration innerhalb der Schleife beheben :

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

Dies hat dann tmpin jedem Schließungsbereich einen eigenen , so dass kein Risiko für dieses Problem besteht.

Hier ist ein einfacher Beweis für das 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();
    }

Ausgaben (zufällig):

1
3
4
4
5
7
7
8
9
9

Fügen Sie eine temporäre Variable hinzu und es funktioniert:

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

(jede Nummer einmal, aber natürlich ist die Bestellung nicht garantiert)

Marc Gravell
quelle
Heilige Kuh ... dieser alte Beitrag hat mir viel Kopfschmerzen erspart. Ich habe immer erwartet, dass die foreach-Variable innerhalb der Schleife liegt. Das war eine große WTF-Erfahrung.
Chris
Eigentlich wurde das als Fehler in foreach-loop angesehen und im Compiler behoben. (Im Gegensatz zu for-Schleife, wo die Variable eine einzelne Instanz für die gesamte Schleife hat.)
Ihar Bury
@Orlangur Ich habe jahrelang direkte Gespräche mit Eric, Mads und Anders darüber geführt. Der Compiler folgte der Spezifikation, war also richtig. Die Spezifikation traf eine Wahl. Einfach: Diese Wahl wurde geändert.
Marc Gravell
Diese Antwort gilt bis zu C # 4, jedoch nicht für spätere Versionen: "In C # 5 befindet sich die Schleifenvariable eines foreach logisch innerhalb der Schleife, und daher werden Schließungen jedes Mal über einer neuen Kopie der Variablen geschlossen." ( Eric Lippert )
Douglas
@Douglas aye, ich habe diese im Laufe der Zeit korrigiert, aber es war ein häufiger Stolperstein, also: Es sind noch einige zu tun!
Marc Gravell
37

Die Antworten von Pop Catalin und Marc Gravell sind richtig. Ich möchte nur einen Link zu meinem Artikel über Schließungen hinzufügen (der sowohl über Java als auch über C # spricht). Ich dachte nur, es könnte ein bisschen Mehrwert bringen.

EDIT: Ich denke, es lohnt sich, ein Beispiel zu nennen, das nicht die Unvorhersehbarkeit von Threading hat. Hier ist ein kurzes, aber vollständiges Programm, das beide Ansätze zeigt. Die Liste "schlechte Aktion" wird zehnmal zehnmal ausgedruckt. Die Liste "Gute Aktion" zählt von 0 bis 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
quelle
1
Danke - ich habe die Frage angehängt, um zu sagen, dass es nicht wirklich um Threads geht.
XYZ
Es war auch in einem der Gespräche, die Sie in Video auf Ihrer Website csharpindepth.com/Talks.aspx
missaghi
Ja, ich scheine mich zu erinnern, dass ich dort eine Threading-Version verwendet habe, und einer der Feedback-Vorschläge war, Threads zu vermeiden - es ist klarer, ein Beispiel wie das obige zu verwenden.
Jon Skeet
Schön zu wissen, dass die Videos gesehen werden :)
Jon Skeet
Selbst wenn forich verstehe, dass die Variable außerhalb der Schleife existiert, ist dieses Verhalten für mich verwirrend. In Ihrem Beispiel für das Schließverhalten , stackoverflow.com/a/428624/20774 , ist die Variable beispielsweise außerhalb des Schließens vorhanden und wird dennoch korrekt gebunden . Warum ist das anders?
James McMahon
17

Wenn Sie Option 2 verwenden müssen, um einen Abschluss um eine sich ändernde Variable zu erstellen, wird der Wert der Variablen verwendet, wenn die Variable verwendet wird, und nicht zum Zeitpunkt der Erstellung des Abschlusses.

Die Implementierung anonymer Methoden in C # und ihre Folgen (Teil 1)

Die Implementierung anonymer Methoden in C # und ihre Folgen (Teil 2)

Die Implementierung anonymer Methoden in C # und ihre Folgen (Teil 3)

Bearbeiten: Um es klar zu machen, sind C # -Verschlüsse " lexikalische Verschlüsse ", dh sie erfassen nicht den Wert einer Variablen, sondern die Variable selbst. Das bedeutet, dass beim Erstellen eines Abschlusses für eine sich ändernde Variable der Abschluss tatsächlich ein Verweis auf die Variable ist und keine Kopie ihres Werts.

Edit2: Links zu allen Blog-Posts hinzugefügt, wenn jemand Interesse daran hat, über Compiler-Interna zu lesen.

Pop Catalin
quelle
Ich denke, das gilt für Wert- und Referenztypen.
Leppie
3

Dies ist eine interessante Frage, und es scheint, als hätten wir gesehen, wie Menschen auf alle möglichen Arten geantwortet haben. Ich hatte den Eindruck, dass der zweite Weg der einzig sichere Weg sein würde. Ich habe einen sehr schnellen Beweis erbracht:

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

Wenn Sie dies ausführen, werden Sie sehen, dass Option 1 definitiv nicht sicher ist.

JoshBerke
quelle
1

In Ihrem Fall können Sie das Problem vermeiden, ohne den Kopiertrick zu verwenden, indem Sie ListOfFooes einer Folge von Threads zuordnen:

var threads = ListOfFoo.Select(foo => new Thread(() => foo.DoSomething()));
foreach (var t in threads)
{
    t.Start();
}
Ben James
quelle
-5
Foo f2 = f;

verweist auf die gleiche Referenz wie

f 

Also nichts verloren und nichts gewonnen ...

Matze
quelle
1
Es ist keine Magie. Es erfasst einfach die Umgebung. Das Problem hier und bei for-Schleifen ist, dass die Erfassungsvariable mutiert (neu zugewiesen) wird.
Leppie
1
leppie: Der Compiler generiert Code für Sie und es ist im Allgemeinen nicht leicht zu erkennen, um welchen Code es sich genau handelt. Dies ist die Definition von Compilermagie, falls es jemals eine gab.
Konrad Rudolph
3
@leppie: Ich bin mit Konrad hier. Die Länge, die der Compiler benötigt, fühlt sich wie Magie an, und obwohl die Semantik klar definiert ist, sind sie nicht gut verstanden. Was ist das alte Sprichwort über etwas, das nicht gut verstanden wurde und mit Magie vergleichbar ist?
Jon Skeet
2
@ Jon Skeet Meinst du "jede ausreichend fortschrittliche Technologie ist nicht von Magie zu unterscheiden" en.wikipedia.org/wiki/Clarke%27s_three_laws :)
AwesomeTown
2
Es verweist nicht auf eine Referenz. Es ist eine Referenz. Es zeigt auf dasselbe Objekt, ist jedoch eine andere Referenz.
mqp