翻译自《CLR via C#, Third Edition》
本章,我将介绍关于线程的基本概念,让大家有个概念,好使用线程。我将解释为什么微软的Windows带来线程的概念、CPU发展趋势、CLR线程和Windows线程的关系、使用线程的系统开销、Windows怎样调度线程,.net framework类暴露那些属性,等等。
这一章在本书的第5部分“线程”,解释Windows和CLR如何一起提供线程架构。我希望读完这些章节后,你可以拿走如何有效使用线程去设计、构建高响应的、高可靠的和可扩展的程序和组件的基础知识。
为什么Windows要支持线程?
在早期的计算机,操作系统不提供线程的概念。实际上,在整个系统中,只有一个线程来执行操作系统代码和应用程序代码。这有个问题,一个长时间运行的任务会使得单线程系统无法执行其他任务。比如,在16位Windows系统中,打印文档会拖延整个机器是很常见的,因为它导致操作系统和其他应用程序停止响应。并且,有时候应用程序会有bug,结果导致死循环,从而让整个机器无法继续执行。
此刻,用户会别无选择地重启计算机。当然,用户讨厌这样做(实际上,他们不得不这样做),因为所有正在运行的程序都被终止了;更重要的是,这些程序正在处理的任何数据都被从内存中拿掉并丢失。随着计算机业界的发展,微软知道16位Windows不能成为很好的系统,所以他们开始着手构建一个新的操作系统能满足公司和个体的需求。这个新的操作系统要成为强壮的、可靠的、可扩展的和安全的,同时 要弥补16位Windows的缺陷。这个操作系统内核最初呈现在Windows NT中。多年来,这个内核经过许多次调整和功能的增加。这个内核的最新版本安装在微软的服务器操作系统和客户端操作系统中。
当微软设计这个操作系统内核时,他们决定用进程来执行每个应用程序。一个进程是一个单独的应用程序所使用的资源的集合。每个进程都有一个虚拟地址空间,确保一个进程所使用的代码和数据不被其他进程所访问。这样做使得应用程序实例健壮,因为一个进程不能让另一个进程的代码和数据出错。另外,进程是无法访问操作系统内核代码和数据的;因此,应用程序代码无法使操作系统代码和数据出错。现在,应用程序代码不能是其他程序或者操作系统出错,整个计算机体验更好了。另外,系统更加安全,因为应用程序代码无法访问用户名称、密码、信用卡信息,或者在其他应用程序或操作系统中使用的敏感信息。
这样非常棒,但是CPU自己呢?如果程序进入死循环呢?好吧,如果机器只有1个CPU,那么它执行死循环并且无法执行做其他事情,因此,虽然数据不会出错并且更安全了,但是对用户来说,系统却停止响应了。微软也需要解决这个问题,线程就是答案。一个线程是CPU在Windows执行工作的基本概念。Windows给每一个进程分配属于它的线程(对CPU来言是函数),如何一个应用程序代码进入死循环,其关联的进程会被冻结,但是其他进程(有属于它的线程)没有被冻结,它们在一直在执行。
线程开销
线程是令人叹为观止的,因为它在程序执行长时间任务时也能让Windows得到响应。并且,线程允许用户使用一个程序(类似任务管理器)去强制杀死一个被冻结的进程,因为它执行任务时间太长了。但是与每个虚拟化机制一样,线程会有空间(内存消耗)和时间(运行时执行性能)的开销。现在让我们浏览一下这些开销。每个线程都有如下这些东东:
- 线程内核对象。操作系统为每个线程分配和初始化一些数据结构。这些数据结构包括很多描述线程的属性(以后会说到)。这些数据结构也包含线程上下文。线程上下文是包含CPU注册指令的一个内存块。当Windows运行在x86 CPU中,线程上下文有大约占用内存700字节。对x64和IA64 CPU来说,线程上下文各自占用1240和2500字节。
- 线程环境块(TEB)。线程环境块是在用户模式(应用程序能快速访问的地址空间)分配和初始化的一个内存块。TEB消耗1个内存页(4KB在x86和x64 CPU上,8KB在IA64 CPU上)。TEB包含线程异常处理链的头;线程进入每个try块时插入1个节点到这个链的头;当线程退出try块时这个节点会被移除。另外,TEB包括线程本地存储和一些GDI和OpenGL图形的数据结构。
- 用户模式堆栈。用户模式堆栈用于方法的本地变量和参数。它也包含方法返回时需要继续执行的地址。默认的,Windows分配1MB内存空间给每个线程的用户模式堆栈(1)。
- 内核模式堆栈。内核模式堆栈当应用程序代码向内核功能传递参数时使用。为了安全原因,Windows复制任何从用户模式到内核,从线程用户模式堆栈到线程内核模式堆栈的任意参数。一旦复制成功,内核可以核实参数的值,由于应用程序代码不能访问内核模式堆栈,程序就不能在参数的值被核实和操作系统内核代码开始执行他们时修改参数的值。另外,内核访问它自己的方法和使用内核模式堆栈来传递自己的参数,去保存函数的本地变量和返回地址。内核模式堆栈在32位Windows上占用12KB,在64位上是24KB。
- DLL线程附加和线程拆卸通知。Windows有一个策略,不论何时一个线程在进程中创建,所有在这个进程中的DLL有DLLMain方法被执行,传递一个DLL_THREAD_ATTACH标记。类似的,不论何时一个线程消亡,所有在这个进程的DLL有DLLMain方法被执行,传递DLL_THREAD_DETACH标记。有些DLL需要这些通知去完成一些为每个线程执行特殊初始化或清理。举个例子,在C运行时库,DLL分配一些线程本地存储状态,C运行时库需要这些状态。
在早期的Windows中,许多进程加载5到6个DLL。但是如今,一些进程加载了上百个DLL。现在,就在我的机器,Microsoft Office Outlook加载了大约250个DLL到进程地址空间。这意味着,无论何时一个新的线程在Office Outlook中创建,250个DLL函数必须在这个线程做任何事情之前被调用。并且这250个DLL函数当Outlook结束时再一次被调用。哇哦,这样在进程中创建或销毁线程严重影响了性能(2)。(托管DLL不存在这个现象)
所以到现在,你可以看到当创建一个线程时、在系统中运行、销毁它,这些所有消耗的空间和时间了。但是情况更糟糕,现在我们要讨论上下文切换。一个有1个CPU的计算机只能在同一时刻执行一件事。因此,Windows不得不对所有在系统中运行的线程共享真实的CPU硬件资源。
在任何给定时间,Windows给CPU分配1个线程。这个线程执行一个时间片(有时是一个定值)。当时间片到期,Windows执行上下文切换到另一个线程。每个上下文切换需要如下动作:
- 保存当前CPU寄存器的值到执行线程的上下文数据结构,线程内核对象中。
- 从存活线程中选择其中1个来调度执行。如果这个线程属于其他进程,那么Windows必须在执行代码或访问数据前切换虚拟地址空间。
- 加载已选择调度执行线程的上下文结构到CPU寄存器。
上下文切换完毕后,CPU执行调度线程直到它的时间片过期,然后开始再一次的上下文切换。Windows执行上下文切换大概需要30ms。上下文切换是实实在在的开销;就是说,没有内存或性能的提高从上下文切换中受益。Windows执行上下文切换来给用户带来一个健壮的、高响应的操作系统。
现在,如果一个程序的线程进入死循环,Windows会定期取代这个线程,分配一个另外的线程到真实CPU上去,然后让另外的这个线程执行一会儿。这个另外的线程可以是任务管理器的线程,所以现在,用户可以使用任务管理器去杀死这个拥有死循环线程的进程。这样做以后,进程消亡,它工作的所有数据也都被摧毁,但是其他进程可以继续执行而不丢失任何数据。当然,用户不需要重启计算机,所以上下文切换是必须的,它虽然损失了性能但是给用户提供了更好的体验。
实际上,这个性能损失比你想象的要更恶劣。是的,性能损失发生在Windows切换上下文到另一个线程中。但是CPU正在执行其他线程,上一个线程的代码和数据还在CPU缓存中,这样做是为了尽量不访问主存,不带来延迟。当Windows上下文切换到另一个线程,新线程很可能执行不同的代码和数据,但不在CPU缓存中。所以CPU必须立即访问主存去取数据到缓存中,从而得到更快的速度。但是这样,30ms后,会发生另一个上下文切换。
这段上下文切换的时间随着不同CPU架构 和速度而不同。建立CPU Cache的时间取决于系统运行的程序,CPU Cache的大小和各种其他因素。所以我不可能给你上下文切换消耗的绝对数值或者估值。只能说,如果你想构建高性能的程序和组件,你必须尽可能避免上下文切换。
另外,当内存执行垃圾回收时,CLR必须暂停所有的线程,沿着他们的堆栈去在堆中寻找对象的根,再一次遍历堆栈(当压缩堆栈时更新对象的根),然后唤醒所有线程。因此,对于垃圾收集器来讲,避免多线程也会极大提高其性能。无论何时你作为一个调式人员,每次断点命中时,Windows会暂停程序中所有线程,当你单步继续执行或运行程序时,它会激活所有被暂停的线程。所以,你的线程越多,你调试就越慢。
从这次讨论中,你会推断出你必须尽可能的避免使用多线程,因为它们消耗了很多内存,花费很多时间去创建、销毁和管理它们。当Windows执行上下文切换时消耗时间,当垃圾收集开始时也是。然而,这次讨论也能帮助你认识到多线程有时也要使用,因为它们允许Windows健壮而且高响应。
我在此指出,多处理器系统可以同时执行多个线程,增加可扩展性(这种能力可以用更少的时间做更多的工作)。Windows会给每个CPU一个线程,每个核心会在其内部执行自己的上下文切换。Windows确保一个单线程不会被多个核心执行一次,因为这样会肆虐。如今,多CPU,超线程CPU,或者多核CPU是很常见的。但是当Windows设计时,单CPU是常见的。Windows增加线程去提高系统的响应和可靠性。如今,线程也被用来增加可扩展性,这仅仅在有多核心计算机中出现。
这本书这一章节讨论了各种各样的Windows和CLR机制,你可以有效的解决创建多线程带来的挑战,并且依然能保持你的代码高响应,允许在多处理器环境下成倍提升。
停止疯狂
如果我们关心最原始状态下的性能,最优化的线程数量应该和机器的CPU数量相同。因此单核系统应该只有一个线程,双核系统应该有两个线程,等等。原因很明显:如果你有多于CPU核心数的线程,就会带来上下文切换,造成性能损失。如果每个CPU只有一个线程,那么没有上下文切换发生,线程也能得到全速运行。
然而,微软设计Windows偏向于高可靠性的、高响应的而不是偏向于原始的速度和性能。我在此提醒:我不认为现今有任何一个人愿意在Windows和.NET Framework上使用程序会终止操作系统和其他程序。因此,Windows给每个进程属于它自己的线程来提高系统可靠性和响应。在我的机器上,举个例子,当我运行任务管理器并选择性能标签,我可以看到如下图像。
它秀出来在我机器上有60个进程运行,我们可以推断至少有60个线程允许,因为每个进程至少一个线程。但是任务管理器也秀出了在我机器上当前有829个线程!这意味着,那有大约829MB的线程堆栈,而我的机器只有2G的内存。这也意味着,每个进程平均有13.8个线程。
现在,看看CPU使用率:它秀出了当前时刻CPU忙的百分比为0。这意味着,100%这个时刻,这些829个线程简直没事干,当这些线程允许时,它们只是占据了未被使用的内存。你不得不问自己:这些程序此刻100%不需要线程做任何事情么?答案是否。现在,如果你想看那个进程是最耗费的,点击进程标签,选择线程列,然后按升序排序,如图25-2。
就如你看到的,Outlook有38个线程并使用了CPU的0%,Microsoft Visual Studio(Devenv.exe)有34个线程并使用了CPU的0%,等等。这里发生什么了?
当开发者学习Windows,他们学到Windows的进程是非常非常昂贵的。创建一个进程通常消耗数秒,很多内存必须被分配、初始化,EXE和DLL必须从磁盘中加载,等等。作为比较,在Windows中创建线程是轻量的,所以开发者决定停止创建进程而去创建线程。所以现在我们有许多线程。但是即使线程比进程轻量,它们依然比系统其它资源要昂贵,所以使用它们需要谨慎和恰当。
毋庸置疑,我们可以确定的说,所有我们讨论的这些程序都无效的使用线程。系统中没必要有那么多线程。只有一件事去在程序中分配资源;需要另一个分配它们并不再使用它们。这是非常浪费的,分配所有的内存给线程堆栈意味着有更少的内存去给更重要的数据使用,比如用户文档。
更糟糕的是,如果我们使用单用户远程桌面访问,如果有100个用户在这台计算机上呢?那会有100个Outlook的实例,每个有38个线程无事可做。3800个线程拥有自己的内核对象,TEB,用户模式堆栈,内核模式堆栈,等等。这非常浪费资源。这种疯狂必须停止,尤其是当运行网络计算机,每个计算机只有1G内存,微软想给用户一个好的体验,这章将会描述如何设计程序使用最少的线程来有效的管理。
现在,今天我承认,系统中的大多数线程是用本地代码创建的。因此,线程的用户模式堆栈仅仅预留地址空间,更可能的是,堆栈不会全部提交到存储中。然而,随着被管理的程序越来越多,或者有更多的组件被加载,越来越多的堆栈被完全提交,分配1M的物理存储。不管怎样,所有线程仍然有一个内核对象,内核模式堆栈,还有其他分配的资源。这种创建线程的趋势乱糟糟的,因为停止廉价;线程是不轻量的,相反,它们昂贵,所以使用它们要聪明一点。
CPU 趋势
在过去,CPU的速度一直在增长,所以程序在新机器上能跑的更快。然而,CPU制造商不能继续制造更快的CPU。当你CPU高速运行时,它们产生很多热量不得不释放。几年前,我从一个尊敬的制造商中得到最新的笔记本。这个笔记本的固件有个bug,不能打开让风扇足够快速;结果,运行一段时间后,CPU和主板都被融化了。这个硬件厂商替换了这台机器,改进了固件从而使得风扇经常运转。不幸的是,这更加耗电,因为风扇需要很多能源。
这些是硬件发展的一些问题。单CPU厂商无法继续生产更快的CPU,它们进而关注使用更小的晶体管,这样可以集成更多的晶体管在一个芯片上。今天,我们一个芯片有两个或多个CPU核心。这种结果是我们的软件只能在我们软件能利用多核心时才能跑的更快。我们如何做?我们要聪明的使用多线程。
计算机今天又三种多核技术:
- 多CPU。有些计算机有多个CPU。这意味着,主板有多个插槽,每个插槽一个CPU。这样主板变的更大了,计算机也变大了,由于有额外的电源消耗,有时这些机器有多种电源供应着。这种计算机部署有数十年了,但是它们今天并不常见,因为它们大小和花费一直在增长。
- 超线程处理器。这种技术(仅仅被Intel掌握)允许一个芯片看起来像两个芯片。这种芯片有两套架构状态,比如CPU寄存器,但是芯片只有一套可执行资源。对Windows来说,这就像系统有两个CPU,所以Windows同时调度两个线程。然而,这种芯片一次只能执行一个线程。当一个线程由于缓存失效、分支预测失效或者数据依赖性而暂停,芯片就切换到另一个线程。这都发生在硬件上,Windows并不知道这会发生;Windows认为两个线程在同时运行。Windows了解超线程CPU,如果你系统中有多个超线程CPU,Windows会首先在每个CPU调度一个线程,这样线程真正的同时执行,然后在已经繁忙的CPU上调度其他的线程。Intel声称一个超线程CPU可以增加10%到30%的性能。
- 多核芯片。几年前,多核心单芯片CPU进入市场。在我写此书时,双核,三核,四核都已经发布。我的笔记本是双核;我肯定用不了多久,我们的手机也会多核。Intel已经研发出了有80个核心的单芯片。哇哦,这是多么强大的计算能力,同时Intel甚至还有多核超线程的CPU。
NUMA架构的计算机
多核CPU表面看起来很强大,但是它们导致一个新问题。现在,多核正在同时访问其他系统资源,因此这些其他系统资源成为整个系统的瓶颈。举个例子,如果有两个核心需要同时访问内存,内存带宽限制了性能,因此一个四核系统只能比单核系统增加30%到70%的性能。为了减负,计算机现在利用一个成为Cache-Coherent Non-Uniform Memory Access(高速缓存相干非一致内存访问)的架构,简写NUMA。
插图25-3展示出了基于NUMA的计算机系统。这个系统有4个节点。每个节点有4个CPU,1个北桥,1个南桥,还有本地内存。有些节点有连接硬盘。所有这些内存可以被其他节点访问;然而,此时访问内存是不唯一的。举个例子,节点1的任何CPU可以非常快的访问节点1的内存。节点1的CPU也可以访问节点2和4的内存,但是有相当大的性能损失。节点1的CPU也可以访问节点3,但是性能损失更糟糕因为在节点1和节点3之间没有直接的线路连接。虽然这16个CPU可以通过4个不同的节点互相访问,但是硬件确保所有CPU的缓存一致性并且和其他同步。
Win32 API给非托管开发者提供了许多功能,允许他们的程序在NUMA的指定节点上明确分配内存,并且强制线程在指定NUMA节点上运行。今天,CLR并没有特地为适应NUMA系统做调整。将来,我想CLR会给每个NUMA节点提供一个垃圾收集堆,可能增加一种程序可以指定对象分配到哪个节点的能力。或者CLR会根据当前哪个节点会访问对象,从而把对象从一个节点到迁移到另一个节点。
回到90年代早期,那时很难想象有一天计算机会有32个CPU。因此,当32位Windows首次构建时,它只能处理最多32个CPU。当微软构建64位Windows时,它设计一台机器能处理多达64个CPU。当时,64个CPU看起来很多,但是今天这看起来计算机会在不远的将来得到更多的CPU。
当构建Windows Server 2008 R2时,微软决定让Windows支持多达256个逻辑处理器。图25-4展示了Windows支持的这些逻辑处理器。如下解释:
- 一个机器每组有1个或多个处理器,每个组有1个到64个逻辑处理器。
- 一个处理器组有1个或多个NUMA节点。每个节点有多个逻辑处理器,缓存,还有主存。
- 每个NUMA节点有1个或多个插槽。
- 每个插槽有1个或多个CPU核心。
- 每个核心有1个或多个逻辑处理器。如果支持超线程的话,这些处理器可以有多于一个的逻辑处理器。
如今,CLR并没有利用处理器组的好处,所以在第0组(默认)创建所有线程,并且在64位Windows下,只能顶多支持64个核心。因为32位Windows仅仅在第0组支持32个核心,所以在32位Windows中,托管程序顶多支持32个核心。
CLR线程和Windows线程
今天,CLR可以在Windows下利用多线程,所以本书的第5部分关注Windows给那些写托管代码程序员暴露的的线程能力。我会解释在Windows中,线程如何工作,CLR如何改变行为(如果是的)。然而,如果你想知道线程的更多信息,我推荐你阅读我早期著作,比如Windows via C/C++, 5th Edition。
如今,一个CLR线程直接映射到一个Windows线程,微软CLR小组保留在将来分离它们的权利。有一天,CLR会带来自己的逻辑线程概念,这样CLR逻辑线程会在需要映射到一个Windows的物理线程。举个例子,创建逻辑线程会比物理线程使用更少的资源,你可以在少量的物理线程中运行许多逻辑线程。例如,CLR可以决定你的一个线程在睡眠状态,然后重新分配它去完成不同的任务。这带来的好处是可以有更简单的代码,更少的资源占用,潜在的提高性能。不幸的是,对CLR小组来说,实现这个方案会是一个巨大的工作量,所以我说在将来,这个特性会实现,而不是很快。
对你来说,所有这一切意味着操控多线程时候,你的代码尽可能少的承担。举例来说,你避免使用P/Invoking来调用本地Windows功能,因为这些功能对CLR线程一无所知。避免使用本地Windows过程,而使用FCL,你可以保证你的代码能在不远的将来从这些性能提升中得到好处。