原文:[CLR via C#]25. 线程基础
一、Windows为什么要支持线程
Microsoft设计OS内核时,他们决定在一个进程(process)中运行应用程序的每个实例。进程不过是应用程序的一个实例要使用的资源的一个集合。每个进程都赋予了一个虚拟地址空间,确保一个进程使用的代码和数据无法由另一个进行访问。这样就确保了应用程序集的健壮性,因为一个进程无法破坏另一个进程里的数据和代码。另外,进程是无法访问到OS的内核代码和数据。
如果一个应用程序进入死循环时,如果只是单核的CPU的,它会无限循环执行下去,不能执行其他代码,这样会使系统停止响应。对此,Microsoft拿出的一个解决方案——线程。线程的职责就是对CPU的虚拟化。Windows为每个进程都提供了该进程专用的线程(功能相当于一个CPU,可将线程理解成一个逻辑CPU)。如果应用程序的代码进入无限循环,与那个代码关联的进程会"冻结",但其他进程不会冻结,会继续执行。
二、线程开销
线程尽管非常强悍,但和一切虚拟化机制一样,线程会产生空间(内存耗用)和时间(运行时的执行性能)上的开销。
- 线程内核对象(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需要利用这些通知,为进程中创建和销毁的每个线程执行一些特殊的初始化或资源清理操作。
现在,你已经知道了创建线程、让它进驻系统以及最后销毁它所需要的全部空间和时间的开销。现在我们开始讨论上下文切换。
单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确保单个线程不会同时在多个内核上调度。
三、停止疯狂
如果追求性能,那么任何计算机最优的线程数就是那台计算机的CPU个数。如果线程数超过了CPU的个数那么就会发生线程上下文切换和性能损失。
在Windows中,创建一个进程的代价是昂贵的。创建一个进程通常要花几秒钟的时间,必须分配大量的内存,这些内存必须初始化,EXE和DLL文件必须从磁盘上加载等等。相反,在Windows创建线程是十分廉价的。所以,开发人员决定停止创建进程,改为创建线程。这就是我们看到有这么多线程的原因。但是,线程相对于其它系统资源还是比较昂贵的,所以还是应该省着用。
必须承认,系统中的大多数线程都是本地代码创建的。所以,线程的用户模式栈仅仅保留(预定)地址空间,而且极有可能没有完全提交来获取物理内存。然而,随着越来越多的应用程序成为托管应用程序,或者在其中运行托管组件,会有越来越多的栈被完全提交,会真实的分配到1MB的物理内存。无论如何,即使抛开用户模式栈不谈,所有线程仍然会分配到内核模式栈以及其它资源。这种觉得线程十分廉价便胡乱创建线程的势头必须停止。
四、CLR线程和Windows线程
CLR现在用的是Windows的线程处理能力。
虽然现在CLR线程直接对应一个Windows线程,但Microsoft CLR团队保留了将来把它从Windows线程中分离的权限。有一天,CLR可能引入它自己的逻辑线程概念,使一个CLR逻辑线程并非一定映射到一个物理Windows线程。据说,逻辑线程将使用比物理线程少的多的资源,所以能在极少量的物理线程上运行大量的逻辑线程。
五、使用专用线程执行异步的计算限制操作
本节将展示如何创建一个线程,并让它执行一次异步计算限制操作。虽然会教你具体如何做,但是强烈建议你避免采用这里展示的技术。相反,应该尽量使用CLR的线程池来执行异步计算限制操作,具体以后会讨论。
如果执行的代码要求处于一种特定的状态,而这种状态对于线程池的线程来说是非比寻常的,就可以考虑创建一个线程 。例如,满足以下任意一个条件,就可以显式创建自己的线程。
- 线程需要以非普通线程优先级运行。所有线程池线程都以普通优先级运行;虽然可以改变这种优先级,但不建议这样做。另外,在不同的线程池操作之间,对优先级的更改是无法持续的。
- 需要线程表现为一个前台线程,防止应用程序在线程结束它的任务之前终止。线程池的线程都是后台线程。如果CLR想要终止线程,它们可能被迫无法完成任务。
- 一个计算限制的任务需要长时间运行。线程池为了判断是否需要创建一个额外的线程,所采用的逻辑是比较复杂的。直接为长时间运行的任务创建一个专用线程,则可以避免这个问题。
- 要启动一个线程,并可能调用Thread的Abort方法来提前终止它。
为了创建一个专用线程,要构造System.Thearding.Thread类的一个实例,向它的构造器传递一个方法的的名称。以下是Thread构造器的原型:
public sealed class Thread : CriticalFinalizerobject,... { public Thread(ParameterizedThreadStart start); //这里没有列出不常用的构造器 }
start参数标识专用线程要执行的方法,这个方法必须和ParameterizedThreadStart委托的签名匹配。
delegate void ParameterizedThreadStart(Oject obj);
构造Thread对象是一个轻量级操作,因为它并不实际创建一个操作系统线程。要实际创建操作系统线程,并让它开始执行回调方法,必须调用Thread的Start方法,向它传递要作为回调方法的实参传递的对象(状态)。以下代码演示了如何创建一个专用线程,并让它异步调用一个方法:
internal static class FirstThread { public static void Go() { 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); // 模拟做其它工作(10 秒钟) dedicatedThread.Join(); // 等待线程终止 Console.ReadLine(); } // 这个方法的前面必须和ParametizedThreadStart委托匹配 private static 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
但有的时候运行上述代码,也可能得到以下结果,因为我无法控制Windows对两个线程进行调度的方式:
Main thread: starting a dedicated thread to do an asynchronous operation
In ComputeBoundOp: state=5
Main thread: Doing other work here...
注意Go()方法调用的Join。Join方法造成调用线程阻塞当前执行的任何代码,直到dedicatedThread所代表的那个线程销毁或终止。
六、使用线程的理由
使用线程有以下三方面的理由:
- 可以使用线程将代码同其他代码隔离 这将提高应用程序的可靠性。事实上,这正是Windows在操作系统中引入线程概念的原因。
- 可以使线程来简化编码 有的时候,如果通过一个任务自己的线程来执行该任务,编码会变得更简单。通常,在你引入线程时,引入的是要相互协作的代码,它们可能要求线程同步构造知道另一个线程在什么时候终止。一旦开始涉及协作,就要使用更多的资源,同时会使代码变得更复杂。所以,在开发使用线程之前,务必确定线程真的能帮到你。
- 可以用线程来实现并发处理 如果知道自己的应用程序要在多CPU机器上运行,那么让多个任务同时运行,就能提高性能。
七、线程调度和优先级
抢占式(preemptive)操作系统必须使用某种算法判断在什么时候调度哪些线程多长时间。本节讨论Windows采用的算法。在前面,已经提到过每个线程的内核对象都包含一个上下文结构。上下文结构反映了当线程上一次执行时,线程的CPU寄存器的状态。在一个时间片之后,Windows检查现有的所有线程内存对象。在这些对象中,只有那些没有正在等待什么的线程才适合调度。 Windows选择一个可调度的线程内核对象,并上下文切换到它。Windows实际记录了每个线程被上下文切换到的次数。可以使用向Microsoft Spy++这样的工具查看这个数据。
Windows之所以被称为一种抢占式多线程操作系统,是因为线程可以在任何时间被停止(被抢占),并调度另一个线程。所以,你不能保证自己的线程一直在运行,不能阻止其他线程的运行。
每个线程都分配了从0(最低)—31(最高)的一个优先级。系统决定将哪个线程分配给一个CPU时,它首先检查优先级31的线程,并以一种轮流的方式调度它们。
只要存在可以调度的优先级31的线程,系统永远不会将优先级0-30的任何线程分配给CPU。这种情况称为饥饿(starvation)。当较高优先级的线程占用了太多的CPU时间,致使较低优先级的线程无法运行时,就会发生这种情况。在多处理器机器上饥饿发生的可能性要小得多,因为这种机器上优先级31的线程和优先级30的线程可以同时运行。系统总是保持各CPU处于忙碌状态,只有没有线程可调度的时候,CPU才空闲下来。
较高优先级的线程总是抢占较低优先级的线程,无论正在运行的是什么较低优先级的线程。
系统启动时,会创建一个名为零页线程的特殊线程。这个线程的优先级定位0,而且整个系统中唯一一个优先级为0的线程。零页线程负责在么有其它线程需要执行的时候,将系统的RAM的所有空闲页清零。
设计应用程序时,应决定自己的应用程序是需要比机器上同时运行的其它应用程序更高还是更低的响应能力。然后,选择一个进程优先级类(priority class)来反映你的决定。Windows支持6个进程优先级类:Idle(空闲),Below Noral(低于标准),Normal(标准),Above Normal(高于标准),High(高)和Realtime(实时)。由于Normal是默认优先级类,所以它是最常用的优先级类。
优先级类和优先级是两个不同的概念。根据定义,线程的优先级取决于两个标准:1)它的进程优先级类 2)在其进程的优先级类中,线程的优先级。进程优先级类和线程优先级构成了一个线程的"基础优先级"。注意,每个线程都有一个动态优先级。线程调度器是根据优先级来决定运行哪个线程。最初,线程的动态优先级适合基础优先级一样的,系统可提升或降低动态优先级,以确保它的响应,并避免现在在处理器时间内"饥饿"。但是,基础优先级在16-31之间的优先级线程,系统不会提升它们的优先级,在0-15优先级之间的线程才会被动提升优先级。
如果一个应用程序(比如屏幕保护程序),在系统什么事情都不做的时候运行,就适合分配Idle优先级类。一些执行统计学跟踪分析的应用程序需要定期更新于体统有关的状态,这种应用程序一般不应该妨碍执行更关键的任务。
只有在绝对必要的时候才应使用High优先级类。Realtime优先级类要经可能的避免。Realtime优先级相当高,它甚至可能干扰操作系统任务,比如阻碍一些必要的磁盘I/O和网络传输。
选好一个优先级类后,就不要再思考你的应用程序和其他应用程序的关系了。现在,应该将所有注意力放在应用程序中的线程上。Windows支持7个相对线程优先级:Idle(空闲),Lowest(最低),Below Normal(低于标准),Normal(标准),Above Normal(高于标准),Highest(最高)和Time-Critical(关键时间(最高的相对线程优先级))。这些优先级相对于进程优先级类的。同样的,由于Normal是默认的相对线程优先级,所以最常用。
这里并没有提到有关0~31的优先级的任何内容。开发者从来不用具体设置一个线程的优先级,也就是不需要将一个线程优先级设置为0~31中的一个。操作系统负责将“优先级类”和“相对线程优先级”映射到一个具体的优先级上。这种映射方式,是随Windows版本的不同而不同的。
线程相对 优先级 |
进程优先级类 |
|||||
---|---|---|---|---|---|---|
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视图。一般不需要特殊安全权限来使用这两个类,虽然某些操作仍需要提升权限才可以。
八、前台线程和后台线程
CLR将每个线程要么视为前台线程,要么视为后台线程。一个进程中的所有前台线程停止时,CLR会强制终止仍然在运行的任何后台进行。这些后台进程被直接终止,不会抛出异常。
因此,前台进程应该用于执行确实想完成的任务,比如将数据从内存缓存区fluch到磁盘。另外,应该为非关键的任务使用后台线程,比如重新计算电子表格的单元格,或者为记录建立索引。这是由于这些工作能在应用程序重启时继续,而且如果用户终止应用程序,就没有必要强迫它保持活动状态。
CLR要提供前台线程和后台线程的概念来更好地支持AppDomain。每个AppDomain都可以运行一个单独的应用程序,每个应用程序都有它自己的前台线程。如果一个应用程序退出,造成它的前台线程终止,则CLR仍然需要保持活动并运行,使其他应用程序继续运行。所有应用程序都退出,它们的所有前台线程都终止后,整个进程就可以被销毁了。
public class Program { public static void Main() { // 创建一个线程 (默认是前台进程) Thread t = new Thread(Worker); // 将前台进程变成后台进程 t.IsBackground = true; t.Start(); // 启动线程 // 如果t是一个前台进程,则应用程序大约10秒后才终止 // 如果t是一个后台进程,则应用程序立即终止 Console.WriteLine("Ruturning from Main"); Console.Read(); } private static void Worker() { Thread.Sleep(10000); // 模拟做10秒钟工作 Console.WriteLine("Ruturning from Worker"); } }
在一个线程的生存期,任何时候可以从前台变成后台,或者从后台变成前台。应用程序的主线程以及通过构造一个Thread对象来显式创建的任何线程都默认为前台线程。另一方面,线程池默认为后台线程。此外,由进入托管执行环境的本地代码创建的任何线程都被标记为后台线程。
提示:要尽量避免使用前台线程。