La anidación aguarda en paralelo.

183

En una aplicación de metro, necesito ejecutar varias llamadas WCF. Hay un número significativo de llamadas a realizar, así que necesito hacerlas en un bucle paralelo. El problema es que el ciclo paralelo sale antes de que se completen todas las llamadas WCF.

¿Cómo refactorizaría esto para que funcione como se espera?

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
fuente

Respuestas:

172

La idea Parallel.ForEach()es que tienes un conjunto de hilos y cada hilo procesa parte de la colección. Como notó, esto no funciona con async- await, donde desea liberar el hilo durante la duración de la llamada asincrónica.

Podrías "arreglarlo" bloqueando los ForEach()hilos, pero eso derrota todo el punto de async- await.

Lo que podría hacer es usar TPL Dataflow en lugar de hacerlo Parallel.ForEach(), que también admite asíncronos Task.

Específicamente, su código podría escribirse usando un TransformBlockque transforma cada identificación en un Customeruso de asynclambda. Este bloque se puede configurar para ejecutarse en paralelo. Debería vincular ese bloque a uno ActionBlockque escriba cada uno Customeren la consola. Después de configurar la red de bloque, puede Post()cada identificación a TransformBlock.

En codigo:

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

Aunque probablemente desee limitar el paralelismo de la TransformBlocka una pequeña constante. Además, puede limitar la capacidad de los elementos TransformBlocky agregarlos de forma asincrónica SendAsync(), por ejemplo, si la colección es demasiado grande.

Como un beneficio adicional en comparación con su código (si funcionó) es que la escritura comenzará tan pronto como termine un solo elemento, y no espere hasta que todo el procesamiento haya finalizado.

svick
fuente
2
Una descripción muy breve de async, extensiones reactivas, TPL y TPL DataFlow - vantsuyoshi.wordpress.com/2012/01/05/… para aquellos como yo que podrían necesitar algo de claridad.
Norman H
1
Estoy bastante seguro de que esta respuesta NO paraleliza el procesamiento. Creo que debe hacer un Parallel.ForEach sobre los identificadores y publicarlos en getCustomerBlock. Al menos eso es lo que encontré cuando probé esta sugerencia.
JasonLind
44
@JasonLind Realmente lo hace. El uso Parallel.ForEach()de Post()elementos en paralelo no debería tener ningún efecto real.
svick
1
@svick Ok, lo encontré, The ActionBlock también necesita estar en Paralelo. Lo estaba haciendo de forma ligeramente diferente, no necesitaba una transformación, así que solo usé un bloque de almacenamiento intermedio e hice mi trabajo en ActionBlock. Me confundí por otra respuesta en las redes.
JasonLind
2
Con lo cual me refiero a especificar MaxDegreeOfParallelism en ActionBlock como lo hace en TransformBlock en su ejemplo
JasonLind
125

La respuesta de svick es (como siempre) excelente.

Sin embargo, creo que Dataflow es más útil cuando en realidad tiene grandes cantidades de datos para transferir. O cuando necesita una asynccola compatible.

En su caso, una solución más simple es usar el asyncparalelismo de estilo:

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
fuente
14
Si desea limitar manualmente el paralelismo (lo que probablemente haga en este caso), hacerlo de esta manera sería más complicado.
svick
1
Pero tiene razón en que Dataflow puede ser bastante complicado (por ejemplo, en comparación con Parallel.ForEach()). Pero creo que actualmente es la mejor opción para hacer casi cualquier asynctrabajo con colecciones.
svick
1
@JamesManning, ¿cómo ParallelOptionsva a ayudar? Solo es aplicable a Parallel.For/ForEach/Invoke, que como el OP establecido no sirve de nada aquí.
Ohad Schneider
1
@StephenCleary Si el GetCustomermétodo está devolviendo a Task<T>, ¿se debería usar Select(async i => { await repo.GetCustomer(i);});?
Shyju
55
@batmaci: Parallel.ForEachno es compatible async.
Stephen Cleary
81

Usar DataFlow como sugirió svick puede ser excesivo, y la respuesta de Stephen no proporciona los medios para controlar la concurrencia de la operación. Sin embargo, eso se puede lograr simplemente:

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

Las ToArray()llamadas se pueden optimizar utilizando una matriz en lugar de una lista y reemplazando las tareas completadas, pero dudo que haga una gran diferencia en la mayoría de los escenarios. Ejemplo de uso según la pregunta del OP:

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

El usuario de EDIT Fellow SO y el experto en TPL, Eli Arbel, me señaló un artículo relacionado de Stephen Toub . Como de costumbre, su implementación es elegante y eficiente:

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
fuente
1
@RichardPierre en realidad esta sobrecarga de Partitioner.Createusos de particiones fragmentadas, que proporciona elementos dinámicamente a las diferentes tareas para que el escenario que describió no tenga lugar. También tenga en cuenta que la partición estática (predeterminada) puede ser más rápida en algunos casos debido a una menor sobrecarga (específicamente sincronización). Para obtener más información, consulte: msdn.microsoft.com/en-us/library/dd997411(v=vs.110).aspx .
Ohad Schneider
1
@OhadSchneider En el // observe las excepciones, si eso arroja una excepción, ¿se disparará a la persona que llama? Por ejemplo, si quisiera que todo el enumerable dejara de procesarse / fallara si alguna parte fallara?
Terry
3
@Terry burbujeará hasta la persona que llama en el sentido de que la tarea superior (creada por Task.WhenAll) contendrá la excepción (dentro de AggregateException) y, en consecuencia, si dicha persona usara await, se lanzaría una excepción en el sitio de la llamada. Sin embargo, Task.WhenAllaún esperará a que se completen todas las tareas y GetPartitionsasignará elementos dinámicamente cuando partition.MoveNextse llame hasta que no queden más elementos para procesar. Esto significa que, a menos que agregue su propio mecanismo para detener el procesamiento (por ejemplo CancellationToken), no sucederá por sí solo.
Ohad Schneider
1
@gibbocool Todavía no estoy seguro de seguir. Supongamos que tiene un total de 7 tareas, con los parámetros que especificó en su comentario. Supongamos además que el primer lote toma la tarea ocasional de 5 segundos y tres tareas de 1 segundo. Después de aproximadamente un segundo, la tarea de 5 segundos se seguirá ejecutando, mientras que las tres tareas de 1 segundo estarán terminadas. En este punto, las tres tareas restantes de 1 segundo comenzarán a ejecutarse (el particionador las suministrará a los tres subprocesos "libres").
Ohad Schneider
2
@MichaelFreidgeim puede hacer algo como var current = partition.Currentantes await bodyy luego usarlo currenten la continuación ( ContinueWith(t => { ... }).
Ohad Schneider
43

Puede ahorrar esfuerzo con el nuevo paquete AsyncEnumerator NuGet , que no existía hace 4 años cuando la pregunta se publicó originalmente. Le permite controlar el grado de paralelismo:

using System.Collections.Async;
...

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

Descargo de responsabilidad: soy el autor de la biblioteca AsyncEnumerator, de código abierto y con licencia del MIT, y estoy publicando este mensaje solo para ayudar a la comunidad.

Serge Semenov
fuente
11
Sergey, deberías revelar que eres autor de la biblioteca
Michael Freidgeim
55
ok, agregó el descargo de responsabilidad. No busco ningún beneficio publicitándolo, solo quiero ayudar a las personas;)
Serge Semenov
Su biblioteca no es compatible con .NET Core.
Corniel Nobel
2
@CornielNobel, es compatible con .NET Core: el código fuente de GitHub tiene una cobertura de prueba para .NET Framework y .NET Core.
Serge Semenov
1
@SergeSemenov He usado mucho su biblioteca por AsyncStreamseso y debo decir que es excelente. No puedo recomendar esta biblioteca lo suficiente.
WBuck
16

Envuelva el Parallel.Foreachen ay en Task.Run()lugar del awaituso de la palabra clave[yourasyncmethod].Result

(necesita hacer la tarea. Ejecutar para no bloquear el hilo de la interfaz de usuario)

Algo como esto:

var yourForeachTask = Task.Run(() =>
        {
            Parallel.ForEach(ids, i =>
            {
                ICustomerRepo repo = new CustomerRepo();
                var cust = repo.GetCustomer(i).Result;
                customers.Add(cust);
            });
        });
await yourForeachTask;
ofcoursedude
fuente
3
¿Cuál es el problema con esto? Lo hubiera hecho exactamente así. Deje Parallel.ForEachhacer el trabajo paralelo, que bloquea hasta que todo esté listo, y luego empuje todo a un hilo de fondo para tener una interfaz de usuario receptiva. ¿Algún problema con eso? Tal vez ese es un hilo dormido demasiado, pero es un código corto y legible.
ygoe
@LonelyPixel Mi único problema es que llama Task.Runcuando TaskCompletionSourcees preferible.
Gusdor
1
@Gusdor Curious: ¿por qué es TaskCompletionSourcepreferible?
Seafish
@ Seafish Una buena pregunta que desearía poder responder. Debe haber sido un día difícil: D
Gusdor
Solo una breve actualización. Estaba buscando exactamente esto ahora, me desplacé hacia abajo para encontrar la solución más simple y encontré mi propio comentario nuevamente. Usé exactamente este código y funciona como se esperaba. Solo se supone que hay una versión de sincronización de las llamadas asíncronas originales dentro del bucle. awaitse puede mover al frente para guardar el nombre adicional de la variable.
ygoe
7

Esto debería ser bastante eficiente y más fácil que hacer que todo el flujo de datos TPL funcione:

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
fuente
¿No debería usarse el ejemplo de uso awaitcomo var customers = await ids.SelectAsync(async i => { ... });:?
Paccc
5

Llego un poco tarde a la fiesta, pero es posible que desee considerar usar GetAwaiter.GetResult () para ejecutar su código asíncrono en contexto de sincronización, pero como se muestra a continuación;

 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);
});
Teoman shipahi
fuente
5

Un método de extensión para esto que hace uso de SemaphoreSlim y también permite establecer el grado máximo de paralelismo

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

Uso de muestra:

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

Después de introducir un montón de métodos auxiliares, podrá ejecutar consultas paralelas con esta sintaxis 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);

Lo que sucede aquí es: dividimos la colección de origen en 10 fragmentos ( .Split(DegreeOfParallelism)), luego ejecutamos 10 tareas cada una procesando sus elementos uno por uno ( .SelectManyAsync(...)) y los fusionamos nuevamente en una sola lista.

Vale la pena mencionar que hay un enfoque más simple:

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

Pero necesita una precaución : si tiene una colección de origen que es demasiado grande, programará Taskde inmediato un elemento para cada elemento, lo que puede causar impactos significativos en el rendimiento.

Los métodos de extensión utilizados en los ejemplos anteriores tienen el siguiente aspecto:

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
fuente