Гніздування чекають у Parallel.ForEach


183

У програмі метро мені потрібно виконати ряд дзвінків WCF. Здійснюється значна кількість дзвінків, тому мені потрібно робити їх у паралельному циклі. Проблема полягає в тому, що паралельний цикл виходить до завершення дзвінків WCF.

Як би ви відреагували, щоб це працювало так, як очікувалося?

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

Відповіді:


172

Вся ідея Parallel.ForEach()полягає в тому, що у вас є набір потоків, і кожен потік обробляє частину колекції. Як ви помітили, це не працює з async- await, де ви хочете звільнити потік протягом тривалості виклику асинхронізації.

Ви можете це "виправити", блокуючи ForEach()нитки, але це перемагає всю точку async- await.

Що ви можете зробити, це використовувати TPL Dataflow замість Parallel.ForEach(), який підтримує асинхронний Tasks добре.

Зокрема, ваш код може бути записаний за допомогою символу a, TransformBlockякий перетворює кожен ідентифікатор у Customerвикористовуваний asyncлямбда. Цей блок можна налаштувати паралельно виконувати. Ви б зв'язали цей блок із тим, ActionBlockщо пише кожен Customerна консоль. Після налаштування блокової мережі Post()кожен ідентифікатор можна перейти до TransformBlock.

У коді:

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

Хоча ви, мабуть, хочете обмежити паралелізм TransformBlockна деякій невеликій постійній. Крім того, ви можете обмежити ємність TransformBlockта додати до неї елементи асинхронно, використовуючи SendAsync(), наприклад, якщо колекція занадто велика.

В якості додаткової переваги в порівнянні з вашим кодом (якщо він працював) є те, що запис розпочнеться, як тільки один елемент буде закінчено, а не чекати, поки вся обробка завершена.


2
Дуже короткий огляд асинхронізації, реактивних розширень, TPL та TPL DataFlow - vantsuyoshi.wordpress.com/2012/01/05/… для таких, як я, яким може знадобитися ясність.
Норман Н

1
Я впевнений, що ця відповідь НЕ паралельно обробляє. Я вважаю, що вам потрібно зробити Parallel.ForEach над ідентифікаторами та опублікувати їх на getCustomerBlock. Принаймні, це я знайшов, коли перевіряв цю пропозицію.
JasonLind

4
@JasonLind Це дійсно так. Використання Parallel.ForEach()для Post()елементів паралельно не повинно бути ніякого реального ефекту.
svick

1
@svick Добре. Я знайшов, що ActionBlock також повинен бути паралельним. Я робив це дещо інакше, мені не потрібна трансформація, тому я просто використовував буферблок і робив свою роботу в ActionBlock. Я заплутався в іншій відповіді на інтербетах.
JasonLind

2
Під яким я маю на увазі вказівку MaxDegreeOfParallelism на ActionBlock, як ви робите на TransformBlock у своєму прикладі
JasonLind

125

Відповідь svick (як завжди) відмінна.

Однак я вважаю Потік даних кориснішим, коли ви фактично маєте велику кількість даних для передачі. Або коли вам потрібна async-сумісна черга.

У вашому випадку більш простим рішенням є просто використовувати asyncпаралелізм -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();

13
Якщо ви хотіли вручну обмежити паралелізм (що ви, швидше за все, робите в цьому випадку), зробити це таким чином було б складніше.
svick

1
Але ви праві, що Dataflow може бути досить складним (наприклад, у порівнянні з Parallel.ForEach()). Але я думаю, що зараз найкращий варіант виконати майже будь-яку asyncроботу з колекціями.
svick

1
@JamesManning як нам ParallelOptionsдопомогти? Це стосується лише тих Parallel.For/ForEach/Invoke, які в міру встановлення ОП тут не приносять користі.
Охад Шнайдер

1
@StephenCleary Якщо GetCustomerметод повертає a Task<T>, чи варто використовувати Select(async i => { await repo.GetCustomer(i);});?
Shyju

5
@batmaci: Parallel.ForEachне підтримує async.
Стівен Клірі

81

Використання DataFlow як запропонованого svick може бути надмірним, а відповідь Стівена не передбачає засобів для контролю одночасності операції. Однак цього можна досягти досить просто:

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

Ці ToArray()виклики можуть бути оптимізовані за допомогою масиву замість списку і замінити завершення завдання, але я сумніваюся , що це буде робити велику частину різниці в більшості сценаріїв. Вибір використання за запитом ОП:

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

Користувач EDIT Fellow SO та майстер TPL Eli Arbel вказав мені на відповідну статтю Стівена Туба . Як завжди, його реалізація є одночасно елегантною та ефективною:

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 насправді ця перевантаженість Partitioner.Createвикористовує парний розділ, який динамічно надає елементи різним завданням, тому описаний вами сценарій не відбудеться. Також зауважте, що статичний (заздалегідь визначений) розподіл може бути швидшим у деяких випадках через менші накладні витрати (зокрема синхронізацію). Для отримання додаткової інформації див: msdn.microsoft.com/en-us/library/dd997411(v=vs.110).aspx .
Охад Шнайдер

1
@OhadSchneider У // спостерігайте за винятками, якщо це кине виняток, чи буде він міхуром до абонента? Наприклад, якщо я хотів, щоб цілий перелік припинив обробку / помилку, якщо якась її частина не вдалася?
Террі

3
@ Повторіть, вона перекинеться до абонента в тому сенсі, що найпопулярніше завдання (створене Task.WhenAll) буде містити виняток (всередині an AggregateException), і, отже, якщо цей користувач буде використаний await, виняток буде кинутий на сайт виклику. Однак, Task.WhenAllвсе одно буде чекати, коли всі завдання завершаться, і GetPartitionsбуде динамічно розподіляти елементи, коли partition.MoveNextбуде викликано, поки більше елементів не залишиться для обробки. Це означає, що якщо ви не додасте власний механізм зупинки обробки (наприклад CancellationToken), це не відбудеться самостійно.
Охад Шнайдер

1
@gibbocool Я досі не впевнений, що слідкую за цим. Припустимо, у вас загалом 7 завдань із параметрами, які ви вказали у коментарі. Далі припустимо, що перша партія займає періодичні завдання 5 секунд і три завдання на 1 секунду. Приблизно через секунду 5-секундне завдання все одно буде виконуватися, тоді як три 1-секундні завдання будуть виконані. На цьому етапі три триваліші 1 секунди завдання почнуть виконувати (вони будуть надані учасником до трьох «вільних» потоків).
Охад Шнайдер

2
@MichaelFreidgeim ви можете зробити щось на кшталт var current = partition.Currentраніше, await bodyа потім використовувати currentу продовженні ( ContinueWith(t => { ... }).
Охад Шнайдер

43

Ви можете зекономити зусилля за допомогою нового пакету NuGet AsyncEnumerator , який не існував 4 роки тому, коли питання було опубліковано спочатку. Це дозволяє контролювати ступінь паралелізму:

using System.Collections.Async;
...

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

Відмова: Я є автором бібліотеки AsyncEnumerator, яка є відкритим кодом і має ліцензію в рамках MIT, і я публікую це повідомлення лише для того, щоб допомогти громаді.


11
Сергію, ви повинні розкрити, що ви є автором бібліотеки
Майкл Фрейджім

5
ок, додали відмову. Я не шукаю жодної вигоди від її реклами, просто хочу допомогти людям;)
Сергій Семенов,

Ваша бібліотека не сумісна з .NET Core.
Корніель Нобель

2
@CornielNobel, він сумісний з .NET Core - вихідний код на GitHub має тестове покриття як для .NET Framework, так і .NET Core.
Серж Семенов

1
@SergeSemenov Я багато використовував вашу бібліотеку для її створення, AsyncStreamsі я повинен сказати, що це чудово. Не можу рекомендувати цю бібліотеку достатньо.
WBuck

16

Згорніть текст Parallel.Foreachу Task.Run()та замість awaitключового слова[yourasyncmethod].Result

(вам потрібно зробити завдання Task.Run, щоб не блокувати потік інтерфейсу)

Щось на зразок цього:

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

3
У чому проблема в цьому? Я зробив би це саме так. Дозвольте Parallel.ForEachвиконувати паралельну роботу, яка блокує, поки все не буде виконано, а потім натисніть всю справу на фонову нитку, щоб мати чуйний інтерфейс користувача. Якісь проблеми з цим? Можливо, це одна спальна нитка занадто багато, але це короткий, читабельний код.
ygoe

@LonelyPixel Моє єдине питання - це те, що він дзвонить, Task.Runколи TaskCompletionSourceкраще.
Гусдор

1
@Gusdor Цікаво - чому TaskCompletionSourceкраще?
Морські риби

@Seafish Хороше запитання, на яке я хотів би відповісти. Повинен був бути
суворий

Просто коротке оновлення. Я шукав саме це зараз, прокручував униз, щоб знайти найпростіше рішення, і знову знайшов власний коментар. Я використав саме цей код і він працює як очікувалося. Це передбачає лише те, що в циклі є синхронізована версія оригінальних викликів Async. awaitможна перемістити спереду, щоб зберегти зайву назву змінної.
ygoe

7

Це має бути досить ефективно та простіше, ніж налагодити роботу всього потоку даних 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;
}

Чи не слід приклад використання використання awaitяк: var customers = await ids.SelectAsync(async i => { ... });?
Paccc

5

Я трохи запізнююся на вечірку, але ви, можливо, захочете скористатися GetAwaiter.GetResult () для запуску коду асинхронізації в контексті синхронізації, але паралельно, як показано нижче;

 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

Метод розширення для цього використовує SemaphoreSlim, а також дозволяє встановити максимальний ступінь паралелізму

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

Використання зразка:

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

5

Ввівши купу допоміжних методів, ви зможете виконувати паралельні запити з цим простим синтаксисом:

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

Що відбувається тут: ми розділимо колекцію джерел на 10 фрагментів ( .Split(DegreeOfParallelism)), потім запустимо 10 завдань, обробляючи їх по черзі ( .SelectManyAsync(...)), і об'єднаємо їх назад в один список.

Варто згадати і простіший підхід:

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

Але для цього потрібна обережність : якщо у вас занадто велика колекція джерела, вона Taskвідразу ж заплануватиме для кожного елемента, що може спричинити значні показники ефективності.

Методи розширення, які використовуються у наведених вище прикладах, виглядають так:

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);
    }
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.