65.9K
CodeProject 正在变化。 阅读更多。
Home

迭代项目列表的不同方法之间的比较

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (9投票s)

2011 年 3 月 8 日

CPOL

10分钟阅读

viewsIcon

36933

downloadIcon

182

比较迭代项目列表的不同方法,并找出最有效的方法

引言

本文旨在考察遍历项目列表的不同方法,并比较使用单线程或多线程以不同方式遍历列表所需的时间。

此类循环的预期用途包括为一组交易定价、解决一些复杂的数学问题、从一系列远程计算机下载文件或其他类似应用。

本文不涵盖需要多线程方法的视觉应用,例如需要后台工作线程的 Winforms 应用程序,因为在这种情况下需要后台线程来确保界面在后台执行某些工作(例如复制大文件)时保持响应。

正在考察的方法有:

  1. 单线程上的传统 for 循环
  2. 为每个项目启动新线程的传统 for 循环
  3. 使用传统任务池
  4. 使用新的任务并行库 for 循环

背景

本文的背景是我在网上寻找一篇文章,希望能提供有关比较以上每种方法运行时间的统计数据,而不是依赖我的直觉。由于缺乏此类文章,我编写了一个程序自己进行测试,并决定分享。

线程循环测试类测试

我们首先需要编写一个测试,展示我们将如何以四种方式之一执行测试方法。

该测试位于名为 LoopExecution.Test 的项目中。

测试如下:

1. [TestMethod]
2. public void Assert_That_Loops_Run()
3. {
4.     List<int> listOfInt = new List<int>();
5.     int numberOfTimesToRun = 32;
6.     for (int i = 0; i < numberOfTimesToRun; i++)
7.    {
8.         listOfInt.Add(i);
9.     }
10. 
11.     LoopExecution<int> loopExecution = new LoopExecution<int>();
12. 
13.     loopExecution.ExecutionMethod += 
	new LoopExecution<int>.MethodToExecute(loopExecution_ExecutionMethod);
14.     loopExecution.CollectionToIterateOver = listOfInt;
15. 
16.     loopExecution.Execute();
17. 
18.     Assert.IsTrue(loopExecution.NormalLoopTime > 0);
19.     Assert.IsTrue(loopExecution.ThreadLoopTime > 0);
20.     Assert.IsTrue(loopExecution.ThreadPoolLoopTime > 0);
21.     Assert.IsTrue(loopExecution.TaskLoopTime > 0);
22. }

测试的第一部分(第 4-9 行)创建了我们将要迭代的列表。我选择使用一个简单的 INT 列表,以便使测试更容易理解和简单。

在第 11 行,我们创建了将执行循环迭代的对象。这是一个泛型类型,因此可以用于测试所有不同的类。

第 13 行将一个事件分配给该类,该事件将在循环的每次迭代中被调用。

下面是测试中使用的代码。它使用了标准的 .NET 事件签名,并且该方法所做的只是写入一个 Debug 消息。

1. void loopExecution_ExecutionMethod
	(object sender, LoopExecution<int>.LoopExecutionEventArgs e)
2.         {
3.    Debug.WriteLine("{0}number{1}.Threadname:{2}.Objectoperatingon{3}",
	e.MethodName,e.LoopExecutionNumber,Thread.CurrentThread.ManagedThreadId,
	e.ObjectToOperateOn);
4. }

需要注意的一个有趣点是,作为事件参数类的一部分,我传递了循环当前迭代的对象。这意味着您可以访问该对象以执行操作。

在测试的第 14 行,将要迭代的列表分配给循环类,然后在第 16 行,我们调用 execute。

为了检查所有不同的循环方法是否都已执行以及为每种方法设置的执行时间,在测试结束时,我会断言每种方法的执行时间都大于零。断言没有特定的值,因为在不同的机器上以及取决于后台运行的内容,您可能会得到不同的结果,但是所有结果都应该大于零。可以通过使用委托设置一个标志来改进断言,该标志指示对于特定方法和迭代,该方法已被调用。

线程循环类

循环执行的主要代码位于名为 LoopExecution.Library 的项目中。

在查看运行循环测试的类的主体之前,我将首先介绍由每种方法触发的事件。

1. public delegate void MethodToExecute(object sender, LoopExecutionEventArgs e);
2. public event MethodToExecute ExecutionMethod;
3. 
4. protected void OnExecuteMethod(object sender, LoopExecutionEventArgs e)
5. {
6.     if (ExecutionMethod != null)
7.     {
8.         ExecutionMethod(sender, e);
9.     }
10. }

这是处理 .NET 事件的一种相当标准的方式,因此我将不再详细介绍。唯一有趣的部分是事件参数。它被定义为一个内部类。这样做的原因是我想确保它使用了主类泛型中定义的相同类型。

Loop Execution Event Args 类如下所示:

1.  public class LoopExecutionEventArgs : EventArgs
2. {
3.     public String MethodName { get; set; }
4.     public T ObjectToOperateOn { get; set; }
5.     public int LoopExecutionNumber { get; set; }
6. }

在第 4 行,类型 T 在主类中定义。这只是一个普通的事件类,额外的信息包括执行它的方法、来自循环的当前对象以及在循环中的当前位置。

主线程循环类

主类的类签名如下:

1. public class LoopExecution<t>
2. {
3. …
4. }

这允许我们通过泛型类型来设置我们将与之交互的对象类型。

现在我们来重点看一下。将运行每个循环并给出每个循环时间的が主要方法。

1. public void Execute()
2. {
3.     NormalExecute();
4.     ThreadExecute();
5.     ThreadPoolExecute();
6.     TaskParallelExecute();
7. }

所以这里没有什么真正令人兴奋的。此方法所做的只是调用每种循环类型的四种方法。这以清晰的代码格式呈现,以便您一眼就能了解该方法的目的。

普通循环

要检查的第一部分是普通循环。它包括两个方法,如下所示:

1. private void NormalExecute()
2. {
3.     watch.Start();
4.     NormalWhileLoop();
5.     NormalLoopTime = watch.ElapsedMilliseconds;
6.     watch.Reset();
7. }
8. 
9. private void NormalWhileLoop()
10. {
11.     for (int i = 0; i < this.CollectionToIterateOver.Count; i++)
12.     {
13.         LoopExecutionEventArgs e = new LoopExecutionEventArgs();
14.         StackFrame stackFrame = new StackFrame();
15.         e.MethodName = stackFrame.GetMethod().ToString();
16.         e.LoopExecutionNumber = i;
17.         e.ObjectToOperateOn = CollectionToIterateOver[i];
18. 
19.         OnExecuteMethod(this, e);
20.     }
21. }

这应该很容易理解。第一个方法使用一个名为 watch 的字段,它只是一个 System.Diagnostics.Stopwatch ,用于标记此循环运行时间的开始和结束。

第二个方法只是一个简单的循环,它迭代列表中的所有项,并使用当前项,然后创建一个 LoopExecutionEventArgs ,包含当前方法和迭代次数。循环所做的只是通过上面看到的 OnExecuteMethod 执行事件回调。

线程循环

程序的下一部分与普通循环类似,但事件回调不是在同一线程上执行,而是创建一个新线程并在该线程上运行事件回调。

1. private void ThreadExecute()
2. {
3.     watch.Start();
4.     ThreadLoop();
5.     ThreadLoopTime = watch.ElapsedMilliseconds;
6.     watch.Reset();
7. }
8. 
9. private void ThreadLoop()
10. {
11. 
12.     for (int i = 0; i < this.CollectionToIterateOver.Count; i++)
13.     {
14.         Thread t = new Thread(x =>
15.         {
16.             LoopExecutionEventArgs e = new LoopExecutionEventArgs();
17.             StackFrame stackFrame = new StackFrame();
18.             e.MethodName = stackFrame.GetMethod().ToString();
19.             e.LoopExecutionNumber = (int)x;
20.             e.ObjectToOperateOn = CollectionToIterateOver[(int)x];
21.             OnExecuteMethod(this, e);
22.         });
23.         t.Start(i);
24.     }
25. }

如您所见,第一个方法与普通循环的第一个方法相同,唯一的区别是将其设置为 ThreadLoop。第二个方法只是使用一个匿名方法来调用事件,然后启动线程。

线程池

接下来的方法利用了线程池。用于此的方法是:

1. private void ThreadPoolExecute()
2. {
3.     watch.Start();
4.     ThreadPoolLoop();
5.     ThreadPoolLoopTime = watch.ElapsedMilliseconds;
6.     watch.Reset();
7. }
8. 
9. private void ThreadPoolLoop()
10. {
11.     int toProcess = this.CollectionToIterateOver.Count;
12.     using (ManualResetEvent resetEvent = new ManualResetEvent(false))
13.     {
14.         for (int i = 0; i < this.CollectionToIterateOver.Count; i++)
15.         {
16.             ThreadPool.QueueUserWorkItem(new WaitCallback(x =>
17.             {
18.                 LoopExecutionEventArgs e = new LoopExecutionEventArgs();
19.                 StackFrame stackFrame = new StackFrame();
20.                 e.MethodName = stackFrame.GetMethod().ToString();
21.                 e.LoopExecutionNumber = (int)x;
22.                 e.ObjectToOperateOn = CollectionToIterateOver[(int)x];
23.                 OnExecuteMethod(this, e);
24.                 if (Interlocked.Decrement(ref toProcess) == 0)
25.                     resetEvent.Set();
26. 
27.             }
28.                             ), i);
29.         }
30.         resetEvent.WaitOne();
31.     }
32. }

第一个方法与我们在其他所有情况下看到的相同。第二个方法更有趣。由于我们使用的是标准的 .NET 线程池,因此存在潜在的竞态条件。线程池将每个工作线程启动为后台进程。这意味着我们需要强制所有线程完成执行,然后应用程序才能继续执行,否则我们将得到此方法执行时间的错误读数。

这是通过使用 ManualResetEvent 并在第 30 行强制其等待来实现的。通过在第 25 行设置事件来运行,直到所有线程都完成运行。第 25 行仅在 toProcess 达到零时调用。toProcess 在第 11 行被设置为列表中的项目数。在这种情况下使用匿名方法的一个优点是,您不需要将 toProcess 的引用传递进去,因为匿名方法可以使用它。

Task Parallel Library

最后一个方法使用了 .NET 4.0 的新任务并行库。

方法如下:

1. private void TaskParallelExecute()
2. {
3.     watch.Start();
4.     TaskParallelLibraryLoop();
5.     TaskLoopTime = watch.ElapsedMilliseconds;
6.     watch.Reset();
7. }
8. 
9. private void TaskParallelLibraryLoop()
10. {
11.     Parallel.For(0, this.CollectionToIterateOver.Count, i =>
12.         {
13.             LoopExecutionEventArgs e = new LoopExecutionEventArgs();
14.             StackFrame stackFrame = new StackFrame();
15.             e.MethodName = stackFrame.GetMethod().ToString();
16.             e.LoopExecutionNumber = i;
17.             e.ObjectToOperateOn = CollectionToIterateOver[i];
18.             OnExecuteMethod(this, e);
19.         })
20.     ;
21. }

第一个方法与其他三个相同,但第二个方法的使用可能对某些人来说是陌生的。根据 MSDN,Parrallel.For 的方法签名是:

public static ParallelLoopResult For(
        int fromInclusive,
        int toExclusive,
        Action<int> body
)

其中 body 是一个为每次迭代执行的委托。

该方法将一个 int (表示将要执行的当前迭代)传递给委托。在这种情况下,委托是一个用于调用执行我们工作的委托的匿名方法。

执行矩阵

用于运行输出矩阵结果的应用程序的所有代码都位于 LoopExecution.Application 中。这目前是一个控制台应用程序,它会提示您希望运行循环的次数,以增量步骤(一次一个)以及用于测试应用程序的两个当前类型的委托。未来的版本有望拥有 GUI,并能够动态加载自定义对象和方法来处理。

计算机规格

为了完整起见,用于运行此应用程序的测试计算机是一台 Intel Core 2 Duo T600 @ 2.00GHz 2.00GHz,配备 3.00GB RAM 和 Windows 7 Home Premium SP1 32 位操作系统。

现在已经解释了用于执行矩阵的类,让我们运行几次并查看结果。

简单测试

第一个测试将使用只向控制台写入的方法。该方法如下所示:

1. static void loopExecution_ExecutionMethodConsoleWriteLine
	(object sender, LoopExecution<int>.LoopExecutionEventArgs e)
2. {
3.     Console.WriteLine("{0} number {1}. Thread name : {2}", 
	e.MethodName, e.LoopExecutionNumber, Thread.CurrentThread.ManagedThreadId);
4. }

这只是一个简单的打印行。

如果我们运行 128 次,将得到以下结果:

Total run time for Normal loop 2210ms 
Total run time for Thread loop 53663ms 
Total run time for Thread Pool 3255 ms 
Total run time for Task Parallel Library 3250ms 

此结果的完整内容可以在 ThreadReultsWriteLine.csv 中找到。

从这些结果中可以注意到几点。

首先,尽管在某些运行中并未在结果中显示,但任务并行库的运行速度确实比普通循环快,但在整个运行过程中,普通循环运行得更快。

第二点也是最明显的一点是,将进程放在单独的线程上并不总是明智的。原因是启动新线程的开销大于实际执行委托所需的时间。

模拟工作测试

为了有一个模拟实际执行耗时工作的委托的示例,使用了以下委托再次运行测试 128 次。

1. static void loopExecution_ExecutionMethodSleep
	(object sender, LoopExecution<int>.LoopExecutionEventArgs e)
2. {
3. 
4.     if (e.LoopExecutionNumber % 11 == 0)
5.     {
6.         Thread.Sleep((int)(e.LoopExecutionNumber * 0.149) + 1);
7.     }
8.     else if (e.LoopExecutionNumber % 23 == 0)
9.     {
10.         Thread.Sleep((int)(e.LoopExecutionNumber * 0.344));
11.     }
12.     Console.WriteLine("{0} number {1}. Thread name : {2}", 
	e.MethodName, e.LoopExecutionNumber, Thread.CurrentThread.ManagedThreadId);
13. }

以上方法仅根据当前迭代使执行线程休眠。这些数字是随机选择的,但我以这样的间隔并带有乘法因子进行选择,以便测试不会花费太长时间。此外,在运行实时应用程序时,完成对象任务所需的时间可能因其访问的是网络共享、数据库、互联网、本地磁盘还是内存对象而异。

如果我们运行 128 次,将得到以下结果:

Total run time for Normal loop 11838ms 
Total run time for Thread loop 63148ms 
Total run time for Thread Pool 6150ms 
Total run time for Task Parallel Library 5110ms 

此测试的完整结果可以在 ThreadReultsSleep.csv 中找到。

这里最有趣的数据是,在这种情况下,使用预创建线程或任务的两种方法是最快的。这是因为,当我们让线程休眠时,其他线程将得到处理,而对于普通循环,由于只有一个线程,它必须等待休眠完成才能继续进行下一次迭代。

另一个值得注意的数据是,线程池和任务并行库之间没有太大区别,尽管如果使用更多线程,这种区别会扩大。然而,除了任务并行库速度更快之外,如果您仔细检查代码,任务并行库会更易于阅读、理解和使用。使用此方法的另一个好处是,您无需担心所有线程在继续应用程序的下一阶段之前完成。因此,从维护的角度来看,任务并行库胜出。

结论

因此,总而言之,对于一个实际程序,如果执行的操作是一个快速操作,那么直接选择多线程选项是没有意义的。

但是,如果执行的操作是计算密集型的,或者依赖于其他外部因素导致线程处于阻塞状态,那么使用多线程应用程序是值得的。

历史

  • 2011 年 3 月 8 日:初始发布
© . All rights reserved.