Dieser Code hängt im Release-Modus, funktioniert aber im Debug-Modus einwandfrei


110

Ich bin darauf gestoßen und möchte den Grund für dieses Verhalten im Debug- und Release-Modus wissen.

public static void Main(string[] args)
{            
   bool isComplete = false;

   var t = new Thread(() =>
   {
       int i = 0;

        while (!isComplete) i += 0;
   });

   t.Start();

   Thread.Sleep(500);
   isComplete = true;
   t.Join();
   Console.WriteLine("complete!");
}

25
Was genau ist der Unterschied im Verhalten?
Mong Zhu

4
Wenn es Java wäre, würde ich annehmen, dass der Compiler keine Aktualisierungen der Variablen 'compile' sieht. Das Hinzufügen von 'flüchtig' zur Variablendeklaration würde dies beheben (und es zu einem statischen Feld machen).
Sebastian

23
Ah, der Heisenbug .
David Richerby

4
Hinweis: Aus diesem Grund enthält die Multithread-Entwicklung beispielsweise Mutexe und atomare Operationen. Wenn Sie mit Multithreading beginnen, müssen Sie eine Reihe bemerkenswerter zusätzlicher Speicherprobleme berücksichtigen, die zuvor nicht offensichtlich waren. Threading-Synchronisationstools wie Mutexe hätten dies behoben.
Cort Ammon

5
@ DavidSchwartz: Das ist natürlich erlaubt . Und es ist dem Compiler, der Laufzeit und der CPU gestattet , das Ergebnis dieses Codes anders als erwartet zu machen. Insbesondere ist es C # nicht gestattet, den Bool- Zugriff nichtatomar zu machen , aber es ist zulässig, nichtflüchtige Lesevorgänge zeitlich rückwärts zu verschieben. Im Gegensatz dazu haben Doppel keine solche Beschränkung der Atomizität; Ein doppeltes Lesen und Schreiben auf zwei verschiedenen Threads ohne Synchronisation darf zerrissen werden.
Eric Lippert

Antworten:


149

Ich vermute, dass der Optimierer durch das Fehlen eines 'flüchtigen' Schlüsselworts für die isCompleteVariable getäuscht wird .

Natürlich können Sie es nicht hinzufügen, da es sich um eine lokale Variable handelt. Und da es sich um eine lokale Variable handelt, sollte sie natürlich überhaupt nicht benötigt werden, da die Einheimischen auf dem Stapel bleiben und natürlich immer "frisch" sind.

Jedoch , nach dem Kompilieren, es ist nicht mehr eine lokale Variable . Da in einem anonymen Delegaten darauf zugegriffen wird, wird der Code aufgeteilt und in eine Hilfsklasse und ein Mitgliedsfeld übersetzt.

public static void Main(string[] args)
{
    TheHelper hlp = new TheHelper();

    var t = new Thread(hlp.Body);

    t.Start();

    Thread.Sleep(500);
    hlp.isComplete = true;
    t.Join();
    Console.WriteLine("complete!");
}

private class TheHelper
{
    public bool isComplete = false;

    public void Body()
    {
        int i = 0;

        while (!isComplete) i += 0;
    }
}

Ich kann jetzt vorstellen , dass der JIT - Compiler / Optimierer in einer Multithread - Umgebung, bei der Verarbeitung von TheHelperKlasse, kann tatsächlich zwischenspeichern Sie den Wert falsein einem gewissen Register oder Stapelrahmen zu Beginn des Body()Verfahrens, und es nie , bis das Verfahren endet aktualisieren. Das liegt daran, dass es KEINE GARANTIE gibt, dass der Thread und die Methode NICHT enden, bevor "= true" ausgeführt wird. Wenn es also keine Garantie gibt, warum nicht zwischenspeichern und die Leistung steigern, indem das Heap-Objekt einmal gelesen wird, anstatt es bei jedem zu lesen Wiederholung.

Genau aus diesem Grund volatileexistiert das Schlüsselwort .

Für diese Helfer-Klasse sein richtig ein kleines bisschen besser 1) in Multi-Threaded - Umgebungen, sollte es haben:

    public volatile bool isComplete = false;

Da es sich jedoch um automatisch generierten Code handelt, können Sie ihn natürlich nicht hinzufügen. Ein besserer Ansatz wäre, einige lock()s um Lese- und Schreibvorgänge hinzuzufügen isCompletedoder andere gebrauchsfertige Synchronisierungs- oder Threading- / Tasking-Dienstprogramme zu verwenden, anstatt zu versuchen, dies Bare-Metal zu tun (was nicht Bare-Metal sein wird). da es C # auf CLR mit GC, JIT und (..) ist.

Der Unterschied im Debug-Modus tritt wahrscheinlich auf, weil im Debug-Modus viele Optimierungen ausgeschlossen sind, sodass Sie den auf dem Bildschirm angezeigten Code debuggen können. Daher while (!isComplete)ist es nicht optimiert, sodass Sie dort einen Haltepunkt setzen können, und wird daher isCompletebeim Methodenstart nicht aggressiv in einem Register oder Stapel zwischengespeichert und bei jeder Schleifeniteration aus dem Objekt auf dem Heap gelesen.

Übrigens. Das sind nur meine Vermutungen. Ich habe nicht einmal versucht, es zu kompilieren.

Übrigens. Es scheint kein Fehler zu sein; Es ist eher eine sehr dunkle Nebenwirkung. Wenn ich damit recht habe, kann es sich auch um einen Sprachmangel handeln. Mit C # sollte es möglich sein, lokale Variablen, die erfasst und in Mitgliedsfelder in den Abschlüssen befördert werden, mit dem Schlüsselwort "flüchtig" zu versehen.

1) Siehe unten für einen Kommentar von Eric Lippert zu volatileund / oder diesen sehr interessanten Artikel, der zeigt, wie komplex es ist, sicherzustellen, dass Code, auf den volatileman sich verlässt, sicher ist. Gut, gut. Sagen wir OK.


2
@EricLippert: whoa, vielen Dank, dass du das so schnell bestätigt hast! Wie denken Sie, gibt es eine Chance, dass wir in einer zukünftigen Version eine volatileOption für lokale Variablen vom Erfassen bis zum Schließen erhalten können? Ich stelle mir vor, es kann ein bisschen schwierig sein, vom Compiler zu verarbeiten ..
Quetzalcoatl

7
@quetzalcoatl: Ich würde nicht damit rechnen, dass diese Funktion bald hinzugefügt wird. Dies ist die Art der Codierung würden Sie wollen entmutigen , und nicht leichter machen . Und außerdem löst es nicht unbedingt jedes Problem, Dinge flüchtig zu machen. Hier ist ein Beispiel, in dem alles volatil ist und das Programm immer noch falsch ist. Kannst du den Fehler finden? blog.coverity.com/2014/03/26/reordering-optimizations
Eric Lippert

3
Verstanden. Ich gebe es auf, zu versuchen, Multithreading-Optimierungen zu verstehen ... es ist verrückt, wie kompliziert es ist.
Zwischen dem

10
@Pikoh: Denken Sie wieder wie ein Optimierer. Sie haben eine Variable, die inkrementiert, aber nie gelesen wird. Eine Variable, die niemals gelesen wird, kann vollständig gelöscht werden.
Eric Lippert

4
@EricLippert jetzt machte mein Verstand einen Klick. Dieser Thread war sehr informativ, vielen Dank, wirklich.
Pikoh

82

Die Antwort von quetzalcoatl ist richtig. Um mehr Licht ins Dunkel zu bringen:

Der C # -Compiler und der CLR-Jitter dürfen sehr viele Optimierungen vornehmen, bei denen davon ausgegangen wird, dass nur der aktuelle Thread ausgeführt wird. Wenn diese Optimierungen das Programm in einer Welt falsch machen, in der nicht der aktuelle Thread ausgeführt wird , ist dies Ihr Problem . Sie müssen Multithread-Programme schreiben, die dem Compiler und dem Jitter mitteilen, welche verrückten Multithread-Inhalte Sie ausführen.

In diesem speziellen Fall darf der Jitter beobachten - muss aber nicht -, dass die Variable vom Schleifenkörper unverändert bleibt, und daraus schließen, dass sich die Variable niemals ändert, da davon ausgegangen wird, dass dies der einzige laufende Thread ist. Wenn es sich nie ändert, muss die Variable einmal und nicht jedes Mal durch die Schleife auf Wahrheit überprüft werden . Und genau das passiert.

Wie kann man das lösen? Schreiben Sie keine Multithread-Programme . Multithreading ist selbst für Experten unglaublich schwer zu finden. Wenn Sie müssen, verwenden Sie die Mechanismen der höchsten Ebene, um Ihr Ziel zu erreichen . Die Lösung besteht hier nicht darin, die Variable flüchtig zu machen. Die Lösung besteht darin , eine stornierbare Aufgabe zu schreiben und den Mechanismus zum Abbrechen der Task Parallel Library zu verwenden . Lassen Sie die TPL sich darum kümmern, dass die Threading-Logik stimmt und die Stornierung ordnungsgemäß über Threads gesendet wird.


1
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben .
Madaras Geist

14

Ich habe mich an den laufenden Prozess gebunden und festgestellt (wenn ich keine Fehler gemacht habe, bin ich damit nicht sehr geübt), dass die ThreadMethode folgendermaßen übersetzt wird:

debug051:02DE04EB loc_2DE04EB:                            
debug051:02DE04EB test    eax, eax
debug051:02DE04ED jz      short loc_2DE04EB
debug051:02DE04EF pop     ebp
debug051:02DE04F0 retn

eax(das den Wert von enthält isComplete) wird beim ersten Mal geladen und nie aktualisiert.


8

Nicht wirklich eine Antwort, aber um mehr Licht in das Thema zu bringen:

Das Problem scheint zu sein , wenn iinnerhalb des Lambda - Körpers erklärt und es ist nur in dem Zuweisungsausdruck lesen. Ansonsten funktioniert der Code im Release-Modus gut:

  1. i außerhalb des Lambda-Körpers erklärt:

    int i = 0; // Declared outside the lambda body
    
    var t = new Thread(() =>
    {
        while (!isComplete) { i += 0; }
    }); // Completes in release mode
  2. i wird im Zuweisungsausdruck nicht gelesen:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { i = 0; }
    }); // Completes in release mode
  3. i wird auch woanders gelesen:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { Console.WriteLine(i); i += 0; }
    }); // Completes in release mode

Meine Wette ist, dass ein Compiler oder eine JIT-Optimierung idie Dinge durcheinander bringt. Jemand, der schlauer als ich ist, wird wahrscheinlich mehr Licht in das Thema bringen können.

Trotzdem würde ich mir darüber keine Sorgen machen, da ich nicht sehe, wo ähnlicher Code tatsächlich irgendeinen Zweck erfüllen würde.


1
siehe meine Antwort, ich bin mir ziemlich sicher, dass es sich um ein 'flüchtiges' Schlüsselwort handelt, das nicht zu einer lokalen Variablen hinzugefügt werden kann (die später tatsächlich zu einem Mitgliedsfeld im Abschluss befördert wird).
quetzalcoatl
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.