《CLR via C#》笔记——线程基础
2012-07-20 14:22 xiashengwang 阅读(1402) 评论(2) 编辑 收藏 举报一,Windows为什么要支持线程
进程(process)是应用程序的一个实例要使用的资源的一个集合。每个进程都有一个虚拟化的地址空间,确保一个进程的代码不会被另外一个进程访问,这确保了应用程序的健壮性和安全性。但当应用程序执行一个无限循环,系统中又只有一个CPU时,他会执行无限循环,不能执行其他东西。为此,Mircorsoft引入了线程。线程(thread)的职责就是对CPU进行虚拟化,Windows为每个进程提供了该进程专用的线程。如果应用程序进入无限循环,与代码相关的进程会被“冻结”,但其他进程可以继续执行。
二,线程开销
线程会产生空间和时间上的开销。每个线程都包含以下要素:
●线程内核对象(thread kernel object) OS创建每一个线程都会分配并初始化这个数据结构。在该数据结构中,包含一组对线程描述的属性;还包括线程上下文(thread context),它是一个内存块,包含了CPU寄存器的集合。x86 CPU的线程上下文是700Byte,x64和IA64 CPU中,分别是1024Byte和2500Byte内存。
●线程环境块(thread environment block,TEB)TEB是在用户模式中分配和初始化的一个内存块。TEB耗一个内存页(x86和x64是4KB,IA64是8KB)。TEB中包含线程异常处理的链首(head)。线程进入try块会在链首插入一个节点,退出try块会删除这个节点。除此之外,TEB还包含线程的“线程本地存储数据”,以及由GDI和OpenGL图形使用的一些数据。
●用户模式栈(user-mode stack) 用户模式栈用于存储传给方法的局部变量和实参。它还包含一个地址;指出当前方法返回时,线程接着应该从什么地方开始执行。默认情况下,Windows为用户模式栈分配1MB的内存。
●内核模式栈(kernel-mode stack) 应用程序向操作系统的一个内核模式的函数传递实参时,会用到内核模式栈。出于安全方面的原因,针对用户模式的代码传给内核的实参,Windows都会把它从用户模式栈复制到内核模式栈。一经复制,内核就可以验证实参的值。由于应用程序不能访问内核模式栈,所以无法修改验证后的实参值。随后,内核调用它自己内部的方法,利用内核模式栈传递它的实参,局部变量以及存储返回地址。32位的Windows上内核模式栈为12KB,64位的Windows上为24KB。
●DLL线程连接(attach)和线程分离(detach)通知 Windows的一个策略是,任何时候在进程中创建一个线程,都会调用那个进程中加载的所有DLL的DllMain方法,并向该方法传递一个DLL_THREAD_ATTACH标志。类似的,任何一个线程销毁终止,都会调用那个进程中加载的所有DLL的DllMain方法,并向该方法传递一个DLL_THREAD_DETACH标志。有的DLL需要这些通知,为进程中创建/销毁的每个线程执行一些特殊的初始化或资源清理操作。
注意:C#和其他大多数托管编程语言生成的DLL没有DllMain函数,所有不会接到通知,这提升了性能。
单CPU每次只能做一件事情,所以,Windows必须在系统中的所有线程之间共享CPU。在给定的时刻,Windows只将一个线程分配给CPU。那个线程允许运行一个“时间片”。一旦时间片到期,Windows就上下文切换到另一个线程。每次上下文切换都要求Windows做以下操作:
1,将CPU寄存器的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中。
2,从现有线程集合中选出一个线程提供调度。如果线程由另一个进程拥有,Windows还要先切换到该进程的虚拟地址空间。
3,将所选线程上下文结构中的值加载到CPU寄存器中。
上下文切换后,CPU执行所选线程,直到它的时间片到期。然后,会发生另一次上下文切换。Windows大约30毫秒执行一次上下文切换。上下文切换通过牺牲性能避免应用程序的线程进入无限循环,带来了更好的用户体验。如果Windows决定再次调度的线程是同一线程,则不会执行上下文切换,这显著改善了性能。设计自己的代码时,上下文切换能避免的就要避免。
除此之外,执行垃圾回收时,CLR必须挂起所有线程,遍历它们的栈来查找它们的根以便对堆中的对象进行标记,再次遍历它们的栈(有的对象在压缩期间发生了移动,所有要更新它们的根),再恢复所有线程。所以,减少线程的数量也会显著提升垃圾回收器的性能。每次使用一个调试器并遇到一个断点,Windows都会挂起正在调试的应用程序的所有线程,并在单步执行或者运行应用程序时恢复所有线程。因此,你的线程越多,调试体验就越差。
根据上面的讨论,我们的结论是必须尽量避免使用线程,因为它要耗费大量的内存,而且需要相当多的时间来创建,销毁和管理。然而,有时我们必须使用线程,因为它使Windows变得健壮,反应更加灵敏。在多CPU的机器上,线程可提高应用程序的可伸缩性。
三,滥用线程
打开我们的任务管理器,可以发现一些应用程序占用了20个,甚至更多的线程数。这些线程占用了大量的内存,却没有干应该做的事情,只是在那里“坐吃等死”。必须承认,今天系统中的大多数线程都是由本地(native)代码创建的。所以,线程的用户模式栈(1MB)仅仅是保留(预订)地址空间,而栈极有可能并没有完全提交(fully committed)来获取物理内存。然而,随着越来越多的应用程序成为托管应用程序,会有越来越多的线程会被“实打实”地分配到完整的1MB的物理内存。
即便抛开用户模式栈不谈,所有线程也会分配到一个内核对象,内核模式以及他们的资源。这种觉得线程廉价就胡乱创建线程的势头必须遏制;线程并不廉价——相反,它们很“贵”,要理性的使用它们。
四,CLR线程和Windows线程
CLR使用的是Windows的线程处理能力。虽然今天,CLR线程直接对应于一个Windows线程,但Mircrosoft CLR团队保留了将来把它从Windows线程分离的权利。有一天,CLR可能引入它自己的逻辑线程,使一个逻辑线程并非映射到一个物理Windows线程。据说,逻辑线程将使用比物理线程少得多的资源,所以能在极少量的物理线程上运行大量的逻辑线程。遗憾的是,CLR团队还没有推出这个功能。
对你来说,这一切意味着在操纵线程时,代码应尽可能少地做出一些假设。例如,应避免P/Invoke本地Windows函数,因为这些函数对CLR线程一无所知。通过避免使用本地Windows函数,坚持使用FCL中的类型,将来性能的到提升之后,你的代码马上就能享受到这种提升。
备注:如果想P/Invoke本地代码,而且代码必须使用当前物理操作系统的线程来执行,那么应该调用System.Threading.Thread的静态BeginThreadAffinity方法。BeginThreadAffinity就是告诉CLR不要切换线程。线程不再需要使用物理操作系统线程运行时,可调用Thread的EndThreadAffinity方法来通知CLR。
五,使用专用线程执行异步的计算限制操作
本节展示如何创建一个线程,并让它执行一次异步计算(asynchronous coumpute-bound)操作。但应注意,强烈建议避免使用这里展示的技术。应尽量使用CLR的线程池来执行异步计算限制操作。然而,满足下面的任何一个条件,就可以显示创建自己的线程。
● 线程需要以非普通线程优先级运行。所有线程池线程都以普通优先级运行;虽然可以更改这个优先级,但不建议那么做。另外,在不同的线程池操作之间,对优先级的更改时无法持续的。
● 需要线程表现为一个前台线程,防止应用程序在线程结束它的任务之前终止。线程池线程始终是后台线程。
● 一个计算限制的任务需要很长的时间运行。线程池为了判断是否需要创建一个额外的线程所采用的逻辑是比较复杂的。直接为长时间运行的任务开一个专用线程,就可以避开这个问题。
● 要启动一个线程,并可能调用Thread的Abort方法来提前终止它。
需要创建一个线程,要构造一个System.Threading.Thread类的一个实例,向它传递方法的名称,它的构造器如下:
public sealed class Thread : CriticalFinalizerObject, _Thread{ public Thread(ParameterizedThreadStart start){ } //这里没有列出一些不常用的构造器 }
ParameterizedThreadStart委托的签名如下:
public delegate void ParameterizedThreadStart(object obj);
下面的代码演示如何创建一个专业线程,让它异步调用一个方法:
public void AsynchronousThreadDemo() { Console.WriteLine("Main thread: starting a dedicated thread " + "to do an asynchronous operation"); Thread dedicateThread = new Thread(ComputeBoundOp); dedicateThread.Start(5); Console.WriteLine("Main thread: Doing other work here..."); Thread.Sleep(5000); dedicateThread.Join();//等待线程终止 Console.WriteLine("Main exit"); } private void ComputeBoundOp(object state) { //这个个方法由一个专用线程执行 Console.WriteLine("In ComputeBoundOp: state={0}", state); Thread.Sleep(1000);//模拟其他任务(1秒) //这个方法返回后,专用线程终止 }
运行程序,可以得到下面的输出:
Main thread: starting a dedicated thread to do an asynchronous operation Main thread: Doing other work here... In ComputeBoundOp: state=5 Main exit
六,使用线程的理由
● 可以使线程将代码同其他代码隔离。这将提高应用程序的可靠性。事实上,这也正是Windows在操作系统中引入线程概念的原因。Windows之所以要用线程来获得可靠性,是因为你的应用程序对于操作系统来说是第三方组件,而Microsoft不会在你发布程序之前对这些代码进行验证。
● 可以使用线程来简化编码 。有时候,如果通过一个任务自己的线程来执行该任务,编码会变得更简单。但是,如果这样做,肯定要使用额外的资源。代码不是十分“经济”。
● 可以用线程来实现并发执行。如果知道自己的应用程序要在多CPU的机器上运行,那么让多个任务同时运行,就能提高性能。现在多核CPU相当普遍,所以设计应用程序来使用多个内核是有意义的。
七,线程调度和优先级
7.1,调度和优先级的概念
Windows之所以被称为一种抢占式多线程操作系统,是因为线程可以在任何时间停止(被抢占),并调度另一个线程。要记住一点,你不能保证自己的线程一直执行,你不能阻止其他线程的运行。
每个线程都分配了从0(最低)到31(最高)的优先级。系统决定将哪个线程分配给一个CPU时,它首先检查优先级为31的线程,并以一种轮流的方式调度它们。如果优先级为31的线程是可调度的,就把它分配给一个CPU。这个线程的时间片结束时,系统检查是否有另一个优先级为31的线程可以运行;如果是,就允许将那个线程分配给一个CPU。
只要系统中存在一个可调度的优先级为31的线程,系统就永远不会将优先级0~30的任何线程分配给CPU。这种情况称为饥饿(starvation)。
较高优先级的线程总是抢占较低优先级的线程,例如:一个优先级为5的线程正在运行,而系统确定一个较高优先级的线程准备好运行,系统会立即挂起(暂停)较低优先级的线程(即使后者的时间片还没有用完),将CPU分配给较高优先级的线程,该线程将获得一个完整的时间片。
顺便说一下,系统启动时,会创建一个零页线程(zero page thread)的特殊线程。这个线程的优先级为0,而且是整个系统中唯一一个优先级为0的线程。零页线程负责在没有其他进程需要执行时,将系统的RAM的所有空闲页清零。
设计应用程序时,应决定自己的应用程序是需要比机器上同时运行的其他应用程序更大还是更小的响应能力。然后选择一个进程优先级类(priority class)来反映你的决定。Windows支持6个进程优先级类:Idle,Below Normal,Normal,Above Normal,High和Realtime。Normal是默认的进程优先级类,所以它也是最常用的进程优先级类。一个应用程序(比如屏幕保护程序)在系统什么事情都不做的时候运行,就适合分配Idle优先级类。只有在绝对必要时才使用High优先级类。Realtime优先级类要尽可能避免,它的优先级相当高,甚至可能干扰操作系统任务,除了需要响应延迟(latency)很短的硬件事件,或一些执行不能中断的非常“短命”的任务。
选好一个优先级类之后,就不要再思考你的应用程序和其他应用程序的关系了。现在,要把注意力放在应用程序中的线程上。Windows支持7个相对线程优先级:Idle,Lowest,Below Normal,Normal,Above Normal,Highest和Time-Critical。这些优先级是相对进程优先级类的。同样,Normal是默认的优先级。
总之,你的进程是一个优先级类的成员。在你的进程中,要为各个线程分配相对优先级。事实上,0~31的线程优先级,是由进程的优先级类和其中的一个线程的相对优先级映射而来的。下图展现了这种映射关系:
表中大多数线程的优先级都是8。对于一个High优先级进程中的一个Normal线程,它的优先级是13。如果将进程的优先级类更改为Idle,线程的优先级变为4。记住,线程的优先级是相对于进程优先级类的。如果更改一个进程的优先级类,线程的相对优先级不会改变,但它的优先级值会变。
注意:表中没有优先级为0的,这是因为0优先级保留给了零页线程。以下优先级也不可获得:17,18,19,20,21,27,28,29或30。当然,如果编写的是运行在内核模式的设备驱动程序,可以获得这些优先级;用户模式的应用程序是不能获得的。还要注意,Realtime优先级的线程,其优先级不能低于16。类似的,非Realtime优先级的线程的优先级不能高于15。“进程优先级类”的概念容易引起一些混淆,人们可能认为意味着Windows能调度进程。然而,Windows永远不会调度进程,它调度的只有线程。“进程优先级类”是Microsoft提出的一个抽象概念,旨在帮助你理解自己的应用程序和其他正在运行的应用程序的关系,它没有别的用途。
7.2,优先级的分配
正常情况下,进程根据启动它的进程来分配一个优先级。大多数进程都是由Windows资源管理器启动的,后者在Normal优先级类中生成它的所有子进程。托管应用程序不应该表现为拥有它们自己的进程;相反,他们应该表现为在一个AppDomain中运行。所以,托管应用程序不应该更改他们的进程的优先级类,因为这会影响进程中运行的所有代码。
另一方面,你的应用程序可以更改它的线程的相对线程优先级,这需要设置Thread的Priority属性,向它传递ThreadPriority的5个值之一,即Lowest,BelowNormal,Normal,AboveNormal或者Highest。然而,就像Windows为自己保留了优先级0和Realtime的一定范围一样。CLR为自己保留了Idle和Time-Critical优先级。今天的CLR还没有Idle优先级运行的线程,但这一点将来可能发生改变。CLR的终结器线程以Time-Critical优先级运行。所以,作为托管应用程序的开发人员,实际只能使用上表中加了底纹的相对线程优先级。
还应指出的是,System.Diagnostics命名空间包含了一个Process类和一个ProcessThread类,Process的Threads属性包含了一个ProcessThread的集合。这两个类提供了进程和线程的Windows视图。(这里的线程是指物理线程)如果要用托管代码来写工具应用程序,或者想构建代码来帮助自己对其进行调试,就可以利用这两个类。事实上,这也正是为什么这两个类在System.Diagnostics命名空间中的原因。应用程序需要特殊的安全权限才能使用这两个类。例如:在Silverlight应用程序或者Asp.net应用程序中,就不可以使用这两个类。
另外,应用程序可以使用AppDomain和Thread类,他们公开了AppDomain和线程的CLR视图。(这里的线程是指托管线程)一般不需要特殊权限来使用这两个类,虽然某些操作仍需要提升权限才可以。
八,前台线程和后台线程
CLR将每个线程要么视为前台线程,要么视为后台线程。一个进程中的所有前台线程停止运行时,CLR强制终止仍在运行的任何后台线程。这些后台线程被直接终止;不会抛出任何异常。
因此,前台线程应用于执行确实想完成的任务,比如将数据从内存缓冲区flash到磁盘。另外,应该为非关键的任务使用后台线程,比如重新计算电子表格的单元格。这是由于这些工作可以在应用程序重启时继续,而且如果用户想立即终止应用程序,就没有必要强迫该线程保持活动状态。
CLR要提供前台线程和后台线程的概念来更好地支持AppDomain。每个AppDomain都可以运行一个单独的应用程序,每个应用程序都有它自己的前台线程。如果一个应用程序退出,造成它的前台线程终止,则CLR仍然要保持活动并运行,使其它应用程序运行。所有应用程序都退出,它们所有的前台线程都终止后,整个进程就可以销毁了。
下面的代码演示了前台线程和后台线程的差异:
static void Main() { //创建一个新线程(默认为前台线程) Thread t = new Thread(Worker); //是线程成为一个后台线程 t.IsBackground = true; //启动线程 t.Start(); //如果t是一个前台线程,则应用程序大约10秒后才终止 //如果t是一个后台线程,则应用程序立即终止 Console.WriteLine("Return form Main."); } private static void Worker() { Thread.Sleep(10000); //模拟工作10秒 //下面这一行代码,只有由一个前台线程执行时,才会显示出来 Console.WriteLine("Return form Worker."); }
在一个线程的生存期中,任何时候都可以从前台线程变成后台线程,或者从后台线程变成前台线程。应用程序的主程序通过构造一个Thread对象来显示创建的任何线程都默认为前台线程。另一方面,线程池线程默认为后台线程。此外,由进入托管执行环境的本地(native)代码创建的任何线程都标记为后台线程。
重要提示:要尽量避免使用前台线程。作者有一次接受一个顾问工作,有个应用程序就是不终止。花了几小时研究问题后,才发现是一个UI组件显示的创建了一个前台线程(默认),这正是进程一直不终止的原因。后来修改组件用了线程池,从而解决了问题。执行效率也提升了。