第二十五章 线程基础
2014-06-28
25.1 Windows为什么要支持线程
Microsoft设计OS内核时,他们决定在一个进程(process)中运行应用程序的每个实例。进程不过是应用程序的一个实例要使用的资源的一个集合。每个进程都赋予了一个虚拟地址空间,确保一个进程使用的代码和数据无法由另一个进行访问。这样就确保了应用程序集的健壮性,因为一个进程无法破坏另一个进程里的数据和代码。另外,进程是无法访问到OS的内核代码和数据。
如果一个应用程序进入死循环时,如果只是单核的CPU的,它会无限循环执行下去,不能执行其他代码,这样会使系统停止响应。对此,Microsoft拿出的一个解决方案——线程。线程的职责就是对CPU的虚拟化。Windows为每个进程都提供了该进程专用的线程(功能相当于一个CPU,可将线程理解成一个逻辑CPU)。如果应用程序的代码进入无限循环,与那个代码关联的进程会"冻结",但其他进程不会冻结,会继续执行。
25.2 线程开销
线程尽管非常强悍,但和一切虚拟化机制一样,线程会产生空间(内存耗用)和时间(运行时的执行性能)上的开销。
- 线程内核对象(thread kernel object) OS为系统中创建的每个线程都分配并初始化这种数据结构。在该数据结构中,包含一组对线程进行描述的属性。 数据结构中还包含所谓的线程上下文(thead context)。上下文是一个内存块,其中包含了CPU的寄存器集合。Windows在一台x86CPU的计算机运行时,线程上下文使用约700字节的内存。对于x64和IA64CPU,上下文分别使用约1240字节和2500字节的内存。
- 线程环境块(thread environment block,TEB) TEB是在用户模式中分配和初始化的一个内存块。TEB耗用1个内存页(x86和x64CPU中是4KB,IA64CPU中是8K)。TEB包含线程的异常处理链首。线程进入的每个try块都在链首插入一个节点。线程退出try块时,会从链中删除该节点。除此之外,TEB还包括线程的"线程本地存储"数据,以及由GDI和OpenGL图形使用的一些数据结构。
- 用户模式栈(user-mode stack) 用户模式栈用于存储传给方法的局部变量和实参。它还包含一个地址:指向当前方法返回时,应该接着从哪个地址开始执行。默认情况下,Windows为每个线程的用户模式分配1MB的内存。
- 内核模式栈(kernel-model stack) 应用程序代码向OS中的一个内核模式的函数传递实参时,会使用内核模式栈。出于安全方面的原因,针对从用户模式的代码传给内核的任何实参,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做以下操作:
- 将CPU寄存器中的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中。
- 从现有线程集合中选出一个线程供调度(这个就是要切换到的线程)。如果该线程由另一个进程拥有,Windows在开始执行任何代码或者任何数据之前,还必须切换CPU"看见"的虚拟地址空间。
- 将所选上下文结构中的值加载到CPU的寄存器中。
上下文切换完成后,CPU执行所选的线程,直到它的时间片到期。然后,会发生另一次上下文切换。Windows大约每30毫秒执行一次上下文切换。上下文切换是净开销;也就是说,上下文切换所产生的开销不会换来任何内存或性能上的收益。Windows执行上下文切换,向用户提供一个健壮的、响应灵敏的操作系统。
事实上,上下文切换对性能的影响可能超出你的想象:
- CPU现在是要执行一个不同的线程,而之前的线程代码和数据还保存在CPU的高速缓存中,这使CPU不必进程访问RAM。当Windows上下文切换到一个新的线程时,这个新线程极有可能要执行不同的代码和数据,这些数据不再CPU的高速缓存中,因此,CPU必须访问RAM来填充它的高速缓存,以恢复告诉执行状态。但是,在30毫秒之后,一次新的上下文切换又发生了。
- 除此之外,执行垃圾回收时,CLR必须挂起所有线程,遍历它们的栈来查找根以便对堆中的对象进行标记,再次遍历它们的栈,再次恢复所有线程。所以,减少线程的数量也会显著提升垃圾回收器的功能。
根据上述讨论,我们的结论是必须尽可能地避免使用线程,因为它们要耗用大量内存,而且需要相当多的时间来创建、销毁和关联。WIndows在进行上下文切换,以及垃圾回收时也会浪费更多的时间。但是不可否认,因为才是Windows变得更健壮,反应更灵敏。
应该指出,安装多个CPU的计算机可以真正同时允许几个线程,这提升应用程序的可伸缩性(在更少的时间内做更多的事)。Windows为每个CPU内核都分配一个线程,每个内核都自己执行到其他线程的上下文切换。Windows确保单个线程不会同时在多个内核上调度。
25.3 停止疯狂
如果追求性能,那么任何计算机最优的线程数就是那台计算机的CPU个数。如果线程数超过了CPU的个数那么就会发生线程上下文切换和性能损失。
在Windows中,创建一个进程的代价是昂贵的。创建一个进程通常要花几秒钟的时间,必须分配大量的内存,这些内存必须初始化,EXE和DLL文件必须从磁盘上加载等等。相反,在Windows创建线程是十分廉价的。所以,开发人员决定停止创建进程,改为创建线程。这就是我们看到有这么多线程的原因。但是,线程相对于其它系统资源还是比较昂贵的,所以还是应该省着用。
必须承认,系统中的大多数线程都是本地代码创建的。所以,线程的用户模式栈仅仅保留(预定)地址空间,而且极有可能没有完全提交来获取物理内存。然而,随着越来越多的应用程序成为托管应用程序,或者在其中运行托管组件,会有越来越多的栈被完全提交,会真实的分配到1MB的物理内存。无论如何,即使抛开用户模式栈不谈,所有线程仍然会分配到内核模式栈以及其它资源。这种觉得线程十分廉价便胡乱创建线程的势头必须停止。
25.6 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。
25.7 使用专用线程执行异步的计算限制操作
本节将展示如何创建一个线程,并让它执行一次异步计算限制操作。虽然会教你具体如何做,但是强烈建议你避免采用这里展示的技术。相反,应该尽量使用CLR的线程池来执行异步计算限制操作,具体以后会讨论。
如果执行的代码要求处于一种特定的状态,而这种状态对于线程池的线程来说是非比寻常的,就可以考虑创建一个线程 。例如,满足以下任意一个条件,就可以显式创建自己的线程:
- 线程需要以非普通线程优先级运行。所有线程池线程都以普通优先级运行;虽然可以改变这种优先级,但不建议这样做。另外,在不同的线程池操作之间,对优先级的更改是无法持续的。
- 需要线程表现为一个前台线程,防止应用程序在线程结束它的任务之前终止。线程池的线程都是后台线程。如果CLR想要终止线程,它们可能*无法完成任务。
- 一个计算限制的任务需要长时间运行。线程池为了判断是否需要创建一个额外的线程,所采用的逻辑是比较复杂的。直接为长时间运行的任务创建一个专用线程,则可以避免这个问题。
- 要启动一个线程,并可能调用Thread的Abort方法来提前终止它。
为了创建一个专用线程,要构造一个System.Threading.Thread类的一个实例,向它传递方法的名称,它的构造器如下:
1 public sealed class Thread : CriticalFinalizerObject, _Thread 2 { 3 public Thread(ParameterizedThreadStart start){ } 4 //这里没有列出一些不常用的构造器 5 }
ParameterizedThreadStart委托的签名如下:
public delegate void ParameterizedThreadStart(object obj);
下面的代码演示如何创建一个专用线程,让它异步调用一个方法:
1 public void AsynchronousThreadDemo() 2 { 3 Console.WriteLine("Main thread: starting a dedicated thread " + 4 "to do an asynchronous operation"); 5 Thread dedicateThread = new Thread(ComputeBoundOp); 6 dedicateThread.Start(5); 7 8 Console.WriteLine("Main thread: Doing other work here..."); 9 Thread.Sleep(5000); 10 11 dedicateThread.Join();//等待线程终止 12 Console.WriteLine("Main exit"); 13 } 14 15 private void ComputeBoundOp(object state) 16 { 17 //这个个方法由一个专用线程执行 18 Console.WriteLine("In ComputeBoundOp: state={0}", state); 19 Thread.Sleep(1000);//模拟其他任务(1秒) 20 //这个方法返回后,专用线程终止 21 }
运行程序,可能得到下面的输出:
Main thread: starting a dedicated thread to do an asynchronous operation
Main thread: Doing other work here...
In ComputeBoundOp: state=5
Main exit
但有的时候运行上述代码,也可能得到以下结果,因为我无法控制Windows对两个线程进行调度的方式:
Main thread: starting a dedicated thread to do an asynchronous operation
In ComputeBoundOp: state=5
Main thread: Doing other work here...
Main exit
25.8 使用线程的理由
- 可以使线程将代码同其他代码隔离。这将提高应用程序的可靠性。事实上,这也正是Windows在操作系统中引入线程概念的原因。Windows之所以要用线程来获得可靠性,是因为你的应用程序对于操作系统来说是第三方组件,而Microsoft不会在你发布程序之前对这些代码进行验证。
- 可以使用线程来简化编码 。有时候,如果通过一个任务自己的线程来执行该任务,编码会变得更简单。但是,如果这样做,肯定要使用额外的资源。代码不是十分“经济”。
- 可以用线程来实现并发执行。如果知道自己的应用程序要在多CPU的机器上运行,那么让多个任务同时运行,就能提高性能。现在多核CPU相当普遍,所以设计应用程序来使用多个内核是有意义的。
25.9 线程调度和优先级
返回
抢占式(preemptive)操作系统必须使用某种算法判断在什么时候调度哪些线程多长时间。本节讨论Windows采用的算法。在前面,已经提到过每个线程的内核对象都包含一个上下文结构。上下文结构反映了当线程上一次执行时,线程的CPU寄存器的状态。在一个时间片之后,Windows检查现有的所有线程内存对象。在这些对象中,只有那些没有正在等待什么的线程才适合调度。Windows选择一个可调度的线程内核对象,并上下文切换到它。Windows实际记录了每个线程被上下文切换到的次数。可以使用向Microsoft Spy++(Visual studio的一个小工具)这样的工具查看这个数据。
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的线程优先级,是由进程的优先级类和其中的一个线程的相对优先级映射而来的。下图展现了这种映射关系:
线程相对 优先级 |
进程优先级类 |
|||||
---|---|---|---|---|---|---|
Idle |
Below Normal |
Normal |
Above Normal |
High |
Real-Time |
|
Time-critical |
15 |
15 |
15 |
15 |
15 |
31 |
Highest |
6 |
8 |
10 |
12 |
15 |
26 |
Above normal |
5 |
7 |
9 |
11 |
14 |
25 |
Normal |
4 |
6 |
8 |
10 |
13 |
24 |
Below normal |
3 |
5 |
7 |
9 |
12 |
23 |
Lowest |
2 |
4 |
6 |
8 |
11 |
22 |
Idle |
1 |
1 |
1 |
1 |
1 |
16 |
请注意,表中线程优先级没有为0的。这是因为0优先级保留给零页线程了,系统不允许其他线程的优先级为0。而且,以下优先级也是不可获得的:17,18,19,20,21,27,28,29和30。当然,如果编写的是运行在内核模式的设备却、驱动程序,可以获得这些优先级。
注意:"进程优先级类"的概念容易引起一些混淆。人们可能认为这意味着Windows能调度进程。然而,Windows永远不会调度进程;它调度的只有线程。"进程优先级类"是Microsoft提出的一个抽象概念,旨在帮助你理解自己的应用程序和其它正在运行应用程序的关系,它没有其它用途。
提示:最好是降低一个线程的优先级,而不是提升另一个线程的优先级。
在你的应用程序中可以更改它的线程的相对线程优先级,这需要设置Thread的Priority属性,向它传递ThreadPriority枚举类型中定义的5个值之一,即Lowest(最低),Below Normal(低于标准),Normal(标准),Above Normal(高于标准),Highest(最高)。CLR为自己保留了Idle和Time-Critical优先级。
应该指出的是,System.Diagnostics命名空间包含一个Process类和一个ProcessThread类。这两个类分别提供了进程和线程的Windows视图。应用程序需要以特殊的安全权限运行才能使用这两个类。例如,在Silverlight应用程序或者ASP.NET应用程序中,就不可以使用这两个类。
另一方面,应用程序可使用AppDomain和Thread类,它们公开了AppDomain和线程的CLR视图。一般不需要特殊安全权限来使用这两个类,虽然某些操作仍需要提升权限才可以。
25.10 前台线程和后台线程
CLR将每个线程要么视为前台线程,要么视为后台线程。一个进程中的所有前台线程停止时,CLR会强制终止仍然在运行的任何后台进行。这些后台进程被直接终止,不会抛出异常。
因此,前台进程应该用于执行确实想完成的任务,比如将数据从内存缓存区fluch到磁盘。另外,应该为非关键的任务使用后台线程,比如重新计算电子表格的单元格,或者为记录建立索引。这是由于这些工作能在应用程序重启时继续,而且如果用户终止应用程序,就没有必要强迫它保持活动状态。
CLR要提供前台线程和后台线程的概念来更好地支持AppDomain。每个AppDomain都可以运行一个单独的应用程序,每个应用程序都有它自己的前台线程。如果一个应用程序退出,造成它的前台线程终止,则CLR仍然需要保持活动并运行,使其他应用程序继续运行。所有应用程序都退出,它们的所有前台线程都终止后,整个进程就可以被销毁了。
在一个线程的生存期,任何时候可以从前台变成后台,或者从后台变成前台。应用程序的主线程以及通过构造一个Thread对象来显式创建的任何线程都默认为前台线程。另一方面,线程池默认为后台线程。此外,由进入托管执行环境的本地(native)代码创建的任何线程都被标记为后台线程。
下面的代码演示了前台线程和后台线程的差异:
1 static void Main() 2 { 3 //创建一个新线程(默认为前台线程) 4 Thread t = new Thread(Worker); 5 6 //是线程成为一个后台线程 7 t.IsBackground = true; 8 9 //启动线程 10 t.Start(); 11 12 //如果t是一个前台线程,则应用程序大约10秒后才终止 13 //如果t是一个后台线程,则应用程序立即终止 14 Console.WriteLine("Return form Main."); 15 } 16 private static void Worker() 17 { 18 Thread.Sleep(10000); //模拟工作10秒 19 20 //下面这一行代码,只有由一个前台线程执行时,才会显示出来 21 Console.WriteLine("Return form Worker."); 22 }
重要提示:要尽量避免使用前台线程。作者有一次接受一个顾问工作,有个应用程序就是不终止。花了几小时研究问题后,才发现是一个UI组件显示的创建了一个前台线程(默认),这正是进程一直不终止的原因。后来修改组件用了线程池,从而解决了问题。执行效率也提升了
参考
[1] 《CLR via C#》笔记——线程基础 http://www.cnblogs.com/xiashengwang/archive/2012/07/20/2601108.html