本文讨论将单线程应用程序重新编写成多线程应用程序的策略。它以Microsoft? Windows? 95和Windows NT?的平台为例,从吞吐量(throughput)和响应方面,与兼容的单线程计算相比较而分析了多线程计算的性能。
介绍
在您所能够找到的有关多线程的资料中,多数都是讲述同步概念的。例如,如何串行化(serialize)共享公共数据的线程。这种把重点放在讨论同步上是有意义的,因为同步是多线程编程中不可缺少的一部分。本文则后退了一步(takes a step back),主要讨论有关多线程中很少有人涉及的一面:决定一个计算如何能够被有意义地拆分为多个线程。本文中所使用的示例程序,THRDPERF,在Microsoft? Windows? 95和Windows NT? 两个平台之上,针对同一个计算采取串行和并发两种方法分别实现了测试套件(test suite),并从吞吐量和性能两方面来比较它们。
本文的第一部分建立了一些有关多线程应用程序的词汇(vocabulary),讨论测试套件的范围,并且介绍了示例程序套件是如何设计的。第二部分讨论测试的结果,并且包括对于多线程应用程序设计的建议。与之相关的文章"Interacting with Microsoft Excel: A Case Study in OLE Automation" 讨论有关该示例程序套件的一个有趣的问题,即使用测试集合所获得的数据是如何使用OLE Automation被输入Microsoft Excel中的。
如果您是经验丰富的多线程应用程序编程者,您可以跳过介绍部分,而直接到下面的“结果”部分。
多线程词汇
很长一段时间以来,您的应用程序一直被使用——它运转出色,是可以信赖的,而且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 tonofmoney)人比较喜欢它,因为要计算出一个给定的任务采用并发执行到底有多大的好处,通常需要多年的研究。本文并不想要教您如何设计多线程应用程序。
相反,我要向您指出某些多线程应用程序设计的问题所在,而且,我使用真实(real-life)的性能测试来讨论我的例子。在阅读过本文后,您应该能够观察一个给定的设计,并且能够决定某种设计是否提高了该应用程序的整体性能。
多线程应用程序设计步骤中的一部分工作,就是要决定在何处存在可能潜在地引起数据毁坏的多线程数据访问冲突,以及如何使用线程的同步来避免这种冲突。
这项任务(以后,本文将称之为线程编序(threadserialization))是许多有关多线程的文章的主题,(例如,MSDNLibrary中的"SynchronizationontheFly"或"Compound Win32SynchronizationObjects"),在本文中将丝毫不涉及对它的讨论。有关在本文中要讨论的,我们将假定需要并发的计算并不共享任何数据,并且因此而不需要任何线程编序。这种约定看起来可能有点苛刻,但是请您牢记,不可能有关于同步多线程应用程序的“通用”的讨论,因为每一次编序都将强加一个唯一的“等待- 醒来”结构(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的问题。
正如在"Multiple Threads in the User Interface"一文中所指出的,多线程和图形用户界面(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的计算在多线程情况下不可能比在单线程情况下的
一般来说,必定会有某些情况,无论多个计算的完成谁先谁后,但是它们的结果必需同步。例如,使用多个线程来并发的读多个文件到内存中,那么文件被处理的顺序我们是不关心的,但是必需等到所有的数据都读入内存之后,应用程序才能开始处理。我们将在“容量”一节讨论这个想法。
在本文中,我们将以消耗的时间,即完成所有的计算所消耗的总的时间,来衡量性能。
容量(Throughput)
容量(或响应),是指每一个计算的平均处理周期(turnaround)的时间。为了演示
容量,让我们假设一个超级市场的例子(它总是一个有关操作系统的极好的演示工具):假每一个计算就是一个在结算柜台被服务的顾客。对于超级市场来说,既可以为每一个顾客?设一个结算柜台,也可以把所有的顾客集中起来通过一个结算柜台。为了我们分析的需要,设是有多个结算柜台的情况,但是,仅有一个收银员(可怜的家伙!)来服务所有的顾客,而不考虑顾是在一个柜台前排队或多个柜台前排队。这个超级收银员将高速地从一个柜台跳到下一个?台,一次仅处理(ringing up)一个顾客的一件商品,然后,就移动到下一个顾客。这个超级的收银员就象是被多
个计算所割裂的CPU。
就象我们在前面的“性能”一节中所看到的,服务所有顾客的总的时间并没有因为有多个结算柜台打开而减少,因为无论顾客是在一个柜台还是多个柜台被服务,总是这一个收银员来完成所有的工作。但是,事情是这样,同只有一个结算柜台相比,顾客还是喜欢这种超级收银员的方式。这是因为一般情况下,顾客的手推车里的商品数的差别是巨大的,某些顾客的手推车中有一大堆的商品,而某些顾客则只想买很少几件商品。如果您曾经只希望买一盒granolabars和一夸脱牛奶,而却排在某个来为全家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 应用程序,快速响应可以被很简单地实现,只要确保长的计算被委托给后台线程,但是实现快速响应所要求的结构可能要求较高的技巧,正如我前面所提到的,某些人可能会等待某个计算在某个时间返回,所以在后台执行一个长的计算可能需要改变用户