Nesting attendent dans Parallel.ForEach

183

Dans une application de métro, je dois exécuter un certain nombre d'appels WCF. Il y a un nombre important d'appels à faire, donc je dois les faire dans une boucle parallèle. Le problème est que la boucle parallèle se termine avant que les appels WCF ne soient terminés.

Comment refactoriser cela pour qu'il fonctionne comme prévu?

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();
Darthg8r
la source

Réponses:

172

L'idée derrière Parallel.ForEach()est que vous avez un ensemble de threads et que chaque thread traite une partie de la collection. Comme vous l'avez remarqué, cela ne fonctionne pas avec async- await, où vous souhaitez libérer le thread pour la durée de l'appel asynchrone.

Vous pouvez «corriger» cela en bloquant les ForEach()threads, mais cela va à l'encontre de tout l'intérêt de async- await.

Ce que vous pouvez faire, c'est utiliser TPL Dataflow à la place de Parallel.ForEach(), qui prend Taskbien en charge les s asynchrones .

Plus précisément, votre code peut être écrit en utilisant un TransformBlockqui transforme chaque identifiant en un en Customerutilisant le asynclambda. Ce bloc peut être configuré pour s'exécuter en parallèle. Vous lieriez ce bloc à un ActionBlockqui écrit chacun Customersur la console. Après avoir configuré le réseau de blocage, vous pouvez Post()chaque identifiant sur le TransformBlock.

Dans du code:

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();

Bien que vous souhaitiez probablement limiter le parallélisme du TransformBlockà une petite constante. En outre, vous pouvez limiter la capacité du TransformBlocket y ajouter les éléments de manière asynchrone en utilisant SendAsync(), par exemple, si la collection est trop grande.

Un avantage supplémentaire par rapport à votre code (s'il a fonctionné) est que l'écriture commencera dès qu'un seul élément est terminé, et n'attendra pas que tout le traitement soit terminé.

svick
la source
2
Un très bref aperçu des extensions asynchrones, réactives, TPL et TPL DataFlow - vantsuyoshi.wordpress.com/2012/01/05/… pour ceux comme moi qui pourraient avoir besoin de clarté.
Norman H
1
Je suis presque sûr que cette réponse ne parallélise PAS le traitement. Je crois que vous devez faire un Parallel.ForEach sur les identifiants et les publier dans getCustomerBlock. Du moins, c'est ce que j'ai trouvé lorsque j'ai testé cette suggestion.
JasonLind
4
@JasonLind C'est vraiment le cas. L'utilisation Parallel.ForEach()d' Post()objets en parallèle ne devrait pas avoir d'effet réel.
svick
1
@svick Ok je l'ai trouvé, l'ActionBlock doit aussi être en parallèle. Je le faisais légèrement différemment, je n'avais pas besoin d'une transformation, j'ai donc simplement utilisé un bufferblock et j'ai fait mon travail dans l'ActionBlock. J'ai été confus par une autre réponse sur les interwebs.
JasonLind
2
J'entends par là spécifier MaxDegreeOfParallelism sur l'ActionBlock comme vous le faites sur le TransformBlock dans votre exemple
JasonLind
125

La réponse de svick est (comme d'habitude) excellente.

Cependant, je trouve que Dataflow est plus utile lorsque vous avez réellement de grandes quantités de données à transférer. Ou lorsque vous avez besoin d'une asyncfile d'attente compatible.

Dans votre cas, une solution plus simple consiste simplement à utiliser le asyncparallélisme -style:

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();
Stephen Cleary
la source
14
Si vous vouliez limiter manuellement le parallélisme (ce que vous faites probablement dans ce cas), le faire de cette manière serait plus compliqué.
svick
1
Mais vous avez raison, Dataflow peut être assez compliqué (par exemple par rapport à Parallel.ForEach()). Mais je pense que c'est actuellement la meilleure option pour faire presque n'importe quel asynctravail avec des collections.
svick
1
@JamesManning comment ParallelOptionsva vous aider? Cela s'applique uniquement à Parallel.For/ForEach/Invoke, qui, comme le PO établi, ne sont d'aucune utilité ici.
Ohad Schneider
1
@StephenCleary Si la GetCustomerméthode renvoie un Task<T>, devrait-on utiliser Select(async i => { await repo.GetCustomer(i);});?
Shyju
5
@batmaci: Parallel.ForEachne prend pas en charge async.
Stephen Cleary
81

Utiliser DataFlow comme suggéré par svick peut être exagéré, et la réponse de Stephen ne fournit pas les moyens de contrôler la concurrence de l'opération. Cependant, cela peut être réalisé assez simplement:

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

Les ToArray()appels peuvent être optimisés en utilisant un tableau au lieu d'une liste et en remplaçant les tâches terminées, mais je doute que cela fasse une grande différence dans la plupart des scénarios. Exemple d'utilisation selon la question du PO:

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

EDIT Fellow utilisateur SO et wiz TPL Eli Arbel m'a indiqué un article connexe de Stephen Toub . Comme d'habitude, sa mise en œuvre est à la fois élégante et efficace:

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

        })); 
}
Ohad Schneider
la source
1
@RichardPierre en fait cette surcharge Partitioner.Createutilise le partitionnement par blocs , qui fournit des éléments de manière dynamique aux différentes tâches de sorte que le scénario que vous avez décrit n'aura pas lieu. Notez également que le partitionnement statique (prédéterminé) peut être plus rapide dans certains cas en raison de moins de frais généraux (en particulier la synchronisation). Pour plus d'informations, consultez: msdn.microsoft.com/en-us/library/dd997411(v=vs.110).aspx .
Ohad Schneider
1
@OhadSchneider Dans les // observer les exceptions, si cela lève une exception, cela va-t-il remonter jusqu'à l'appelant? Par exemple, si je voulais que tout l'énumérable arrête le traitement / échoue si une partie de celui-ci échoue?
Terry
3
@Terry cela remontera jusqu'à l'appelant dans le sens où la tâche la plus haute (créée par Task.WhenAll) contiendra l'exception (à l'intérieur d'un AggregateException), et par conséquent si ledit appelant était utilisé await, une exception serait lancée dans le site d'appel. Cependant, Task.WhenAllattendra toujours que toutes les tâches soient terminées et GetPartitionsallouera dynamiquement des éléments lors de l' partition.MoveNextappel jusqu'à ce qu'il ne reste plus d'éléments à traiter. Cela signifie que si vous n'ajoutez pas votre propre mécanisme pour arrêter le traitement (par exemple CancellationToken), il ne se produira pas tout seul.
Ohad Schneider
1
@gibbocool Je ne suis toujours pas sûr de suivre. Supposons que vous ayez un total de 7 tâches, avec les paramètres que vous avez spécifiés dans votre commentaire. Supposons en outre que le premier lot prend la tâche occasionnelle de 5 secondes et trois tâches de 1 seconde. Après environ une seconde, la tâche de 5 secondes sera toujours en cours d'exécution tandis que les trois tâches d'une seconde seront terminées. À ce stade, les trois tâches restantes d'une seconde commenceront à s'exécuter (elles seraient fournies par le partitionneur aux trois threads "libres").
Ohad Schneider
2
@MichaelFreidgeim vous pouvez faire quelque chose comme var current = partition.Currentavant await body, puis l'utiliser currentdans la suite ( ContinueWith(t => { ... }).
Ohad Schneider
43

Vous pouvez économiser des efforts avec le nouveau package NuGet AsyncEnumerator , qui n'existait pas il y a 4 ans lorsque la question a été publiée à l'origine. Il vous permet de contrôler le degré de parallélisme:

using System.Collections.Async;
...

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

Avertissement: je suis l'auteur de la bibliothèque AsyncEnumerator, qui est open source et sous licence MIT, et je publie ce message juste pour aider la communauté.

Serge Semenov
la source
11
Sergey, vous devez divulguer que vous êtes un auteur de la bibliothèque
Michael Freidgeim
5
ok, a ajouté l'avertissement. Je ne cherche aucun avantage à en faire la publicité, je veux juste aider les gens;)
Serge Semenov
Votre bibliothèque n'est pas compatible avec .NET Core.
Corniel Nobel
2
@CornielNobel, il est compatible avec .NET Core - le code source sur GitHub a une couverture de test pour .NET Framework et .NET Core.
Serge Semenov
1
@SergeSemenov J'ai beaucoup utilisé votre bibliothèque pour son AsyncStreamset je dois dire que c'est excellent. Je ne saurais trop recommander cette bibliothèque.
WBuck
16

Enveloppez le Parallel.Foreachdans un Task.Run()et au lieu de l' awaitutilisation du mot clé[yourasyncmethod].Result

(vous devez faire la tâche Task.Run pour ne pas bloquer le thread de l'interface utilisateur)

Quelque chose comme ça:

var yourForeachTask = Task.Run(() =>
        {
            Parallel.ForEach(ids, i =>
            {
                ICustomerRepo repo = new CustomerRepo();
                var cust = repo.GetCustomer(i).Result;
                customers.Add(cust);
            });
        });
await yourForeachTask;
ofcoursedude
la source
3
Quel est le problème avec ça? J'aurais fait exactement comme ça. Laissez Parallel.ForEachfaire le travail parallèle, qui bloque jusqu'à ce que tout soit terminé, puis poussez le tout sur un thread d'arrière-plan pour avoir une interface utilisateur réactive. Des problèmes avec ça? C'est peut-être un thread de trop, mais c'est un code court et lisible.
ygoe
@LonelyPixel Mon seul problème est qu'il appelle Task.Runquand TaskCompletionSourcec'est préférable.
Gusdor
1
@Gusdor Curious - pourquoi est-il TaskCompletionSourcepréférable?
Seafish
@Seafish Une bonne question à laquelle j'aimerais pouvoir répondre. Doit avoir été une journée difficile: D
Gusdor
Juste une courte mise à jour. Je cherchais exactement cela maintenant, j'ai fait défiler vers le bas pour trouver la solution la plus simple et j'ai retrouvé mon propre commentaire. J'ai utilisé exactement ce code et cela fonctionne comme prévu. Il suppose uniquement qu'il existe une version Sync des appels Async d'origine dans la boucle. awaitpeut être déplacé à l'avant pour enregistrer le nom de la variable supplémentaire.
ygoe
7

Cela devrait être assez efficace et plus facile que de faire fonctionner tout le flux de données 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;
}
John Gietzen
la source
L'exemple d'utilisation ne devrait-il pas utiliser awaitcomme var customers = await ids.SelectAsync(async i => { ... });:?
Paccc
5

Je suis un peu en retard pour faire la fête mais vous voudrez peut-être envisager d'utiliser GetAwaiter.GetResult () pour exécuter votre code asynchrone dans un contexte de synchronisation, mais en parallèle comme ci-dessous;

 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);
});
Shipahi Teoman
la source
5

Une méthode d'extension pour cela qui utilise SemaphoreSlim et permet également de définir le degré maximum de parallélisme

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

Exemple d'utilisation:

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

Après avoir introduit un tas de méthodes d'assistance, vous pourrez exécuter des requêtes parallèles avec cette syntaxe simple:

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

Ce qui se passe ici est: nous divisons la collection source en 10 morceaux ( .Split(DegreeOfParallelism)), puis exécutons 10 tâches, chacune traitant ses éléments un par un ( .SelectManyAsync(...)) et les fusionnons en une seule liste.

Il convient de mentionner qu'il existe une approche plus simple:

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

Mais il faut une précaution : si vous avez une collection source trop volumineuse, elle en planifiera Taskimmédiatement un pour chaque élément, ce qui peut entraîner des baisses de performances significatives.

Les méthodes d'extension utilisées dans les exemples ci-dessus se présentent comme suit:

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);
    }
}
Vitaliy Ulantikov
la source