การทำรังกำลังรออยู่ใน 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ซึ่งคุณต้องการปล่อยเธรดในระหว่างการโทรแบบ async

คุณสามารถ“แก้ไข” ที่โดยการปิดกั้นForEach()หัวข้อ แต่ที่เอาชนะจุดรวมของ-asyncawait

สิ่งที่คุณจะทำคือการใช้TPL DataflowแทนParallel.ForEach()ซึ่งสนับสนุนตรงกันTasks ดี

โดยเฉพาะอย่างยิ่งรหัสของคุณสามารถเขียนได้โดยใช้การTransformBlockแปลงแต่ละ id เป็นการCustomerใช้asyncแลมบ์ดา บล็อกนี้สามารถกำหนดค่าให้ดำเนินการแบบขนาน คุณจะเชื่อมโยงบล็อกนั้นกับสิ่งActionBlockที่เขียนแต่ละรายการCustomerไปยังคอนโซล หลังจากที่คุณตั้งค่าเครือข่ายบล็อกที่คุณสามารถPost()แต่ละ ID 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
ภาพรวมคร่าวๆของ async, ส่วนขยายรีแอกทีฟ, TPL และ TPL DataFlow - vantsuyoshi.wordpress.com/2012/01/05/…สำหรับคนอย่างฉันที่อาจต้องการความชัดเจน
นอร์แมน H

1
ฉันค่อนข้างมั่นใจว่าคำตอบนี้ไม่ได้เป็นการประมวลผลแบบขนาน ฉันเชื่อว่าคุณต้องทำ Parallel.ForEid แต่ละรหัสและโพสต์เหล่านั้นใน getCustomerBlock อย่างน้อยนั่นคือสิ่งที่ฉันพบเมื่อฉันทดสอบข้อเสนอแนะนี้
JasonLind

4
@ JasonLind มันทำจริงๆ การใช้Parallel.ForEach()กับPost()รายการแบบขนานไม่ควรมีผลกระทบใด ๆ จริง
svick

1
@svick ตกลงฉันพบว่า TheBlockBlock ยังต้องอยู่ในแบบคู่ขนาน ฉันกำลังทำมันแตกต่างกันเล็กน้อยฉันไม่ต้องการการแปลงดังนั้นฉันจึงใช้ bufferblock และทำงานของฉันใน ActionBlock ฉันสับสนจากคำตอบอีกคำหนึ่งเกี่ยวกับใยแมงมุม
JasonLind

2
โดยที่ผมหมายถึงการระบุ MaxDegreeOfParallelism บน ActionBlock เหมือนที่คุณทำใน TransformBlock ในตัวอย่างของคุณ
JasonLind

125

คำตอบของ svickนั้นยอดเยี่ยม (ตามปกติ)

อย่างไรก็ตามฉันพบว่า Dataflow มีประโยชน์มากกว่าเมื่อคุณมีข้อมูลจำนวนมากที่จะถ่ายโอน หรือเมื่อคุณต้องการasyncคิวที่เข้ากันได้

ในกรณีของคุณวิธีแก้ปัญหาที่ง่ายกว่าคือการใช้ความasyncขนานของสไตล์:

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ซึ่งเป็นที่จัดตั้ง OP ไม่ได้ใช้ที่นี่
Ohad Schneider

1
@StephenCleary หากGetCustomerวิธีการคืน a Task<T>ควรใช้Select(async i => { await repo.GetCustomer(i);});หรือไม่
Shyju

5
@batmaci: ไม่สนับสนุนParallel.ForEach async
Stephen Cleary

81

การใช้ DataFlow ตามคำแนะนำ svick อาจเกินความจำเป็นและคำตอบของ Stephen ไม่ได้ให้วิธีการควบคุมการทำงานพร้อมกัน อย่างไรก็ตามสามารถทำได้ค่อนข้างง่าย:

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()โทรสามารถปรับให้เหมาะสมโดยใช้อาเรย์แทนรายการและแทนที่งานที่เสร็จสมบูรณ์ แต่ฉันสงสัยว่ามันจะสร้างความแตกต่างอย่างมากในสถานการณ์ส่วนใหญ่ ตัวอย่างการใช้งานตามคำถามของ OP:

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

แก้ไขเพื่อนเพื่อให้ผู้ใช้และ TPL Wiz Eli Arbelชี้ให้ฉันไปบทความที่เกี่ยวข้องจากสตีเฟ่น Toub ตามปกติการใช้งานของเขานั้นทั้งสวยงามและมีประสิทธิภาพ:

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
Ohad Schneider

1
@OhadSchneider ใน // สังเกตข้อยกเว้นหากข้อผิดพลาดมันจะทำให้ผู้โทรเข้ามา? ตัวอย่างเช่นถ้าฉันต้องการให้นับทั้งหมดหยุดการประมวลผล / ล้มเหลวหากส่วนใดส่วนหนึ่งของมันล้มเหลว?
เทอร์รี่

3
@ เทอร์รี่มันจะทำให้ผู้โทรรู้สึกว่างานที่ได้รับความนิยมสูงสุด (สร้างโดยTask.WhenAll) จะมีข้อยกเว้น (ในตัวAggregateException) และหากผู้โทรดังกล่าวใช้งานawaitข้อยกเว้นจะถูกโยนลงในไซต์การโทร อย่างไรก็ตามTask.WhenAllจะยังคงรอให้งานทั้งหมดเสร็จสมบูรณ์และGetPartitionsจะจัดสรรองค์ประกอบแบบไดนามิกเมื่อpartition.MoveNextมีการเรียกใช้จนกว่าจะไม่มีองค์ประกอบอื่น ๆ เหลือให้ดำเนินการ ซึ่งหมายความว่าหากคุณเพิ่มกลไกของคุณเองสำหรับการหยุดการประมวลผล (เช่นCancellationToken) มันจะไม่เกิดขึ้นด้วยตัวเอง
Ohad Schneider

1
@gibbocool ฉันยังไม่แน่ใจว่าฉันติดตาม สมมติว่าคุณมีงานทั้งหมด 7 งานโดยมีพารามิเตอร์ที่คุณระบุไว้ในความคิดเห็นของคุณ ยิ่งไปกว่านั้นสมมติว่าชุดแรกใช้งานเป็นครั้งคราว 5 วินาทีและงาน 1 วินาทีสามงาน หลังจากนั้นประมาณหนึ่งวินาทีงาน 5 วินาทีจะยังคงดำเนินการอยู่ในขณะที่งาน 1 วินาทีที่สามจะเสร็จสิ้น เมื่อถึงจุดนี้งานที่เหลืออีก 1 วินาทีจะเริ่มดำเนินการสามงาน
Ohad Schneider

2
@MichaelFreidgeim คุณสามารถทำสิ่งที่ชอบvar current = partition.Currentก่อนหน้านี้await bodyและจากนั้นใช้currentในการต่อเนื่อง ( ContinueWith(t => { ... })
Ohad Schneider

43

คุณสามารถประหยัดความพยายามด้วยAsyncEnumerator NuGet Packageใหม่ซึ่งไม่ได้มีอยู่เมื่อ 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
Sergey คุณควรเปิดเผยว่าคุณเป็นผู้แต่งห้องสมุด
Michael Freidgeim

5
ตกลงเพิ่มข้อจำกัดความรับผิดชอบ ฉันไม่ได้แสวงหาผลประโยชน์ใด ๆ จากการโฆษณาเพียงต้องการช่วยผู้คน;)
Serge Semenov

ห้องสมุดของคุณไม่สามารถทำงานร่วมกับ. NET Core
คอร์นีเลียโนเบล

2
@CornielNobel มันเข้ากันได้กับ. NET Core - ซอร์สโค้ดบน GitHub มีการทดสอบครอบคลุมสำหรับทั้ง. NET Framework และ. NET Core
Serge Semenov

1
@SergeSemenov ฉันใช้ห้องสมุดของคุณเป็นจำนวนมากAsyncStreamsและฉันต้องบอกว่ามันยอดเยี่ยม ไม่สามารถแนะนำห้องสมุดนี้ได้เพียงพอ
WBuck

16

ตัดคำParallel.ForeachในTask.Run()และแทนการawaitใช้คำหลัก[yourasyncmethod].Result

(คุณต้องทำสิ่ง Task.Run เพื่อไม่ให้บล็อกเธรด UI)

บางสิ่งเช่นนี้

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ทำงานแบบขนานซึ่งบล็อกจนกว่าจะเสร็จสิ้นทั้งหมดจากนั้นผลักสิ่งทั้งหมดไปยังเธรดพื้นหลังเพื่อให้มี UI ที่ตอบสนอง มีปัญหาอะไรไหม? อาจจะเป็นหนึ่งในหัวข้อการนอนหลับมากเกินไป แต่มันสั้นรหัสที่สามารถอ่านได้
ygoe

@ LonelyPixel ปัญหาเดียวของฉันคือมันโทรTask.Runเมื่อTaskCompletionSourceจะดีกว่า
Gusdor

1
@Gusdor Curious - ทำไมจะTaskCompletionSourceดีกว่า
Seafish

@ Seafish เป็นคำถามที่ดีที่ฉันต้องการฉันสามารถตอบ ต้องเป็นวันที่หยาบกร้าน: D
Gusdor

เพียงแค่อัปเดตสั้น ๆ ฉันกำลังมองหาสิ่งนี้อยู่ในขณะนี้เลื่อนลงเพื่อค้นหาโซลูชันที่ง่ายที่สุดและพบความคิดเห็นของฉันอีกครั้ง ฉันใช้รหัสนี้ทุกประการและทำงานตามที่คาดไว้ เพียงถือว่ามีรุ่น Sync ของการโทร Async ดั้งเดิมภายในลูป awaitสามารถย้ายที่ด้านหน้าเพื่อบันทึกชื่อตัวแปรพิเศษ
ygoe

7

สิ่งนี้ควรมีประสิทธิภาพและง่ายกว่าการทำให้ TPL Dataflow ทำงานได้อย่างสมบูรณ์:

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 () เพื่อเรียกใช้รหัส async ของคุณในบริบทการซิงค์ แต่มีความคล้ายคลึงกันดังนี้

 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);
    }
}
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.