进程开销

 

线程是非常强悍的一个概念,因为它们使Windows即使在执行长时间运任务时也能随时响应。另外,线程允许用户使用一个应用程序(比如“任务管理器”)强制终止似乎已经冻结的一个应用程序(它也有可能正在执行一个长时间运行的任务)。但是,和一切虚拟化机制一样,线程会产生空间(内存耗用)和时间(运行时的执行性能)上的开销。

 

下面更详细地探讨这种开销。每个线程中,都有以下要素:

l线程内核对象thread kernel objectOS为系统中创建的每个线程都分配并初始化这种数据结构之一。在该数据结构中,包含一组对线程进行描述的属性。数据结构中还包含所谓的线程上下文。上下文是一个内存块,其中包含了CPU的寄存器集合。Windows在一台使用x86 CPU的计算机上运行时,线程上下文使用约700字节的内存。对于x64IA64 CPU,上下文分别使用约1024字节和2500字节的内存

l线程环境块thread environment blockTEBTEB是在用户模式(应用程序代码能够快速访问的地址空间)中分配和初始化的一个内存块。TEB耗用1个内存页。TEB包含线程的异常处理链表头部。线程进入try块都在链表头部插入一个节点。线程退出try块时,会从链表中删除该节点。除此之外,TEB还包含线程的“线程本地存储”数据,以及由GDIGraphics Device Interface,图形设备接口)和OpenGL图形使用的一些数据结构

l用户模式栈用户模式栈用于存储传给方法的局部变量和实参。它还包含一个地址,指向当前方法返回时,线程接着应该从什么地方开始执行。默认情况下,Windows为每个线程的用户模式栈分配1MB的内存

l内核模式栈应用程序代码向操作系统中的一个内核模式的函数传递实参时,还会使用内核模式栈。出于安全方面的原因,针对从用户模式的代码传递给内核的任何实参,Windows都会把它们从线程的用户模式栈复制到线程的内核模式栈。一经复制,内核就可验证实参的值。由于应用程序代码不能访问内核模式栈,所以应用程序无法修改验证之后的实参值。OS内核代码将开始对复制的值进行处理。除此之外,内核会调用它自己内部的方法,并利用内核模式栈传递它自己的实参、存储函数的局部变量以及返回地址。在32Windows上运行时,内核模式栈大小为12KB,在64Windows上为24KB

lDLL线程附加(attach)和线程分离(detach通知 Windows的一个策略是,任何时候在进程中创建一个线程,都会调用那个进程中加载的所有DLLDLLMain方法,并向该方法传递一个DLL_THREAD_ATTACH标志。类似地,任何时候一个线程终止,都会调用进程中的所有DLLDLLMain方法,并向该方法传递一个DLL_THREAD_DETACH标志。所有的DLL需要利用这些通知,为进程中创建/销毁的每个线程执行一些特殊的初始化或资源清理操作。例如,C-RuntimeDLL会分配一些线程本地存储状态。线程使用C-Runtime库中包含的函数时,需要用到这些状态

 

Windows的早期岁月,许多进程只加载了5个或6DLL。但如今,有的进程可能加载几百个DLL。就拿目前来说,在我的机器上,Microsoft Office Outlook在它的进程地址空间中加载了大约250DLL。这意味着每次在Outlook中创建新线程,都必须先调用250DLL函数,然后线程才能开始做它的事。Outlook中的线程终止时,这250个函数还要再调用一遍。这严重影响了再进程中创建和销毁线程时的性能。

 

现在,你已经知道了创建线程,让它进驻系统以及最后销毁它所需要的全部空间和时间开销。但还没有完——现在开始讨论上下文切换。单CPU的计算机一次只能做一件事,所以,Windows必须在系统中的所有线程(逻辑CPU)之间共享物理CPU

在任何给定的时刻,Windows只将一个线程分配给一个CPU。那个线程允许运行一个“时间片”(有时也称为“量”或者“量程”,即quantum)。一旦时间片到期,Windows就上下文切换到另一个线程。每次上下文切换都要求Windows执行以下操作。

 

1.CPU寄存器中的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中

2.从现有线程集合中选出一个线程供调度。如果该线程由另一个进程拥有,Windows在开始执行任何代码或者接触任何数据之前,还不许切换CPU“看见”的虚拟地址空间

3.将所选上下文结构中的值加载到CPU的寄存器中

 

上下文切换完成后,CPU执行所选的线程,知道它的时间片到期。然后,会发生另一次上下文切换。Windows大约每30毫秒执行一次上下文切换。上下文切换是净开销,也就说,上下文切换所产生的开销不会带来任何内存或性能上的收益。Windows执行上下文切换,向用户提供一个健壮的、响应灵敏的操作系统。

 

现在,如果一个应用程序的线程进入无限循环,Windows会定期抢占(preempt)它,将一个不同的线程分配给一个实际的CPU,让新线程运行一会。假定新线程是“任务管理器”的线程,用户可利用“任务管理器”终止包含了无线循环线程的进程。之后,进程会终止,它处理的所有数据也会被销毁。但是,系统中的其他进程都继续运行,不会丢失它们的数据。当然,用户也不必重启计算机。所以,上下文切换通过牺牲性能换来了好得多的用户体验。

 

事实上,上下文切换对性能的影响可能超出你的想象。是的,当Windows上下文切换到另一个线程时,会发生一定的性能损失。但是,CPU现在是要执行一个不同的线程,而之前的线程的代码和数据还在CPU的高速缓存中,这使CPU不必经常访问RAM。当Windows上下文切换到一个新线程时,这个新线程极有可能要执行不同的代码并访问不同的数据,这些代码和数据不在CPU的高速缓存中。因此,CPU必须访问RAM来填充它的高速缓存,以恢复高速执行状态。但是,在30毫秒之后,一次新的上下文切换又发生了。

 

执行上下文切换所需的时间取决于CPU架构和速度。而填充CPU缓存所需的时间取决于系统中运行的应用程序、CPU缓存的大小以及其他各种因素。所以,无法为每一次上下文切换的时间开销给出一个确定的值,甚至无法给出一个估计的值。唯一确定的是,如果要构建高性能的应用程序组件,就应尽可能地避免上下文切换。

重要:一个时间片结束时,如果Windows决定再次调度同一个线程,那么Windows不会执行上下文切换。相反,线程将继续运行,这显著改进了性能。设计自己的代码时注意,上下文切换能避免的就要尽量避免。

 

线程可自主提前终止其时间片。这是经常都会发生的一件事情,因为线程经常要等待I/O操作。例如“记事本”程序经常都会处于空闲状态,什么事情都不做,这个线程是在等待输入。如果用户按键盘上的J键,Windows会唤醒该线程,让它处理按键操作。该线程可能花5毫秒处理按键,然后调用一个Win32函数,告诉Windows它准备好处理下一个输入事件。如果没有更多的输入,Windows会使该线程进入等待队列(时间片的剩余的部分就放弃了),使线程在任何CPU上都不再调度,直到发生下一次输入事件。这增强了系统的总体性能。因为正在等待I/O操作完成的线程不会在CPU上调度,所以不会浪费CPU时间,节省出来的时间可供其他线程使用。

 

除此之外,执行垃圾回收时,CLR必须挂起所有线程,遍历它们的栈来查找根以便对堆中的对象进行标记,再次遍历它们的栈(有的对象在压缩期间发生了移动,所以要更新它们的根),再恢复所有线程。所以,减少线程的数量也会显著提升垃圾回收器的性能。每次使用一个调试器并遇到一个断点,Windows都会挂起正在调试的应用程序中的所有线程,并在单步执行或者运行应用程序时恢复所有线程。因此,你用的线程越多,调试体验也就越差。

 

根据上述讨论,我们的结论是必须尽可能地避免使用线程,因为它们要耗用大量内存,而且需要相当多的时间来创建、销毁和管理。Windows在线程之间进行上下文切换,以及在发生垃圾回收的时候,也会浪费掉不少时间。然而,根据上述讨论,我们还得出了另一个结论,那就是有时必须使用线程,因为它们使Windows变得更健壮,反应更灵敏。

 

应该指出的是,安装了多个CPU(或者一个多核CPU)的计算机可以真正同时运行几个线程,这提升了应用程序的可伸缩性(在少量时间里做更多工作的能力)。Windows为每个CPU内核都分配一个线程,每隔内核都自己执行到其他线程的上下文切换。Windows确保单个线程不会同时在多个内核上调度,因为这会带来巨大的混乱。今天,许多计算机都包含了多个CPU,超线程CPU或者多核CPU。但是,Windows最初设计时,单CPU计算机才是主流,所以Windows设计了线程来增强系统的响应能力和可靠性。今天,线程还被用于增强应用程序的可伸缩性,但在只有在多CPU计算机才有可能发生

强应用程序的可伸缩性,但在只有在多CPU计算机才有可能发生
posted @ 2019-11-28 15:03  路一  阅读(329)  评论(0编辑  收藏  举报