关于延续任务的Task.WaitAll()只会延迟原始任务的执行?

时间:2014-02-25 20:01:04

标签: c# .net multithreading task-parallel-library

背景

我有一个控制台应用程序,它创建Tasks来处理来自数据库的数据(我们称之为Level1任务)。每个任务都会再次创建自己的任务,以处理分配给它的每个数据部分(Level2任务)。

每个Level2任务都有一个与之关联的继续任务,以及用于在继续任务上执行WaitAll的代码,然后再继续。

我在.NET 4.0(没有async / await

问题:

这造成了一个问题 - 事实证明,如果以这种方式完成,则在调度所有可用的Level1任务之前,没有启动任何Level2任务。这在任何方面都不是最佳的。

问题:

这似乎可以通过更改代码来等待原始Level2任务及其继续任务来解决。但是,我不完全确定为什么会这样。

你有什么想法吗?

我唯一能想到的是 - 由于延续任务还没有开始,所以没有必要等待它完成。但即使是这种情况,我也希望至少有一些Level2任务已经启动。他们从未做过。

示例:

我创建了一个示例控制台应用程序,它确实证明了这种行为:

  1. 按原样运行,您将看到它首先安排所有任务,然后才开始从Level2任务中获取实际行。

  2. 但是注释掉标记的代码块并取消注释替换,所有工作都按预期工作。

  3. 你能告诉我为什么吗?

    public class Program
    {
        static void Main(string[] args)
        {
            for (var i = 0; i < 100; i++)
            {
                Task.Factory.StartNew(() => SomeMethod());
                //Thread.Sleep(1000);
            }
    
            Console.ReadLine();
        }
    
        private static void SomeMethod()
        {
            var numbers = new List<int>();
    
            for (var i = 0; i < 10; i++)
            {
                numbers.Add(i);
            }
    
            var tasks = new List<Task>();
    
            foreach (var number in numbers)
            {
                Console.WriteLine("Before start task");
    
                var numberSafe = number;
    
                /* Code to be replaced START */
    
                var nextTask = Task.Factory.StartNew(() =>
                {
                    Console.WriteLine("Got number: {0}", numberSafe);
                })
                    .ContinueWith(task =>
                    {
                        Console.WriteLine("Continuation {0}", task.Id);
                    });
    
                tasks.Add(nextTask);
    
                /* Code to be replaced END */
    
                /* Replacement START */
    
                //var originalTask = Task.Factory.StartNew(() =>
                //{
                //    Console.WriteLine("Got number: {0}", numberSafe);
                //});
    
                //var contTask = originalTask
                //    .ContinueWith(task =>
                //    {
                //        Console.WriteLine("Continuation {0}", task.Id);
                //    });
    
                //tasks.Add(originalTask);
                //tasks.Add(contTask);
    
                /* Replacement END */
            }
    
            Task.WaitAll(tasks.ToArray());
        }
    }
    

4 个答案:

答案 0 :(得分:4)

我认为您正在看到Task Inlining行为。引自MSDN

  

在某些情况下,当等待任务时,可以在执行等待操作的线程上同步执行。这提高了性能,因为它可以通过利用已经阻塞的现有线程来防止需要额外的线程,否则。为了防止由于重入引起的错误,只有在相关线程的本地队列中找到等待目标时才会发生任务内联。

你不需要100个任务才能看到这个。我已经修改了你的程序,有4个1级任务(我有四核CPU)。每个1级任务只创建一个2级任务。

static void Main(string[] args)
{
    for (var i = 0; i < 4; i++)
    {
        int j = i;
        Task.Factory.StartNew(() => SomeMethod(j)); // j as level number
    }
}

在原始程序中,nextTask是继续任务 - 所以我只是简化了方法。

private static void SomeMethod(int num)
{
    var numbers = new List<int>();

    // create only one level 2 task for representation purpose
    for (var i = 0; i < 1; i++)
    {
        numbers.Add(i);
    }

    var tasks = new List<Task>();

    foreach (var number in numbers)
    {
        Console.WriteLine("Before start task: {0} - thread {1}", num, 
                              Thread.CurrentThread.ManagedThreadId);

        var numberSafe = number;

        var originalTask = Task.Factory.StartNew(() =>
        {
            Console.WriteLine("Got number: {0} - thread {1}", num, 
                                    Thread.CurrentThread.ManagedThreadId);
        });

        var contTask = originalTask
            .ContinueWith(task =>
            {
                Console.WriteLine("Continuation {0} - thread {1}", num, 
                                    Thread.CurrentThread.ManagedThreadId);
            });

        tasks.Add(originalTask); // comment and un-comment this line to see change in behavior

        tasks.Add(contTask); // same as adding nextTask in your original prog.

    }

    Task.WaitAll(tasks.ToArray());
}

以下是示例输出 - 评论tasks.Add(originalTask); - 这是您的第一个阻止。

Before start task: 0 - thread 4
Before start task: 2 - thread 3
Before start task: 3 - thread 6
Before start task: 1 - thread 5
Got number: 0 - thread 7
Continuation 0 - thread 7
Got number: 1 - thread 7
Continuation 1 - thread 7
Got number: 3 - thread 7
Continuation 3 - thread 7
Got number: 2 - thread 4
Continuation 2 - thread 4

一些示例输出 - 保持tasks.Add(originalTask);这是你的第二个块

Before start task: 0 - thread 4
Before start task: 1 - thread 6
Before start task: 2 - thread 5
Got number: 0 - thread 4
Before start task: 3 - thread 3
Got number: 3 - thread 3
Got number: 1 - thread 6
Got number: 2 - thread 5
Continuation 0 - thread 7
Continuation 1 - thread 7
Continuation 3 - thread 7
Continuation 2 - thread 4

正如你在第二种情况下看到的,当你在启动它的同一个线程上等待originalTask时,task inlining将使它在同一个线程上运行 - 这就是为什么你看到{{1早先的消息。

答案 1 :(得分:2)

您的代码存在的问题是阻止 Task.WaitAll(tasks.ToArray())。默认的TPL任务计划程序不会为您使用Factory.StartNew开始的每个任务使用新的池线程。然后你启动100个Level1任务,每个任务用Task.WaitAll阻止一个线程。

这造成了瓶颈。使用默认大小ThreadPool,我得到〜20个并发运行的线程,其中只有4个实际同时执行(CPU核心数)。

因此,一些任务将仅排队,并将在稍后开始,因为早期任务已完成。要了解我的意思,请尝试更改您的代码:

static void Main(string[] args)
{
    for (var i = 0; i < 100; i++)
    {
        Task.Factory.StartNew(() => SomeMethod(), 
            TaskCreationOptions.LongRunning);
    }

    Console.ReadLine();
}

TaskCreationOptions.LongRunning会为您提供所需的行为,但这当然是错误的解决方案。

正确解决方案是尽可能避免阻止代码。如果你必须全部完成,你应该只在最高级别进行阻塞等待。

要解决此问题,您的代码可以重新计算,如下所示。请注意ContinueWhenAllUnwrap和(可选)ExecuteSynchronously的使用,这有助于消除阻塞代码并减少所涉及的池线程数。这个版本的表现要好得多。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public class Program
{
    static void Main(string[] args)
    {
        var tasks = new List<Task>();

        for (var i = 0; i < 100; i++)
        {
            tasks.Add(Task.Factory.StartNew(() => SomeMethod(i)).Unwrap());
        }

        // blocking at the topmost level
        Task.WaitAll(tasks.ToArray());

        Console.WriteLine("Enter to exit...");
        Console.ReadLine();
    }

    private static Task<Task[]> SomeMethod(int n)
    {
        Console.WriteLine("SomeMethod " + n);

        var numbers = new List<int>();

        for (var i = 0; i < 10; i++)
        {
            numbers.Add(i);
        }

        var tasks = new List<Task>();

        foreach (var number in numbers)
        {
            Console.WriteLine("Before start task " + number);

            var numberSafe = number;

            var nextTask = Task.Factory.StartNew(() =>
            {
                Console.WriteLine("Got number: {0}", numberSafe);
            })
            .ContinueWith(task =>
            {
                Console.WriteLine("Continuation {0}", task.Id);
            }, TaskContinuationOptions.ExecuteSynchronously);

            tasks.Add(nextTask);
        }

        return Task.Factory.ContinueWhenAll(tasks.ToArray(), 
            result => result, TaskContinuationOptions.ExecuteSynchronously);
    }
}

理想情况下,在现实生活中,您应尽可能坚持使用自然异步API(例如"Using SqlDataReader’s new async methods in .Net 4.5"),并使用Task.Run / Task.Factory.StartNew仅适用于CPU绑定的计算任务。对于服务器端应用程序(例如,ASP.NET Web API),Task.Run / Task.Factory.StartNew通常只会增加冗余线程切换的开销。它不会加速HTTP请求的完成,除非您确实需要并行执行多个CPU绑定作业,这会损害可伸缩性。

我理解以下可能不是一个可行的选项,但我强烈建议升级到VS2012 +并使用async/await来实现这样的逻辑。这非常值得投资,因为它大大加快了编码过程并生成更简单,更清晰且更不容易出错的代码。您仍然可以使用Microsoft.Bcl.Async来定位.NET 4.0。

答案 2 :(得分:1)

如果我没记错的话,等待尚未安排的任务可以同步执行。 (参见here)在另一种情况下,这种行为将适用于您的代码,这并不奇怪。

请记住,线程行为是高度依赖于实现和机器的,这里发生的事情可能就是这样:

  • 鉴于调用Task.StartNew和任务实际执行到线程池之间的延迟,大多数所谓的“1级”任务(如果不是全部的话)都安排在第一个之前他们真的被执行了。
  • 由于默认任务调度程序使用.NET ThreadPool,因此此处调度的所有任务都可能在ThreadPool线程上执行。
  • 执行“1级”任务后,调度队列全部填入“1级”任务。
  • 每次执行“1级”任务时,它会根据需要安排任意数量的“2级”任务,但这些任务都是在“1级”任务之后安排的。
  • 当“Level 1”任务到达等待“Level 2”任务的所有延续时,执行线程进入等待状态。
  • 当许多ThreadPool线程处于等待状态时,程序迅速达到ThreadPool饥饿,迫使ThreadPool分配新线程(总共可能超过100个)来解决饥饿问题
  • 一旦“Level 1”任务的最后一个调用等待状态,ThreadPool将至少再分配一个额外的线程。
  • 这最后分配的额外线程现在可以首次执行“Level 2”任务及其继续,因为所有“Level 1”任务都已完成。
  • 一段时间后,一个“1级”任务将完成他所有的“2级”任务。然后,这个“1级”任务将从等待中唤醒并完成其执行,从而释放另一个ThreadPool线程,并加速执行剩余的“2级”任务并继续执行。

当您使用替代方法时会发生什么变化,因为您在要等待的任务数组中直接引用“Level 2”任务,Task.WaitAll方法有机会同步执行“Level 2”任务而不是闲着。 在初始情况下不会发生这种情况,因为延续任务无法同步运行。

总而言之,在ThreadPool线程中等待会导致线程饥饿以及您观察到的奇怪行为。虽然等待任务的代码中的优化使线程饥饿行为逐渐消失,但显然不是你应该依赖的东西。

为了解决你最初的问题,你最好按照lil-raz的建议去除你的内心任务。

如果您有权访问C#5.0,您还可以考虑使用async / await模式编写代码,而不必依赖等待。

答案 3 :(得分:0)

我不得不说这段代码真的不乐观,因为你创建了100个任务并且它并不意味着你将拥有100个线程,并且在每个任务中你创建了两个新任务,你就超过了调度程序。如果这些任务与数据库读取有关,为什么不将它们标记为长处理并丢弃内部任务?