Problemi potenziali nel parallelismo di dati e attività

In molti casi, Parallel.For e Parallel.ForEach possono offrire miglioramenti significativi delle prestazioni nei normali cicli sequenziali. Le operazioni necessarie per parallelizzare il ciclo comportano tuttavia delle complessità che possono determinare problemi che in un codice sequenziale sono meno frequenti o addirittura assenti. In questo argomento sono elencati alcuni suggerimenti da tenere presenti quando si scrivono cicli paralleli.

Non presupporre che l'approccio in parallelo sia sempre più veloce

In determinati casi l'esecuzione di un ciclo parallelo potrebbe essere più lenta dell'equivalente sequenziale. La regola generale di base è che per i cicli paralleli con poche iterazioni e con delegati dell'utente veloci raramente si verifica un aumento significativo della velocità di esecuzione. Poiché molti fattori influiscono sulle prestazioni, è comunque consigliabile misurare sempre i risultati effettivi.

Evitare di scrivere in percorsi di memoria condivisi

Nel codice sequenziale spesso si eseguono operazioni di lettura e scrittura su variabili o campi di classe statici. Tuttavia, ogni volta che più thread eseguono un accesso simultaneo a queste variabili, è molto probabile che si verifichino race condition. Anche se è possibile sincronizzare l'accesso alla variabile mediante l'utilizzo di blocchi, il costo di questa sincronizzazione può influire negativamente sulle prestazioni. È pertanto consigliabile evitare o almeno limitare il più possibile l'accesso allo stato condiviso in un ciclo parallelo. Il modo migliore per eseguire questa operazione è usare gli overload di Parallel.For e Parallel.ForEach che usano una variabile System.Threading.ThreadLocal<T> per archiviare lo stato thread-local durante l'esecuzione del ciclo. Per altre informazioni, vedere Procedura: Scrivere un ciclo Parallel.For con variabili di thread locali e Procedura: Scrivere un ciclo Parallel.ForEach con variabili partition-local.

Evitare parallelizzazioni eccessive

L'utilizzo dei cicli paralleli comporta costi di sovraccarico dovuti al partizionamento della raccolta di origine e alla sincronizzazione dei thread di lavoro. I vantaggi della parallelizzazione vengono limitati ulteriormente dal numero di processori nel computer. Non si ottiene alcun aumento di velocità eseguendo più thread con vincoli di calcolo in un unico processore. È pertanto fondamentale evitare la parallelizzazione eccessiva di un ciclo.

La situazione più comune in cui si verifica la parallelizzazione eccessiva è quando si utilizzano cicli annidati. Nella maggior parte dei casi è meglio parallelizzare solo il ciclo esterno, a meno che non sussista almeno una delle condizioni seguenti:

  • È noto che il ciclo interno è molto lungo.

  • Si eseguono calcoli dispendiosi in ogni ordine. L'operazione mostrata nell'esempio non è dispendiosa.

  • È noto che il sistema di destinazione presenta un numero di processori sufficiente per gestire il numero di thread che verranno prodotti dalla parallelizzazione della query su cust.Orders.

In ogni caso, il miglior modo per determinare la forma ottimale della query è tramite lo svolgimento di test e misure.

Evitare chiamate a metodi non thread-safe

La scrittura in metodi di istanza non thread-safe da un ciclo parallelo può comportare un danneggiamento dei dati che può passare inosservato nel programma. Può inoltre comportare la generazione di eccezioni. L'esempio seguente mostra uno scenario in cui più thread tentano di chiamare simultaneamente il metodo FileStream.WriteByte. Tuttavia, la classe non supporta le chiamate simultanee.

FileStream fs = File.OpenWrite(path);
byte[] bytes = new Byte[10000000];
// ...
Parallel.For(0, bytes.Length, (i) => fs.WriteByte(bytes[i]));
Dim fs As FileStream = File.OpenWrite(filepath)
Dim bytes() As Byte
ReDim bytes(1000000)
' ...init byte array
Parallel.For(0, bytes.Length, Sub(n) fs.WriteByte(bytes(n)))

Limitare le chiamate ai metodi thread-safe

La maggior parte dei metodi statici in .NET è thread-safe e può essere chiamata simultaneamente da più thread. Tuttavia, anche in questi casi, la sincronizzazione da applicare può comportare un rallentamento significativo della query.

Nota

Per verificare ciò basta inserire nelle query alcune chiamate a WriteLine. Anche se questo metodo viene utilizzato a scopo dimostrativo negli esempi della documentazione, è consigliabile evitare di utilizzarlo nei cicli paralleli, a meno che non sia necessario.

Tenere presente i problemi di affinità di thread

Alcune tecnologie, ad esempio l'interoperabilità COM per i componenti apartment a thread singolo (STA, Single-Threaded Apartment), Windows Form e Windows Presentation Foundation (WPF), impongono restrizioni di affinità di thread che richiedono l'esecuzione del codice in un thread specifico. Ad esempio, sia in Windows Form sia in WPF, l'accesso a un controllo può essere eseguito solo nel thread in cui è stato creato. Ciò significa, ad esempio, che non è possibile aggiornare un controllo elenco da un ciclo parallelo, a meno che non si configuri l'utilità di pianificazione del thread in modo che venga pianificato solo il thread UI. Per altre informazioni, vedere Specifica di un contesto di sincronizzazione.

Prestare attenzione quando si attendono delegati chiamati da Parallel.Invoke

In determinate circostanze Task Parallel Library rende inline un'attività, ovvero viene eseguito sull'attività nel thread attualmente in esecuzione. Per ulteriori informazioni, vedere Task Schedulers. Questa ottimizzazione delle prestazioni può in alcuni casi condurre a un deadlock. Due attività potrebbero ad esempio eseguire lo stesso codice di delegato, che segnala quando si verifica un evento, quindi attende che l'altra attività segnali un evento. Se la seconda attività viene resa inline nello stesso thread del primo, e il primo entra in un ciclo di attesa, la seconda attività non sarà mai in grado di segnalare il rispettivo evento. Per evitare questa situazione, è possibile specificare un timeout sull'operazione di attesa o utilizzare costruttori di thread espliciti per garantire che un'attività non blocchi l'altra.

Non presupporre che le iterazioni di Foreach, For e ForAll vengano eseguite sempre in parallelo

È importante tenere presente che le singole iterazioni in un ciclo For, ForEach o ForAll possono non necessariamente essere eseguite in parallelo. È pertanto necessario evitare di scrivere codice la cui correttezza dipenda dall'esecuzione parallela delle iterazioni o dall'esecuzione delle iterazioni in un particolare ordine. Il codice seguente, ad esempio, è molto probabile che conduca a un deadlock:

ManualResetEventSlim mre = new ManualResetEventSlim();
Enumerable.Range(0, Environment.ProcessorCount * 100)
    .AsParallel()
    .ForAll((j) =>
        {
            if (j == Environment.ProcessorCount)
            {
                Console.WriteLine("Set on {0} with value of {1}",
                    Thread.CurrentThread.ManagedThreadId, j);
                mre.Set();
            }
            else
            {
                Console.WriteLine("Waiting on {0} with value of {1}",
                    Thread.CurrentThread.ManagedThreadId, j);
                mre.Wait();
            }
        }); //deadlocks
Dim mres = New ManualResetEventSlim()
Enumerable.Range(0, Environment.ProcessorCount * 100) _
.AsParallel() _
.ForAll(Sub(j)

            If j = Environment.ProcessorCount Then
                Console.WriteLine("Set on {0} with value of {1}",
                                  Thread.CurrentThread.ManagedThreadId, j)
                mres.Set()
            Else
                Console.WriteLine("Waiting on {0} with value of {1}",
                                  Thread.CurrentThread.ManagedThreadId, j)
                mres.Wait()
            End If
        End Sub) ' deadlocks

In questo esempio, un'unica iterazione imposta un evento e tutte le altre iterazioni attendono l'evento. Nessuna delle iterazioni in attesa può essere completata fino a quando non viene completata l'iterazione di impostazione dell'evento. È tuttavia possibile che le iterazioni in attesa blocchino tutti i thread utilizzati per eseguire il ciclo parallelo, prima che l'iterazione di impostazione dell'evento abbia avuto la possibilità di essere eseguita. Ciò comporta un deadlock. L'iterazione di impostazione dell'evento non verrà mai eseguita e le iterazioni in attesa non verranno mai riattivate.

In particolare, l'avanzamento di un'iterazione di un ciclo parallelo non deve dipendere da un'altra iterazione del ciclo. Se il ciclo parallelo decide di pianificare le iterazioni in sequenza ma nell'ordine opposto, si verificherà un deadlock.

Evitare di eseguire cicli paralleli sul thread UI

È importante mantenere reattiva l'interfaccia utente dell'applicazione. Se un'operazione comporta lavoro sufficiente a garantire la parallelizzazione, è probabile che non debba essere eseguita sul thread UI. Il carico di lavoro dell'operazione dovrebbe invece essere ripartito in modo che l'operazione venga eseguita su un thread in background. Se ad esempio si desidera utilizzare un ciclo parallelo per calcolare dati di cui deve essere eseguito il rendering in un controllo dell'interfaccia utente, è consigliabile eseguire il ciclo all'interno di un'istanza dell'attività anziché direttamente in un gestore eventi dell'interfaccia utente. Solo quando il calcolo principale è stato completato dovrebbe essere eseguito nuovamente il marshalling dell'aggiornamento dell'interfaccia utente nel thread UI.

Se si eseguono cicli paralleli sul thread UI, evitare di aggiornare controlli dell'interfaccia utente dall'interno del ciclo. Se si prova ad aggiornare controlli dell'interfaccia utente dall'interno di un ciclo parallelo in esecuzione sul thread UI possono verificarsi un danneggiamento dello stato, eccezioni, aggiornamenti ritardati e deadlock, a seconda di come viene richiamato l'aggiornamento dell'Interfaccia utente. Nell'esempio seguente il ciclo parallelo blocca il thread UI sul quale è in esecuzione fino a che non vengono completate tutte le iterazioni. Se tuttavia un'iterazione del ciclo è in esecuzione su un thread in background (come può accadere per For), la chiamata al metodo Invoke comporta l'invio di un messaggio al thread UI e l'attesa da parte dei blocchi dell'elaborazione del messaggio. Poiché il thread UI è bloccato nell'esecuzione di For, il messaggio non potrà mai essere elaborato e si verifica un deadlock del thread UI.

private void button1_Click(object sender, EventArgs e)
{
    Parallel.For(0, N, i =>
    {
        // do work for i
        button1.Invoke((Action)delegate { DisplayProgress(i); });
    });
}
Private Sub Button1_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click

    Dim iterations As Integer = 20
    Parallel.For(0, iterations, Sub(x)
                                    Button1.Invoke(Sub()
                                                       DisplayProgress(x)
                                                   End Sub)
                                End Sub)
End Sub

Nell'esempio seguente viene mostrato come evitare il deadlock mediante l'esecuzione del ciclo in un'istanza dell'attività. Il thread UI non è bloccato dal ciclo e il messaggio può essere elaborato.

private void button1_Click(object sender, EventArgs e)
{
    Task.Factory.StartNew(() =>
        Parallel.For(0, N, i =>
        {
            // do work for i
            button1.Invoke((Action)delegate { DisplayProgress(i); });
        })
         );
}
Private Sub Button2_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click

    Dim iterations As Integer = 20
    Task.Factory.StartNew(Sub() Parallel.For(0, iterations, Sub(x)
                                                                Button1.Invoke(Sub()
                                                                                   DisplayProgress(x)
                                                                               End Sub)
                                                            End Sub))
End Sub

Vedi anche