L'annidamento ti aspetta in Parallel.ForEach


183

In un'app della metropolitana, devo eseguire una serie di chiamate WCF. Vi è un numero significativo di chiamate da effettuare, quindi devo eseguirle in un ciclo parallelo. Il problema è che il ciclo parallelo termina prima che le chiamate WCF siano tutte complete.

Come rifatteresti questo per funzionare come previsto?

var ids = new List<string>() { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" };
var customers = new  System.Collections.Concurrent.BlockingCollection<Customer>();

Parallel.ForEach(ids, async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    var cust = await repo.GetCustomer(i);
    customers.Add(cust);
});

foreach ( var customer in customers )
{
    Console.WriteLine(customer.ID);
}

Console.ReadKey();

Risposte:


172

L'idea alla base Parallel.ForEach()è che hai un set di thread e ogni thread elabora parte della raccolta. Come hai notato, questo non funziona con async- await, dove vuoi rilasciare il thread per la durata della chiamata asincrona.

Potresti "sistemarlo" bloccando i ForEach()thread, ma questo sconfigge l'intero punto di async- await.

Quello che potresti fare è usare TPL Dataflow invece di Parallel.ForEach(), che supporta Taskbene i asincroni .

In particolare, il tuo codice potrebbe essere scritto usando un TransformBlockche trasforma ogni id in un Customerusando il asynclambda. Questo blocco può essere configurato per l'esecuzione in parallelo. Collegheresti quel blocco a un oggetto ActionBlockche scrive ciascuno Customersulla console. Dopo aver configurato la rete a blocchi, è possibile Post()ciascun ID per il TransformBlock.

Nel codice:

var ids = new List<string> { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" };

var getCustomerBlock = new TransformBlock<string, Customer>(
    async i =>
    {
        ICustomerRepo repo = new CustomerRepo();
        return await repo.GetCustomer(i);
    }, new ExecutionDataflowBlockOptions
    {
        MaxDegreeOfParallelism = DataflowBlockOptions.Unbounded
    });
var writeCustomerBlock = new ActionBlock<Customer>(c => Console.WriteLine(c.ID));
getCustomerBlock.LinkTo(
    writeCustomerBlock, new DataflowLinkOptions
    {
        PropagateCompletion = true
    });

foreach (var id in ids)
    getCustomerBlock.Post(id);

getCustomerBlock.Complete();
writeCustomerBlock.Completion.Wait();

Anche se probabilmente vuoi limitare il parallelismo della TransformBlocka qualche piccola costante. Inoltre, è possibile limitare la capacità di TransformBlocke aggiungere gli elementi ad esso in modo asincrono utilizzando SendAsync(), ad esempio, se la raccolta è troppo grande.

Come ulteriore vantaggio rispetto al tuo codice (se ha funzionato) è che la scrittura inizierà non appena un singolo articolo è finito e non attendere fino al termine di tutta l'elaborazione.


2
Una breve panoramica di asincrono, estensioni reattive, TPL e TPL DataFlow - vantsuyoshi.wordpress.com/2012/01/05/… per quelli come me che potrebbero aver bisogno di un po 'di chiarezza.
Norman H,

1
Sono abbastanza sicuro che questa risposta NON parallelizzi l'elaborazione. Credo che tu debba fare un Parallel.For ogni sopra gli ID e pubblicarli su getCustomerBlock. Almeno questo è quello che ho trovato quando ho testato questo suggerimento.
JasonLind,

4
@JasonLind Lo fa davvero. L'uso Parallel.ForEach()di Post()oggetti in parallelo non dovrebbe avere alcun effetto reale.
svick,

1
@svick Ok L'ho trovato, anche ActionBlock deve essere in parallelo. Lo stavo facendo in modo leggermente diverso, non avevo bisogno di una trasformazione, quindi ho usato solo un bufferblock e fatto il mio lavoro in ActionBlock. Mi sono confuso da un'altra risposta sugli interwebs.
JasonLind,

2
Con questo intendo specificare MaxDegreeOfParallelism su ActionBlock come fai su TransformBlock nel tuo esempio
JasonLind

125

la risposta di svick è (come al solito) eccellente.

Tuttavia, trovo che il flusso di dati sia più utile quando in realtà hai grandi quantità di dati da trasferire. O quando hai bisogno di una asynccoda compatibile.

Nel tuo caso, una soluzione più semplice è usare solo il asyncparallelismo di tipo:

var ids = new List<string>() { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" };

var customerTasks = ids.Select(i =>
  {
    ICustomerRepo repo = new CustomerRepo();
    return repo.GetCustomer(i);
  });
var customers = await Task.WhenAll(customerTasks);

foreach (var customer in customers)
{
  Console.WriteLine(customer.ID);
}

Console.ReadKey();

14
Se si desidera limitare manualmente il parallelismo (cosa che molto probabilmente si fa in questo caso), farlo in questo modo sarebbe più complicato.
svick

1
Ma hai ragione sul fatto che il flusso di dati può essere piuttosto complicato (ad esempio se confrontato con Parallel.ForEach()). Ma penso che sia attualmente l'opzione migliore per fare quasi qualsiasi asynclavoro con le raccolte.
svick

1
@JamesManning come ParallelOptionsti aiuterà? È applicabile solo a Parallel.For/ForEach/Invoke, che come stabilito dall'OP non sono utili qui.
Ohad Schneider,

1
@StephenCleary Se il GetCustomermetodo restituisce un Task<T>, si dovrebbe usare Select(async i => { await repo.GetCustomer(i);});?
Shyju,

5
@batmaci: Parallel.ForEachnon supporta async.
Stephen Cleary,

81

L'uso di DataFlow come suggerito da svick può essere eccessivo e la risposta di Stephen non fornisce i mezzi per controllare la concorrenza dell'operazione. Tuttavia, ciò può essere ottenuto piuttosto semplicemente:

public static async Task RunWithMaxDegreeOfConcurrency<T>(
     int maxDegreeOfConcurrency, IEnumerable<T> collection, Func<T, Task> taskFactory)
{
    var activeTasks = new List<Task>(maxDegreeOfConcurrency);
    foreach (var task in collection.Select(taskFactory))
    {
        activeTasks.Add(task);
        if (activeTasks.Count == maxDegreeOfConcurrency)
        {
            await Task.WhenAny(activeTasks.ToArray());
            //observe exceptions here
            activeTasks.RemoveAll(t => t.IsCompleted); 
        }
    }
    await Task.WhenAll(activeTasks.ToArray()).ContinueWith(t => 
    {
        //observe exceptions in a manner consistent with the above   
    });
}

Le ToArray()chiamate possono essere ottimizzate utilizzando un array anziché un elenco e sostituendo le attività completate, ma dubito che farebbe molta differenza nella maggior parte degli scenari. Esempio di utilizzo secondo la domanda del PO:

RunWithMaxDegreeOfConcurrency(10, ids, async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    var cust = await repo.GetCustomer(i);
    customers.Add(cust);
});

EDIT Fellow SO e la maga TPL Eli Arbel mi hanno indicato un articolo correlato di Stephen Toub . Come al solito, la sua implementazione è sia elegante che efficiente:

public static Task ForEachAsync<T>(
      this IEnumerable<T> source, int dop, Func<T, Task> body) 
{ 
    return Task.WhenAll( 
        from partition in Partitioner.Create(source).GetPartitions(dop) 
        select Task.Run(async delegate { 
            using (partition) 
                while (partition.MoveNext()) 
                    await body(partition.Current).ContinueWith(t => 
                          {
                              //observe exceptions
                          });

        })); 
}

1
@RichardPierre in realtà questo sovraccarico di Partitioner.Createutilizza il partizionamento di blocchi, che fornisce elementi dinamicamente alle diverse attività in modo che lo scenario che hai descritto non avrà luogo. Si noti inoltre che il partizionamento statico (predeterminato) può essere più veloce in alcuni casi a causa del minor sovraccarico (in particolare la sincronizzazione). Per ulteriori informazioni, consultare: msdn.microsoft.com/en-us/library/dd997411(v=vs.110).aspx .
Ohad Schneider,

1
@OhadSchneider Nelle // osservate le eccezioni, se ciò genera un'eccezione, passerà al chiamante? Ad esempio, se volessi che l'intero enumerabile smettesse di elaborare / fallire se una parte di esso falliva?
Terry,

3
@Terry si sposterà al chiamante nel senso che l'attività più in alto (creata da Task.WhenAll) conterrà l'eccezione (all'interno di un AggregateException) e, di conseguenza, se tale chiamante viene utilizzato await, nel sito di chiamata verrà generata un'eccezione. Tuttavia, Task.WhenAllattenderà comunque il completamento di tutte le attività e GetPartitionsallocherà in modo dinamico gli elementi quando partition.MoveNextviene chiamato fino a quando non vengono più elaborati altri elementi. Ciò significa che a meno che non si aggiunga il proprio meccanismo per interrompere l'elaborazione (ad es. CancellationToken) Non accadrà da solo.
Ohad Schneider,

1
@gibbocool Non sono ancora sicuro di seguirlo. Supponiamo di avere un totale di 7 attività, con i parametri che hai specificato nel tuo commento. Supponiamo inoltre che il primo batch esegua l'attività occasionale di 5 secondi e tre attività di 1 secondo. Dopo circa un secondo, l'attività di 5 secondi continuerà a essere eseguita mentre le tre attività di 1 secondo saranno completate. A questo punto inizieranno le restanti tre attività da 1 secondo (sarebbero fornite dal partizionatore ai tre thread "liberi").
Ohad Schneider,

2
@MichaelFreidgeim puoi fare qualcosa come var current = partition.Currentprima await bodye poi usare currentnella continuazione ( ContinueWith(t => { ... }).
Ohad Schneider,

43

Puoi risparmiare sforzo con il nuovo pacchetto NuGet di AsyncEnumerator , che non esisteva 4 anni fa quando la domanda era stata originariamente pubblicata. Ti permette di controllare il grado di parallelismo:

using System.Collections.Async;
...

await ids.ParallelForEachAsync(async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    var cust = await repo.GetCustomer(i);
    customers.Add(cust);
},
maxDegreeOfParallelism: 10);

Disclaimer: sono l'autore della libreria AsyncEnumerator, che è open source e con licenza MIT, e sto pubblicando questo messaggio solo per aiutare la comunità.


11
Sergey, dovresti rivelare che sei un autore della biblioteca
Michael Freidgeim il

5
ok, aggiunto il disclaimer. Non sto cercando alcun beneficio dalla pubblicità, voglio solo aiutare le persone;)
Serge Semenov,

La tua libreria non è compatibile con .NET Core.
Corniel Nobel,

2
@CornielNobel, è compatibile con .NET Core: il codice sorgente su GitHub ha una copertura di test sia per .NET Framework che .NET Core.
Serge Semenov,

1
@SergeSemenov Ho usato molto la tua libreria per questo AsyncStreamse devo dire che è eccellente. Non posso raccomandare abbastanza questa libreria.
WBuck,

16

Avvolgi Parallel.Foreachin a Task.Run()e invece della awaitparola chiave use[yourasyncmethod].Result

(è necessario eseguire l'attività Task.Run per non bloccare il thread dell'interfaccia utente)

Qualcosa come questo:

var yourForeachTask = Task.Run(() =>
        {
            Parallel.ForEach(ids, i =>
            {
                ICustomerRepo repo = new CustomerRepo();
                var cust = repo.GetCustomer(i).Result;
                customers.Add(cust);
            });
        });
await yourForeachTask;

3
Qual è il problema con questo? Lo avrei fatto esattamente così. Lascia Parallel.ForEachfare il lavoro parallelo, che blocca fino a quando tutto è fatto, quindi spingere il tutto su un thread in background per avere un'interfaccia utente reattiva. Qualche problema con quello? Forse è un thread in sospeso troppo, ma è un codice breve e leggibile.
giovedì

@LonelyPixel Il mio unico problema è che chiama Task.Runquando TaskCompletionSourceè preferibile.
Gusdor,

1
@Gusdor Curious - perché è TaskCompletionSourcepreferibile?
Pesce di mare

@Seafish Una buona domanda a cui vorrei poter rispondere. Deve essere stata una giornata difficile: D
Gusdor,

Solo un breve aggiornamento. Stavo cercando esattamente questo ora, ho scorrere verso il basso per trovare la soluzione più semplice e ho trovato di nuovo il mio commento. Ho usato esattamente questo codice e funziona come previsto. Presuppone solo che ci sia una versione Sync delle chiamate Async originali all'interno del loop. awaitpuò essere spostato nella parte anteriore per salvare il nome della variabile aggiuntiva.
giovedì

7

Questo dovrebbe essere piuttosto efficiente e più semplice rispetto al funzionamento dell'intero flusso di dati TPL:

var customers = await ids.SelectAsync(async i =>
{
    ICustomerRepo repo = new CustomerRepo();
    return await repo.GetCustomer(i);
});

...

public static async Task<IList<TResult>> SelectAsync<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, Task<TResult>> selector, int maxDegreesOfParallelism = 4)
{
    var results = new List<TResult>();

    var activeTasks = new HashSet<Task<TResult>>();
    foreach (var item in source)
    {
        activeTasks.Add(selector(item));
        if (activeTasks.Count >= maxDegreesOfParallelism)
        {
            var completed = await Task.WhenAny(activeTasks);
            activeTasks.Remove(completed);
            results.Add(completed.Result);
        }
    }

    results.AddRange(await Task.WhenAll(activeTasks));
    return results;
}

L'esempio di utilizzo non dovrebbe usare awaitcome var customers = await ids.SelectAsync(async i => { ... });:?
Paccc,

5

Sono un po 'in ritardo per festeggiare, ma potresti prendere in considerazione l'utilizzo di GetAwaiter.GetResult () per eseguire il tuo codice asincrono nel contesto di sincronizzazione ma parallelamente come di seguito;

 Parallel.ForEach(ids, i =>
{
    ICustomerRepo repo = new CustomerRepo();
    // Run this in thread which Parallel library occupied.
    var cust = repo.GetCustomer(i).GetAwaiter().GetResult();
    customers.Add(cust);
});

5

Un metodo di estensione per questo che utilizza SemaphoreSlim e consente anche di impostare il massimo grado di parallelismo

    /// <summary>
    /// Concurrently Executes async actions for each item of <see cref="IEnumerable<typeparamref name="T"/>
    /// </summary>
    /// <typeparam name="T">Type of IEnumerable</typeparam>
    /// <param name="enumerable">instance of <see cref="IEnumerable<typeparamref name="T"/>"/></param>
    /// <param name="action">an async <see cref="Action" /> to execute</param>
    /// <param name="maxDegreeOfParallelism">Optional, An integer that represents the maximum degree of parallelism,
    /// Must be grater than 0</param>
    /// <returns>A Task representing an async operation</returns>
    /// <exception cref="ArgumentOutOfRangeException">If the maxActionsToRunInParallel is less than 1</exception>
    public static async Task ForEachAsyncConcurrent<T>(
        this IEnumerable<T> enumerable,
        Func<T, Task> action,
        int? maxDegreeOfParallelism = null)
    {
        if (maxDegreeOfParallelism.HasValue)
        {
            using (var semaphoreSlim = new SemaphoreSlim(
                maxDegreeOfParallelism.Value, maxDegreeOfParallelism.Value))
            {
                var tasksWithThrottler = new List<Task>();

                foreach (var item in enumerable)
                {
                    // Increment the number of currently running tasks and wait if they are more than limit.
                    await semaphoreSlim.WaitAsync();

                    tasksWithThrottler.Add(Task.Run(async () =>
                    {
                        await action(item).ContinueWith(res =>
                        {
                            // action is completed, so decrement the number of currently running tasks
                            semaphoreSlim.Release();
                        });
                    }));
                }

                // Wait for all tasks to complete.
                await Task.WhenAll(tasksWithThrottler.ToArray());
            }
        }
        else
        {
            await Task.WhenAll(enumerable.Select(item => action(item)));
        }
    }

Esempio di utilizzo:

await enumerable.ForEachAsyncConcurrent(
    async item =>
    {
        await SomeAsyncMethod(item);
    },
    5);

5

Dopo aver introdotto una serie di metodi di supporto, sarai in grado di eseguire query parallele con questa semplice sintassi:

const int DegreeOfParallelism = 10;
IEnumerable<double> result = await Enumerable.Range(0, 1000000)
    .Split(DegreeOfParallelism)
    .SelectManyAsync(async i => await CalculateAsync(i).ConfigureAwait(false))
    .ConfigureAwait(false);

Quello che succede qui è: suddividiamo la raccolta di sorgenti in 10 blocchi ( .Split(DegreeOfParallelism)), quindi eseguiamo 10 attività ognuna elaborando i suoi elementi uno per uno ( .SelectManyAsync(...)) e unendoli nuovamente in un unico elenco.

Vale la pena ricordare che esiste un approccio più semplice:

double[] result2 = await Enumerable.Range(0, 1000000)
    .Select(async i => await CalculateAsync(i).ConfigureAwait(false))
    .WhenAll()
    .ConfigureAwait(false);

Ma ha bisogno di una precauzione : se hai una raccolta di origine troppo grande, pianificherà Taskimmediatamente un oggetto per ogni elemento, il che potrebbe causare importanti risultati in termini di prestazioni.

I metodi di estensione utilizzati negli esempi precedenti sono i seguenti:

public static class CollectionExtensions
{
    /// <summary>
    /// Splits collection into number of collections of nearly equal size.
    /// </summary>
    public static IEnumerable<List<T>> Split<T>(this IEnumerable<T> src, int slicesCount)
    {
        if (slicesCount <= 0) throw new ArgumentOutOfRangeException(nameof(slicesCount));

        List<T> source = src.ToList();
        var sourceIndex = 0;
        for (var targetIndex = 0; targetIndex < slicesCount; targetIndex++)
        {
            var list = new List<T>();
            int itemsLeft = source.Count - targetIndex;
            while (slicesCount * list.Count < itemsLeft)
            {
                list.Add(source[sourceIndex++]);
            }

            yield return list;
        }
    }

    /// <summary>
    /// Takes collection of collections, projects those in parallel and merges results.
    /// </summary>
    public static async Task<IEnumerable<TResult>> SelectManyAsync<T, TResult>(
        this IEnumerable<IEnumerable<T>> source,
        Func<T, Task<TResult>> func)
    {
        List<TResult>[] slices = await source
            .Select(async slice => await slice.SelectListAsync(func).ConfigureAwait(false))
            .WhenAll()
            .ConfigureAwait(false);
        return slices.SelectMany(s => s);
    }

    /// <summary>Runs selector and awaits results.</summary>
    public static async Task<List<TResult>> SelectListAsync<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, Task<TResult>> selector)
    {
        List<TResult> result = new List<TResult>();
        foreach (TSource source1 in source)
        {
            TResult result1 = await selector(source1).ConfigureAwait(false);
            result.Add(result1);
        }
        return result;
    }

    /// <summary>Wraps tasks with Task.WhenAll.</summary>
    public static Task<TResult[]> WhenAll<TResult>(this IEnumerable<Task<TResult>> source)
    {
        return Task.WhenAll<TResult>(source);
    }
}
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.