关于线程的诞生
早期的16位Windows只有一个执行线程,在执行各种程序时,如果这个线程运行出现了问题,就会“冻结”整个系统,使得系统处于未响应状态。这是一件多么尴尬的事儿,无论是用户还是微软自己,都不能长时间的忍受这种状况!不过,在那个时代,能有一台电脑,能使用Windows本身就是一件极其奢侈和有乐趣的事儿,也许用户还是能接受这种状况的。不过随着芯片技术的发展,微软是绝对不能安于现状的,所以必须设计一个健壮、可靠、易于伸缩和安全的系统,以便于和新的芯片搭配,所以微软推出了Windows NT,第一次搭载了全新的内核,后续的Windows版本,都是基于这个新的内核。那么这个内核和线程有什么关系呢?在新的系统内核中,操作系统管理多个进程,每个进程又管理这多个线程。
在进程中运行每个应用程序的实例。而进程是应用程序实例要使用的资源的集合,每一个进程都有一个独立的虚拟地址空间,所以进程之间不能访问各自的代码和数据,当然也不能访问OS本身的数据。这就保证了系统运行的安全。可是,早期计算机只有一个CPU,如果CPU执行陷入死循环,那么系统任然会停止响应。为了解决这个问题,微软拿出了一个解决方案——线程。让每个进程拥有各自的线程。线程的职责就是对CPU进行虚拟化,对于应用程序来说,实际上一个线程相当于一个CPU,把所有的代码执行任务交给线程,如果某个线程执行出错,那么只有与这个线程关联的进程会挂掉,其他的进程不受影响,在OS的控制下,它们继续很好的执行!
线程究竟包含什么?
现在才是干货。在Windows中,每一个线程都会有这么几个要素。
·线程内核对象(Thread Kernel Object):这是线程非常重要的一个数据结构,它包含了一些对线程描述的属性,而且包含线程上下文(thead context)。上下文表示的是线程的执行信息,这些信息分别被保存在CPU的寄存器上。线程切换的时候,首先会保存当前线程的上下文信息,然后切换到另一个线程,最后恢复另外一个线程的上下文信息到实际的CPU寄存器中。
·线程环境块(thread environment block,TEB):当线程执行进入一个try块(try{}catch{}用于捕获异常)的时候,就会在TEB中记录一个节点。与此同时,TEB还包含了一些图形接口相关的数据结构。
·用户模式栈(user-mode stack):线程执行中的局部变量,方法调用参数都被存储在这儿。而且还指出了在当前线程执行的方法结束后,线程应该回到什么地方接着执行,类似中断过程。关于用户模式栈,是一个线程消耗系统内存资源的大户。为什么这么说呢?之前提到的 线程内核对象 和 TEB 它们的大小只有几kb,最小都不足1kb,但是Windows 给 用户模式栈 的初始大小就是1MB,而且随着实际需要,系统会调拨更多的物理内存给它。也许你会觉着不就1MB嘛,我内存是8GB的。那你就年轻了,然而就在笔者写这篇笔记的时候,我的Windows 10 系统有121个进程,有2100个线程。粗略计算,至少有2100x1MB的内存空间用于创建线程了,也就是2GB。
·内核模式栈(kernal-mode stack):应用程序调用内核模式函数传参时使用。为什么会有内核模式栈出现呢?其实还是为了安全,用户调用内核模式函数的参数会被从用户模式栈复制到内核模式栈,然后它的功能就和用户模式栈则差不多了。简单理解就是内核模式栈服务于系统内核方法,用户模式站服务于用户自己的方法。
·DLL线程连接和线程分离通知:再Windows中有一个策略,再进程中创建线程时,会遍历当前进程加载的所有非托管DLL的DllMain方法。然后传递一个attach标记。所以创建一个线程会有一定的性能损耗。现在一个常用的的应用,都会加载N多的Dll。例如Vs2015,它在运行的时候至少会加载三四百个DLL,它们中有许多是非托管的Dll,遍历用这些Dll的入口函数也是一件不小的工作。不过值得高兴的一点是Windows 提供了一个Win32 方法DisableThreadLibraryCalls,非托管的Dll调用这个方法,就可以不去理会线程连接的通知,但是,许多的非托管Dll的程序员都不知道这个函数的存在,这就很尴尬了!
以上五点就是一个线程会包含的基本要素了。线程很好,因为它可以让我们的程序在表面上并行运行,但是滥用线程可就不好了!物极必反。创建一个线程会对系统的运行造成一定的性能损耗,虽然这个损耗可能很小很小,但是会由量变到质变。在一个进程中,每一时刻都只能有一个确定的线程执行,然而N多的线程是通过不断切换执行的,Windows大概每30ms就回执行一次线程切换,正如前面所说的,在线程切换的时候,要经历一个保存线程上下文—切换线程—加载线程上下文的过程。如果线程切换的频率很低,必然没有什么问题,但是在高频率的线程切换中,会影响系统性能。Cpu在读取数据时,从高速缓存cache中读取的速度远大于内存ram,而执行线程上下文切换,会导致原本在cache中的数据丢失,需要重新从ram中读取数据。这就是影响性能的地方。
目前为止,在一个单核cpu上,确定时刻只能由一个线程执行。直到多CPU计算机、多核CPU的出现,才能真正实现在同一时刻,有多个线程同时执行。而多CPU的计算机由于成本,功耗等各种原因,使用的并不多。真正得到普及的是多核CPU。试想,在多核CPU上,最完美的状态就是CPU有多少个核心,就创建多少个线程,这样就不会有线程切换,性能得到了保障。实际在一个Windows系统中,同时会有上千个线程。美好的理想就这么残忍的被现实打败了!但是在多核CPU普及的今天,我们的确从其多核心的计算中受益。
线程切换的时候,需要考虑一个问题:在一个进程中,有许多的线程,如何选择切换对象呢?在Windows中,引入了线程优先级的概念。Windows 将这种优先级量化为32个级别,数值越大,优先级越高,数值从0到31。切换时会优先切换高优先级的线程。但,问题又出现了,开发者并不能很好的掌握线程的优先级应该设置为多少。为了解决这个问题,Windows又引入了一个优先级抽象层,有两个概念“进程优先级类“和“相对线程优先级”。进程优先级类针对每一个进程,分为idle,below normal,normal,above normal,high,realtime六个级别,而相对线程优先级则分为idle,lower,below normal,normal,above normal,highest,time-critical七个级别,他们的不同组合对应了32个数字优先级。具体看图
idle | lowest | below normal | normal | above normal | highest | time-critical | |
idle | 1 | 2 | 3 | 4 | 5 | 6 | 15 |
below normal | 1 | 4 | 5 | 6 | 7 | 8 | 15 |
normal | 1 | 6 | 7 | 8 | 9 | 10 | 15 |
above normal | 1 | 8 | 9 | 10 | 11 | 12 | 15 |
high | 1 | 11 | 12 | 13 | 14 | 15 | 15 |
realtime | 16 | 22 | 23 | 24 | 25 | 26 | 31 |
.Net中的线程
前后台线程:在.net中创建的线程分为前后台线程,前台线程如果没有结束,则整个应用程序就不能结束,依然会暂留在进程中,直到前台线程结束。而与之对应的后台线程则不同,一旦应用程序终止,后台线程随即终止,无论是否完成。我们可以通过System.Threading这个命名空间下的类来操作线程。而通过直接实例化一个Thead对象来创建的线程,默认是一个前台线程,可以通过IsBackground属性将其设置为后台线程。但是讲道理,平时使用的线程中,一般都是后台线程,通过线程池创建的线程都是后台线程。
多线程编程无论在什么编程语言中,都是一个重要的话题,而以上这些,只是.net中多线程编程的开始!
声明:笔记中内容均参考自Jeffrey Richter著作的《CLR Via C#》,个人也很看同作者的一些观点,像大神致敬!