Win32多线程是Microsoft Developer Network 技术小组研发的多线程应用程序的策略。
Win32 多线程的性能
作者:Microsoft公司供稿
Ruediger R. Asche
摘要
本文讨论将
单线程应用程序重新编写成多线程应用程序的策略。它以Microsoft? Windows? 95和Windows NT?的平台为例,从
吞吐量(throughput)和响应方面,与兼容的单线程计算相比较而分析了多线程计算的性能。
程序介绍
在您所能够找到的有关多线程的资料中,多数都是讲述同步概念的。例如,如何
串行化(serialize)共享公共数据的线程。这种把重点放在讨论同步上是有意义的,因为同步是多线程编程中不可缺少的一部分。本文则后退了一步(takes a step back),主要讨论有关多线程中很少有人涉及的一面:决定一个计算如何能够被有意义地拆分为多个线程。本文中所使用的示例程序,THRDPERF,在Microsoft? Windows? 95和Windows NT? 两个平台之上,针对同一个计算采取串行和并发两种方法分别实现了测试套件(test suite),并从
吞吐量和性能两方面来比较它们。
如果您是经验丰富的多线程应用程序编程者,您可以跳过介绍部分,而直接到下面的“结果”部分。
多线程词汇
很长一段时间以来,您的应用程序一直被使用——它运转出色,是可以信赖的,而且 the whole bit——但它十分的迟缓,并且您有如何利用多线程的想法。但是,在开始这样做之前请稍等一会儿,因为这里有许多的陷阱,它们使您相信某种多线程设计是非常完美的,但实际上并不是这样。
在您跳至有关要进入的结论之前,首先让我们澄清一下在本文中将不讨论的内容:
在 Microsoft Win32?
应用程序编程接口(API)下提供多线程访问的库是不同的,但是我们不关注这一问题。示例程序套件,Threadlib.exe,是在一个Microsoft Foundation Class Library (MFC)应用程序中使用Win32多线程API来编写的,但是,您是使用Microsoft C运行时(CRT)库、MFC库,还是单纯的(barebones) Win32 API来创建和维持线程,我们并不关心。
实际上,每一种库最后都要调用 Win32
系统服务CreateThread来创建一个工作线程,并且多线程本身总是要通过操作系统来执行。您想要使用哪一种包装机制将不会影响本文的论题。当然,您是使用某一个还是使用其它的包装库(wrapper library),可能会引起性能上的差异,但是在这儿,我们主要讨论多线程的本质,而不关心其包装(wrapper)。
本文所讨论的是在单处理器机器上运行的多线程应用程序。
多处理器计算机则是一个完全不同的主题,并且本文中所讨论的结论,几乎没有一个可以应用于多处理器的机器中。我还没有这样的机会在一个运行 Windows NT 系统的可调整的(scalable)对称多线程(SMP)机器上执行该示例。如果您有这样的机会,我非常高兴地希望知道您的结果。
在本文中,我更喜欢一般性地引用“计算”。计算被定义为您的应用程序的一个子任务,可以被作为整体或部分来执行,可以早于或迟于另一个计算,或者与其他的计算同时发生。例如,让我们假设某个应用程序需要用户的数据,并且需要保存这些数据到磁盘。我们可以假定输入数据包含一种计算,而保存这些数据则是另一种计算。根据应用程序的计算的设计,下面两种情况都是可能的:一种是数据的保存和新数据的输入是同时交叉进行的;另一种是直到用户已经输入了全部的数据才可是将数据保存到磁盘上。第一种情况一般可以使用某种形式的多线程来实现;我们称这种组织计算的方式为并发或交互。后一种情况一般可以用
单线程应用程序来实现,在本文中,它被称为串行执行。
有关并发应用程序的设计是一个非常复杂的过程。一般非常有钱的(who make a ton of money)人比较喜欢它,因为要计算出一个给定的任务采用并发执行到底有多大的好处,通常需要多年的研究。本文并不想要教您如何设计多线程应用程序。相反,我要向您指出某些多线程应用程序设计的问题所在,而且,我使用真实(real-life)的
性能测试来讨论我的例子。在阅读过本文后,您应该能够观察一个给定的设计,并且能够决定某种设计是否提高了该应用程序的整体性能。
同步多线程应用程序的“通用”的讨论,因为每一次编序都将强加一个唯一的“等待-醒来”结构(waiting-and-waking pattern)到已编序的线程,它将直接地影响性能。
Win32下的大多数输入/输出(I/O)操作有两种形态:同步或异步。已经被证明在许多的情况下,一个使用同步I/O的多线程设计可以被使用异步
单线程I/O的设计来模拟。本文并不讨论作为多线程替代形式的异步
单线程I/O,但是,我建议您最好两种设计都考虑。
注意到Win32 I/O
系统设计的方式是提供一些机制,使得异步I/O要优于同步I/O(例如,I/O全能端口(completion ports))。我计划在以后的文章中讨论有关同步I/O和异步I/O的问题。
图形用户界面(GUI)不能很好地共同工作。在本文中,我假设后台线程可以执行其工作而根本不需要使用Windows GUI;我所处理的这种类型的线程仅仅是“工作线程”,它仅在后台执行计算,而不需要与用户的直接交互。
有有限计算,同样也有与之相对应的无限计算。服务器端应用程序中的一个“倾听”线程就是无限计算的一个例子,它没有任何的目的,只是等待一个客户连接到服务器。在一个客户已经连接之后,该线程就发送一个通知到
主线程,并且返回到“倾听”状态,直到下一个客户的连接。很自然,这样的一种计算不可能驻留在同一个作为应用程序用户界面(UI)的线程之中,除非使用一种异步
I/O操作。(请注意,这个特定的问题能够,也应该通过使用异步I/O和全能(completion)端口来解决,而不是使用多线程,我在这里使用这个例子仅仅是用作演示)。在本文中,我将只考虑有限计算,就是说,应用程序的子任务将在有限的时间段之后结束。
基于CPU的计算和基于I O的计算
对于一个单个的线程,决定所给定的计算是否是一个优秀的方案的最重要因素是,该计算是一个基于CPU的计算还是基于I/O的计算。基于CPU的计算是指这种计算的大多数时间CPU都非常“忙”。典型的基于CPU的计算如下:
复杂的数学计算,例如复数的计算、图形的处理、或屏幕后台图形计算
对驻留在内存中的文件图像的操作,例如在一个文本文件的
内存镜像中的给定字符串。
相比较而言,基于I/O的计算是这样的一种计算,它的大多数时间要花费在等待I/O请求的结束。在大多数的操作系统中,正在进入的设备I/O将被异步地处理,可能是由一个专门的I/O处理器来处理,或由一个有效率的
中断处理程序来处理,并且,来自于某个应用程序的I/O请求将会挂起调用线程,直到I/O结束。一般来说,花费大部分时间来等待I/O请求的线程不会与其他的线程争夺CPU时间;因此,同基于CPU的线程相比,基于I/O的计算可能不会降低其他线程的性能,(稍后,我将解释这一论点)
但是请注意,这种比较是非常理论性的。大多数的计算都不是纯粹的基于I/O的或纯粹的基于CPU的,而是基于I/O的计算和基于CPU的计算都包含。同一集合的计算可能在一种方案中使用顺序计算而运行良好,而在另一种方案中使用并发的计算,这取决于基于CPU的计算和基于I/O的计算的相对划分。
多线程设计的目标
在想要对您的应用程序应用多线程之前,您应该问问自己这种转变的目标是什么。多线程有许多潜在的优点:
增强的性能
增强的容量(throughput)
更好地用户快速响应(responsiveness)
让我们依次讨论上面的每一个优点。
性能
考虑到时间,让我们简单地定义“性能”就是给定的一个或一组计算所消耗的全部时间。按照其定义,则性能的比较就仅仅是对有限计算而言的。
无论您相信与否,多线程方案对应用程序的性能的提高是非常有限的。这里面的原因不是很明显,但是它非常有道理:
除非是该应用程序运行于一个多处理器的机器上,(在这种情况下,子计算真正地是
并行执行的),基于CPU的计算在多线程情况下不可能比在
单线程情况下的执行速度快。这是因为,无论计算被分解成小块(在多线程的情况下)或大块(在同一线程中计算按顺序挨个执行的情况下),只有一个CPU,而且它必需执行所有的计算。结果是,对于一组给定的计算,如果是以多个线程来执行,那么一般会比按串行方式计算完成的时间要长,因为它增加了创建线程和在线程之间切换CPU的额外负担。
一般来说,必定会有某些情况,无论多个计算的完成谁先谁后,但是它们的结果必需同步。例如,使用多个线程来并发的读多个文件到内存中,那么文件被处理的顺序我们是不关心的,但是必需等到所有的数据都读入内存之后,应用程序才能开始处理。我们将在“
容量”一节讨论这个想法。
在本文中,我们将以消耗的时间,即完成所有的计算所消耗的总的时间,来衡量性能。
容量
容量(或响应),是指每一个计算的平均处理周期(turnaround)的时间。为了演示容量,让我们假设一个超级市场的例子(它总是一个有关操作系统的极好的演示工具):假设每一个计算就是一个在结算柜台被服务的顾客。对于超级市场来说,既可以为每一个顾客开设一个结算柜台,也可以把所有的顾客集中起来通过一个结算柜台。为了我们分析的需要,假设是有多个结算柜台的情况,但是,仅有一个收银员(可怜的家伙!)来服务所有的顾客,而不考虑顾客是在一个柜台前排队或多个柜台前排队。这个超级收银员将高速地从一个柜台跳到下一个柜台,一次仅处理(ringing up)一个顾客的一件商品,然后,就移动到下一个顾客。这个超级的收银员就象是被多个计算所割裂的CPU。
就象我们在前面的“性能”一节中所看到的,服务所有顾客的总的时间并没有因为有多个结算柜台打开而减少,因为无论顾客是在一个柜台还是多个柜台被服务,总是这一个收银员来完成所有的工作。但是,事情是这样,同只有一个结算柜台相比,顾客还是喜欢这种超级收银员的方式。这是因为一般情况下,顾客的手推车里的商品数的差别是巨大的,某些顾客的手推车中有一大堆的商品,而某些顾客则只想买很少几件商品。如果您曾经只希望买一盒 granola bars和一夸脱牛奶,而却排在某个来为全家24口人采购的先生后面,那您就知道我说的是意味着什么了。
无论怎样,如果您能够被 Clark Kent 先生以高速度服务,而不是在那里排队,您就不会太在意完成结帐的时间是否稍长,因为不管怎么样,两件商品都会很快地被处理完。而满载着为24口人采购的商品的手推车是在另一个柜台被处理的,所以您可以很快就完成结帐而离开。
因此,容量就是度量在一个给定的时间内有多少个计算可以被执行。每一个计算是这样度量它的进程的,那就是要比较以下的两个时间:完成本计算花费了多少的时间,以及假设该计算被首先处理的话要花费多少时间。换句话说,如果您去了超级市场,并且希望两分钟就离开那里,但是实际上您花费了两个小时来为您的两件商品结算,原因是您排在了购买其1997生产线的 Betty Crocker 的后面,那么不得不说,您的进程非常失败。
在本文中,我们这样定义一个计算的响应时间,计算完成所消耗的时间除以预计要消耗的时间。那么,如果一个应该消耗 10 毫秒(ms)的计算,而实际上消耗了 20 ms,那么它的响应处理周期就是 2,但是,如果就是同一个计算,却消耗了 200 ms (可能是因为有另一个长的计算与之竞争并优先)才结束,那么响应处理周期就是 20。显然,响应处理周期是越短越好。
我们在后面将会看到,在将多线程引入一个应用程序中时,即使导致了整体性能的下降,容量仍然可能是一个有实际意义的因素;但是,要使容量成为一个有实际意义的因素,必需满足下面的一些条件:
每一个计算必需是相互独立的,只要计算一结束,任何计算的结果都可以被处理或使用。如果您是某个大学足球队的队员,而且您们每一个队员都在同一个超级市场买自己的旅行食品,那么您的商品是先被处理还是后被处理、您花费了多长的时间为两件商品结帐、以及您为此等待了多长的时间,这些都无关紧要,因为最后您的汽车是不会离开的,除非所有的队员都买完了食品。所不同的只是您的等待时间,要么是花费在排队等待结帐,要么是如果超级收银员已经为您服务,时间就花费在等待其他人上。
这一点很重要,但却常被忽略。就象我前面所提到的,大多数的应用程序迟早都会显式或隐式地同步其计算。例如,如果您的应用程序从不同的文件并发地收集数据,您可能会想要在
屏幕上显示结果,或者把它们保存到另一个文件中。在前面一种情况下(在
屏幕上显示结果),您应该意识到,大多数
图形系统都执行某种的内部
批处理或串行操作,除非所有的输出数据都已收集到,否则是根本不会有好的显示的;在后面的情况下,(保存结果到另一个文件),除非整个原型文件已被写入完毕,一般不是您的应用程序(或其他的应用程序)所能完全处理的。所以,如果某个人或某些东西以某种形式将结果顺序化了,不管是应用程序、操作系统、甚至是用户,那么您在处理文件时所能得到的好处可能就会消失了。
计算之间在量上必需有明显的差异。如果超级市场中的每一个顾客都只有两件商品需要结帐,则超级收银员方式一点优势都没有;如果他不得不在3个结算柜台之间跳来跳去,而每一个要被服务的顾客仅有2个(或3个、4个或n个)商品要结算,那么每一个顾客都不得不等待几倍的时间来完成他或她的结算,这比让所有的顾客在一起排队还要糟糕。在这里把多线程想象为shock吸收装置:短的计算并不会冒被排在长的计算之后的危险,但是它们被分成线程并且花费了更多的时间,而本来它们可以在更短的时间内完成。
如果计算的长短可以事先决定,那么串行处理可能比多线程处理要好,您只要简单地以时间长短按升序排列计算就可以了。在超级市场的例子中,就相当于按照顾客的商品数来站排(Express Lane 方案的一种变种),这种想法是基于这样的考虑,只有很少的商品的顾客很喜欢它,因为他们不会为一点的事情而耽误很多的时间, 而那些有很多货物的顾客也不会在意,因为无论如何要完成其所有的结算都要花费很长的时间,而且在他们前面的每一个人的商品都比它少。
如果只是大致知道计算时间的一个范围,但是您的应用程序不能排序这些计算,那么您应该花些时间做一次最坏情况的分析。在这样的分析中,您应该假定这些计算不是按照时间的升序顺序来排序的,相反,它们是按照时间的降序来排序的。从响应这个角度来讲,这中方案是最坏的情形,因为按照前面所定义的公式,每一个计算都将具有其最高可能的响应处理周期。
快速响应(Responsiveness)
我将在这里讨论的、应用程序多线程化的最后一个准则是快速响应(在语言上与响应非常接近,足以使您迷惑不解)。在本文中,如果一个应用程序的设计是保证用户总是能够在一个很短的时间(很短的时间指时间非常短,使得用户感觉不到应用程序被挂起)内完成与应用程序的交互,那么我们就简单一点,定义该应用程序为响应快速的应用程序。
对于一个带有 GUI 的 Win32 应用程序,快速响应可以被很简单地实现,只要确保长的计算被委托给后台线程,但是实现快速响应所要求的结构可能要求较高的技巧,正如我前面所提到的,某些人可能会等待某个计算在某个时间返回,所以在后台执行一个长的计算可能需要改变用户界面(例如,需要添加一个“取消”按钮,并且依赖该计算结果的菜单项也不得不变灰)。
除了性能、容量和快速响应之外,其他的一些原因也可能影响多线程设计。例如,在某些结构下,必需让计算以一种伪随机方式(脑海中再次出现的例子是Bolzmann 机器类型的神经网络,在这种网络中,仅当该网络中的每一个节点异步执行其计算时,该互联网络的预期行为才能够工作)。但是,在本文中,我将把讨论的范围限制在上面所涉及的三个因素,那就是:性能、容量和快速响应。
测试的实现
我曾经听说过许多关于抽象(abstraction)机制的讨论,说它封装了所有多线程的糟糕(nasty)方面到一个 C++ 对象中,并且因此使一个应用程序获得了多线程的全部优点,而不是缺点。
在本文中,我一开始就设计这样一个抽象。我将为一个 C++ 的类 ConcurrentExecution 定义一个原型,该类将含有成员函数例如:DoConcurrent 和 DoSerial,并且这两个成员函数的参数将是一个普通
对象数组和一个
回调函数数组,这些回调函数将被每一个对象并发或串行地调用。该 C++ 类将封装所有关于保持该线程和内部
数据结构的真实(gory)细节。
但是,对我来说,从一开始我就十分清楚,这样的一个抽象的用处十分有限,因为在设计一个多线程应用程序时的最大量的工作成了一个无法自动完成的任务,这个工作就是决定如何实现多线程。ConcurrentExecution 的第一个限制是
回调函数将不允许显式或隐式的共享数据;或回调函数需要任何其他形式的同步操作,而这些同步操作将立刻牺牲掉所有该抽象所带来的优点,并且打开所有“精彩”的同步世界中的陷阱和圈套,例如死锁、竞争冲突、或需要非常复杂的复合同步对象。
同样,也不允许那些可能潜在地被并发执行的计算来调用 UI,因为就象我前面所讲到的,Win32 API 对于调用 UI 的线程强迫了许多个隐式的同步操作。请注意,还有许多其他的 API 子集和库对于共享它们的线程强迫了隐式的同步操作。
这些的限制使 ConcurrentExecution 只具有极其有限的功能,说具体一点,就是一个管理纯粹工作者线程的抽象(完全独立的计算大多数情况下仅限于在非连续内存区域的数学计算)。
然而,事实证明实现 ConcurrentExecution 类并且在
性能测试中使用它是非常有用的,因为,当我实现了该类,并且设计和运行了该测试之时,许多关于多线程的隐藏起来的细节都暴露出来了。请清楚以下一点,虽然 ConcurrentExecution 类可以使多线程更容易处理,但是如果想要在商业产品中使用它,那么该类的实现还需要一些其他的工作。特别要提到的一点时,我忽略了所有的错误情况处理,这是不可忍受的。但是我假定只用于测试时(我明显地使用了 ConcurrentExecution),错误不会出现。
ConcurrentExecution类
下面是 ConcurrentExecution 类的原型:
该类是从 Thrdlib.dll 库中导出的,而 Thrdlib.dll 库是示例测试套件 THRDPERF 中的一个工程。在讨论该类的内部结构之前,让我们首先讨论成员函数的语义(semantics):
您可能会注意到
构造函数 ConcurrentExecution 有一个数字参数。该参数指定了该类的实例所支持的“并发的最大度数”;换句话说,如果某个 ConcurrentExecution 的实例被创建时,n 是它的一个参数,那么在任何给定的时间不能有超过 n 个计算在执行。根据我们以前的分析,该参数就意味“无论有多少个顾客在等待,打开的结算柜台数不要多于 n 个”。
这是在这里被实现的唯一有趣的成员函数。DoForAllObjects 的主要参数是一个对象的
数组、一个处理器函数、和一个终结器函数。关于对象完全没有强制的格式;每次该处理器被调用时,将有一个对象被传递给它,而且完全由该处理器来解释对象。第一个参数 iNoOfObjects,仅仅是要 ConcurrentExecution 知道在
对象数组中的元素数。请注意,在调用 DoForAllObjects 时,如果
对象数组的长度为 1,那么它与调用 CreateThread 就非常相似(有一点不同,那就是 CreateThread 不接受一个终结器参数)。
DoForAllObjects 的语义如下:处理器将为每一个对象而调用。对象被处理的顺序并未指定;所有能够担保的只是每一个对象都将在某个时间被传递给处理器。并发的最大度数是由传递给 ConcurrentExecution 对象的
构造函数的参数来决定的。
处理器函数不能访问共享的数据,并且不能调用到 UI 或做任何其他需要显式或隐式地串行操作的事情。目前,仅存在一个处理器函数能够对所有的对象工作;但是,要使用处理器
数组来替代该处理器参数将是简单的。
该处理器的原型如下:
typedef DWORD (WINAPI *CONCURRENT_EXECUTION_ROUTINE)
(LPVOID lpParameterBlock);
当该处理器已经完成了在一个对象上的工作之后,终结器函数将立即被调用。与处理器不同,终结器函数是在该调用函数的环境中被串行调用的,并且可以调用所有的例程和访问调用程序所能够访问的所有数据。但是,应该要注意的是,终结器应该被尽可能地优化,因为终结器中的长计算会影响 DoForAllObjects 的性能。请注意,尽管只要处理器结束了每一个对象终结器就会立即被调用,直到最后一个对象已经被终结之前,DoForAllObjects 本身并没有返回。
我们为什么要经历这么多使用终结器的痛苦?我们同样可以让每一个计算在处理器函数的最终结束时执行终结器代码,是吗?
这样基本上是可以的;但是,有必要强调终结器是在调用 DoForAllObjects的线程环境中被调用的。这样的设计使在每一个计算进入时处理它们的结果更加容易,而无须担心同步问题。
终结器函数的原型如下:
typedef DWORD (WINAPI *CONCURRENT_FINISHING_ROUTINE)
(LPVOID lpParameterBlock,LPVOID lpResultCode);
第一个参数是被处理的对象,第二个参数是处理器函数在该对象上的结果。
DoForAllObjects 的同类是 DoSerial,DoSerial 与 DoForAllObjects 具有相同的
参数列表,但是计算是被以串行的顺序处理的,并且以列表中的第一个对象开始。
请注意:本节的讨论是非常技术性的,所以假设您理解很多有关 Win32 线程 API 的知识。如果您对如何使用 ConcurrentExecution 类来收集测试数据更加感兴趣,而不是对 ConcurrentExecution::DoForAllObjects 是如何被实现的感兴趣,那么您现在就可以跳到下面的“使用 ConcurrentExecution 来试验线程性能”一节。
让我们从 DoSerial 开始,因为它很大程度上是一个“不费脑筋的家伙”:
这段代码只是循环遍历该
数组,在每一次迭代中调用处理器,然后在处理器和对象本身的结果上调用终结器。干得既干净又漂亮,不是吗?
令人感兴趣的成员函数是 DoForAllObjects。乍一看,DoForAllObjects 所要做的也没有什么特别的——请求操作系统创建为每一个计算一个线程,并且确保终结器函数能够被正确地调用。但是,有两个问题使得 DoForAllObjects 比它的表面现象要复杂:第一,当计算的数目多于可用的
线程数时,ConcurrentExecution 的一个实例所创建的“并发的最大度数”参数可能需要一些附加的记录(bookkeeping)。第二,每一个计算的终结器函数都是在调用 DoForAllObjects 的线程的上下文中被调用的,而不是在该计算运行所处的线程上下文中被调用的;并且,终结器是在处理器结束之后立刻被调用的。要处理这些问题还是需要很多技巧的。
让我们深入到代码中,看看究竟是怎么样的。该段代码是从文件 Thrdlib.cpp 中继承来的,但是为了清除起见,已经被精简了:
首先,我们为每一个对象创建单独的线程。因为我们使用 CREATE_SUSPENDED 来创建该线程,所以还没有线程被启动。另一种方法是在需要时创建每一个线程。我决定不使用这种替代的策略,因为我发现当在一个同时运行了多个线程的应用程序中调用时, CreateThread 调用是非常浪费的;这样,同在运行时创建每一个线程相比,在此时创建线程的开销将更加容易接受,
DoForAllObjects 的核心是 hPnt,它是一个
对象数组,这些对象是当 ConcurrentExecution 对象被构造时分配的。该
数组能够容纳最大数目的线程,此最大数目与在
构造函数关于决定如何填充和释放的 slots 算法如下:该
对象数组是从头到尾遍历的,并且对于每一个对象,我们都做如下的事情:如果尚未有 slot 已经被填充,我们使用当前的对象来填充该数组中的第一个 slot,并且继续执行将要处理当前对象的线程。如果至少有一个 slot 被使用,我们使用 WaitForMultipleObjects 函数来决定是否有正在运行的任何计算已经结束;如果是,我们在该对象上调用终结器,并且为新对象“重用”该 slot。请注意,我们也可以首先填充每一个空闲的 slot,直到没有剩余的 slots 为止,然后开始填充空的 slot。但是,如果我们这样做了,那么空出 slot 的终结器函数将不会被调用,直到所有的 slot 都已经被填充,这样就违反了我们有关当处理器结束一个对象时,终结器立刻被调用的要求。
最后,还有没有空闲 slot 的情况(就是说,当前激活的
线程数等于 ConcurrentExecution 对象所允许的最大并发度数)。在这种情况下,WaitForMultipleObjects 将被再次调用以使得 DoForAllObjects 处于“睡眠”状态,直到有一个 slot 空出;只要这种情况一发生,终结器就被在空出 slot 的对象上调用,并且工作于当前对象的线程被继续执行。
终于,所有的计算要么都已经结束,要么将占有
对象数组中的 slot。下列的代码将会处理所有剩余的线程:
使用 ConcurrentExecution 来试验线程性能
性能测试的范围如下:测试应用程序 Threadlibtest.exe 的用户可以指定是否测试基于 CPU 的或基于 I/O 的计算、执行多少个计算、计算的时间有多长、计算是如何排序的(为了测试最糟的情况与随机延迟),以及计算是被并发执行还是串行执行。
为了消除意外的结果,每一个测试可以被执行十次,然后将十次的结果拿来平均,以产生一个更加可信的结果。
变量的变形。在测试中使用的计算长度在基础值 10 和 3,500 ms 之间变动(我一会儿将讨论这一问题),计算的数目在 2 和 20 之间变化。如果在运行该测试的计算机上安装了 Microsoft Excel,Threadlibtest.exe 将会把结果转储在一个 Microsoft Excel
工作表纯文本文件 请牢记,运行整个的
测试程序将总是以最糟的情况来排序计算(就是说,执行的顺序是串行的,最长的计算将被首先执行,其后跟随着第二长的计算,然后以次类推)。这种方案牺牲了串行执行的灵活性,因为并发执行的响应时间在一个非最糟的方案下也没有改变,而该串行执行的响应时间是有可能提高的。
正如我前面所提到的,在一个实际的方案中,您应该分析每一个计算的时间是否是可以预测的。
该段代码首先创建一个 ConcurrentExecution 类的对象,然后,取样当前时间,(用于统计计算所消耗的时间和响应时间),并且,根据所请求的是串行执行还是并发执行,分别调用 ConcurrentExecution 对象 DoSerial 或 DoForAllObjects 成员。请注意,对于当前的执行我请求最大并发度数为 25;如果您想要运行有多于 25 个计算的
测试程序,那么您应该提高该值,使它大于或等于运行您的测试程序所需要的最大
并发数。
让我们看一下处理器和终结器,以得到精确测量的结果:
处理器模拟一个计算,其长度已经被放到一个与计算有关的
数据结构 THREADBLOCKSTRUCT 中。THREADBLOCKSTRUCT 保持着与计算有关的数据,如其延迟和终止时间(以性能计数“滴答”来衡量),以及反向
指针,指向实用化该结构的视图(view)。
通过简单的使计算“睡眠”指定的时间就可以模拟基于I/O的计算。基于 CPU的计算将进入一个空的 for 循环。这里的一些注释是为了帮助理解代码的功能:计算是基于 CPU 的,并且假定其执行时间为指定的毫秒数。在本
测试程序的早期版本中,我仅仅是要 for 循环执行足够多的次数以满足指定的延迟的需求,而不考虑数字的实际含义。(根据相关的代码,对于基于I/O的计算该数字实际意味着毫秒,而对于基于CPU的计算,该数字则意味着迭代次数。)但是,为了能够使用绝对时间来比较基于CPU的计算和基于I/O的计算,我决定重写这段代码,这样无论对于基于CPU的计算还是基于I/O的计算,与计算有关的延迟都是以毫秒测量。
我发现对于具有指定的、预先定义时间长度的基于CPU的计算,要编写代码来模拟它并不是一件简单的事情。原因是这样的代码本身不能查询系统时间,因为所引发的调用迟早都会交出 CPU,而这违背了基于 CPU 的计算的要求。试图使用异步
多媒体时钟事件同样没有得到满意的效果,原因是 Windows NT 下计时器服务的工作方式。设置了一个多媒体计时器的线程实际上被挂起,直到该计时器回调被调用;因此,基于 CPU 的计算突然变成了基于 I/O 的操作了。
于是,最后我使用了一个有点儿低劣的技巧:CThreadLibTestView::OnCreate 中的代码执行 100 次循环从 1 到 100,000 计数,并且取样通过该循环所需要的平均时间。结果被保存在
成员变量 m_iBiasFactor 中,该成员变量是一个浮点数,它在处理器函数中被使用来决定毫秒如何被“翻译”成迭代次数。不幸的是,因为操作系统的高度戏剧性的天性,要决定实际上运行一个指定长度的计算要迭代多少次给定的循环是困难的。但是,我发现该策略在决定基于CPU的操作的计算时间方面,完成了非常可信的工作。
编译程序将检测具有空的主体的 for 循环,并且删除这些循环。
终结器非常简单:当前时间被取样并保存在计算的 THREADBLOCKSTRUCT 中。在测试结束之后,该代码计算执行 ExecuteTest 的时间和终结器为每一个计算所调用的时间之间的差异。然后,所有计算所消耗的时间由所有已完成的计算中最后一个计算完成时所消耗的时间所决定,而响应时间则是每一个计算的响应时间的平均值,这里,每一个响应时间,同样,定义为从测试开始该线程消耗的时间除以该线程的延迟因子。请注意,终结器在
主线程上下文中
串行化的运行,所以在共享的 iEndIndex 变量上的递增指令是安全的。
结果
如果您想要在您的计算机上重新创建该测试结果,您需要做以下的事情:
如果您需要改变测试参数,例如最大计算数或协议文件的位置,请编辑 THRDPERF 示例工程中的 Threadlibtestview.cpp,然后重新编译生成该应用程序。(请注意,要编译生成该应用程序,您的计算机需要对
长文件名的支持。)
请确保文件 Thrdlib.dll 在一个 Threadlibtest.exe 能够链接到它的位置。
如果您想要使用 Microsoft Excel 来查看测试的结果,请确定 Microsoft Excel 已经正确地被安装在运行该测试的计算机上。
原始数据生成图表,我使用了几个宏来重新排列该结果,并且为您生成了图表。我憎恨数字(number crunching),但是我必需称赞 Microsoft Excel,因为即使象我一样的
工作表妄想狂(spreadsheet-paranoid),也可以提供这样漂亮的用户界面,在几分钟之内把几列数据装入有用的图表。
我所展现的测试结果是在一个 486/33 MHz 的带有 16 MB RAM 的系统收集而来的。该计算机同时安装了 Windows NT (3.51 版) 和 Windows 95;这样,在两个操作系统上的不同测试结果就是可比的了,因为它们基于同样的
硬件系统。
那么,现在让我们来解释这些值。这里是总结计算结果的图表;后面有其解释。该图表应该这样来看:每一个图表的 x 轴有 6 个值(除了有关长计算的消耗时间表,该表仅含有 5 个值,这是因为在我的测试运行时,对于非常长的计算计时器溢出了)。一个值代表多个计算;我以 2、5、8、11、14 和 17 个计算来运行每一个测试。在 Microsoft Excel 结果
工作表中,您将会找到对于基于CPU的计算和基于I/O的计算的线程的每一种计算数目的结果,延迟(delay bias)分别是 10 ms、30 ms、90 ms、270 ms,、810 ms 和 2430 ms,但是在该图表中,我仅包括了 10 ms 和 2430 ms 的结果,这样所有的数字都被简化,而且更容易理解。
对于消耗时间图表来说, y 轴的值与毫秒对应,对于响应时间图表来说,y 轴的值与相对(relative turnaround)长度(即,实际执行所花费的毫秒数除以预期的毫秒数)相对应。
基于 CPU 的任务
正如我们前面所提到的,在一个单处理器的计算机中,基于 CPU 的任务的并发执行速度不可能比串行执行速度快,但是我们可以看到,在 Windows NT 下线程创建和切换的额外开销非常小;对于非常短的计算,并发执行仅仅比串行执行慢 10%,而随着计算长度的增加,这两个时间就非常接近了。以响应时间来衡量,我们可以发现对于长计算,并发执行相对于串行执行的响应增益可以达到 50%,但是对于短的计算,串行执行实际上比并发执行更加好。
Windows 95 和 Windows NT 之间的比较
如果我们看一看有关长计算的图表,我们可以发现在 Windows 95 和 Windows NT 下其行为是极其类似的。请不要被这样的事实所迷惑,即好象 Windows 95 处理基于I/O的计算与基于CPU的计算不同于 Windows NT。我把这一结果的原因归结为这样一个事实,那就是我用来决定多少个测试循环与 1 毫秒相对应的算法(如前面所述)是非常不精确的;我发现同样是这个算法,在完全相同的环境下执行多次时,所产生的结果之间的差异最大时可达20%。所以,比较基于 CPU 的计算和基于 I/O 的计算实际上是不公平的。
Windows 95 和 Windows NT 之间不同的一点是当针对短的计算时。对于并发的基于I/O的短计算,Windows NT 的效果要好得多。我把这一结果得原因归结为更加有效率得线程创建方案。请注意,对于长得计算,串行与并发
I/O操作之间的差别消失了,所以这里我们处理的是固定的、相对比较小的额外开销。
对于短的计算,以响应时间来衡量,请注意,在 Windows NT 下,在10个线程处有一个断点,在这里更多的计算并发执行有更好的效果,而对于 Windows 95 ,则是串行计算有更好的容量。
请注意这些比较都是基于当前版本的操作系统得到的(Windows NT 3.51 版和 Windows 95),如果考虑到操作系统的问题,那么线程引擎非常有可能被增强,所以两个操作系统之间的各自行为的差异有可能消失。但是,有一点很有趣的要注意,短计算一般不必要使用多线程,尤其是在 Windows 95 下。
建议
这些结果可以推出下面的建议:决定多线程性能的最主要因素是基于 I/O 的计算和基于 CPU 的计算的比例,决定是否采用多线程的主要条件是前台的用户响应。
让我们假定在您的应用程序中,有多个子计算可以在不同的线程中潜在地被执行。为了决定对这些计算使用多线程是否有意义,要考虑下面的几点。
如果用户界面响应分析决定某些事情应该在第二线程中实现,那么,决定将要执行的任务是基于I/O的计算还是基于CPU 的计算就很有意义。基于I/O的计算最好被重新定位到后台线程中。(但是,请注意,异步
单线程的 I/O 处理可能比多线程同步I/O要好,这要看问题而定)非常长的基于CPU的线程可能从在不同的线程中被执行获益;但是,除非该线程的响应非常重要,否则,在同一个后台线程中执行所有的基于 CPU 的任务可能比在不同的线程中执行它更有意义。请记住在任何的情况下,短计算在并发执行时一般会在线程创建时有非常大的额外开销。
如果对于基于CPU的计算 — 即每一个计算的结果只要得到了就立刻能应用的计算,响应是最关键的,那么,您应该尝试决定这些计算是否能够以升序排序,在此种情况下这些计算串行执行的整体性能仍然会比
并行执行要好。请注意,有一些计算机的体系结构的设计是为了能够非常有效率地处理长的计算(例如矩阵操作),那么,在这样的计算机上对长的计算实行多线程化的话,您可能实际上牺牲了这种结构的优势。
所有的这些分析都假定该应用程序是运行在一个单处理器的计算机上,并且计算之间是相互独立的。实际上,如果计算之间相互依赖而需要串行设计,串行执行的性能将不受影响(因为串行是隐式的),而并发执行的版本将总是受到不利的影响。
我还建议您基于计算之间相互依赖的程度决定多线程的设计。在大多数情况下子计算线程化不用说是好的,但是,如果对于拆分您的应用程序为多个可以在不同线程处理的子计算的方法有多种选择,我推荐您使用同步的复杂性作为一个条件。换句话说,如果拆分成多个线程而需要非常少和非常直接的同步,那么这种方案就比需要使用大量且复杂的
线程同步的方案要好。
最后一个请注意是,请牢记线程是一种系统资源,不是无代价的;所以,there may be a greater penalty to multithreading than performance hits alone. 作为第一规则(rule of thumb),我建议您在使用多线程时要保持理智并且谨慎。在多线程确实能够给您的应用程序设计带来好处的时候才使用它们,但是,如果串行计算可以达到同样的效果,就不要使用它们。
总结
运行附加的性能测试套件产生了一些特殊的结果,这些结果提供了许多有关并发应用程序设计的内部逻辑。请注意我的许多假定是非常基本的;我选择了比较非常长的计算和非常短的计算,我假定计算之间完全独立,要么完全是基于I/O的计算,要么是完全基于CPU的计算。而绝大多数的现实问题,如果以计算长度和 boundedness 来衡量,都是介于这两种情况之间的。请把本文中的材料仅仅作为一个起点,它使您更加详细地分析您的应用程序,以决定是否使用多线程。