Come eseguire il multithread in modo sicuro ed efficiente in .NET

0
158

Il multithreading può essere utilizzato per velocizzare drasticamente le prestazioni della tua applicazione, ma nessun aumento di velocità è gratuito—la gestione dei thread paralleli richiede un'attenta programmazione e, senza le dovute precauzioni, puoi incorrere in race condition, deadlock e persino arresti anomali.

Cosa rende difficile il multithreading?

A meno che tu non dica diversamente al tuo programma, tutto il tuo codice viene eseguito sul “Thread principale.” Dal punto di ingresso della tua applicazione, scorre ed esegue tutte le tue funzioni una dopo l'altra. Questo ha un limite alle prestazioni, poiché ovviamente puoi fare così tanto solo se devi elaborare tutto uno alla volta. La maggior parte delle CPU moderne ha sei o più core con 12 o più thread, quindi ci sono prestazioni rimaste sul tavolo se non le stai utilizzando.

Tuttavia, non è così semplice come “attivare il multithreading.” Solo cose specifiche (come i loop) possono essere correttamente multithreadate e ci sono molte considerazioni da tenere in considerazione quando lo fai.

Il primo e più importante problema sono le condizioni di gara< /forte>. Questi si verificano spesso durante le operazioni di scrittura, quando un thread modifica una risorsa condivisa da più thread. Questo porta a un comportamento in cui l'output del programma dipende da quale thread termina o modifica qualcosa per primo, il che può portare a comportamenti casuali e imprevisti.

Questi possono essere molto, molto semplici, ad esempio, forse devi tenere un conteggio parziale di qualcosa tra i loop. Il modo più ovvio per farlo è creare una variabile e incrementarla, ma questo non è thread-safe.

Pubblicità

Questa race condition si verifica perché non si tratta solo di “aggiungerne uno alla variabile” in senso astratto; la CPU sta caricando il valore di number nel registro, aggiungendo uno a quel valore e quindi memorizzando il risultato come nuovo valore della variabile. Non sa che, nel frattempo, anche un altro thread stava provando a fare esattamente lo stesso e ha caricato un valore di numero che presto sarà errato. I due thread sono in conflitto e, alla fine del ciclo, il numero potrebbe non essere uguale a 100.

.NET offre una funzionalità che aiuta a gestire questo problema: la parola chiave di blocco. Ciò non impedisce di apportare modifiche definitive, ma aiuta a gestire la concorrenza consentendo solo a un thread alla volta di ottenere il blocco. Se un altro thread tenta di immettere un'istruzione di blocco durante l'elaborazione di un altro thread, attenderà fino a 300 ms prima di procedere.

Puoi solo bloccare i tipi di riferimento, quindi viene creato un modello comune un oggetto lock in anticipo e usarlo come sostituto per bloccare il tipo di valore.

< /p>

Tuttavia, potresti notare che ora c'è un altro problema: deadlock. Questo codice è un esempio del caso peggiore, ma qui è quasi esattamente lo stesso che eseguire un normale ciclo for (in realtà un po' più lento, poiché thread e blocchi aggiuntivi sono un sovraccarico aggiuntivo). Ogni thread tenta di ottenere il blocco, ma solo uno alla volta può avere il blocco, quindi solo un thread alla volta può effettivamente eseguire il codice all'interno del blocco. In questo caso, questo è l'intero codice del ciclo, quindi l'istruzione lock rimuove tutti i vantaggi del threading e rende tutto più lento.

In genere, si desidera bloccare in base alle esigenze ogni volta che è necessario effettuare scritture. Tuttavia, ti consigliamo di tenere a mente la concorrenza quando scegli cosa bloccare, perché anche le letture non sono sempre thread-safe. Se un altro thread sta scrivendo sull'oggetto, leggerlo da un altro thread può fornire un valore errato o far sì che una particolare condizione restituisca un risultato improprio.

Fortunatamente, ci sono alcuni trucchi per farlo correttamente dove puoi bilanciare la velocità del multithreading mentre usi i lock per evitare race condition.

Usa Interlocked per operazioni atomiche

Per le operazioni di base, l'utilizzo dell'istruzione lock può essere eccessivo. Sebbene sia molto utile per il blocco prima di modifiche complesse, è troppo sovraccarico per qualcosa di semplice come l'aggiunta o la sostituzione di un valore.

Pubblicità

Interlocked è una classe che racchiude alcune operazioni di memoria come l'aggiunta, la sostituzione e il confronto. I metodi sottostanti sono implementati a livello di CPU e garantiti come atomici e molto più veloci dell'istruzione di blocco standard. Dovrai usarli quando possibile, anche se non sostituiranno completamente il blocco.

Nell'esempio sopra, la sostituzione del blocco con una chiamata a Interlocked.Add() accelererà molto l'operazione. Anche se questo semplice esempio non è più veloce del semplice non utilizzare Interlocked, è utile come parte di un'operazione più ampia ed è comunque un aumento della velocità.

Ci sono anche Incremento e Decremento per le operazioni ++ e — , che ti faranno risparmiare due intere sequenze di tasti. Avvolgono letteralmente Add(ref count, 1) sotto il cofano, quindi non c'è una velocità specifica per usarli.

Puoi anche utilizzare Exchange, un metodo generico che imposterà una variabile uguale al valore passato. Tuttavia, dovresti stare attento con questo—se lo stai impostando su un valore che hai calcolato utilizzando il valore originale, questo non è thread-safe, poiché il vecchio valore potrebbe essere stato modificato prima di eseguire Interlocked .Exchange.

CompareExchange verificherà l'uguaglianza di due valori, e sostituisci il valore se sono uguali.

Usa raccolte thread-safe

Le raccolte predefinite in System.Collections.Generic possono essere usate con il multithreading, ma non sono completamente thread-safe. Microsoft fornisce implementazioni thread-safe di alcune raccolte in System.Collections.Concurrent.

Annuncio

Tra queste includono ConcurrentBag, una raccolta generica non ordinata e ConcurrentDictionary, un dizionario thread-safe. Ci sono anche code e stack simultanei e OrderablePartitioner, che può dividere origini dati ordinabili come Lists in partizioni separate per ogni thread.

Cerca di parallelizzare i loop< /h2>

Spesso, il posto più semplice per il multithread è in loop grandi e costosi. Se puoi eseguire più opzioni in parallelo, puoi ottenere un'enorme velocità nel tempo di esecuzione complessivo.

Il modo migliore per gestirlo è con System.Threading.Tasks.Parallel. Questa classe fornisce sostituzioni per i cicli for e foreach che eseguono i corpi del ciclo su thread separati. È semplice da usare, anche se richiede una sintassi leggermente diversa:

Ovviamente, il problema qui è che devi assicurarti che DoSomething() sia thread-safe e non interferisca con nessuna variabile condivisa. Tuttavia, non è sempre facile come sostituire il ciclo con un ciclo parallelo e in molti casi è necessario bloccare gli oggetti condivisi per apportare modifiche.

Per alleviare alcuni dei problemi con i deadlock, Parallel.For e Parallel.ForEach forniscono funzionalità aggiuntive per gestire lo stato. Fondamentalmente, non tutte le iterazioni verranno eseguite su un thread separato—se hai 1000 elementi, non creerà 1000 thread; creerà tutti i thread che la tua CPU può gestire ed eseguirà più iterazioni per thread. Ciò significa che se stai calcolando un totale, non devi eseguire il blocco per ogni iterazione. Puoi semplicemente passare una variabile subtotale e, alla fine, bloccare l'oggetto e apportare modifiche una volta. Ciò riduce drasticamente il sovraccarico su elenchi molto grandi.

Pubblicità

Diamo un'occhiata a un esempio. Il codice seguente richiede un grande elenco di oggetti e deve serializzare ciascuno separatamente su JSON, finendo con un elenco<string> di tutti gli oggetti. La serializzazione JSON è un processo molto lento, quindi dividere ogni elemento su più thread è un grande aumento di velocità.

Ci sono un sacco di argomenti e molti da disfare qui:

  • Il primo argomento accetta un IEnumerable, che definisce i dati su cui viene eseguito il ciclo. Questo è un ciclo ForEach, ma lo stesso concetto funziona per i cicli For di base.
  • La prima azione inizializza la variabile subtotale locale. Questa variabile sarà condivisa su ogni iterazione del ciclo, ma solo all'interno dello stesso thread. Gli altri thread avranno i propri subtotali. Qui, lo stiamo inizializzando su un elenco vuoto. Se stavi calcolando un totale numerico, potresti restituire 0 qui.
  • La seconda azione è il corpo del ciclo principale. Il primo argomento è l'elemento corrente (o l'indice in un ciclo For), il secondo è un oggetto ParallelLoopState che puoi usare per chiamare .Break() e l'ultimo è la variabile subtotale.
    • In questo ciclo, puoi operare sull'elemento e modificare il subtotale. Il valore restituito sostituirà il totale parziale per il ciclo successivo. In questo caso, serializziamo l'elemento in una stringa, quindi aggiungiamo la stringa al subtotale, che è un elenco.
  • Infine, l'ultima azione prende il subtotale ‘risultato’ dopo che tutte le esecuzioni sono terminate, consentendo di bloccare e modificare una risorsa in base al totale finale. Questa azione viene eseguita una volta, alla fine, ma viene comunque eseguita su un thread separato, quindi sarà necessario bloccare o utilizzare metodi interbloccati per modificare le risorse. Qui, chiamiamo AddRange() per aggiungere l'elenco dei subtotali all'elenco finale.

Unity Multithreading

Uno nota finale—se stai utilizzando il motore di gioco Unity, dovrai fare attenzione con il multithreading. Non puoi chiamare alcuna API Unity, altrimenti il ​​gioco si bloccherà. È possibile usarlo con parsimonia eseguendo operazioni API sul thread principale e passando avanti e indietro ogni volta che è necessario parallelizzare qualcosa.

Soprattutto, questo si applica alle operazioni che interagiscono con la scena o la fisica motore. La matematica di Vector3 non è interessata e sei libero di usarla da un thread separato senza problemi. Sei anche libero di modificare i campi e le proprietà dei tuoi oggetti, a condizione che non richiamino alcuna operazione Unity nascosta.