最近在总结多线程、CLR线程池以及TPL编程实践,重读一遍CLR via C#,比刚上班的时候收获还是很大的。还得要多读书,读好书,同时要多总结,多实践,把技术研究透,使用好。
话不多说,直接上博文吧。先说一下,为什么Windows要支持线程机制?
1. Windows为什么要支持线程
计算机的早期时代,操作系统没有线程的概念,整个系统只运行着一个执行线程,其中包含操作系统代码和应用程序代码。只用一个执行线程的问题在于,长时间运行的任务会阻止其他任务的执行。例如16位Windows的时代,打印文档的应用程序很容易“冻结”整个机器。
Microsoft 在设计Windows NT这个版本的OS内核时,决定在一个进程中运行应用程序的每个实例。进程实际是应用程序的实例要使用的资源的集合。每个进程都被赋予了一个虚拟地址空间,确保一个进程中使用的代码和数据无法由另一个进程访问。这就确保了应用程序实例的健壮性。同时,进程访问不了OS的内核代码和数据;所以,应用程序代码破坏不了操作系统的代码和数据
如果应用程序发生死循环会发生什么?如果机器只有一个CPU,它会执行死循环,不能执行其他任何程序。Microsoft 的解决方案就是线程。作为一个Windows概念,线程的职责是对CPU进行虚拟化。Windows为每个进程都提供了该进程专用的线程(功能相当于一个CPU)。应用程序的代码进行死循环,与代码关联的进程会“冻结”,但其他进程(它们有自己的线程)不会冻结,它们会继续执行。
线程很强大,因为它们使Windows即使在执行长时间运行的任务时,也能随时响应。
但是,和一切虚拟化机制一样,线程有空间(内存消耗)和时间(运行时的执行性能)的开销。
2. 线程开销有哪些?
每个线程都有以下要素组成:
线程内核对象(thread kernel object):线程的描述属性和线程上下文,上下文是包含CPU寄存器集合内存块。对于x86、x64、ARM CPU架构,线程上下文分别使用约700,1240和350字节的内存。
线程环境块(thread environment block,TEB):用户模式(应用程序代码能快速访问的地址空间)中分配和初始化的内存块。TEB耗用一个内存页( x86、x64、ARM CPU 中是4KB)
用户模式栈(user-mode stack):用户模式栈存储传给方法的局部变量和实参,它还包含一个地址:指出当前方法返回时,线程应该从什么地方接着执行。Windows默认为每个线程的用户模式栈分配1MB内存。Windows只是保留1MB地址空间,在线程实际需要时才会提交(调拨)物理内存。
内核模式栈(kernel-mode stack):应用程序代码向操作系统中的内核模式传递参数时,还会使用内核模式栈,出于安全的考虑,Windowd会把这些实参从线程的用户模式栈复制到线程的内核模式栈。32windows 内核模式栈大小12KB,64位是24KB
DLL线程连接(Attach)和线程分离(Detach)通知:Windows的一个策略是,任何时候在进程中创建线程,都会调用进程中加载的所有非托管DLL的DllMain方法,并向该方法传递DLL_THREAD_ATTACH标志。同样的,任何时候线程终止,都会调用进程中的所有非托管DLL的DllMain方法,并向该方法传递DLL_THREAD_DETACH标志。
上下文切换
单CPU计算机一次只能做一件事情。所以,Windows必须在操作系统中的所有线程(逻辑CPU)之间共享物理CPU。
Windows任何时刻只将一个线程分配给一个CPU。那个线程能运行一个“时间片”的长度。时间片到期,Windows就将上下文切换到另一个线程。每次上下文切换都要求Windows执行一下操作:
- 将CPU寄存器的值保存到当前正在运行的线程的内核内部的一个上下文结构中
- 从现有线程集合中选出一个线程供调度
- 将所选上下文结构中的值加载到CPU寄存器中
当Windows上下文切换到另一个线程时,会产生一定的性能损失。
一个时间片结束时,如果Windows决定再次调度同一个线程(而不是切换到另一个线程),那么Windows不会执行上下文切换。
3. 使用线程的理由:什么场景下使用线程
可响应性(通常是针对客户端GUI应用程序):客户端GUI应用程序中,可以将一些工作交给一个线程进行,使GUI线程能灵敏的响应用户的输入。在这个过程中创建的线程数可能超过CPU的核心数,会浪费系统资源和降低性能。但是用户体验得到改善和增强。
性能(对于客户端和服务端应用程序):由于Windows每个CPU调度一个线程,而且多个CPU能并发执行这些线程,所以同时执行多个操作可以提升性能。
4. CLR线程池机制
创建和销毁线程是一个昂贵的操作,要耗费大量的时间,同时,太多的线程会浪费内存资源。由于操作系统必须调度可运行的线程并执行上下文切换,所以太多的线程会影响性能。
为了改善这个情况,CLR提供了线程池机制,每个CLR一个线程池。
CLR线程池并不会在CLR初始化的时候立刻建立线程,而是在应用程序要创建线程来执行任务时,线程池才初始化一个线程。线程的初始化与其他的线程一样。在完成任务以后,该线程不会自行销毁,而是以挂起的状态返回到线程池。直到应用程序再次向线程池发出请
求时,线程池里挂起的线程就会再度激活执行任务。
这样既节省了创建线程所造成的性能损耗,也可以让多个任务反复重用同一线程,从而在应用程序生存期内节约大量开销。
5. 进程、线程和应用程序域
进程(Process):Windows系统中的一个基本概念,它包含着一个运行程序所需要的资源。进程之间是相对独立的,一个进程无法访问另一个进程的数据(除非利用分布式计算方式),一个进程运行的失败也不会影响其他进程的运行,Windows系统就是利用进程把工作划分为多个独立的区域的。进程可以理解为一个程序的基本边界。
应用程序域(AppDomain):一组程序集的逻辑容器。CLR在初始化在初始化时创建第一个AppDomain(默认AppDomain),这个AppDomain在进程终止时被销毁。.NET的程序集正是在应用程序域中运行的。
一个进程可以包含有多个应用程序域,一个应用程序域也可以包含多个程序集。
在一个应用程序域中包含了一个或多个上下文context,使用上下文CLR就能够把某些特殊对象的状态放置在不同容器当中
线程(Thread):进程中的基本执行单元,在进程入口执行的第一个线程被视为这个进程的主线程。在.NET应用程序中,都是以Main()方法作为入口的,当调用此方法时系统就会自动创建一个主线程。
线程主要是由CPU寄存器、调用栈和线程本地存储器(Thread Local Storage,TLS)组成的。CPU寄存器主要记录当前所执行线程的状态,调用栈主要用于维护线程所调用到的内存与数据,TLS主要用于存放线程的状态信息。
6. 进程、线程和应用程序域的关系
进程、应用程序域、线程的关系如下图,
一个进程内可以包括多个应用程序域,也有包括多个线程,线程也可以穿梭于多个应用程序域当中。但在同一个时刻,线程只会处于一个应用程序域内。
周国庆
2017/5/26