任务和任务并行库 (TPL):多线程变得容易






4.82/5 (55投票s)
让我们来了解一下从旧的多线程世界到 Task Parallel Library(简称 TPL)的演变。在哪些场景下,你会选择利用 TPL 而不是自己创建线程?TPL 真正具有优势的方面有哪些?
引言
Task 作为 .Net 框架类库(FCL)的一部分发布以来已经很久了,它作为线程池线程的一个包装。现在,微软强烈建议几乎所有的多线程后台运行工作都使用 Task,除非有非常特殊的情况。因此,理解为什么以及如何使用 Task 比直接使用 Thread 类更好,是非常有意义的。从多线程的角度来看,使用 Task 时我们可以利用哪些优势或新功能?为了熟练使用这两种方式,需要对 Thread 和 Task 进行逐点比较。
关于附件代码的说明
要在您的计算机上运行附件代码,您需要以下软件作为先决条件:
- Visual Studio 2012 或更高版本
我希望读者能够充分了解以下内容,以便轻松阅读本文:
- C# 语言基础
- 使用 .Net 框架创建和运行 Windows 窗体应用程序的基础知识
- 使用 Visual Studio 2012(或更高版本)IDE 的基本知识。
- 操作系统概念,如线程调度和上下文切换。
对线程、任务和 .Net 中的 Task Parallel Library (TPL) 的基本了解将是一个加分项,尽管我将尽我最大的努力在它们出现时解释基本术语。由于这是对旧线程世界和现代任务世界进行的比较研究,所以本文可能不适合从零开始理解 Tasks 和 Task Parallel Library 的 101 课程。如果您甚至没有听说过 task 或 TPL 这个词,我建议您在继续阅读我的文章之前,先浏览一下 这个 MSDN 文章。
背景 - 现代硬件上的多线程世界
自从我们从 DOS/Console 操作系统 (OS) 迁移到现代 GUI 操作系统以来,我们都知道可以并行处理多个工作。在过去,当只有单核 CPU 时,操作系统通过在 CPU 中调度并行运行的线程并使用各种调度算法(例如,Round Robin)在它们之间进行上下文切换,来提供并行工作的逻辑印象。现在,当现代硬件拥有多核 CPU 时,即您的计算机主板上的物理 CPU 内部核心数量超过 1 时,操作系统的调度算法得到了真正的提升,因为它拥有多个物理 CPU 来实际并行执行工作。
注意:请始终记住,拥有多个 CPU 和多个核心是不同的概念。对于多个 CPU,您的主板上将连接一个以上的物理 CPU(在家用 PC 或台式计算机上不常见)。对于多个核心,您的主板上只有一个 CPU,但由于有多个核心,您的 CPU 实际上可以并行执行多个指令。
多线程程序 - 它的真正含义
线程是操作系统调度的最小执行单元。任何程序或应用程序至少有一个线程,无论是您的 ASP .Net 应用程序还是控制台应用程序。如果您想更快地完成工作,您将不得不采用多个并行运行的线程,但当执行单元越多时,由于不正确的线程同步、数据损坏、死锁、无限等待、无响应 UI 等问题,出现混乱的可能性就越多(我本应说无限可能)。
您问任何程序员,他几乎肯定对多线程程序有一定的恐惧。单线程执行总是很顺利,但它们需要很长时间,因为有一个单一的执行线程负责按顺序(即一个接一个地,像队列一样)执行系统中发生的每一项工作。没有并行性,就像在物理世界中您可以驾驶汽车,同时通过蓝牙免提设备接听手机。
我的计算机的 CPU 配置
我的主板上有一个 CPU,它有四个 CPU 核心,如下面的快照所示。我在 Windows 任务管理器中处于性能选项卡。正如您所看到的,在 CPU 使用历史记录部分有四个块,代表四个物理核心。
线程 - FCL 中的演变
美好的旧时光 (.Net 1.1)
如果您曾经编写过一个基本的多线程程序,我们最终都会使用 System.Threading
命名空间中的 Thread
类。在这篇文章中,我将采用一个非常基本的问题,然后尝试将其从一个普通的线程应用程序演变成一个基于 Task (TPL) 的实现,并逐步添加更多的需求/约束/功能。这是我办公室里一位亲爱的同事给出的问题陈述:
开始编码
问题陈述:我想尽快计算前一百万个自然数的和。
您将如何解决它?
解决方案 #1 (非常基础):第一个默认的方式是使用 for 或 while 循环顺序地对所有元素求和,如下面的代码片段所示。可以通过单击附件代码中 Windows 窗体应用程序的标题为“Sum Using Simple For Loop”的按钮来调用它。
private void btnSumUsingFor_Click(object sender, EventArgs e)
{
ClearResultLabel();
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (var i = 0 ; i < 1000000; i++)
{
_grandTotal += i + 1;
}
stopwatch.Stop();
MessageBox.Show(String.Format("Sum total of all the numbers in the array is {0}. It took {1} miliseconds to perform this sum.", _grandTotal, stopwatch.ElapsedMilliseconds));
//resetting sumTotal for next button click
_grandTotal = 0;
}
然后我的朋友告诉我,这个顺序求和算法非常慢。我想要非常快地得到结果。真的很快!
解决方案 #2 (引入多线程):然后我开始运用我既懂算法又懂多线程的大脑。我找到了一个新的解决方案来加快执行速度。我想到可以将整个输入分成十个相等的部分。使用十个独立的线程并行计算这十个部分的子总和。一旦所有十个线程都准备好了部分总和,我们就可以将它们的输出相加得到最终总和。让我们在代码中实现它。可以通过单击附件代码中 Windows 窗体应用程序的标题为“Multi-Threading without thread Synchronization”的按钮来调用此函数。
private int _grandTotal;
//Some code has been removed for brevity and clarity.
//Please download attached code for complete reference
private void btnNormalThreading_Click(object sender, EventArgs e)
{
ClearResultLabel();
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
List<Thread> threadList = new List<Thread>();
for (var i = 0L; i < 10; i++)
{
var threadStart = new ParameterizedThreadStart(AddNumbersWithoutThreadSynchronization);
var thread = new Thread(threadStart);
thread.Start(i * 100000 + 1);
threadList.Add(thread);
}
foreach (var curthread in threadList)
{
//wait for each thread to finish one by one.
curthread.Join();
}
MessageBox.Show(String.Format("Sum total of all the numbers in the array is {0}. It took {1} miliseconds to perform this sum.", _grandTotal, stopwatch.ElapsedMilliseconds));
//resettting sumTotal for next button click
_grandTotal = 0;
}
private void AddNumbersWithoutThreadSynchronization(object lowerBound)
{
//Below line if uncommented will result in an invalidOperationException
//as lblTotal is owned by the UI thread and you can not updated it from a background thread.
//lblTotal.Text = "2";
var subTotal = 0L;
var counter = 0;
long temp = (long)lowerBound;
while (counter < 100000)
{
subTotal += temp;
temp++;
counter++;
}
_grandTotal += subTotal;
}
如果您在您的机器上运行该应用程序,您应该得到总和 500000500000。如果不是这样,请不要担心,我会解决这个问题,所以继续阅读。
关于性能的说明:简单直接的解决方案。不是吗?让我说明一点,在所有可能的情况下,这个实现多线程的新解决方案可能不会生成性能更好的应用程序,也就是说,多线程解决方案所需的时间可能更长。原因是每个线程执行的任务的大小和类型。在这里,我们试图做非常琐碎的添加自然数的任务,对于这种任务,简单的 for 循环可能表现最好。由于主要是算术计算,CPU 在这 10 个线程的所有工作中都处于积极状态。像数学计算这样涉及 CPU 的作业称为 CPU 绑定或 CPU 密集型作业。
您的应用程序可能执行许多其他类型的作业,例如 I/O 绑定作业,在这种作业中,您会联系设备驱动程序从磁盘读取数据,或者网络绑定作业,在这种作业中,您会联系网卡驱动程序通过网络读取数据。在这些 I/O 绑定或网络绑定任务中,CPU 完全不参与,而是空闲的。因此,当您混合 CPU 绑定和 I/O 绑定工作时,一些线程可以执行 CPU 绑定工作,而另一些可以执行 I/O 绑定工作。在这种情况下,CPU 需要以循环方式调度更少数量的线程,这最终会因为减少了操作系统称为上下文切换的开销而提高性能。您很快就会看到这一点。
顺便说一句,我在这篇文章中仅为演示目的选择了求自然数和这个初步的例子。在实际生活中,当您实际解决复杂问题时,例如处理磁盘上的多个 CSV 文件(I/O 绑定)或进行多个远程 Web 服务调用(网络绑定)等,这些工作将在单独的线程上执行,您将真正受益于并行性。
那么,有哪些原因会让多线程应用程序的性能变差呢?:
- 线程创建的开销:线程的创建会占用程序的执行时间。新建
Thread
类就是其中一项活动。 - 线程增加内存占用:每个线程最终都是一个数据结构,需要在主内存中保存一些信息,例如,线程在执行时会维护一个堆栈来保存 CPU 寄存器值、程序上下文数据等。因此,它会占用您主内存中的空间。与单线程应用程序相比,这会导致您的应用程序内存占用量更高。
- 昂贵的上下文切换:上下文切换是一个操作系统 (OS) 概念,其中您的 OS 尝试根据其遵循的调度算法,为系统中并行运行的所有线程分配 CPU 时间片。因此,所有线程逐个获得 CPU 时间片,当新线程需要抢占 CPU 中的位置时,就会发生上下文切换,即在 CPU 开始实际执行之前,必须移除当前线程的执行上下文,并将新线程的执行上下文放回原处。在上下文切换期间,必须为当前将被抢占的线程保存 CPU、内存寄存器、堆栈状态、CPU 缓存等的整个状态。因此,在实际工作发生的 CPU 周期之间,存在上下文切换周期,这也需要时间。虽然与实际 CPU 周期相比它很小,但如果应用程序中有太多相同优先级的线程,并且上下文切换频繁发生,那么它可能会产生影响。
您可以在下面的图片中自己看看,如果没有上下文切换的成本,一个假想的多线程世界会是什么样子。但实际上,操作系统必须承担上下文切换的成本,才能为用户提供多任务处理的感觉,这样他才能看到他并行执行的每个任务在一两秒后都有一些进展。这里我考虑了只有两个线程的情况。如果您增加了系统中的线程数,上下文切换的成本只会增长。
好了,继续,让我们先整理一下在将解决方案从单线程转换为多线程后出现的问题区域:
- 全局变量的风险使用:不幸的是,当您使用线程来调度计算工作或函数时,该函数的返回类型应该是
void
。 .Net v3.5 或之前的编程范例不允许您将返回某些值的函数分配给线程执行。因此,如果调用的函数打算返回某个值,您最终会使用全局变量来设置该值。不幸的是,全局变量具有全局作用域,即它们对整个类都是可访问的,并且容易被误用,也可能被另一个线程意外覆盖。因此,如果您在上面的**解决方案 #2** 中实际上得到了前一百万个自然数的正确和,那只是您运气好。由任何一个线程错误地更新全局变量都会导致结果错误。我们将在下一节中看到原因。 -
线程同步(上下文切换的陷阱):在大多数多线程程序中,您可能已经使用了**共享资源**。就像在本例中,
_grandTotal
变量是一个共享资源,您的应用程序的所有线程都可以访问它。每个启动的线程都在以累积(运行总计)的方式添加百万分之一的整数的子总和。如果共享资源没有为多线程环境正确同步,则总是会引起问题。例如,在这种情况下,想象一下如果两个线程同时完成了它们的子总和工作。线程 #1 创建了一个它被要求求和的范围的子总和,即subTotal1
。它读取当前_grandTotal
的值,即X
。现在,在线程 #1 可以将subTotal1
的值添加到X
之前,它根据操作系统的时间片轮转算法被 CPU 抢占了。与此同时,线程 #2 获得了 CPU,它再次读取_sumTotal
的值为X
。它创建了一个它被要求求和的范围的子总和,即sumTotal2
。将sumTotal2
添加到X
得到值Y
,然后将值 Y 写回_grandTotal
。然后线程 #1 再次获得 CPU 时间片,创建X
(_sumTotal
的陈旧值)和subTotal1
的总和,并将其写回_grandTotal
。因此,在两个线程执行完毕后,我们得到的值不是X
+subTotal1
+subTotal2
,而是X
+subTotal1
,这是由于线程同步问题造成的。这会搞乱整个算法的输出。您将在下一节“并非多线程世界中一切都不好——过去的辉煌遗产”中看到**解决方案 #3**,该节讨论了用于修复此问题的线程同步构造。 - UI 无响应:让我们来了解一下负责用户界面的主 GUI 线程的世界,用户在与应用程序交互时会与之交互。下图描绘了主 GUI 线程的痛苦(红色部分)。
在处理诸如从磁盘获取文件或单击按钮时从 Web 服务获取响应之类的工作时,您的应用程序卡住的情况有多常见。这是桌面应用程序中的常见场景。当我尝试用鼠标移动窗口时,请看应用程序标题栏中的**(未响应)**文本。
卡住的 UI 始终是单线程应用程序中的一个问题,当您将 GUI 线程一直用于完成所有工作,例如从远程服务器获取 Web 响应时。一旦您的程序进入此类请求,您的应用程序 UI 将完全卡住,如下面的代码所示。
private void btnLongRunningWork_Click(object sender, EventArgs e) { PerformLongWork(); } private void PerformLongWork() { //this simulates some long work like a web service call or //reading a huge from from disk Thread.Sleep(5000); //do some long work here }
在此期间,您的 Web 服务器响应,您的用户必须坐着不动,无处可点击您的 UI。您的 UI 在标题栏中显示“(未响应)”作为后缀文本,这表示 UI 无响应。在这种情况下,您无法单击按钮或使用鼠标或键盘与程序的 UI 进行交互。
两种重要场景:
情况 1:即使您尝试在此情况下采用传统的多线程(参见标题为“Long work with Threading”的按钮的单击事件处理程序)并将长期任务交给您的主 GUI 线程,这也没有帮助。为什么?因为在将工作交给新创建的线程后,您会调用新线程的Join
API 来等待该长期工作完成。现在您的 GUI 线程没有做繁重的工作。但不幸的是,它还没有空闲!看下面的代码片段:
private void btnLongWorkWithThreading_Click(object sender, EventArgs e) { var threadStart = new ThreadStart(PerformLongWork); var thread = new Thread(threadStart); thread.Start(); //Now main GUI thread starts waiting for the work to complete //GUI thread is still busy unable to do any real UI work thread.Join(); } private void PerformLongWork() { //this simulates some long work Thread.Sleep(5000); //do some long work here }
这实际上又导致 UI 卡住,因为您的 GUI 线程现在忙于等待另一个线程完成,而不是做实际工作。您的 GUI 线程没有空闲来刷新 UI 元素,因此您的 UI 对最终用户来说不是交互式的。因此,即使采用了多线程,您也未能使 UI 响应 :(。
情况 2:另外,如果您不自己创建线程,而是考虑使用 CLR 的线程池线程,那么您的情况会更糟糕。CLR 的线程池线程不提供任何句柄来调用Join
API 等待它们完成工作。因此,在这种情况下,您将使用 while 循环轮询一个全局变量标志,例如isThreadPoolWorkComplete
,以更改反映线程池线程完成工作的状态。这是代码片段。请参阅标题为“Long work with thread pool”的按钮的单击事件处理程序。public bool isThreadPoolWorkComplete = false; private void btnLongWorkWithThreadpool_Click(object sender, EventArgs e) { ThreadPool.QueueUserWorkItem(PerformLengthyWork, null); //Now main GUI thread starts waiting for the work to complete //GUI thread is still busy unable to do any real UI work while (!isThreadPoolWorkComplete) { Thread.Sleep(1000); //keep waiting as threadpool thread has not finished its job } } private void PerformLengthyWork(object input) { //this simulates some long work like a web service call or //reading a huge from from disk Thread.Sleep(5000); //do some long work here }
因此,您的主 GUI 线程将再次忙于执行您的 while 循环。所以,同样,这里没有办法让主 GUI 线程空闲以响应用户事件,例如按钮单击或刷新 UI 以显示工作进度条。
- 非协作式取消:我的朋友想要 UI 上的一个取消按钮,如下面的快照所示,如果求和操作花费的时间太长,他将使用该按钮停止程序执行(可怜的家伙,他不知道 UI 会卡住,因为他永远无法点击按钮,因为 UI 会因为上述问题 #3 而卡住:D)。
因此,如果您实现了多线程解决方案,您肯定想要一种方法来停止/杀死线程以停止其执行。线程支持使用其 Abort API 进行取消。Abort API 不够优雅,因为它不会以优雅的方式停止线程的执行,而是 CLR 会引发ThreadAbortException
来停止执行,该异常会贯穿当前在该线程上执行的函数的调用堆栈。想象一下引发异常来优雅地终止工作以停止执行。听起来有矛盾,不是吗?但这正是它在 .Net 框架中的实现方式。您可以阅读有关此异常类的更多详细信息,请访问 此处。
并非多线程世界中一切都不好——过去的辉煌遗产
解决方案 #3 (带有线程同步的多线程):当然,旧的多线程世界因为线程同步而有点痛苦,但至少**全局变量的风险使用**问题可以在正常线程世界中通过使用 Interlocked.Add
或 lock
关键字等线程同步构造来解决。不用说,即使在任务出现之前,多线程世界也一直在处理诸如共享资源争用、竞态条件和死锁等问题。在我们的例子中,正如我们在上一节中讨论的,共享资源 _grandTotal
很可能会被并行运行的线程滥用。用部分总和增量全局变量是一个关键事件,一次应该只有一个线程执行此操作,以避免覆盖或陈旧读取。
因此,如果解决方案 #2 给您带来了错误的输出,您应该检查标题为“Multi-Threading With Synchronization”的按钮的单击事件处理程序中使用的算法,该算法使用 Interlocked.Add
线程同步构造来安全地更新全局变量 _grandTotal
。这是供您参考的代码片段:
private long _grandTotal; //Some code has been removed for brevity and clarity. //Please download attached code for complete reference private void btnWithSync_Click(object sender, EventArgs e) { ClearResultLabel(); Stopwatch stopwatch = new Stopwatch(); stopwatch.Start(); long counter = 0; List<Thread> threadList = new List<Thread>(); for (var i = 0L; i < 10; i++) { var threadStart = new ParameterizedThreadStart(AddNumbersWithSynchronization); var thread = new Thread(threadStart); thread.Start(i * 100000 + 1); threadList.Add(thread); } foreach (var threadf in threadList) { //wait for each thread to finish one by one. threadf.Join(); } stopwatch.Stop(); MessageBox.Show(String.Format("Sum total of all the numbers in the array is {0}. It took {1} miliseconds to perform this sum.", _grandTotal, stopwatch.ElapsedMilliseconds)); //resettting _grandTotal for next button click _grandTotal = 0; } private void AddNumbersWithSynchronization(object lowerBound) { var subTotal = 0L; var counter = 0; long temp = (long)lowerBound; while (counter < 100000) { subTotal += temp; temp++; counter++; } Interlocked.Add(ref _grandTotal, subTotal); }
关于性能的说明:请始终记住,线程同步也伴随着性能成本,因为现在所有线程都不能盲目地去更新全局变量。线程同步构造确保一次只有一个线程进行更新。如果两个线程同时到达,其中一个线程将不得不等待,以便在更新之前看到由另一个线程更新的最新值。
此解决方案的性能甚至可能比前两个更差,因为大部分工作都是 CPU 绑定的,导致所有 10 个线程的上下文切换,并且您正在使用带有线程同步的多线程。因此,您将支付所有三者的成本——线程的创建和管理、CPU 上下文切换和线程同步。
介绍 TPL 世界中的 Tasks
现在,让我们逐一解决所有障碍。可能我无法用几段话量化传统多线程世界的真正痛苦,但我敢肯定,一旦您开始使用 Tasks,您就会体会到事情变得多么容易。如果您长期使用过线程,那么我相信在完成本文后,您会认同我的观点。Task
是 System.Threading.Tasks
命名空间中的一个引用类型,位于 mscorlib.dll
中,任何 C# 项目都会隐式引用它。
现在,我将把我们多线程的**解决方案 #2** 转换为一个使用 Task Parallel Library 中提供的 Task
的程序。在查看代码后,我们将再次审查我们的痛点,看看它们是否仍然存在,还是随风而逝 :)。这是相关的代码片段:
//That Global variable isn't missing from this code snippet. I actually didn't need them in my TPL
//based imnplementaion using tasks.
//Some code has been removed for brevity and clarity.
//Please download attached code for complete reference
private void btnWithTasks_Click(object sender, EventArgs e)
{
ClearResultLabel();
long degreeofParallelism = 10;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
long lowerbound = 0;
long upperBound = 0;
List<Task<long>> tasks = new List<Task<long>>();
long countOfNumbersToBeAddedByOneTask = 100000; //1 lakh
for (int spawnedThreadNumber = 1; spawnedThreadNumber <= degreeofParallelism; spawnedThreadNumber++)
{
lowerbound = ++upperBound;
upperBound = countOfNumbersToBeAddedByOneTask * spawnedThreadNumber;
//copying the values to be passed to task in local variables to avoid closure variable
//issue. You can safely ignore this concept for now to avoid a detour. For now you
//can assume I've done bad programming by creating two new local variables unnecessarily.
var lowerLimit = lowerbound;
var upperLimit = upperBound;
tasks.Add(Task.Run(() => AddNumbersBetweenLimits(lowerLimit, upperLimit)));
}
Task.WhenAll(tasks).ContinueWith(task => CreateFinalSum(tasks));
stopwatch.Stop();
MessageBox.Show("time taken to do sum operation (in miliseconds) : " + stopwatch.ElapsedMilliseconds);
}
private static void CreateFinalSum(List<Task<long>> tasks)
{
var finalValue = tasks.Sum(task => task.Result);
MessageBox.Show("Sum is : " + finalValue);
}
private static long AddNumbersBetweenLimits(long lowerLimitInclusive, long upperLimitInclusive)
{
long sumTotal = 0;
for (long i = lowerLimitInclusive; i <= upperLimitInclusive; i++)
{
sumTotal += i;
}
return sumTotal;
}
让我们根据 TPL 的实现评估传统多线程程序的缺点。它们还存在吗?
- 全局变量的风险使用 -> 不再! Task 可以执行具有非 void 返回类型的异步函数,这在以前使用普通线程或线程池线程时是不支持的。在工作执行完成后,您可以使用
<TaskInstance>.Result
属性查询任务返回的值。由于这是可能的,所以我实现我的算法的方式是,我将部分求和的并行工作分成单独的任务,并在查询它们时让它们准备好部分总和,在CreateFinalSum
方法中。我没有让我的单个任务去更新某个全局变量来创建最终总和,因为这会再次引起共享资源的争用。 - 线程同步 -> 无缝的线程同步:要了解我所有的 10 个单独任务是否已完成部分总和的创建,我使用了
Task.WhenAll(tasks).ContinueWith
,这是 Task Parallel Library 的一个非常基本的功能。当ContinueWith
调用中的 lambda 表达式参数执行时,我保证所有部分总和都已准备就绪。现在,我使用 lambda 表达式调用我的CreateFinalSum
方法,该方法将简单地以顺序方式添加<TaskInstance>.Result
中的所有部分总和,而无需担心脏读或错误覆盖。所有任务实例的引用都存储在一个List
数据结构中。 - UI 无响应 -> **在 TPL 任务的工作进行时,随时可以单击 UI 按钮**:在 TPL 世界中,UI 卡住和标题栏中出现“未响应”文本的情况将不复存在。这可能是您的最终用户在您的应用程序中看到的最大的解脱,因为即使后台任务正在进行,他们也可以自由地与 UI 进行交互。
首先,我希望向您展示一个卡住的 UI。实际上,在现代硬件上,我们在这个练习中进行的求和任务仍然非常非常快,我们不会注意到 UI 卡顿一秒钟或任何类似的事情。当您执行一些耗时任务时,例如从 Web 服务调用获取数据或从磁盘读取大文件,UI 卡住/挂起会更加明显。为了保持我当前的示例代码简单,我简单地添加了以下语句来模拟一个耗时任务:
Thread.Sleep(5000); // 使线程睡眠 5 秒以模拟耗时任务
您可能需要从AddNumbersWithSynchronization
函数中取消注释上面的代码行,以查看 UI 卡住的行为。现在,取消注释上面的代码行并通过单击标题为“Multi-Threading With Synchronization”的按钮再次启动求和过程,您会发现您无法单击表单上的任何其他按钮。您可以通过单击标题为“Click Me!”的按钮来测试这一点,看看是否会弹出消息框。它不会。
现在,出于类似的原因,我在CreateFinalSum
方法中也添加了相同的代码行,以便 TPL 也需要很长时间才能完成任务。现在,如果您通过单击“Multi-Threading with Tasks in TPL”标题按钮再次启动求和过程,您会注意到在最终总和显示在消息框中之前会花费一段时间。但与此同时,您的 UI 完全响应。您可以随意单击“Click Me!”按钮多少次都能看到用户友好的消息。任务是如何实现这一点的?让我们稍微绕道一下。
绕道:任务和 Task Parallel Library 的一些基础知识
现在是时候学习一些关于任务及其实现的内部基础知识了,这些基础知识使任务能够帮助您的应用程序展现出许多出色的行为。任务是一个独立的世界,要获得完整的细节,我建议阅读 Jeffrey Richter 的《CLR via C#》(第 4 版)的第 26、27 和 28 章。好吧,以最简单的方式理解它们——Tasks 在 .Net 框架中占有一席之地,这样我们就可以通过现有的 API 来摆脱限制,而这些 API 曾用于与 Thread pool 线程进行交互。
线程池是由 CLR 为您的进程管理的线程池。线程池线程的最大数量是有限的。一个进程可用的最大线程池线程数在每个 .Net 版本中都有所不同。最初在 .Net 1.1 中,它从 25 开始。今天,在 32 位机器上是 1023,在 64 位机器上是 32768。每次启动 .Net 可执行文件时,线程池线程都会准备好执行您想分配给它们的任何后台任务。有一个 APIThreadPool.QueueUserWorkItem
,过去用于将我们想在线程池线程上执行的工作排队。此 API 有许多缺点。举几个例子,无法知道正在执行您任务的线程池线程的完成或运行状态。此外,无法从在 ThreadPool 线程上执行的函数获取返回值,等等。
Tasks 和 TPL 在 .Net v4.0 中出现,为您提供了与线程池线程交互的全新模型,并消除了与线程池交互的现有 API 中的所有缺点。因此,以下代码行将在线程池线程上执行。结果,您的主 UI 线程保持完全空闲,这让您有机会在求和过程进行时与 UI 进行交互,并且您可以单击标题为“Click Me!”的按钮。
Task.WhenAll(tasks).ContinueWith(task => CreateFinalSum(tasks));
在 .Net 4 中有全新的异步函数,它们通过async
-await
关键字对帮助您无缝实现这一切。强烈建议您在此处 探索它们。在我看来,异步函数是 C# 4.0 最迷人的特性。供您快速参考,在异步函数内部使用的await
关键字是在线程池线程上执行等待(等待后台工作完成)的关键,而不是让主 GUI 线程忙碌。这才是真正让您的 UI 保持响应的东西。这将帮助您摆脱我在前面章节中展示的图片中的红色云彩。考虑到文章篇幅不断增加以及避免过多绕道,我无法在此详细介绍 async-await 关键字和异步函数。
- 非协作式取消 -> **Tasks 会协作以结束它们的生命周期**:如果您想在中间取消任务,Tasks 支持协作式取消。这非常简单。在我们的应用程序中,单击标题为“Tasks with Cancellation”的按钮来启动前一百万个自然数的求和过程。这次,我们为获取部分总和调用的所有十个任务都支持取消。这是该方法的签名。请看函数的第三个参数。
private static long AddNumbersBetweenLimitsWithCancellation(long lowerLimitInclusive, long upperLimitInclusive, CancellationToken token)
每次在进行后续求和之前,它们都会检查传递给函数的IsCancellationRequested
标志,该函数在开始时就已传递。现在,考虑到这个求和是一个耗时任务,您可能想在中间取消它。所以单击标题为“Cancel Tasks”的按钮。这会导致 token 的IsCancellationRequested
属性被设置为 true。这会导致执行部分求和的函数立即返回,而不是继续执行。
实际上,您的函数也应该通过使用 token 来支持优雅终止。看看它有多优雅。我只是结束了我的函数,而不是杀死、谋杀或中止线程,如果不是 Tasks 的话,否则就会发生这种情况。最后,您将收到一条错误消息,而不是总和值,因为系统能够检测到任务已被用户中途取消。这是相关的代码片段:
//some code has been removed for brevity.
//please refer attached source code for complete reference.
CancellationTokenSource ts = new CancellationTokenSource();
private void btnCancelTasks_Click(object sender, EventArgs e)
{
ts.Cancel();
}
private void btnTasksWithCancellation_Click(object sender, EventArgs e)
{
long degreeofParallelism = 10;
long lowerbound = 0;
long upperBound = 0;
List<Task<long>> tasks = new List<Task<long>>();
long countOfNumbersToBeAddedByOneTask = 100000; //1 lakh
for (int spawnedThreadNumber = 1; spawnedThreadNumber <= degreeofParallelism; spawnedThreadNumber++)
{
lowerbound = ++upperBound;
upperBound = countOfNumbersToBeAddedByOneTask * spawnedThreadNumber;
//copying the values to be passed to task in local variables to avoid closure variable
//issue. You can safely ignore this concept for now to avoid a detour. For now you
//can assume I've done bad programming by creating two new local variables unnecessarily.
var lowerLimit = lowerbound;
var upperLimit = upperBound;
tasks.Add(Task.Run(() => AddNumbersBetweenLimitsWithCancellation(lowerLimit, upperLimit, ts.Token)));
}
Task.WhenAll(tasks).ContinueWith(task => CreateFinalSumWithCancellationHandling(tasks));
}
private static long AddNumbersBetweenLimitsWithCancellation(long lowerLimitInclusive, long upperLimitInclusive, CancellationToken token)
{
long sumTotal = 0;
for (long i = lowerLimitInclusive; i <= upperLimitInclusive; i++)
{
//deliberately added a sleep statement to emulate a long running task
//this will give the user a chance to cancel the partial summation tasks in the middle when they are not yet complete.
Thread.Sleep(1000);
if (token.IsCancellationRequested)
{
sumTotal = -1;//set some invalid value so that calling function can detect that method was cancelled mid way.
break;
}
sumTotal += i;
}
return sumTotal;
}
private static void CreateFinalSumWithCancellationHandling(List<Task<long>> tasks)
{
long grandTotal = 0;
foreach (var task in tasks)
{
if (task.Result < 0)
{
MessageBox.Show("Task was cancelled mid way. Sum opertion couldn't complete.");
return;
}
grandTotal += task.Result;
}
var finalValue = tasks.Sum(task => task.Result);
//Did you require a context switch to UI worker thread here before showing the
//MessageBox control which is a UI element. What would have you done if you were NOT using TPL.
MessageBox.Show("Sum is : " + finalValue);
}
我们正逐步接近文章的结尾。在我目前的对 TPL 的理解中,我试图展现 TPL 如何让你比传统多线程世界或线程池线程更具优势的细节。还有一些有趣的方面,任务与传统的 C# 多线程编程不同。让我们继续。
从后台线程更新 UI 控件
如果您以前处理过 Windows 窗体应用程序中的后台线程,您可能已经知道我要说什么了。如果没有,让我们做一个非常小的练习。转到 AddNumbersWithoutThreadSynchronization
函数并取消注释代码 lblTotal.Text = "2";
。现在单击标题为“Multi-Threading without thread Synchronization”的按钮。您将收到 InvalidOperationException
,如下图所示。它说交叉线程操作无效。
此错误背后的基本概念是,所有 UI 控件都由启动您程序的 Main
方法时启动的主 UI 线程创建和拥有。CLR 施加了一个限制,只有创建这些控件的线程(UI 线程)才能修改 UI 控件的属性,例如,如果您想更改标签控件的 Text、Size、Location 属性,则应从 UI 线程执行此操作。
因此,当我们在 AddNumbersWithoutThreadSynchronization
方法中时,该方法正在非 UI 线程(默认是后台线程的线程池线程)上执行,此时当我尝试设置 lblTotal
控件的 Text
属性时,会引发 InvalidOperationException
。我们如何解决这个问题?并不难!如果确实需要从非 UI 线程更新 UI 控件,请使用我为标题为“Update UI Control from background”的按钮所做的代码。这里有一个快速代码片段供您参考:
private void btnUpdateUiControl_Click(object sender, EventArgs e)
{
ClearResultLabel();
var threadStart = new ThreadStart(UpdateLabelTextAsync);
var thread = new Thread(threadStart);
thread.Start();
}
delegate void UpdateUi();
private void UpdateLabelTextAsync()
{
UpdateUi functionUi = UpdateLabelControl;
//do work which doesn't involve UI controls.
//.....
//.....
//.....
//.....
//Now we have to update UI control so special handling required.
//InvokeRequired == true means you are on a non-UI thread.
if (lblTotal.InvokeRequired)
lblTotal.BeginInvoke(functionUi);
}
private void UpdateLabelControl()
{
lblTotal.Text = "Changed the text from background thread.";
}
因此,基本上,您需要将执行上下文从后台线程更改为 UI 线程,然后再更新 UI 控件。这是通过 BeginInvoke
方法完成的。为了实现这一点,我们定义了一个委托和一个单独的方法 UpdateLabelControl
,它执行 UI 更新相关的工作。
在 TPL 世界中也可以实现这一点。虽然在 TPL 中它也不透明,但您会发现它要干净得多。正如我在上一段关于主线程执行上下文的讨论中所提到的,您可以在其下更新 UI 控件。TPL 通过任务调度程序的概念来实现该逻辑执行上下文。TPL 世界中有两种主要的任务调度程序,它们随 .Net 框架一起提供,即**线程池任务调度程序**(用于执行任务)和**同步上下文任务调度程序**(用于执行 UI 线程相关工作)。如果您在任务内部将当前使用的任务调度程序设置为后者,您将自动切换到 UI 线程,并且可以进行 UI 更新而不会引发异常。如果您单击标题为“Update UI With Summation - TPL”的按钮,您将看到 UI 上的一个标签被更新为前一百万个自然数的总和。此 UI 更新来自使用任务异步执行的函数 ContinuationAction
。让我们在代码中看看它是如何工作的。
private readonly TaskScheduler uiContextTaskScheduler;
#region Constructor and Finalizers
public MainForm()
{
InitializeComponent();
uiContextTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
}
#endregion
private static long AddNumbersBetweenLimits(long lowerLimitInclusive, long upperLimitInclusive)
{
long sumTotal = 0;
for (long i = lowerLimitInclusive; i <= upperLimitInclusive; i++)
{
sumTotal += i;
}
return sumTotal;
}
private void btnUpdateUiTpl_Click(object sender, EventArgs e)
{
ClearResultLabel();
long degreeofParallelism = 10;
long lowerbound = 0;
long upperBound = 0;
List<Task<long>> tasks = new List<Task<long>>();
long countOfNumbersToBeAddedByOneTask = 100000; //1 lakh
for (int spawnedThreadNumber = 1; spawnedThreadNumber <= degreeofParallelism; spawnedThreadNumber++)
{
lowerbound = ++upperBound;
upperBound = countOfNumbersToBeAddedByOneTask * spawnedThreadNumber;
//copying the values to be passed to task in local variables to avoid closure variable
//issue. You can safely ignore this concept for now to avoid a detour. For now you
//can assume that I've done a bad programming by creating two new local variables unnecessarily.
var lowerLimit = lowerbound;
var upperLimit = upperBound;
tasks.Add(Task.Run(() => AddNumbersBetweenLimits(lowerLimit, upperLimit)));
}
Task.WhenAll(tasks).ContinueWith(ContinuationAction, tasks, uiContextTaskScheduler);
}
private void ContinuationAction(Task task, object o)
{
var partialSumTasks = (List<Task<long>>) o;
var finalValue = partialSumTasks.Sum(eachtask => eachtask.Result);
lblTotal.Text = "Sum is : " + finalValue;
}
让我们稍微解析一下上面的代码,以了解这里发生了什么。我们从一开始就知道,重要的是获得正确的上下文,然后设置标签控件的 Text
属性就如同小菜一碟,您已经知道了。因此,该 UI 上下文通过以下代码行中的第三个参数 uiContextTaskScheduler
传递给了 ContinuationAction 函数。本质上,ContinuationAction
的整个内容将在主 UI 线程上执行。因此,请始终尝试只将那部分代码放在此类函数中,这些函数的唯一工作是更新 UI。如果您给这些函数分配了更多可以在后台线程上完成的工作,那么 UI 卡住的问题将再次出现。
Task.WhenAll(tasks).ContinueWith(ContinuationAction, tasks, uiContextTaskScheduler);
多核 CPU - TPL 如何更好地利用它们
您可能听很多人说过 TPL 能够更好地利用多核 CPU,这在当今 PC 硬件领域非常普遍。但是,非常基本的想法是,任务最终也是一些后台线程在做我的工作,我自己可以创建它们,那么它们如何才能在多核 CPU 架构的最佳利用方面产生差异呢?让我们快速浏览几点,这可能会让您思考,并帮助您选择 TPL 的路线。
线程的创建和维护:Tasks 最终利用 CLR 最优管理的线程池线程。如果您自己实现了整个多线程,那么您将承担创建、维护和释放线程的成本,这始终可能存在问题。不必要地创建更多线程只会增加应用程序的内存工作集。TPL 非常高效地管理所有这些。
上下文切换的成本:我已经谈过了上下文切换,当您的 CPU 根据操作系统的调度算法处理系统中多个线程的时间片分配时,就会发生上下文切换。上下文切换是有成本的。线程越多,上下文切换的成本就越高。理想情况下,对于一个 2 核 CPU,应该有 2 个线程并行才能获得最佳性能。因此,TPL 在管理各种因素方面非常智能,以确保它创建或释放的线程数量对于您的应用程序和系统来说是最佳的。
TPL 内置了许多启发式方法,可以根据情况决定是创建新的线程池线程,还是利用池中已有的空闲线程,甚至是在现有线程无所事事地空闲时将其终止。我对您的编程技能毫无疑问,但作为个人程序员自己构建这些启发式方法将非常复杂,最终您将只是重新发明 MS 程序员多年来为您做过的轮子。那么为什么不利用 TPL 呢?
我应该为所有多线程工作使用 TPL 吗?- 不
我个人认为 TPL 应该是您进行任何新的多线程工作的首选选择,但有几个要点可以帮助您在三个世界之间做出决定。
何时手动创建和使用线程?
如果您出于以下原因(但不限于此)而希望对系统中创建的新线程拥有绝对控制权:
- 我的线程应该何时创建?
- 我的进程中应该创建多少新线程?
- 我的线程应该何时被释放?
- 从操作系统调度算法的角度来看,新线程的优先级应该是什么?例如,高、中、低。
- 我的线程应该是后台线程还是前台线程?
何时使用线程池更合适?
如果您的后台工作主要是纯粹的“即发即弃”异步工作,那么使用 QueueUserWorkItem
可能更合适,例如处理目录中不断创建的 CSV 文件的内容,然后将读取的内容转储到数据库表中。
何时使用 TPL 更合适?
以下场景可能促使您使用 TPL:
- 如果您想使用 TPL 的新功能(延续选项、了解线程的完成状态、完成工作后返回值等)。
- 如果您想始终保持 UI 响应。
- 如果您曾经在 Windows 应用商店应用程序上工作过或想在上面工作,那么 TPL 是利用线程池的新途径。虽然
ThreadPool
类也已重新引入到Windows.System.Threading
命名空间中以利用其QueueUserWorkItem
API,因为 Windows 应用商店应用不再可以访问System.Threading
命名空间。 - 如果您有其他不适合本节中提到的两种情况的多线程需求,即直接创建线程以获得完全控制,或使用传统的线程池进行即发即弃场景。
关注点
- C# 中引入的 Async-await 关键字如何利用 TPL 和 Tasks?
历史
2016年7月17日 - 首次发布
2016年7月28日 - 添加了一个适合利用传统线程池线程的示例。
2016年8月10日 - 修正了适合 TPL 的场景,因为 Windows 应用商店应用也可以直接通过不同的命名空间访问 ThreadPool
类。