线程基础(CLR via C#)

1、线程基础

1.1、线程职责

线程的职责是对CPU进行虚拟化。Windows 为每个进程豆提供了该进程专用的线程(功能相当于一个CPU)。应用程序的代码进入死循环,于那个代码关联的进程会“冻结”,但其他进程不会冻结,它们会继续执行!

1.2、线程开销

和一切虚拟化机制一样,线程有空间(内存耗用)和时间(运行时的执行性能)上的开销。

每个线程都有以下要素。

  • 线程内核对象(Thread Kernel Object)

    OS 为系统中创建的每个线程都分配并初始化这种数据结构之一。数据结构包含一组对线程进行描述的属性。数据结构还包含所谓的线程上下文(Thread Context)。上下文是包含CPU寄存器集合的内存快。对于x86,x64ARM CPU架构,线程上下文分别使用约700,1240,和350字节的内存。

  • 线程环境块(Thread Environment Block,TEB)

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

  • 用户模式栈(User-Mode Stack)

    用户模式栈用于存储传给方法的局部变量实参。它还包含一个地址;指出当前方法返回时,线程接着应该从什么地方开始执行。默认情况下,Windows为每个线程的用户模式栈分配1MB内存。更具体的说,Windows 只是保留1MB 的地址空间,在线程实际需要时才会提交物理内存。

  • 内核模式栈(Kernel-Mode Stack)

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

  • DLL线程连接(Attach)和线程分离(Detach)通知

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

    PS:C#和其他大多数托管变成语言生成的DLL没有DllMain 函数。所以,托管DLL不会收到DLL_Thread_ATTACHDLL_THREAD_DETACH通知,这提升了性能。此外,非托管DLL可调用Win32 DisableThreadLibraryCalls 函数来决定不理会这些通知。遗憾的是,许多非托管开发人员都不知道有这个函数。

Windows 任何时刻只将一个线程分配给一个CPU。那个线程只能运行一个“时间片”(有时也称为“量”或者“量程”,即 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缓存的大小以及其他各种因素。所以,我不可能给你一个关于上下文切换时间的确切值,甚至是估计值。唯一确定的是,如果要构建高性能的应用程序和组件,就应该尽可能的避免上下文切换。

!!!:

在一个时间片结束的时候,如果windows决定再次调度同一个线程(而不是切换到另一个线程),那么windows不会执行上下文切换。相反,线程将继续运行。这显著的改进了性能。设计自己的代码的时候,注意能避免上下文切换的就避免。

!!!:

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

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

根据上述讨论,我们的结论是必须尽可能的避免使用线程,因为它们要消耗大量内存而且它们需要很多时间去创建、销毁、管理。Windows在线程知己那进行上下文切换,以及在发生GC的时候也会浪费不少时间。然而,根据上述讨论,我们还得出了另一个结论,那就是有时必须使用线程,因为它们使windows更加健壮。

原文地址:https://www.cnblogs.com/xiyin/p/6591976.html