CLR via C# 笔记 -- 线程基础(26)

1. Microsoft 设计这个OS内核时,决定在一个进程中运行应用程序的每个实例。进程实际是应用程序的实例要使用的资源的集合。每个进程都被赋予了一个虚拟地址空间,确保在一个进程中使用的代码和数据无法由另一个进程访问。

2. 如果发生死循环会停止响应,所以有了线程,线程的职责是对CPU进行虚拟化。Windows为每个进程都提供了该进程专用的线程(功能相当于一个CPU)。应用程序的代码进入死循环,与那个代码关联的进程会“冻结”,但其他进程(它们有自己的线程)不会被冻结,它们会继续执行。

3. 线程开销。

  1). 线程内核对象(thread kernel object)。OS为系统中创建的每个线程都分配并初始化这种数据结构之一。数据结构包含一组对线程进行描述的属性。数据结构还包含所谓的线程上下文(thread context)。上下文是包含CPU寄存器集合的内存块。对于X86、X64和ARM CPU架构,线程上下文分别使用约700、1240、350字节的内存。

  2). 线程环境块(thread environment block, TEB)。TEB是在用户模式(应用程序代码能快速访问的地址空间)中分配和初始化的内存块。TEB耗用1个内存也(x86、x64和ARM CPU中是4KB)。TEB包含线程的异常处理链首(head)。线程进入的每个try块都在链首才入一个节点(node);线程退出try块时从链中删除该节点。此外,TEB还包含线程的“线程本地存储”数据,以及由GDI(Graphics Device Interface,图形设备接口)和OpenGL图形使用的一些数据结构。

  3). 用户模式栈(user-mode stack)。用户模式栈存储传给方法的局部变量和实参。它还包含一个地址,指出当前方法返回时,线程应该从什么地方接着运行。Window默认为每个线程的用户模式栈分配1MB内存。更具体地说,Windows只是保留1MB地址空间,在线程实际需要时才会提交(调拨)物理内存。

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

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

注意:C# 和其他大多数托管编程语言生成的Dll没有DllMain函数。所以,托管Dll不会收到 DLL_THREAD_ATTACH 和 DLL_THREAD_DETACH 通知,这提升了性能。此外,非托管DLL可调用 Win32 DisableThreadLibraryCalls函数来决定不理会这些通知。

4. 单CPU计算机一次只能做一件事情。所以Windows必须在系统中的所有线程(逻辑CPU)之间共享物理CPU。Windows任何时刻只将一个线程分配给一个CPU。那个线程能运行一个"时间片"(有时也称为“量”或者“量程”,即quantrm)的长度。时间片到期,Windows就上下文切换到另一个线程。每次上下文切换都要求Windows执行以下操作。

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

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

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

上下文切换完成后,CPU执行所选的线程,直到它的时间片到期。然后发生上次上下文切换。Windows大约每30毫秒执行一次上下文切换。上下文切换是净开销,上下文切换所产生的开销不会换来任何内存或性能上的收益。事实上,上下文切换对性能的影响可能超出你的想象。是的,当Windows上下文切换到另一个线程时,会产生一定的性能损失。但是,CPU现在是要执行一个不同的线程,而之前的线程的代码和数据还在CPU的高速缓存(cache)中,这使CPU不必经常访问RAM(它的速度比CPU高速缓存慢得多)。当Windows上下文切换到新线程时,这个新线程极有可能要执行不同的代码并访问不同的数据,这些代码和数据不在CPU的高速缓存中。因此,CPU必须访问RAM来填充它的高速缓存,以恢复高速执行状态。但在30秒之后,一次新的上下文切换又发生了。

一个时间片结束时,如果Windows决定再次调度同一个线程(而不是切换到另一个线程),那么Windows不会执行上下文切换,那么Windows不会执行上下文切换。相反,线程将继续运行。

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

执行上下文切换所需的时间取决于CPU架构和速度。而填充CPU缓存所需要的时间取决于系统中运行的应用程序、CPU缓存的大小以及其他各种因素。所以,无法为每一次上下文切换的时间开销给出确定值,甚至无法给出估计值。唯一确定的是,要构建高性能应用程序和组件,就应该尽量避免上下文切换。此外,执行垃圾回收时,CLR必须挂起(暂停)所有线程,遍历它们的栈来查找根以便对堆中的对象进行标记,再次遍历它们的栈(有的对象在压缩期间发生了移动,所以要更新它们的根),在恢复所有线程。所以,减少线程的数量也会显著提升垃圾回收器的性能。每次使用调试器并遇到断点,Windows都会挂起正在调试的应用程序中的所有线程,并在单步执行或者运行应用程序时恢复所有线程。所以,线程越多,调试体验越差。

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

应该指出的是,安装了多个CPU的计算机可以真正同时运行几个线程,这提升了应用程序的可伸缩性(用更少的时间做更多的工作)。Windows为每个CPU内核都分配一个线程,每个内核都自己执行到其他线程的上下文切换。Windows确保单个线程不会同时在多个内核上调度,因为这会带来巨大的混乱。

5. 显示创建专用线程,使用System.Threading.Thread类构造实例

  1). 线程需要以非普通线程优先级运行。

  2). 需要线程表现为一个前台线程,防止应用程序在线程结束任务前终止。

  3). 计算限制的任务需要长时间运行。

  4). 要启动线程,并可能调用Thread的Abort方法来提前终止它。

6. 使用线程的理由

  1). 可响应性(通常是对于客户端GUI应用程序)。Windows为每个进程提供它自己的线程,确保发生死循环的应用程序不会妨碍其他应用程序。

  2). 性能,由于Windows每个CPU调度一个线程,而且多个CPU能并发执行这些线程,所以同时执行多个操作能提升性能。

7. 线程调度和优先级。

  每个线程的内核对象都包含一个上下文结构,上下文结构反映了线程上一次执行完毕后CPU寄存器的状态。在一个时间片(time-slice)之后,Windows检查现存的所有线程内核对象。在这些对象中,只有那些没有正在等待什么的线程才适合调度。Windows选择一个可调度的线程内核对象,并上下文切换得到它。Windows实际记录了每个线程被上下文切换到的次数。然后,线程开始执行代码,并在其进程的地址空间处理数据。又过了一个时间片之后,Windows执行下一次上下文切换。Windows从系统启动开始便一直执行上下文切换,直到系统关闭为止。

  Windows 之所以被称为抢占式多线程操作系统,是因为线程可在任何时间停止(被抢占)并调用另一个线程。你不能保证自己的线程一直运行,你阻止不了其他线程的运行。

  每个线程都分配了从0(最低)到31(最高)的优先级。系统决定为CPU分配那个线程时,首先检查优先级31的线程,并以一种轮流(Round-robin)方式调度它们。如果优先级31的一个线程可以调度,就把它分配给CPU。在这个线程的时间片结束时,系统检查是否有另一个优先级31的线程可以运行;如果是,就允许将那个线程分配给CPU。

  只要存在可调度的优先级31的线程,系统就永远不会将优先级0~30的任何线程分配给CPU。这种情况称为饥饿(starvation)。较高优先级的线程占用了太多CPU时间,造成较低优先级的线程无法运行,就会发生这种情况。多处理器机器发生饥饿的可能性要小得多,因为这种机器上优先级为31的线程和优先级为30的线程可以同时运行。系统总是保持各个CPU处于忙碌状态,只有没有线程可调度的时候,CPU才会空闲下来。

  较高优先级的线程总是抢占较低优先级的线程,无论正在运行的是什么较低优先级的线程。例如,如果有一个优先级为5的线程在运行,而系统确定有一个较高优先级的线程准备好运行,系统会立即挂起(暂停)较低优先级的线程(即使后者的时间片还没用完),将CPU分配给较高优先级的线程,该线程将获取一个完成的时间片。

  系统启动时会创建一个特殊的零页线程(zero page thread)。该线程的优先级是0,而且是整个系统唯一优先级为0的线程。在没有其他线程需要”干活儿“的时候,零页线程将系统RAM的所有空闲页清零。

  优先级类:Idle、Below Normal、Normal、Above Normal、High、Realtime。默认是Normal。

  每个进程都有一个动态优先级(dynamic priority)。线程调度器根据这个优先级来决定要执行哪个线程。最初,线程的动态优先级和它的基础优先级是相同的。系统可提升(boost)和降低(lower)动态优先级,以确保它的可响应性,并避免线程在处理器时间内”饥饿“。但是,对于基础优先级16~31之间的线程,系统不会提升它们的优先级。只是基础优先级在0到15之间的线程才会被动态提升.

  Realtime优先级类要尽可能地避免。Realtime优先级相当高,它甚至可能干扰操作系统任务,比如阻碍一些必要的磁盘I/O和网络传输。

线程\进程 Idle Below Normal Normal Above Normal High Realtime
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。而且以下优先级也不可获得:17,18,19,20,21,27,28,29或者30。以内核模式运行的设备驱动程序才能获得这些优先级;

最好是降低一个线程优先级,而不是提升另一个线程的优先级。如果线程要执行长时间的计算限制任务,比如编译代码、拼写检查、电子表格重新计算等,一般应降低该线程的优先级。如果线程要快速响应某个事件,运行短暂时间,再恢复为等待状态,则应提高该线程的优先级。

posted @ 2021-06-06 11:25  Karl_Albright  阅读(67)  评论(0编辑  收藏  举报