25.2 Thread Overhead . 线程开销
线程是非常强大的,因为它允许windows 即使在“应用程序执行一个长时间运行的任务”情况下也能及时响应。另外,线程允许用户使用一个应用程序(比如“任务管理器”) 强制终止一个似乎已经冻结的应用程序。但是,和一切虚拟化机制一样,线程会有空间(内存消耗)和时间(运行时的执行性能)上的消耗。下面我们来更详细的探讨这种消耗,每个线程中,下面这些东西线程都具备:
■ 线程内核对象(Thread kernel object) OS为系统中创建每个线程都分配并初始化这些数据结构之一。在数据结构中,包含一组对线程进行描述的属性(在本章后面部分讨论)。数据结构中还包含所谓的线程上下文。上下文是一个内存块,其中包含了CPU的寄存器集合。Windows在一台x86 CPU 的计算机上运行时,线程上下文使用700字节的内存。对于x64和IA64 CPU,上下文分别使用约1240字节和2500字节的内存。
■ 线程环境块(Thread environment block,TEB)TEB是在用户模式下分配的内存块(应用程序代码能快速访问的地址空间)。TEB消耗一个内存也(x86和x64 CPU中是4kb,IA64 CPU是8kb)。TEB包含线程的异常处理链首;当线程在try块中存在,这个节点会从链中移除。另外,TEB包含线程的“线程本地存储”数据,以及由GDI和OpenGL图形使用的一些数据结构。
■ 用户模式栈(User-mode stack) 用户模式栈用于存储传给方法的局部变量和参数。还包含一个当当前方法返回时,将要执行的线程的地址。默认情况下,windows为每个线程的用户模式栈分配1MB的内存。
■ 内核模式栈(Kernel-mode stack)在OS中当应用程序传递参数给一个内核模式函数时,也会使用内核模式栈。 出于安全方面的原因,针对从用户模式的代码传给内核的任何实参,Windows都会把它们从线程的用户模式栈复制到线程的内核模式栈。一经复制,内核就可验证实参的值。由于应用程序代码不能访问内核模式栈,所以应用程序无法修改验证之后的实参值。OS内核代码将开始对复制的值进行处理。除此之外,内核会调用它自己内部的方法,并利用内核模式栈传递它自己的实参、存储函数的局部变量以及存储返回地址。在32位Windows上运行时,内核模式栈大小为12 KB;在64位Windows上运行时,大小则为24 KB。
■ DLL线程连接和线程分离通知(DLL thread-attach and thread-detach notifications)Windows的一个策略是,任何时候在进程中创建一个线程,都会调用那个进程中加载的所有DLL的DllMain方法,并向该方法传递一个DLL_THREAD_ATTACH标志。类似地,任何时候一个线程终止,都会调用进程中的所有DLL的DllMain方法,并向该方法传递一个DLL_THREAD_DETACH标志。有的DLL需要利用这些通知,为进程中创建/销毁的每个线程执行一些特殊的初始化或(资源)清理操作。例如,C-Runtime库DLL会分配一些线程本地存储状态。线程使用C-Runtime库中包含的函数时,需要用到这些状态。
在以前的windows里,很多进程只能加载5、6个DLL,但是现在,已经可以加载几百个DLL了。在很多机器里,Microsoft Office Outlook 在它的进程地址空间中大约加载了250个DLL。这意味着,如果一个新的线程在OfficeOutlook中创建,250个DLL函数会被调用,然后线程才能开始做它设计做的事情。Outlook中的线程终止时,这250个函数还要再重新调用易变。这严重影响了在进程创建和销毁线程时的性能。
现在,你已经知道了创建线程、让它进驻系统以及最后销毁它所需要的全部空间和时间开销。但是情况似乎更加糟糕-----现在我们来讨论下上下文切换。单CPU的计算机一次只能做一件事情。一次,windows必须在系统中的所有线程(逻辑CPU)之间共享物理CPU。
在任何给定的时间里,windows只将一个线程分配给一个CPU。那个线程可以运行一个“时间片”(time-slice or quantum)。当时间片结束,windows上下文就会切换到另一个线程。每个上下文切换都需要windows做一下操作:
1.将CPU寄存器中的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中。
2.从现有的线程中选择一个线程去调度。如果这个线程是被另一个进程所拥有,windows在开始执行任何代码或者接触任何数据之前,还必须切换CPU 看见的虚拟地址空间。
3.将所选上下文结构中的值加载到CPU的寄存器中。
上下文切换完成后,CPU会执行选择的线程直到它的时间片结束。然后另一个上下文切换发生。Windows大概每30ms 执行一次上下文切换。上下文切换是净开销;也就是说,上下文切换索产生的开销不会换来任何内存或是性能上的收益。Windows执行上下文切换,向用户提供一个健壮、响应灵敏的操作系统。
现在,如果一个应用程序的线程进入无限循环,windows会定期抢占(preempt)它,将一个不同的线程分配给一个实际的CPU,然后让这个新线程运行一会。假定新线程是任务管理器里的线程,现在终端用户就可以利用任务管理器来结束这个包含了无限循环线程的进程。之后,进程会终止,它处理的所有数据也会被销毁。但是,系统中的其他所有进程都继续运行,不会跌势它们的数据。当然,用户也不用重启。所以上下文切换通过牺牲性能来换取更好的用户体验。
实际上,性能上的损耗比你想想中的还要厉害。是的,当windows上下文切换到另一个线程的时候,会发生一定的性能损失。但是,CPU现在是要执行一个不同的线程,而之前的线程的代码和数据还在CPU的告诉缓存(cache)中,这使得CPU不必经常访问RAM(他的速度比CPU告诉缓存慢得多)。当windows上下文切换到一个新线程时,这个新线程极有可能要执行不同的代码并访问不同的数据,这些代码和数据不在CPU的告诉缓存中。因此,CPU必须访问RAM来填充它的告诉缓存,以回复高速执行状态。但是,在30ms后,一次新的上下文切换又发生了。
执行上下文切换的时间取决于不同的CPU架构和速度。而填充CPU缓存所需的时间取决于系统中运行的应用程序、CPU缓存的大小以及其他各种因素。所以,我不可能给你一个关于上下文切换时间的确切值,甚至是估计值。唯一确定的是,如果要构建高性能的应用程序和组件,就应该尽可能的避免上下文切换。
Important:在一个时间片结束的时候,如果windows决定再次调度同一个线程(而不是切换到另一个线程),那么windows不会执行上下文切换。相反,线程将继续运行。这显著的改进了性能。设计自己的代码的时候,注意能避免上下文切换的就避免。
Important:一个线程可以自动的提前结束它的时间片。这是经常发生的,因为线程经常要等待I/O操作(键盘,鼠标,文件,网络等等)来完成。比如,“记事本”程序的线程经常处于重现状态,什么事情都不做:这个线程是在等待输入。如果用户按键盘上的J键,windows会唤醒“记事本”线程,让它处理按键操作。“记事本”线程可能花费5ms来处理按键,然后调用一个win32函数,告诉windows它准备好处理下一个输入事件。如果没有更多的输入事件,windows会让“记事本”线程进入等待状态(时间片剩余的部分就放弃了),使线程在任何CPU上都不再调度,知道发生下一次输入事件。这增强了系统的总体性能,因为正在等待I/O操作的完成的线程不会在CPU上调度,所以不会浪费CPU时间;节省出来的时间可供在CPU上调度其他线程。
另外,执行GC的时候,CLR必须挂起所有的线程,遍历它们的栈来查找根以便对堆中的对象进行标记,再次遍历它们的栈(有的对象在压缩期间发生了移动,所以要更新它们的根),再恢复所有的线程。所以,减少线程的数量也会显著提升GC的性能。每次使用调试器并调试到一个断点,windows都会挂起正在调试的应用程序中的所有线程,并在但不执行或者运行应用程序时恢复所有线程。因此,你用的线程越多,调试体验也就越差。
根据上述讨论,我们的结论是必须尽可能的避免使用线程,因为它们要消耗大量内存而且它们需要很多时间去创建、销毁、管理。Windows在线程知己那进行上下文切换,以及在发生GC的时候也会浪费不少时间。然而,根据上述讨论,我们还得出了另一个结论,那就是有时必须使用线程,因为它们使windows更加健壮。
我还要指出的是,多核CPU的计算机可以真正同时运行几个线程,这提升了应用程序的可伸缩性(在少量的时间里做更多工作的能力)。Windows为每个CPU内核都分配一个线程,每个内核都自己执行到其他线程的上下文切换。Windows确保单个线程不会同事在多个内核上调度,因为这会带来巨大的混乱。今天许多计算机都包含了多个CPU、超线程CPU或者多核CPU。但是,windows最初设计的时候,单CPU计算机才是主流,所以windows设计类线程来增强系统的响应能力和可靠性。今天,线程还被用于增强应用程序的伸缩性,但在只有在多核计算机上才有可能发生。
在本书的剩余章节,将讨论如何利用windows和CLR提供的各种机制,在保持代码的响应能力的同时创建尽量少的线程;同时,如果代码在多核计算机上运行,还提升它的可伸缩性。