Questo codice si blocca in modalità di rilascio ma funziona correttamente in modalità di debug


110

Mi sono imbattuto in questo e voglio sapere il motivo di questo comportamento in modalità debug e rilascio.

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
qual è esattamente la differenza nel comportamento?
Mong Zhu

4
Se fosse Java, presumo che il compilatore non veda gli aggiornamenti alla variabile "compile". L'aggiunta di "volatile" alla dichiarazione della variabile lo risolverebbe (e lo renderebbe un campo statico).
Sebastian

23

4
Nota: questo è il motivo per cui lo sviluppo multithread ha cose come mutex e operazioni atomiche. Quando si inizia a utilizzare il multithreading, è necessario considerare una serie notevole di problemi di memoria aggiuntivi che prima non erano ovvi. Gli strumenti di sincronizzazione dei thread come i mutex avrebbero risolto il problema.
Cort Ammon

5
@DavidSchwartz: Certamente questo è permesso . Ed è consentito al compilatore, al runtime e alla CPU di rendere il risultato di quel codice diverso da quello che ci si aspetta. In particolare, C # non è autorizzato a rendere non atomico l' accesso bool , ma è consentito spostare le letture non volatili all'indietro nel tempo. I doppi, al contrario, non hanno tale restrizione sull'atomicità; una doppia lettura e scrittura su due thread diversi senza sincronizzazione può essere strappata.
Eric Lippert

Risposte:


149

Immagino che l'ottimizzatore venga ingannato dalla mancanza di parola chiave "volatile" sulla isCompletevariabile.

Ovviamente non puoi aggiungerlo, perché è una variabile locale. E naturalmente, poiché è una variabile locale, non dovrebbe essere affatto necessaria, perché i locali sono tenuti in pila e sono naturalmente sempre "freschi".

Tuttavia , dopo la compilazione, non è più una variabile locale . Poiché si accede a un delegato anonimo, il codice viene suddiviso e viene tradotto in una classe helper e in un campo membro, qualcosa come:

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

Posso immaginare ora che il compilatore / ottimizzatore JIT in un ambiente multithread, durante l'elaborazione della TheHelperclasse, possa effettivamente memorizzare nella cache il valore falsein qualche registro o stack frame all'inizio del Body()metodo e non aggiornarlo mai fino al termine del metodo. Questo perché NON C'È GARANZIA che il thread & metodo NON finirà prima che "= true" venga eseguito, quindi se non c'è garanzia, perché non metterlo nella cache e ottenere il miglioramento delle prestazioni della lettura dell'oggetto heap una volta invece di leggerlo ogni volta iterazione.

Questo è esattamente il motivo per cui volatileesiste la parola chiave .

Affinché questa classe helper sia corretta un po 'meglio 1) in ambienti multi-thread, dovrebbe avere:

    public volatile bool isComplete = false;

ma, ovviamente, poiché è codice generato automaticamente, non è possibile aggiungerlo. Un approccio migliore sarebbe quello di aggiungere alcuni messaggi di lock()lettura e scrittura isCompleted, o di utilizzare altre utilità di sincronizzazione o threading / tasking pronte per l'uso invece di provare a farlo bare-metal (che non sarà bare-metal, poiché è C # su CLR con GC, JIT e (..)).

La differenza nella modalità di debug si verifica probabilmente perché in modalità di debug sono escluse molte ottimizzazioni, quindi puoi, beh, eseguire il debug del codice che vedi sullo schermo. Pertanto while (!isComplete)non è ottimizzato in modo da poter impostare un punto di interruzione lì, e quindi isCompletenon viene memorizzato nella cache in modo aggressivo in un registro o stack all'avvio del metodo e viene letto dall'oggetto sull'heap ad ogni iterazione del ciclo.

BTW. Sono solo le mie ipotesi su questo. Non ho nemmeno provato a compilarlo.

BTW. Non sembra essere un bug; è più simile a un effetto collaterale molto oscuro. Inoltre, se ho ragione, potrebbe trattarsi di una carenza di linguaggio: C # dovrebbe consentire di posizionare la parola chiave "volatile" sulle variabili locali che vengono acquisite e promosse a campi membro nelle chiusure.

1) vedi sotto per un commento di Eric Lippert su volatilee / o questo articolo molto interessante che mostra i livelli di complessità coinvolti nell'assicurare che il codice che si basa volatilesia sicuro ..uh, bene ..uh, diciamo OK.


2
@EricLippert: whoa, grazie mille per averlo confermato così velocemente! Come pensi, c'è qualche possibilità che in qualche versione futura possiamo ottenere volatileun'opzione sulle variabili locali da cattura a chiusura? Immagino che possa essere un po 'difficile da elaborare dal compilatore ..
quetzalcoatl

7
@quetzalcoatl: non conterei che questa funzionalità venga aggiunta presto. Questo è il tipo di codifica che vorresti scoraggiare e non rendere più semplice . Inoltre, rendere le cose volatili non risolve necessariamente tutti i problemi. Ecco un esempio in cui tutto è volatile e il programma è ancora sbagliato; riesci a trovare il bug? blog.coverity.com/2014/03/26/reordering-optimizations
Eric Lippert

3
Inteso. Rinuncio a cercare di capire le ottimizzazioni del multithreading ... è folle quanto sia complicato.
Tra il

10
@Pikoh: Ancora una volta, pensa come un ottimizzatore. Hai una variabile che viene incrementata ma non viene mai letta. Una variabile che non viene mai letta può essere eliminata completamente.
Eric Lippert

4
@EricLippert ora la mia mente ha fatto un clic. Questo thread è stato molto istruttivo, grazie mille, davvero.
Pikoh

82

La risposta di quetzalcoatl è corretta. Per fare più luce su di esso:

Il compilatore C # e il jitter CLR possono eseguire numerose ottimizzazioni che presuppongono che il thread corrente sia l'unico thread in esecuzione. Se queste ottimizzazioni rendono il programma errato in un mondo in cui il thread corrente non è l'unico thread in esecuzione, questo è il tuo problema . Ti viene richiesto di scrivere programmi multithread che dicano al compilatore e al jitter quali folli cose multithread stai facendo.

In questo caso particolare il jitter è consentito - ma non obbligatorio - per osservare che la variabile è invariata dal corpo del ciclo e quindi concludere che - essendo per ipotesi l'unico thread in esecuzione - la variabile non cambierà mai. Se non cambia mai, è necessario verificare la verità della variabile una volta , non ogni volta attraverso il ciclo. Ed è proprio questo che sta accadendo.

Come risolverlo? Non scrivere programmi multithread . Il multithreading è incredibilmente difficile da ottenere, anche per gli esperti. Se è necessario, utilizza i meccanismi di livello più alto per raggiungere il tuo obiettivo . La soluzione qui non è rendere la variabile volatile. La soluzione qui è scrivere un'attività annullabile e utilizzare il meccanismo di cancellazione della libreria Task Parallel Library . Lascia che il TPL si preoccupi di ottenere la logica di threading corretta e di inviare correttamente l'annullamento ai thread.


1
I commenti non sono per discussioni estese; questa conversazione è stata spostata nella chat .
Il fantasma di Madara il

14

Mi sono attaccato al processo in esecuzione e ho scoperto (se non ho commesso errori, non sono molto esperto con questo) che il Threadmetodo si traduce in questo:

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

eax(che contiene il valore di isComplete) viene caricato la prima volta e non viene mai aggiornato.


8

Non proprio una risposta, ma per fare più luce sulla questione:

Il problema sembra essere quando iviene dichiarato all'interno del corpo lambda e viene letto solo nell'espressione di assegnazione. Altrimenti, il codice funziona bene in modalità di rilascio:

  1. i dichiarato al di fuori del corpo lambda:

    int i = 0; // Declared outside the lambda body
    
    var t = new Thread(() =>
    {
        while (!isComplete) { i += 0; }
    }); // Completes in release mode
  2. i non viene letto nell'espressione di assegnazione:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { i = 0; }
    }); // Completes in release mode
  3. i viene letto anche altrove:

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

La mia scommessa è che qualche compilatore o ottimizzazione JIT per quanto riguarda ista rovinando le cose. Qualcuno più intelligente di me sarà probabilmente in grado di fare più luce sulla questione.

Tuttavia, non me ne preoccuperei troppo, perché non riesco a vedere dove un codice simile potrebbe effettivamente servire a qualsiasi scopo.


1
vedi la mia risposta, sono abbastanza sicuro che si tratti di una parola chiave 'volatile' che non può essere aggiunta a una variabile locale (che in realtà viene successivamente promossa a un campo membro nella chiusura) ..
quetzalcoatl
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.