.net 4.0 学习笔记(4)—— 线程基础(下)
使用专用线程来异步执行计算限制的操作
在这一节,我将展示如何创建线程和如何使用异步来执行计算限制的操作。在这开始之前,我强调你要避免使用我给你展示的这种技术。作为代替,你应该尽量使用CLR线程池来异步执行计算限制的操作,我会在26章“计算限制的异步模式”来详细阐述。
然而,有一些情况你可能需要明确创建线程来执行一个特殊的计算限制的操作。典型的,如果你执行代码需要有特殊状态的线程,并与线程池线程不同,你可以创建一个专用线程。例如,在以下条件为真是明确创建你自己的线程:
- 你需要线程允许一个非普通优先级。所有的线程池线程都允许在普通优先级。当然,这你可以改变,但是不推荐,在线程池操作过程中,优先级的改变不会持续。
- 你需要线程作为前台线程运转,从而防止程序终止一直到线程完成任务。欲了解更多信息,参见“前台线程与后台线程的对比”这一节。线程池线程总是后台线程,如果CLR决定终止进程它们就不会完成任务。
- 受计算限制的任务需要时间非常长;这样,我不会让线程池负担逻辑,因为它试图找出是否需要创建一个额外的线程。
- 我想开始线程并很可能用Thread.Abort方法来过早的结束它。(在第22章讨论,“CLR宿主和应用程序域”)
为了创建专有线程,你需要使用System.Threading.Thread类的构造函数,传递执行的方法名称到它的构造函数。以下是Thread的构造函数原型:
public sealed class Thread : CriticalFinalizerObject, ... { public Thread(ParameterizedThreadStart start); // Less commonly used constructors are not shown here }
Start方法的参数指定一个方法,它会被专用线程执行,这个方法必须和ParameterizedThreadStart委托的签名一样。(6)(不推荐使用ThreadStart委托)
delegate void ParameterizedThreadStart(Object obj);
构造一个Thread对象是一个相当轻量的操作,因为它不实际需要创建一个操作系统物理线程。要真实创建一个操作系统物理线程并让它执行返回方法,你必须调用Thread.Start方法,传递object(state)来作为回调方法的参数。以下代码演示了如何创建专用线程并异步执行:
using System; using System.Threading; public static class Program { public static void Main() { Console.WriteLine("Main thread: starting a dedicated thread " + "to do an asynchronous operation"); Thread dedicatedThread = new Thread(ComputeBoundOp); dedicatedThread.Start(5); Console.WriteLine("Main thread: Doing other work here..."); Thread.Sleep(10000); // Simulating other work (10 seconds) dedicatedThread.Join(); // Wait for thread to terminate Console.WriteLine("Hit <Enter> to end this program..."); Console.ReadLine(); } // This method's signature must match the ParameterizedThreadStart delegate private static void ComputeBoundOp(Object state) { // This method is executed by a dedicated thread Console.WriteLine("In ComputeBoundOp: state={0}", state); Thread.Sleep(1000); // Simulates other work (1 second) // When this method returns, the dedicated thread dies } }
当我编译并运行这段代码时,我得到如下输出:
Main thread: starting a dedicated thread to do an asynchronous operation
Main thread: Doing other work here...
In ComputeBoundOp: state=5
有时当我运行这段代码时,会得到以下输出,因为我无法控制Windows如何调动这两个线程:
Main thread: starting a dedicated thread to do an asynchronous operation
In ComputeBoundOp: state=5
Main thread: Doing other work here...
注意我在Main函数中调用了Join方法。Join方法导致调用线程停止运行任何代码直到用dedicatedThread标示的线程销毁自己或被终止。
使用线程的原因
使用线程的原因有3个:
- 你可以使用线程来隔离代码。这可以提高程序可靠性,实际上,这就是为什么Windows在操作系统中引入线程的概念。Windows为了可靠性而需要线程,因为你的程序对操作系统来讲,是一个第三方组件,微软不确定在发布之前你的代码质量如何。然而,你可以在发布之前测试,既然你测试整个程序,你应该了解它们是健壮且高质量的。这样来讲,你的程序需要的健壮性并不像操作系统那么高,因此,你的程序不需要使用线程来提高健壮性。如果你的程序支持加载由别人开发的组件,你的程序需要更加健壮,并且使用线程可以满足这个需求。
- 你可以使用线程来让你的代码更简洁。有时用它自己的线程来执行任务是很简单的事情。当然,你这样做的时候,你正在使用额外资源,并没有有效的编写代码。现在,计算在一些重要资源上我也可以编写简洁代码。如果我不这样做,我仍然会一直写机器语言而不是成为C#开发者。但是有时我看见人们使用线程来选择一个更容易的编程技术,实际上,他们本质上让他们的生活(和代码)变得更复杂了。通常,当你引进线程时,你同样带来需要线程同步结构来决定什么时候其他线程结束。一旦你这样处理,你就在使用更多的资源,并且让你的代码更难懂。所以在开始使用线程之前,请确保它们真正对你有帮助。
- 你可以使用线程来同时处理。如果,仅仅如果你知道你的程序是运行在一个多处理器中,你可以并发执行任务来得到性能提升。如今,多处理器系统非常普遍,所以让你的程序支持多处理器是有意义的,你可以参见第26和第27章。
现在,我会和你分享一下我的观点。每个计算机都有一个难以置信的强大资源在里面:CPU自己。如果有人花钱在计算机上,计算机会一直运行着。换句话说,我相信CPU应该一直达到100%的利用率。我会给你两个告诫。第一,如果计算机使用电池,你不会想让CPU一直100%运转,因为这样会很快把电耗光。第二,一些数据中心更喜欢有10台机器有50%的CPU利用率而不是5台机器100%的CPU利用率,因为正在全速运行的CPU往往会产生极大的热量,而这需要冷却系统,像HVAC(供电空调)这样的冷却系统的成本要高于多台计算机供电能力,降低运行。虽然数据中心发现管理多台机器的成本越来越高,因为每台机器不得不定期升级硬件和软件还有显示器,但是这样比运行冷却系统要花费少。
现在,如果你同意我的观点。那么下一步就是想出CPU应该做什么。在我给你我的思路前,让我先告诉你一些事情。在过去,开发者和用户总是觉得计算机不够强大。因此,我们开发者不会仅仅执行代码除非用户给我们权限并指定对程序来讲,通过UI控件比如菜单、按钮、复选框来消耗CPU资源是OK的。
但是现在,一切都变了。计算机发展带来庞大的计算能力,甚至更强大的计算能力会出现在不远的将来。在这章开始时,我向你展示了任务管理器报告我的CPU那时占用0%。如果我的计算机是一个四核而不是双核,任务管理器会更经常报告0%。当80核浮出水面,机器一直会像没有做任何事情。对计算机采购人来说,这好像他们在花费更多的金钱来买更强大的CPU,计算机却做更少的工作。
这就是为什么计算机制造商很难卖给用户多核计算机:软件很难利用硬件资源,用户也从更多的CPU中得不到好处。我想说的是,现在我们有大量的计算能力,而且越来越多,因此开发者可以积极消费。是的,过去,我们很难想象让我们的程序执行一些计算除非我们知道用户想要计算的结果。但是现在,我们有额外的计算能力,我们可以做梦了。
这有个例子:当你停止在Visual Studio编辑器编辑时,Visual Studio自动产生编译并编译你的代码。这让开发者难以置信的高产,因为它们能边打字边在源文件中看到警告的错误,这样可以很快的解决它们。实际上,开发者今天的编辑-编译-调试循环会变成-编辑-调试循环,因为编译代码一直在发生。你,作为一个用户,不会关心这,因为有很多计算能力可用,你正在做的其他事情受到编译器频繁执行编译的影响微乎其微。实际上,我期盼着,在未来的Visual Studio版本,编译菜单会完全消失,因为这一切都自动化了。不仅仅程序的UI更简单了,程序给用户提供的“答案”,让他们更高产了。
当我们移除像菜单这样的UI组件时,计算机变得更简洁了。这会有更少的选项,更少的概念让他们去读和理解。这就是多核革命,允许我们去移除这些UI项,因此让软件对用户来讲更简洁,使得我的祖母某一天会感觉使用计算机很舒服。对开发者来讲,移除UI项通常会带来更少的测试量,同时对用户的基本代码会提供更少的选项。如果你现在本地化UI项文字和你的文档(类似微软),移除UI项意味着你可以书写更少的文档,你不必本地化这些文档。所有这些都会给你的组织节省时间和金钱。
这有一些敢作敢为的CPU消耗:拼写检查和语法检查,重新计算电子表格,索引你的磁盘文件以加速搜索,整理你的硬盘以提高IO性能。
我想生活在一个UI减少并简洁的世界,我会有更多的可视面积去显示我正在工作的数据,程序提供给我信息,帮助我更快更有效的完成工作,而不是我告诉程序去为我得到信息。我想近几年已经有这样的硬件来给开发者使用了。这正是软件创造性的利用硬件的时机。
线程调度与优先级
一个抢先式的操作系统必须有某种算法来决定哪个线程什么时候会被调度,调度多长时间。在这一节,我们来看一下Windows使用的算法。在这章的早期,我提到每个线程内核对象都有一个上下文结构。这个上下文结构反映了线程最后一次执行的CPU寄存器状态。在一个时间片过后,Windows查看当前存在的所有线程内核对象。在这些对象中,只有没有在等待的线程才会被调度。Windows从中选择一个可调度线程内核对象并执行上下文切换到它。Windows实际上保存每个线程得到上下文切换的次数。你可以使用Microsoft Spy++来查看。图25-5展示了线程的属性。注意这个线程已经被调度了32768次。
在这一点上,这个线程正在执行代码并且在它的进程地址空间中操作数据。在另一个时间片过后,Windows执行另一个上下文切换。Windows执行上下文切换在从系统启动好直到关机。
Windows被称作抢先式多线程操作系统,因为一个线程可以在任意时刻被停止,另一个线程可以被调度。正如你所看到的,你这样控制,单不多。记住,你不能保证你的线程总是被运行而其他线程不允许被运行。
每个线程被分配一个从0(最低)到31(最高)的优先级。当系统开始决定哪个线程被分配到CPU时,它首先检查优先级为31的线程,然后调度它们用一个循环赛的方式。如果优先级为31的线程被调度,它就被分配到CPU。在一个线程时间片的结尾,系统检查是否另一个优先级为31的线程可以运行;如果是的,那么它允许那个线程被分配到CPU。
只要优先级为31的线程被调度,系统不再分配其他优先级从0到30的线程给CPU。这个条件成为饥饿,这发生在当高优先级线程可以分配很多CPU时间而防止低优先级线程执行。饥饿很少发生在多处理器上,因为一个优先级31的线程和优先级30的线程可以同时运行。系统总是试图保持CPU繁忙,CPU只在没有线程可调度时空闲。
更高的优先级线程总是抢占更低的优先级线程,不顾更低的优先级线程是否在执行。例如,如果一个优先级为5的线程正在运行并且系统检测到一个更高的优先级线程已经就绪,系统立即暂停这个低优先级线程(计算它处于时间片的中期)然后分配CPU给更高优先级线程,让它得到一个完整的时间片。
顺便说一下,当系统启动时,它创建了一个特殊线程称作0页线程。这个线程被分配优先级为0并且在整个系统中只有它运行在优先级为0。这个0页线程当没有其他线程执行工作时它则负责将空闲的内存页置零。
微软明白给线程分配优先级对开发者来说毫无理由的太难了。这个线程优先级应该为10么?另一个线程应该为23么?为了解决这个问题,Windows在线程优先级系统上暴露一个抽象层。
当开始设计你的程序时,你应该决定是否你的程序需要比其他程序或多或少的得到响应。你选择进程优先级来反映你的决定。Windows支持6个进程优先级:空闲,低于标准,标准,高于标准,高,实时。当然,标准是默认的,因此是用的最多的优先级。
空闲优先级对一直运行但不做事情的程序是完美的(比如屏幕保护程序)。一个不被作为交互的计算机可能一直忙,不要使用屏幕保护程序来竞争CPU时间。统计跟踪程序会对系统定期更新状态,通常不应该干扰更关键的任务。
只有绝对必要的时候才使用高优先级。你应该避免使用实时优先级。实时优先级是非常高的,并且能干扰操作系统任务,比如防止IO请求和网络流量的发生。另外,实时优先级进程的线程能防止键盘和鼠标得到及时响应,导致用户觉得系统被冻结。基本来讲,你应该有个好理由去使用实时优先级,比如你想用少量延迟来响应硬件事件或向执行很短存在的任务。
一旦你选择一个优先级,你应该停止思考如何你的程序关联其他程序,仅仅关注与你的程序中的线程。Windows支持7种相关的线程优先级:空闲,最低,低于标准,标准,高于标准,最高,关键。这些优先级与进程优先级相关。再一次,普通相关优先级是默认的,因此它是最常见的。
所以,简要的说,你的进程是有优先级的,你分配线程优先级是对互相关联的。你会注意到我没有说0到31的优先级。程序开发者不会直接和优先级打交道。作为替代,系统映射到进程优先级并和线程优先级关联。表25-1展示了进程优先级和线程优先级如何映射到优先级级别。
举例来说,一个在标准优先级的进程里的标准优先级线程被分配第8级优先级。因为大多数进程是标准优先级的,并且大多数线程是标准优先级,所有大多数线程被分配第8级优先级。
如果你有一个标准优先级的线程在高优先级进程中,线程会分配第13级优先级。如果你把进程的优先级改成空闲,线程的优先级就会变成4。记住,线程优先级是和进程优先级相关联的。如果你改变进程的优先级,那么线程的相关优先级不会改变,但是它的优先级数字会改变。
注意,这个表没有展示任何优先级为0的线程。这是因为0优先级是为0页线程准备的,系统不再分配给其他线程0这个优先级。而且,以下优先级不可能分配到:17,18,19,20,21,27,28,29和30。如果你在内核模式编写设备驱动程序,你可以得到这些优先级;用户模式不可以。也要注意,一个拥有实时优先级进程的线程不可能低于优先级16。同样的,其他没有实时优先级的不可能高于15。
通常的,一个被分配优先级的进程基于让它启动的进程。大多数进程被Windows Explorer启动,它创建子进程的优先级为标准。托管程序被支持因为它们有自己的进程;它们在AppDomain被支持,所以托管程序不支持改变它们进程的优先级,因为这样会导致所有在进程中运行的代码受到影响。举例来说,许多ASP.NET程序单独运行一个进程,每个程序有自己的AppDomain。这同样对Silverlight程序也是正确的,它们都运行在Internet浏览器进程,并且托管的存储过程,运行在Microsoft SQL Server进程。
换句话说,如果你的程序可以通过设置Thread.Priority属性来改变线程相关优先级,传递定义在ThreadPriority枚举5个值中的一个(最低,低于标准,标准,高于标准,最高)。然而,Windows为自己提供第0级和实时范围的优先级,CLR为自己提供空闲和关键优先级。今天,CLR没有线程运行在优先级,但这会在将来所改变。然而,CLR的解析器线程,在第21章讨论”自动内存管理“,运行在关键优先级。因此,作为一个托管开发者,你能够得到在表25-1列出的5个突出显示的线程优先级。
我应该指出,System.Diagnostics命名空间下包含Process类和ProcessThread类。这些类各自从Windows视角提供了进程和线程。这些类为开发者提供了用托管代码来方便调试代码。实际上,这也就是为什么叫System.Diagnostics命名空间。程序需要运行在一个特殊安全许可来使用这两个类。比如,你不能再Silverlight程序或者ASP.NET程序中使用这些类。
另一方面,程序可以使用AppDomain和Thread类,它们从CLR视角暴露出来AppDomain和Thread。大多数情况下,虽然运行这两个类并不需要特殊安全许可,但是某些操作仍然需要。
前台线程与后台线程
CLR认为每个线程要么是前台线程,要么就是后台线程。当进程的所有前台线程都终止时,CLR强制结束任何仍然在运行的后台线程。这些后台线程立即终止;没有异常抛出。
因此,你应该使用前台线程来执行那些你想完全执行完毕的任务,比如把数据从内存缓冲区提交到硬盘上。你应该使用后台线程来执行那些不关键的任务,比如重新计算电子表格单元格或者索引记录,因为这些工作可以在程序重启时在一处执行,如果用户想终止它,没必要强制程序一直存活。
CLR为了更好的支持AppDomain需要提供前台线程和后台线程的概念。正如你所见的,每个AppDomain能够在一个单独的程序中运行,每个AppDomain有自己的前台线程。如果一个AppDomain退出,是由于它的前台线程终止,那么CLR仍然需要继续执行,这样其他AppDomain也可以继续执行。当所有AppDomain全部退出时,它们的所有前台线程都终止,整个进程就被摧毁。
以下代码演示了前台线程和后台线程的不同:
using System;
using System.Threading;
public static class Program {
public static void Main() {
// Create a new thread (defaults to foreground)
Thread t = new Thread(Worker);
// Make the thread a background thread
t.IsBackground = true;
t.Start(); // Start the thread
// If t is a foreground thread, the application won't die for about 10 seconds
// If t is a background thread, the application dies immediately
Console.WriteLine("Returning from Main");
}
private static void Worker() {
Thread.Sleep(10000); // Simulate doing 10 seconds of work
// The line below only gets displayed if this code is executed by a foreground thread
Console.WriteLine("Returning from Worker");
}
}
很有可能在它的生命周期中的任何时刻,一个线程从前台线程变成后台线程。任何程序的主要线程和其他线程都显式的被一个Thread的对象默认创建成前台线程。另一方面,线程池线程默认是后台线程。任何被本地代码创建的线程进入托管执行环境就被标记为后台线程。
现在呢?
在这一章,我解释了线程的基本概念,我希望我让你清楚了线程是非常昂贵的资源,应该节省的使用它。最好使用CLR的线程池。线程池自动为你管理线程的创建和销毁。线程池创建很多线程可以重用在不同的任务中,这样你的程序只需要一些线程就可以完成任务。
在第26章,我会关注如何使用CLR线程池线程来执行计算限制的操作。在第27章,我会讨论使用线程池来结合CLR的异步编程模式来执行IO限制的操作。
略……