第11章线程池的使用
为什么要使用线程池?
因为创建线程和释放线程是要消耗系统资源的,如果要完成一个工作要不停的创建和释放线程必然会造成很大的系统资源的浪费,所以用线程池。在线程本次工作完成后,不释放线程,让线程等待。再有需要让线程去完成的工作时就把原来创建的线程取过来继续使用。这样节省了重复的创建释放线程的过程。
到目前为止,已经知道创建多线程应用程序是非常困难的。需要会面临两个大问题。一个是要对线程的创建和撤消进行管理,另一个是要对线程对资源的访问实施同步。为了对资源访问实施同步,Wi n d o w s提供了许多基本要素来帮助进行操作,如事件、信标、互斥对象和关键代码段等。这些基本要素的使用都非常方便。为了使操作变得更加方便,唯一的方法是让系统能够自动保护共享资源。在如何对线程的创建和撤消进行管理的问题上,Microsoft公司的Windows 2000提供了一些新的线程池函数,使得线程的创建、撤消和基本管理变得更加容易。这个新的通用线程池并不完全适合每一种环境,但是它常常可以适合你的需要,并且能够节省大量的程序开发时间。
新的线程池函数使你能够执行下列操作:
• 异步调用函数。
• 按照规定的时间间隔调用函数。
• 当单个内核对象变为已通知状态时调用函数。
• 当异步I/O请求完成时调用函数。
为了完成这些操作,线程池由4个独立的部分组成。定时器 等待 I/O 非I/O.
11.1 方案1:异步调用函数
假设有一个服务器进程,该进程有一个主线程,正在等待客户机的请求。当主线程收到该请求时,它就产生一个专门的线程,以便处理该请求。这使得应用程序的主线程循环运行,并等待另一个客户机的请求。这个方案是客户机/服务器应用程序的典型实现方法。虽然它的实现方法非常明确,但是也可以使用新线程池函数来实现它。
当服务器进程的主线程收到客户机的请求时,它可以调用QueueUserWorkItem这个函数.
BOOL QueueUserWorkItem(
PTHREAD_START_ROUTINE pfnCallback,
PVOID pvContext,
ULONG dwFlags);
该函数将一个“工作项目”排队放入线程池中的一个线程中并且立即返回。所谓工作项目是指一个(用pfnCallback参数标识的)函数,它被调用并传递单个参数pvContext。最后,线程池中的某个线程将处理该工作项目,导致函数被调用。所编的回调函数必须采用下面的原型:
DWORD WINAPI WorkItemFunc(PVOID pvContext);
尽管必须使这个函数的原型返回DWORD,但是它的返回值实际上被忽略了。
注意,你自己从来不调用CreateThread。系统会自动为你的进程创建一个线程池,线程池中的一个线程将调用你的函数。另外,当该线程处理完客户机的请求之后,该线程并不立即被撤消。它要返回线程池,这样它就可以准备处理已经排队的任何其他工作项目。你的应用程序的运行效率可能会变得更高,因为不必为每个客户机请求创建和撤消线程。另外,由于线程与完成端口相关联,因此可以同时运行的线程数量限制为C P U数量的两倍。这就减少了线程的上下文转移的开销。
该函数的内部运行情况是, QueueUserWorkItem检查非I/O组件中的线程数量,然后根据负荷量(已排队的工作项目的数量)将另一个线程添加给该组件。接着QueueUserWorkItem执行对PostQueuedCompletionStatus的等价调用,将工作项目的信息传递给I/O完成端口。最后,在完成端口上等待的线程取出信息(通过调用GetQueuedCompletionStatus),并调用函数。当函数返回时,该线程再次调用GetQueuedCompletionStatus,以便等待另一个工作项目。线程池希望经常处理异步I/O请求,即每当线程将一个I/O请求排队放入设备驱动程序时,便要处理异步I/O请求。当设备驱动程序执行该I/O时,请求排队的线程并没有中断运行,而是继续执行其他指令。异步I/O是创建高性能可伸缩的应用程序的秘诀,因为它允许单个线程处理来自不同客户机的请求。该线程不必顺序处理这些请求,也不必在等待I/O请求运行结束时中断运行。
但是,Wi n d o w s对异步I/O请求规定了一个限制,即如果线程将一个异步I/O请求发送给设备驱动程序,然后终止运行,那么该I/O请求就会丢失,并且在I/O请求运行结束时,没有线程得到这个通知。在设计良好的线程池中,线程的数量可以根据客户机的需要而增减。因此,如果线程发出一个异步I/O请求,然后因为线程池缩小而终止运行,那么该I/O请求也会被撤消。因为这种情况实际上并不是你想要的,所以你需要一个解决方案。如果你想要给发出异步I/O请求的工作项目排队,不能将该工作项目插入线程池的非I/O组件中。必须将该工作项目放入线程池的I/O组件中进行排队。该I/O组件由一组线程组成,如果这组线程还有尚未处理的I/O请求,那么它们决不能终止运行。因此你只能将它们用来运行发出异步I/O请求的代码。
若要为I/O组件的工作项目进行排队,仍然必须调用QueueUserWorkItem函数,但是可以为dwFlags参数传递WT_EXECUTEINIOTHREAD。通常只需传递WT_EXECUTEDEFAULT(定义为0),这使得工作项目可以放入非I/O组件的线程中。Windows提供的函数(如RegNotifyChangeKeyValue)能够异步执行与非I/O相关的任务。这些函数也要求调用线程不能终止运行。如果想使用永久线程池的线程来调用这些函数中的一个,可以使用WT_EXECUTEINPERSISTENTTHREAD标志,它使定时器组件的线程能够执行已排队的工作项目回调函数。由于定时器组件的线程决不会终止运行,因此可以确保最终发生异步操作。应该保证回调函数不会中断,并且保证它能迅速执行,这样,定时器组件的线程就不会受到不利的影响。设计良好的线程池也必须设法保证线程始终都能处理各个请求。如果线程池包含4个线程,并且有1 0 0个工作项目已经排队,每次只能处理4个工作项目。如果一个工作项目只需要几个毫秒来运行,那么这是不成问题的。但是,如果工作项目需要运行长得多的时间,那么将无法及时处理这些请求。
当然,系统无法很好地预料工作项目函数将要进行什么操作,但是,如果知道工作项目需要花费很长的时间来运行, 那么可以调用QueueUserWorkItem 函数,为它传递WT_EXECUTELONGFUNCTION标志。该标志能够帮助线程池决定是否要将新线程添加给线程池。如果线程池中的所有线程都处于繁忙状态,它就会强制线程池创建一个新线程。因此,如果同时对10 000个工作项目进行了排队(使用WT_EXECUTELONGFUNCTION标志),那么这10000 个线程就被添加给该线程池。如果不想创建10000个线程,必须分开调用QueueUserWorkItem函数,这样某些工作项目就有机会完成运行。线程池不能对线程池中的线程数量规定一个上限,否则就会发生渴求或死锁现象。假如有10000个排队的工作项目,当第10001个项目通知一个事件时,这些工作项目将全部中断运行。如果你已经设置的最大数量为10 000个线程,第10 001个工作项目没有被执行,那么所有的10 000个线程将永远被中断运行。当使用线程池函数时,应该查找潜在的死锁条件。当然,如果工作项目函数在关键代码段、信标和互斥对象上中断运行,那么必须十分小心,因为这更有可能产生死锁现象。始终都应该了解哪个组件(I/O、非I/O、等待或定时器等)的线程正在运行你的代码。另外,如果工作项目函数位于可能被动态卸载的D L L中,也要小心。调用已卸载的D L L中的函数的线程将会产生违规访问。若要确保不卸载带有已经排队的工作项目的D L L,必须对已排队工作项目进行引用计数,在调用QueueUserWorkItem函数之前递增计数器的值,当工作项目函数完成运行时则递减该计数器的值。只有当引用计数降为0时,才能安全地卸载D L L。
11.2 方案2:按规定的时间间隔调用函数
有时应用程序需要在某些时间执行操作任务。Wi n d o w s提供了一个等待定时器内核对象,因此可以方便地获得基于时间的通知。许多程序员为应用程序执行的每个基于时间的操作任务创建了一个等待定时器对象,但是这是不必要的,会浪费系统资源。相反,可以创建一个等待定时器,将它设置为下一个预定运行的时间,然后为下一个时间重置定时器,如此类推。然而,要编写这样的代码非常困难,不过可以让新线程池函数对此进行管理。若要调度在某个时间运行的工作项目,首先要调用下面的函数,创建一个定时器队列:HANDLE CreateTimerQueue();
定时器队列对一组定时器进行组织安排。例如,有一个可执行文件控制着若干个服务程序。每个服务程序需要触发定时器,以帮助保持它的状态,比如客户机何时不再作出响应,何时收集和更新某些统计信息等。让每个服务程序占用一个等待定时器和专用线程,这是不经济的。相反,每个服务程序可以拥有它自己的定时器队列(这是个轻便的资源),并且共享定时器组件的线程和等待定时器对象。当一个服务程序终止运行时,它只需要删除它的定时器队列即可,因为这会删除该队列创建的所有定时器。
一旦拥有一个定时器队列,就可以在该队列中创建下面的定时器:
BOOL CreateTimerQueueTimer();
工作回调函数必须采用下面的原型:
VOID WINAPI WaitOrTimeCallback();
当不再想要触发定时器时,必须通过调用下面的函数将它删除:
BOOL DeleteTimeQueueTimer();
一旦创建了一个定时器,可以调用下面这个函数来改变它的到期时间和到期周期:
BOOL ChangeTimerQueueTimer();
这里传递了定时器队列的句柄和想要修改的现有定时器的句柄。可以修改定时器的dwDueTime和dwPeriod。注意,试图修改已经触发的单步定时器是不起作用的。另外,你可以随意调用该函数,而不必担心死锁。
当不再需要一组定时器时,可以调用下面这个函数,删除定时器队列:
BOOL DeleteTimerQueueEX();
该函数取出一个现有的定时器队列的句柄,并删除它里面的所有定时器,这样就不必为删除每个定时器而显式调用DeleteTimerQueueTimer。hCompletionEvent参数在这里的语义与它在DeleteTimerQueueTimer函数中的语义是相同的。这意味着它存在同样的死锁可能性,因此必须小心。
在开始介绍另一个方案之前,让我们说明两个其他的项目。首先,线程池的定时器组件创建等待定时器,这样,它就可以给APC项目排队,而不是给对象发送通知。这意味着操作系统能够连续给APC项目排队,并且定时器事件从来不会丢失。因此,设置一个定期定时器能够保证每个间隔时间都能为你的工作项目排队。如果创建一个定期定时器,每隔10s触发一次,那么每隔10s就调用你的回调函数。必须注意这在使用多线程时也会发生必须对工作项目函数的各个部分实施同步。
如果不喜欢这种行为特性,而希望你的工作项目在每个项目执行之后的10s进行排队,那么应该在工作项目函数的结尾处创建单步定时器。或者可以创建一个带有高超时值的单个定时器,并在工作项目函数的结尾处调用ChangeTimerQueueTimer.
11.3方案3:当单个内核对象变为已通知状态时调用函数
Microsoft发现,许多应用程序产生的线程只是为了等待内核对象变为已通知状态。一旦对象得到通知,该线程就将某种通知移植到另一个线程,然后返回,等待该对象再次被通知。有些编程人员甚至编写了代码,在这种代码中,若干个线程各自等待一个对象。这对系统资源是个很大的浪费。当然,与创建进程相比,创建线程需要的的开销要小得多,但是线程是需要资源的。每个线程有一个堆栈,并且需要大量的CPU指令来创建和撤消线程。始终都应该尽量减少它使用的资源。
如果想在内核对象得到通知时注册一个要执行的工作项目,可以使用另一个新的线程池函数:
BOOL RegisterWaitForSingleObject();
该函数负责将参数传送给线程池的等待组件。
当工作项目准备执行时,它被默认排队放入非I/O组件的线程中。这些线程之一最终将会醒来,并且调用你的函数,该函数的原型必须是下面的形式:
VOID WINAPI WaitOrTimeCallbackFunc();
现在,如果正在等待一个自动重置的事件内核对象。一旦该对象变为已通知状态,该对象就重置为它的未通知状态,并且它的工作项目将被放入队列。这时,该对象仍然处于注册状态,同时,等待组件再次等待该对象被通知,或者等待超时(它已经重置)结束。当不再想让该等待组件等待你的注册对象时,必须取消它的注册状态。即使是使用WT_EXECUTEONLYONCE标志注册的并且已经拥有队列的工作项目的等待组件,情况也是如此。调用下面这个函数,可以取消等待组件的注册状态:
BOOL UnregisterWaitEx();
11.4方案4:当异步I/O请求完成运行时调用函数
最后一个方案是个常用的方案,即服务器应用程序发出某些异步I/O请求,当这些请求完成时,需要让一个线程池准备好来处理已完成的I/O请求。这个结构是I/O完成端口原先设计时所针对的一种结构。如果要管理自己的线程池,就要创建一个I/O完成端口,并创建一个等待该端口的线程池。还需要打开多个I/O设备,将它们的句柄与完成端口关联起来。当异步I/O请求完成时,设备驱动程序就将“工作项目”排队列入该完成端口。
这是一种非常出色的结构,它使少数线程能够有效地处理若干个工作项目,同时它又是一种很特殊的结构,因为线程池函数内置了这个结构,使你可以节省大量的设计和精力。若要利用这个结构,只需要打开设备,将它与线程池的非I/O组件关联起来。记住,I/O组件的线程全部在一个I/O组件端口上等待。若要将一个设备与该组件关联起来,可以调用下面的函数:
BOOL BindIoCompletionCallback();
该函数在内部调用CreateIoCompletionPort,传递hDevice和内部完成端口的句柄。调用BindIoCompletionCallback也可以保证至少有一个线程始终在非I/O组件中。与该设备相关联的完成关键字是重叠完成例程的地址。这样,当该设备的I/O运行完成时,非I/O组件就知道要调用哪个函数,以便它能够处理已完成的I/O请求。该完成例程必须采用下面的原型:
BOOL OverlappedCompletionRoutine();
第12章纤程
Microsoft公司给Windows添加了一种纤程,以便能够非常容易地将现有的UNIX服务器应用程序移植到Windows中。UNIX服务器应用程序属于单线程应用程序(由Windows定义),但是它能够为多个客户程序提供服务。换句话说,UNIX应用程序的开发人员已经创建了他们自己的线程结构库,他们能够使用这种线程结构库来仿真纯线程。该线程包能够创建多个堆栈,保存某些CPU寄存器,并且在它们之间进行切换,以便为客户机请求提供服务。
显然,若要取得最佳的性能,这些UNIX应用程序必须重新设计,仿真的线程库应该用Windows提供的纯线程来替代。然而,这种重新设计需要花费数月甚至更长的时间才能完成,因此许多公司首先将它们现有的UNIX代码移植到Windows中,这样就能够将某些应用软件推向Windows市场。
当你将UNIX代码移植到Windows中时,一些问题就会因此而产生。尤其是Windows管理线程的内存栈的方法要比简单地分配内存复杂得多。Windows内存栈开始时的物理存储器的容量比较小,然后根据需要逐步扩大。这个过程在第16章“线程的堆栈”中详细介绍。由于结构化异常处理机制的原因,代码的移植就更加复杂了。为了能够更快和更正确地将它们的代码移植到Windows中,Microsoft公司在操作系统中添加了纤程。本章将要介绍纤程的概念、负责操作纤程的函数以及如何利用纤程的特性。要记住,如果有设计得更好的使用Windows自身线程的应用程序,那么应该避免使用纤程。
12.1纤程的操作
首先要注意的一个问题是,实现线程的是Windows内核。操作系统清楚地知道线程的情况,并且根据Microsoft定义的算法对线程进行调度。纤程是以用户方式代码来实现的,内核并不知道纤程,并且它们是根据用户定义的算法来调度的。由于你定义了纤程的调度算法,因此,就内核而言,纤程采用非抢占式调度方式。需要了解的下一个问题是,单线程可以包含一个或多个纤程。就内核而言,线程是抢占调度的,是正在执行的代码。然而,线程每次执行一个纤程的代码—--你决定究竟执行哪个纤程。当使用纤程时,你必须执行的第一步操作是将现有的线程转换成一个纤程。可以通过调用ConvertThreadToFiber函数来执行这项操作:该函数为纤程的执行环境分配相应的内存(约为200字节)。该执行环境由下列元素组成:
•一个用户定义的值,它被初始化为传递给ConvertThreadToFiber的pvParam参数的值。
•结构化异常处理链的头。
•纤程内存栈的最高和最低地址(当将线程转换成纤程时,这也是线程的内存栈)。
•CPU寄存器,包括堆栈指针、指令指针和其他。
当对纤程的执行环境进行分配和初始化后,就可以将执行环境的地址与线程关联起来。该线程被转换成一个纤程,而纤程则在该线程上运行。ConvertThreadToFiber函数实际上返回纤程的执行环境的内存地址。虽然必须在晚些时候使用该地址,但是决不应该自己对该执行环境数据进行读写操作,因为必要时纤程函数会为你对该结构的内容进行操作。现在,如果你的纤程(线程)返回或调用ExitThread函数,那么纤程和线程都会终止运行。
除非打算创建更多的纤程以便在同一个线程上运行,否则没有理由将线程转换成纤程。若要创建另一个纤程,该线程(当前正在运行纤程的线程)可以调用CreateFiber函数。
PVOID CreateFiber(
DWORD dwStackSize,
PFIBER_START_ROUTINE pfnstartAddress,
PVOID pvParam);
CreateFiber首先设法创建一个新内存栈,它的大小由dwStackSize参数来指明。通常传递的参数是0,按照默认设置,它创建一个内存栈,其大小可以扩展为1MB,不过开始时有两个存储器页面用于该内存栈。如果设定一个非0值,那么就用设定的大小来保存和使用内存栈。接着,CreateFiber函数分配一个新的纤程执行环境结构,并对它进行初始化。该用户定义的值被设置为传递给CreateFiber的pvParam参数的值,新内存栈的最高和最低地址被保存,同时,纤程函数的内存地址(作为pfnStartAddress参数来传递)也被保存。
PfnStartAddress参数用于设定必须实现的纤程例程的地址,它必须采用下面的原型:
VOID WINAPI FiberFunc(PVOID pvParam);
当纤程被初次调度时,该函数就开始运行,并且将原先传递给CreateFiber的pvParam的值传递给它。可以在这个纤程函数中执行想执行的任何操作。但是该函数的原型规定返回值是VOID,这并不是因为返回值没有任何意义,而是因为该函数根本不应该返回。如果纤程确实返回了,那么线程和该线程创建的所有纤程将立即被撤消。与ConvertThreadToFiber函数一样,CreateFiber函数也返回纤程运行环境的内存地址。但是,与ConvertThreadToFiber不同的是,这个新纤程并不执行,因为当前运行的纤程仍然在执行。在单个线程上,每次只能运行一个纤程。若要使新纤程能够运行,可以调用SwitchToFiber函数:
VOID SwithToFiber(PVOID pvFiberExecutionContext);
SwitchToFiber函数只有一个参数,即pvFiberExecutionContext,它是上次调用ConvertThreadToFiber或CreateFiber函数时返回的纤程的执行环境的内存地址。该内存地址告诉该函数要对哪个纤程进行调度。SwitchToFiber函数在内部执行下列操作步骤:
1)它负责将某些当前的CPU寄存器保存在当前运行的纤程执行环境中,包括指令指针寄存器和堆栈指针寄存器。
2)它将上一次保存在即将运行的纤程的执行环境中的寄存器装入CPU寄存器。这些寄存器包括堆栈指针寄存器。这样,当线程继续执行时,就可以使用该纤程的内存栈。
3)它将纤程的执行环境与线程关联起来,线程运行特定的纤程。
4)它将线程的指令指针设置为已保存的指令指针。线程(纤程)从该纤程上次执行的地方开始继续执行。
SwitchToFiber函数是纤程获得CPU时间的唯一途径。由于你的代码必须在相应的时间显式调用SwitchToFiber函数,因此你对纤程的调度可以实施全面的控制。记住,纤程的调度与线程调度毫不相干。纤程运行所依赖的线程始终都可以由操作系统终止其运行。当线程被调度时,当前选定的纤程开始运行,而其他纤程则不能运行,除非显式调用SwitchToFiber函数。若要撤消纤程,可以调用DeleteFiber函数:该函数用于删除pvFiberExecutionContext参数指明的纤程,当然这是纤程的执行环境的地址。该函数能够释放纤程栈使用的内存,然后撤消纤程的执行环境。但是,如果传递了当前与线程相关联的纤程地址,那么该函数就在内部调用ExitThread函数,该线程及其创建的所有纤程全部被撤消。DeleteFiber函数通常由一个纤程调用,以便删除另一个纤程。已经删除的纤程的内存栈将被撤消,纤程的执行环境被释放。注意,纤程与线程之间的差别在于,线程通常通过调用ExitThread函数将自己撤消。实际上,用一个线程调用TerminateThread函数来终止另一个线程的运行,是一种不好的方法。如果你确实调用了TerminateThread函数,系统并不撤消已经终止运行的线程的内存栈。可以利用纤程的这种能力来删除另一个纤程,后面介绍示例应用程序时将说明这是如何实现的。为了使操作更加方便,还可以使用另外两个纤程函数。一个线程每次可以执行一个纤程,操作系统始终都知道当前哪个纤程与该线程相关联。如果想要获得当前运行的纤程的执行环境的地址,可以调用GetCurrentFiber函数:另一个使用非常方便的函数是GetFiberData:前面讲过,每个纤程的执行环境包含一个用户定义的值。这个值使用作为ConvertThreadToFiber或CreateFiber的pvParam参数而传递的值进行初始化。该值也可以作为纤程函数的参数来传递。GetFiberData只是查看当前执行的纤程的执行环境,并返回保存的值。无论GetCurrentFiber还是GetFiberData,运行速度都很快,并且通常是作为内蕴函数(infrinsicfuncfion)来实现的,这意味着编译器能够为这些函数生成内联代码。
第13章Windows的内存结构
操作系统使用的内存结构是理解操作系统如何运行的最重要的关键。本章将要介绍Microsoft公司的Windows操作系统使用的内存结构。
13.1进程的虚拟地址空间
每个进程都被赋予它自己的虚拟地址空间。对于32位进程来说,这个地址空间是4GB,因为32位指针可以拥有从0x00000000至0xFFFFFFFF之间的任何一个值。这使得一个指针能够拥有4294967296个值中的一个值,它覆盖了一个进程的4GB虚拟空间的范围。对于64位进程来说,这个地址空间是16EB(1018字节),因为64位指针可以拥有从0x0000000000000000至0xFFFFFFFFFFFFFFFF之间的任何值。这使得一个指针可以拥有18446744073709551616个值中的一个值,它覆盖了一个进程的16EB虚拟空间的范围。这是相当大的一个范围。
由于每个进程可以接收它自己的私有的地址空间,因此当进程中的一个线程正在运行时,该线程可以访问只属于它的进程的内存。属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。
注意在Windows2000中,属于操作系统本身的内存也是隐藏的,正在运行的线程无法访问。这意味着线程常常不能访问操作系统的数据。Windows98中,属于操作系统的内存是不隐藏的,正在运行的线程可以访问。因此,正在运行的线程常常可以访问操作系统的数据,也可以破坏操作系统(从而有可能导致操作系统崩溃)。在Windows98中,一个进程的线程不可能访问属于另一个进程的内存。
前面说过,每个进程有它自己的私有地址空间。进程A可能有一个存放在它的地址空间中的数据结构,地址是0x12345678,而进程B则有一个完全不同的数据结构存放在它的地址空间中,地址是0x12345678。当进程A中运行的线程访问地址为0x12345678的内存时,这些线程访问的是进程A的数据结构。当进程B中运行的线程访问地址为0x12345678的内存时,这些线程访问的是进程B的数据结构。进程A中运行的线程不能访问进程B的地址空间中的数据结构。反之亦然。
当你因为拥有如此大的地址空间可以用于应用程序而兴高采烈之前,记住,这是个虚拟地址空间,不是物理地址空间。该地址空间只是内存地址的一个范围。在你能够成功地访问数据而不会出现违规访问之前,必须赋予物理存储器,或者将物理存储器映射到各个部分的地址空间。本章后面将要具体介绍这是如何操作的。
13.2虚拟地址空间如何分区
每个进程的虚拟地址空间都要划分成各个分区。地址空间的分区是根据操作系统的基本实现方法来进行的。不同的Windows内核,其分区也略有不同。表13-1显示了每种平台是如何对进程的地址空间进行分区的。
如你所见,32位Windows2000的内核与64位Windows2000的内核拥有大体相同的分区,差别在于分区的大小和位置有所不同。另一方面,可以看到Windows98下的分区有着很大的不同。下面让我们看一下系统是如何使用每一个分区的。
13.2.1NULL指针分配的分区—适用于Windows2000和Windows98
进程地址空间的这个分区的设置是为了帮助程序员掌握NULL指针的分配情况。如果你的进程中的线程试图读取该分区的地址空间的数据,或者将数据写入该分区的地址空间,那么CPU就会引发一个访问违规。保护这个分区是极其有用的,它可以帮助你发现NULL指针的分配情况。
C/C++程序中常常不进行严格的错误检查。例如,下面这个代码就没有进行任何错误检查:
Int* pnSomeInteger = (int*) malloc(sizeof(int));
*pnSomeInteger =5;
如果malloc不能找到足够的内存来满足需要,它就返回NULL。但是,该代码并不检查这种可能性,它认为地址的分配已经取得成功,并且开始访问0x00000000地址的内存。由于这个分区的地址空间是禁止进入的,因此就会发生内存访问违规现象,同时该进程将终止运行。这个特性有助于编程员发现应用程序中的错误。
13.2.2MS-DOS/16位Windows应用程序兼容分区—仅适用于Windows98
进程地址空间的这个4MB分区是Windows98需要的,目的是维护MS-DOS应用程序与16位应用程序之间的兼容性。不应该试图从32位应用程序来读取该分区的数据,或者将数据写入该分区。在理想的情况下,如果进程中的线程访问该内存,CPU应该产生一个访问违规,但是由于技术上的原因,Microsoft无法保护这个4MB的地址空间。
在Windows2000中,16位MS-DOS与16位Windows应用程序是在它们自己的地址空间中运行的,32位应用程序不会对它们产生任何影响。
13.2.3用户方式分区—适用于Windows2000和Windows98
这个分区是进程的私有(非共享)地址空间所在的地方。一个进程不能读取、写入、或者以任何方式访问驻留在该分区中的另一个进程的数据。对于所有应用程序来说,该分区是维护进程的大部分数据的地方。由于每个进程可以得到它自己的私有的、非共享分区,以便存放它的数据,因此,应用程序不太可能被其他应用程序所破坏,这使得整个系统更加健壮。
Windows2000在Windows2000中,所有的.exe和DLL模块均加载这个分区。每个进程可以将这些DLL加载到该分区的不同地址中(不过这种可能性很小)。系统还可以在这个分区中映射该进程可以访问的所有内存映射文件。
Windows98在Windows98中,主要的Win32系统DLL(Kernel32.dll,AdvAPI32.dll,User32.dll和GDI32.dll)均加载共享内存映射文件分区中。.exe和所有其他DLL模块则加载到这个用户方式分区中。所有进程的共享DLL均位于相同的虚拟地址中,但是其他DLL可以将这些DLL加载到用户方式分区的不同地址中(不过这种可能性不大)。另外,在Windows98中,用户方式分区中决不会出现内存映射文件。
当我最初观察32位进程的地址空间的时候,我惊奇地发现可以使用的地址空间还不到我的进程的全部地址空间的一半。难道内核方式分区真的需要上面的一半地址空间吗?实际上回答是肯定的。系统需要这个地址空间,供内核代码、设备驱动程序代码、设备I/O高速缓存、非页面内存池的分配和进程页面表等使用。实际上Microsoft将内核压缩到这个2GB空间之中。在64位Windows2000中,内核终于得到了它真正需要的空间。
1.在x86的Windows2000中获得3GB用户方式分区
多年来,编程人员一直强烈要求扩大用户方式的地址空间。为了满足这个需要,Microsoft允许x86的Windows2000 Advanced Server版本和Windows2000 DataCenter版本将用户方式分区扩大为3GB。若要使所有进程都能够使用3GB用户方式分区和1GB内核方式分区,必须将/3GB开关附加到系统的BOOT.INI文件的有关项目中。表13-1中的“32位Windows2000(x86 w/3GB用户方式)”这一列显示了使用3GB开关时它的地址空间是个什么样子。
在Microsoft添加/3GB开关之前,应用程序无法看到设置了高位的内存指针。一些有创意的编程员自己将这个高位用作一个标志,这个标志只对他们的应用程序具有意义。这时,当应用程序访问内存地址时,运行的代码将在内存地址被使用之前清除该指针的高位。可以想象,当应用程序在3GB的用户方式环境中运行时,该应用程序转眼之间就会运行失败。
Microsoft不得不提出一个解决方案,以便使该应用程序能够在3GB环境中运行。当系统准备运行一个应用程序时,它要查看该应用程序是否与/LARGEADDRESSAWARE链接程序开关相链接。如果是链接的,那么应用程序就声称它并没有对内存地址执行什么特殊的操作,并且完全准备充分利用3GB用户方式地址空间。另一方面,如果该应用程序没有与/LARGEADDRESSAWARE开关相链接,那么操作系统将保留0x80000000至0xBFFFFFFF之间的1GB区域。这可以防止在已经设置了高位的内存地址上进行内存分配。
注意,内核已经被紧紧地压缩到了一个2GB的分区中。当使用3GB的开关时,内核勉强地被放入一个1GB的分区中。使用/3GB的开关,可以减少系统能够创建的线程、堆栈和其他资源的数量。此外,系统最多只能使用16GB的RAM,而通常情况下最多可以使用64GB的RAM,因为内核方式中没有足够的虚拟地址空间可以用来管理更多的RAM。
注意当操作系统创建进程的地址空间时,需要检查一个可执行的LARGEADDRESSAWARE标志。对于DLL,系统则忽略该标志。在编写DLL时,必须使之能够在3GB用户方式分区中正确地运行,否则它们的行为特性是无法确定的。
2.在64位Windows2000中获得2GB用户方式分区
Microsoft发现许多编程人员需要尽可能迅速而方便地将现有的32位应用程序移植到64位环境中去。但是,在许多源代码中,指针被视为32位值。如果简单地重新编写应用程序,就会造成指针被截断的错误和不正确的内存访问。然而,如果系统能够确保不对0x000000007FFFFFFF以上的内存地址进行分配,那么应用程序就能很好地运行。当较高的33位是0时,将64位地址截断为32位地址,不会产生任何问题。通过在地址空间范围内运行应用程序,而这个地址空间范围将进程的可用地址空间限制为最低的GB,那么系统就能够确保这一点。默认情况下,当启动一个64位应用程序时,系统将保留从0x000000080000000开始的所有用户地址空间。这可以确保在底部的2GB64位地址空间中进行所有的内存分配。这就是地址空间的范围。对于大多数应用程序来说,这个地址空间足够了。若要使64位应用程序能够访问它的全部4TB(terabyte)用户方式分区,该应用程序必须使用/LARGEADDRESSAWARE链接开关来创建。
注意当操作系统创建进程的64位地址空间时,要检查一个可执行文件的LARGEADDRESSAWARE标志。如果是DLL,那么系统将忽略该标志。编写DLL时,必须使之能够在整个4TB用户方式分区中正确地运行,否则它们的行为特性将无法确定。
13.2.464KB禁止进入的分区—仅适用于Windows2000
这个位于用户方式分区上面的64KB分区是禁止进入的,访问该分区中的内存的任何企图均将导致访问违规。Microsoft之所以保留该分区,是因为这样做将使得Microsoft能够更加容易地实现操作系统。当将内存块的地址和它的长度传递给Windows函数时,该函数将在执行它的操作之前使内存块生效。
13.2.5共享的MMF分区—仅适用于Windows98
这个1GB分区是系统用来存放所有32位进程共享数据的地方。例如,系统的动态链接库Kernel32.dll、AdvAPI32.dll、User32.dll和GDI32.dll等,全部存放在这个地址空间分区中,因此,所有32位进程都能很容易同时访问它们。系统还为每个进程将DLL加载相同的内存地址。此外,系统将所有内存映射文件映射到这个分区中。内存映射文件将在第17章中详细介绍。
13.2.6内核方式分区—适用于Windows2000和Windows98
这个分区是存放操作系统代码的地方。用于线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序的代码全部在这个分区加载。驻留在这个分区中的一切均可被所有进程共享。在Windows2000中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址,你的线程将会产生访问违规,导致系统向用户显示一个消息框,并关闭你的应用程序。关于访问违规和如何处理这些违规的详细说明,请参见第23、24和25章的内容。
Windows98不幸的是,在Windows98中该分区中的数据是不受保护的。任何应用程序都可以从该分区读取数据,也可以写入数据,因此有可能破坏操作系统。
13.3地址空间中的区域
当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的,即未分配的。若要使用该地址空间的各个部分,必须通过调用VirtualAlloc函数(第15章介绍)来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留(reserving)。每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始。对于不同的CPU平台来说,分配粒度是各不相同的。但是,至今所有的CPU平台(x86、32位Alpha、64位Alpha和IA-64)都使用64KB这个相同的分配粒度。
当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是系统在管理内存时使用的一个内存单位。与分配粒度一样,不同的CPU,其页面大小也是不同的。x86使用的页面大小是4KB,而Alpha(当既能运行32位Windows2000也能运行64位Windows2000时)使用的页面大小则是8KB。
注意有时系统能够代表你的进程来保留地址空间的区域。例如,系统可以分配一个地址空间区域,以便存放进程环境块(FEB)。FEB是由系统创建、操作和撤消的一个小型数据结构。当创建一个进程时,系统就为FEB分配一个地址空间区域。系统也需要创建一个线程环境块(TEB),以便管理进程中当前存在的所有线程。用于这些TEB的区域将根据进程中的线程被创建和撤消等情况而保留和释放。虽然系统规定,要求保留的地址空间区域均从分配粒度边界(目前所有平台上均为64KB)开始,但是系统本身并不受这个规定的限制。为你的进程的PEB和TEB保留的地址空间区域很可能不是从64KB这个边界开始的。不过这些保留区域仍然必须是CPU的页面大小的倍数。
如果想保留一个10KB的地址空间区域,系统将自动对你的请求进行四舍五入,使保留的地址空间区域的大小是页面大小的倍数。这意味着,在x86平台上,系统将保留一个12KB的区域,在Alpha平台上,系统将保留一个16KB的区域。
当你的程序算法不再需要访问已经保留的地址空间区域时,该区域应该被释放。这个过程称为释放地址空间的区域,它是通过调用VirtualFree函数来完成的。
13.4提交地址空间区域中的物理存储器
若要使用已保留的地址空间区域,必须分配物理存储器,然后将该物理存储器映射到已保留的地址空间区域。这个过程称为提交物理存储器。物理存储器总是以页面的形式来提交的。若要将物理存储器提交给一个已保留的地址空间区域,也要调用VirtualAlloc函数。
当将物理存储器提交给地址空间区域时,不必将物理存储器提交给整个区域。
当你的程序算法不再需要访问保留的地址空间区域中已提交的物理存储器时,该物理存储器应该被释放。这个过程称为回收物理存储器,它是通过VirtualFree函数来完成的。
13.5物理存储器与页文件
在较老的操作系统中,物理存储器被视为计算机拥有的RAM的容量。换句话说,如果计算机拥有16MB的RAM,那么加载和运行的应用程序最多可以使用16MB的RAM。今天的操作系统能够使得磁盘空间看上去就像内存一样。磁盘上的文件通常称为页文件,它包含了可供所有进程使用的虚拟内存。
操作系统与CPU相协调,共同将RAM的各个部分保存到页文件中,当运行的应用程序需要时,再将页文件的各个部分重新加载到RAM。由于页文件增加了应用程序可以使用的RAM的容量,因此页文件的使用是视情况而定的。如果没有页文件,那么系统就认为只有较少的RAM可供应用程序使用。但是,我们鼓励用户使用页文件,这样他们就能够运行更多的应用程序,并且这些应用程序能够对更大的数据集进行操作。最好将物理存储器视为存储在磁盘驱动器(通常是硬盘驱动器)上的页文件中的数据。这样,当一个应用程序通过调用VirtualAlloc函数,将物理存储器提交给地址空间的一个区域时,地址空间实际上是从硬盘上的一个文件中进行分配的。系统的页文件的大小是确定有多少物理存储器可供应用程序使用时应该考虑的最重要的因素,RAM的容量则影响非常小。现在,当你的进程中的一个线程试图访问进程的地址空间中的一个数据块时,将会发生两种情况之一,参见图13-2中的流程图。
在第一种情况中,线程试图访问的数据是在RAM中。在这种情况下,CPU将数据的虚拟内存地址映射到内存的物理地址中,然后执行需要的访问。
在第二种情况中,线程试图访问的数据不在RAM中,而是存放在页文件中的某个地方。这时,试图访问就称为页面失效,CPU将把试图进行的访问通知操作系统。这时操作系统就寻找RAM中的一个内存空页。如果找不到空页,系统必须释放一个空页。如果一个页面尚未被修改,系统就可以释放该页面。但是,如果系统需要释放一个已经修改的页面,那么它必须首先将该页面从RAM拷贝到页交换文件中,然后系统进入该页文件,找出需要访问的数据块,并将数据加载到空闲的内存页面。然后,操作系统更新它的用于指明数据的虚拟内存地址现在已经映射到RAM中的相应的物理存储器地址中的表。这时CPU重新运行生成初始页面失效的指令,但是这次CPU能够将虚拟内存地址映射到一个物理RAM地址,并访问该数据块。系统需要将内存页面拷贝到页文件并反过来将页文件拷贝到内存页面的次数越多,你的硬盘倒腾的次数就越多,系统运行得越慢(倒腾意味着操作系统要花费更多的时间将页面从内存中转出转进,而不是将时间用于程序的运行)。因此,通过给你的计算机增加更多的RAM,就可以减少运行应用程序所需的倒腾次数,这就必然可以大大提高系统的运行速度。所以必须遵循一条基本原则,那就是要让你的计算机运行得更块,增加更多的RAM。实际上,在大多数情况下,若要提高系统的运行性能,增加RAM比提高CPU的速度所产生的效果更好。
不在页文件中维护的物理存储器
当阅读了上一节后,你必定会认为,如果同时运行许多文件的话,页文件就可能变得非常大,而且你会认为,每当你运行一个程序时,系统必须为进程的代码和数据保留地址空间的一些区域,将物理存储器提交给这些区域,然后将代码和数据从硬盘上的程序文件拷贝到页文件中已提交的物理存储器中。
实际上系统并不进行上面所说的这些操作。如果它进行这些操作的话,就要花费很长的时间来加载程序并启动它运行。相反,当启动一个应用程序的时候,系统将打开该应用程序的.exe文件,确定该应用程序的代码和数据的大小。然后系统要保留一个地址空间的区域,并指明与该区域相关联的物理存储器是在.exe文件本身中。即系统并不是从页文件中分配地址空间,而是将.exe文件的实际内容即映像用作程序的保留地址空间区域。当然,这使应用程序的加载非常迅速,并使页文件能够保持得非常小。
当硬盘上的一个程序的文件映像(这是个.exe文件或DLL文件)用作地址空间的区域的物理存储器时,它称为内存映射文件。当一个.exe文件或DLL文件被加载时,系统将自动保留一个地址空间的区域,并将该文件映像映射到该区域中。但是,系统也提供了一组函数,使你能够将数据文件映射到一个地址空间的区域中。关于内存映射文件的详细说明,将在第17章中介绍。
Windows2000 Windows2000能够使用多个页文件。如果多个页文件存在于不同的物理硬盘驱动器上,系统的运行将能得快得多,因为它能够将数据同时写入多个驱动器。打开SystemPropertiesControlPanel(系统属性控制面板)小程序,再选择Advanced选项卡,单击PerformanceOptions(性能选项)按钮,就能够添加或删除页文件。注意当.exe或DLL文件从软盘加载时,Windows98和Windows2000都能将整个文件从软盘拷贝到系统的RAM中。此外,系统将从页文件中分配足够的内存,以便存放该文件的映像。如果系统选择对当前包含该文件的一部分映像的RAM页面进行裁剪,那么该内存属于只能写入的内存。如果系统RAM上的负载比较小,那么文件始终都可以直接从RAM来运行。
Microsoft不得不通过软盘来运行的映射文件,这样,安装应用程序才能正确运行。安装程序常常从一个软盘开始,然后用户将软盘从驱动器中取出来,再插入另一个软盘。如果系统需要回到第一个软盘,以便加载.exe或DLL文件的某些代码,当然该代码已经不再在软盘驱动器中了。然而,由于系统将文件拷贝到RAM(并且受页文件的支持),要访问安装程序是不会有任何问题的。
系统并不将RAM映射文件拷贝在其他可换式介质上,如光盘或网络驱动器,除非映射文件是用/SWAPRUN:CD或/SWAPRUN:NET开关链接的。注意,Windows98不支持/SWAPRUN映像标志。
13.6保护属性
已经分配的物理存储器的各个页面可以被赋予不同的保护属性。表13-2显示了这些保护属性。
x86和AlphaCPU不支持“执行”保护属性,不过操作系统软件却支持这个属性。这些CPU将读访问视为执行访问。这意味着如果将PAGE_EXECUTE保护属性赋予内存,那么该内存也将拥有读优先权。当然,不应该依赖这个行为特性,因为在其他CPU上的Windows实现代码很可能将“执行”保护视为“仅为执行”保护。
表13-2页面的保护属性
保护属性描述
PAGE_NOACCESS 如果试图在该页面上读取、写入或执行代码,就会引发访问违规
PAGE_READONLY 如果试图在该页面上写入或执行代码,就会引发访问违规
PAGE_READWRITE 如果试图在该页面上执行代码,就会引发访问违规
PAGE_EXECUTE 如果试图在该页面上对内存进行读取或写入操作,就会引发访问违规
PAGE_EXECUTE_READ 如果试图在该页面上对内存进行写入操作,就会引发访问违规
PAGE_EXECUTE_READWRITE 对于该页面不管执行什么操作,都不会引发访问违规
PAGE_WRITECOPY 如果试图在该页面上执行代码,就会引发访问违规。如果试图在该页面上写入内存,就会导致系统将它自己的私有页面(受页文件的支持)拷贝赋予该进程
PAGE_EXECUTE_WRITECOPY 对于该地址空间的区域,不管执行什么操作,都不会引发访问违规。如果试图在该页面上的内存中进行写入操作,就会将它自己的私有页面(受页文件的支持)拷贝赋予该进程
Windows98Windows98只支持PAGE_NOACCESS、PAGE_READONLY和PAGE_READWRITE等保护属性。
13.6.1Copy-On-Write访问
表13-2列出的保护属性都是非常容易理解的,不过最后两个属性需要作一些说明。一个是PAGE_WRITECOPY,另一个是PAGE_EXECUTE_WRITECOPY。这两个属性的作用是为了节省RAM的使用量和页文件的空间。Windows支持一种机制,使得两个或多个进程能够共享单个内存块。因此,如果10个Notepad实例正在运行,那么所有实例可以共享应用程序的代码和数据页面。让所有实例共享同样的内存页面将能够大大提高系统的性能,但是这要求所有实例都将该内存视为只读或只执行的内存。如果一个实例中的线程将数据写入内存修改它,那么其他实例看到的这个内存也将被修改,从而造成一片混乱。
为了防止出现这种混乱,操作系统给共享内存块赋予了Copy-On-Write保护属性。当一个.exe或DLL模块被映射到一个内存地址时,系统将计算有多少页面是可以写入的(通常包含代码的页面标为PAGE_EXECUTE_READ,而包含数据的页面则标为PAGE_READWRITE)。然后,系统从页文件中分配内存,以适应这些可写入的页面的需要。除非该模块的可写入页面是实际的写入模块,否则这些页文件内存是不使用的。
当一个进程中的线程试图将数据写入一个共享内存块时,系统就会进行干预,并执行下列操作步骤:
1)系统查找RAM中的一个空闲内存页面。注意,当该模块初次被映射到进程的地址空间时,该空闲页面将被页文件中已分配的页面之一所映射。当该模块初次被映射时,由于系统要分配所有可能需要的页文件,因此这一步不可能运行失败。
2)系统将试图被修改的页面内容拷贝到第一步中找到的页面。该空闲页面将被赋予PAGE_READWRITE或PAGE_EXECUTE_READWRITE保护属性。原始页面的保护属性和数据不发生任何变化。
3)然后系统更新进程的页面表,使得被访问的虚拟地址被转换成新的RAM页面。当系统执行了这3个操作步骤之后,该进程就可以访问它自己的内存页面的私有实例。第17章还要详细地介绍共享内存和Copy-On-Write保护属性。
此外,当使用VirtualAlloc函数来保留地址空间或者提交物理存储器时,不应该传递PAGE_WRITECOPY或PAGE_EXECUTE_WRITECOPY。如果传递的话,将会导致VirtualAlloc调用的失败。对GetLastError的调用将返回ERROR_INVALID_PARAMETER。当操作系统映射.exe或DLL文件映像时,这两个属性将被操作系统使用。
Windows98Windows98不支持Copy-On-Write保护。当Windows98发现需要Copy_On_Write保护时,它就立即进行数据的拷贝,而不是等待试图对内存进行写入操作。
13.6.2特殊的访问保护属性的标志
除了上面介绍的保护属性外,还有3个保护属性标志,即PAGE_NOCACHE,PAGE_WRITECOMBINE和PAGE_GUARD。可以用OR逐位将它们连接,以便将这3个标志用于任何一个保护属性(PAGE_NOCACHE除外)。
第一个保护属性标志PAGE_NOCACHE用于停用已提交页面的高速缓存。一般情况下最好不要使用该标志,因为它主要是供需要处理内存缓冲区的硬件设备驱动程序的开发人员使用的。
第二个保护属性PAGE_WRITECOMBINE也是供设备驱动程序开发人员使用的。它允许把单个设备的多次写入合并在一起,以便提高运行性能。
最后一个保护属性标志PAGE_GUARD可以在页面上写入一个字节时使应用程序收到一个通知(通过一个异常条件)。该标志有一些非常巧妙的用法。Windows2000在创建线程堆栈时使用该标志。关于该标志的详细说明,参见第16章。
Windows98Windows98将忽略PAGE_NOCACHE、PAGE_WRITECOMBINE和PAGE_GUARD这3个保护属性标志。
13.7综合使用所有的元素
本节要将地址空间、分区、区域、内存块和页面等元素综合起来加以使用。区域类型共有4个值,即空闲,私有,映像或映射。表1 3 - 4对它们进行了介绍。
类型说明
空闲 该区域的虚拟地址不受任何内存的支持。该地址空间没有被保留。应用程序既可以将一个区域保留在显示的基地址上,也可以保留在空闲区域中的任何位置上
私有 该区域的虚拟地址将受系统的页文件的支持。
映像 该区域的虚拟地址原先受内存映射的映像文件(如.exe或DLL文件)的支持,但也许不再受映像文件的支持。例如,当写入模块映像中的全局变量时,“写入时拷贝”的机制将由页文件来支持特定的页面,而不是受原始映像文件的支持
映射 该区域的虚拟地址原先是受内存映射的数据文件的支持,但也许不再受数据文件的支持。例如,数据文件可以使用“写入时拷贝”的保护属性来映射。对文件的任何写入操作都将导致页文件而不是原始数据支持特定的页面
13.7.1与Windows98地址空间的差别
两个地址空间表的最大不同是在Windows98下缺少了某些的信息。例如,每个区域和块能反映出地址空间的区域是空闲、保留还是私有的。你决不会看到映射或者映像之类的字样,因为Windows98没有提供更多的信息来指明支持该区域的物理存储器的是个内存映射文件还是包含在.exe或DLL中的文件映像。
你会发现大多数地址空间区域的大小是分配粒度(64KB)的倍数。如果包含在地址空间区域中的块的大小不是分配粒度的倍数,那么在地址空间区域的结尾处常常有一个保留的地址空间块。这个地址空间块的大小必须使得地址空间区域能够符合分配粒度边界(64KB)倍数的要求。例如,从地址0x00530000开始的地址空间区域包含两个地址块,一个是4KB的已提交内存块,另一个是占用60KB内存地址范围的已保留的地址块。
最后,保护标志从来不反映执行或copy-on-write访问权,因为Windows98不支持这些标志。它也不支持3个保护属性标志,即PAGE_NOCACHE、PAGE_WRITECOMBINE和PAGE_GUARD。由于不支持PAGE_GUARD标志,因此VMMap使用更加复杂的技术来确定是否已经为线程的堆栈保留了地址空间区域。
你将注意到,与Windows2000不同,在Windows98中,0x80000000至0xBFFFFFFF之间的地址空间区域是可以查看的。这个分区包含了所有32位应用程序共享的地址空间。如你所见,有4个系统DLL被加载了这个地址空间区域,可以供所有进程使用。
13.8数据对齐的重要性
本节不再讨论进程的虚拟地址空间问题,而是要介绍数据对齐的重要性。数据对齐并不是操作系统的内存结构的一部分,而是CPU结构的一部分。当CPU访问正确对齐的数据时,它的运行效率最高。当数据大小的数据模数的内存地址是0时,数据是对齐的。例如,WORD值应该总是从被2除尽的地址开始,而DWORD值应该总是从被4除尽的地址开始,如此等等。当CPU试图读取的数据值没有正确对齐时,CPU可以执行两种操作之一。即它可以产生一个异常条件,也可以执行多次对齐的内存访问,以便读取完整的未对齐数据值。
显然,如果CPU执行多次内存访问,应用程序的运行速度就会放慢。在最好的情况下,系统访问未对齐的数据所需要的时间将是访问对齐数据的时间的两倍,不过在有些情况下,访问时间可能更长。为了使应用程序获得最佳的运行性能,编写的代码必须使数据正确地对齐。
下面让我们更加深入地说明x86CPU是如何进行数据对齐的。X86CPU的EFLAGS寄存器中包含一个特殊的位标志,称为AC(对齐检查的英文缩写)标志。按照默认设置,当CPU首次加电时,该标志被设置为0。当该标志是0时,CPU能够自动执行它应该执行的操作,以便成功地访问未对齐的数据值。然而,如果该标志被设置为1,每当系统试图访问未对齐的数据时,CPU就会发出一个INT17H中断。x86的Windows2000和Windows98版本从来不改变这个CPU标志位。因此,当应用程序在x86处理器上运行时,你根本看不到应用程序中出现数据未对齐的异常条件。
现在让我们来看一看AlphaCPU的情况。AlphaCPU不能自动处理对未对齐数据的访问。当未对齐的数据访问发生时,CPU就会将这一情况通知操作系统。这时,Windows2000将会确定它是否应该引发一个数据未对齐异常条件。它也可以执行一些辅助指令,对问题默默地加以纠正,并让你的代码继续运行。按照默认设置,当在Alpha计算机上安装Windows2000时,操作系统会对未对齐数据的访问默默地进行纠正。然而,可以改变这个行为特性。当引导Windows2000时,系统就会在注册表中查找的这个关键字:HKEY_LOCAL_MACHINE\CurrentControlSet\Control\Session Manager
在这个关键字中,可能存在一个值,称为EnableAlignmentFaultExceptions。如果这个值不存在(这是通常的情况),Windows2000会默默地处理对未对齐数据的访问。如果存在这个值,系统就能获取它的相关数据值。如果数据值是0,系统会默默地进行访问的处理。如果数据值是1,系统将不执行默默的处理,而是引发一个未对齐异常条件。几乎从来都不需要修改该注册表值的数据值,因为如果修改有些应用程序能够引发数据未对齐的异常条件并终止运行。为了更加容易地修改该注册表项。Alpha处理器上运行的MicrosoftVisualC++版本包含了一个小型实用程序AXPAlign.exe。该实用程序只是修改注册表值的状态,或者显示值的当前状态。当用该实用程序修改数据值后,必须重新引导操作系统,使所做的修改生效。如果不使用AXPAlign实用程序,仍然可以让系统为进程中的所有线程默默地纠正对未对齐数据的访问,方法是让进程的线程调用SetErrorMode函数:
UINT SetErrorMode(UINT fuErrorMode);
就我们的讨论来说,需要说明的标志是SEM_NOALIGNMENTFAULTEXCEPT标志。当该标志设定后,系统会将自动纠正对未对齐数据的访问。当该标志重新设置时,系统将不纠正对未对齐数据的访问,而是引发数据未对齐异常条件。注意,修改该标志将会影响拥有调用该函数的线程的进程中包含的所有线程。换句话说,改变该标志不会影响其他进程中的任何线程。
还要注意,进程的错误方式标志是由所有的子进程继承的。因此,在调用CreateProcess函数之前,必须临时重置该标志(不过通常不必这样做)。
当然,无论在哪个CPU平台上运行,都可以调用SetErrorMode函数,传递SEM_NOALIGNMENTFAULTEXCEPT标志。但是,结果并不总是相同。如果是x86系统,该标志总是打开的,并且不能被关闭。如果是Alpha系统,那么只有当EnableAlignmentFaultExceptions注册表值被设置为1时,才能关闭该标志。
可以使用Windows2000的MMCPerformanceMonitor来查看每秒钟系统执行多少次数据对齐的调整修改。
第14章虚拟内存
这一章主要具体介绍几个Windows函数,这些函数能够提供关于系统内存管理以及进程中的虚拟地址空间等信息。
14.1系统信息
许多操作系统的值是根据主机而定的,比如页面的大小,分配粒度的大小等。这些值决不应该用硬编码的形式放入你的源代码。相反,你始终都应该在进程初始化的时候检索这些值,并在你的源代码中使用检索到的值。GetSystemInfo函数将用于检索与主机相关的值。
必须传递SYSTEM_INFO结构的地址给这个函数。这个函数将初始化所有的结构成员然后返回。
下面是SYSTEM_INFO数据结构的样子。
Typedef struct _SYSTEM_INFO{
Union{
DWORD dwOemId;
Struct {
WORD wProcessorArchitecture;
WORD wReserved;
};
};
DWORD dwPageSize;
LPVOID lpMinimumApplictionAddress;
LPVOID lpMaximumApplictionAddress;
DWORD_PTR dwActiveProcessorMask;
DWORD dwNumberofProcessors;
DWORD dwProcessorType;
DWORD dwAllocationGranularity;
WORD wProcessorLevel;
WORD wProcessorRevision;
}SYSTEM_INFO,*LPSYSTEM_INFO;
当系统引导时,它要确定这些成员的值是什么。对于任何既定的系统来说,这些值总是相同的,因此决不需要为任何既定的进程多次调用该函数。由于有了GetSystemInfo函数,因此应用程序能够在运行的时候查询这些值。在该结构的所有成员中,只有4个成员与内存有关。
表14-1与内存有关的成员函数
成员名 描述
dwPageSize 用于显示CPU的页面大小。在x86CPU上,这个值是4096字节。在AlphaCPU上,这个值是8192字节。在IA-64上,这个值是8192字节
lpMinimumApplicationAddress 用于给出每个进程的可用地址空间的最小内存地址。在Windows98上,这个值是4194304,或0x00400000,因为每个进程的地址空间中下面的4MB是不能使用的。在Windows2000上,这个值是65536或0x00010000,因为每个进程的地址空间中开头的64KB总是空闲的
lpMaximumApplicationAddress 用于给出每个进程的可用地址空间的最大内存地址。在Windows98上,这个地址是2147483647或0x7FFFFFFF,因为共享内存映射文件区域和共享操作系统代码包含在上面的2GB分区中。在Windows2000上,这个地址是内核方式内存开始的地址,它不足64KB
dwAllocationGranularity 显示保留的地址空间区域的分配粒度。截止到撰写本书时,在所有Windows平台上,这个值都是65536
该结构的其他成员与内存管理毫无关系,为了完整起见,下面也对它们进行了介绍(见表
14-2)。
表14-2与内存无关的成员函数
成员名 描述
dwOemId 已作废,不引用
WRederved 保留供将来使用,不引用
dwNumberOfProcessors 用于指明计算机中的CPU数目
dwActiveProcessorMask 一个位屏蔽,用于指明哪个CPU是活动的(允许运行线程)
dwProcessorType 只用于Windows98,不用于Windows2000,用于指明处理器的类型,如Intel386、486或Pentium
wProcessorArchitecture 只用于Windows2000,不用于Windows98,用于指明处理的结构,如Intel、Alpha、Intel64位或Alpha64位
wProcessorLevel 只用于Windows2000,不用于Windows98,用于进一步细分处理器的结构,如用于设定IntelPentiumPro或PentiumII
wProcessorRevision 只用于Windows2000,不用于Windows98,用于进一步细分处理器的级别
书中给也了一个系统信息示例应用程序。
14.2虚拟内存的状态
Windows函数GlobalMemoryStatus可用于检索关于当前内存状态的动态信息:
当调用GlobalMemoryStatus时,必须传递一个MEMORYSTATUS结构的地址。下面显示了MOMORYSTATUS的数据结构。
Typedef struct _MEMORYSTATUS{
DWORD dwLength;
DWORD dwMemoryLoad;
SIZE_T dwTotalphys;
SIZE_T dwAvailphys;
SIZE_T dwTotalPageFile;
SIZE_T dwAvailPageFile;
SIZE_T dwTotalVirtual;
SIZE_T dwAvailVirtual;
} MEMORYSTATUS,*LPMEMORYSTATUS;
在调用GlobalMemoryStatus之前,必须将dwLength成员初始化为用字节表示的结构的大小,即一个MEMORYSTATUS结构的大小。这个初始化操作使得Microsoft能够将成员添加给将来的Windows版本中的这个结构,而不会破坏现有的应用程序。当调用GlobalMemoryStatus时,它将对该结构的其余成员进行初始化并返回。下面的VMStat示例应用程序将要描述各个成员及其含义。
如果希望应用程序在内存大于4GB的计算机上运行,或者合计交换文件的大小大于4GB,那么可以使用新的GlobalMemoryStatusEx函数。必须给该函数传递新的MEMORYSTATUSEX结构的地址:Typedef struct _MEMORYSTATUSEX{
DWORD dwLength;
DWORD dwMemoryLoad;
DWORDLONG ullTotalphys;
DWORDLONG ullAvailphys;
DWORDLONG ullTotalPageFile;
DWORDLONG ullAvailPageFile;
DWORDLONG ullTotalVirtual;
DWORDLONG ullAvailVirtual;
DWORDLONG ullAvailExtendedVirtual;
} MEMORYSTATUSEX,*LPMEMORYSTATUSEX;
这个结构与原先的MEMORYSTATUS结构基本相同,差别在于新结构的所有成员的大小都是64位宽,因此它的值可以大于4GB。最后一个成员是ullAvailExtendedVirtual,用于指明在调用进程的虚拟地址空间的极大内存(VLM)部分中未保留内存的大小。该VLM部分只适用于某些配置中的某些CPU结构。
书中给出了一个虚拟内存状态示例应用程序(VMStat)
14.3确定地址空间的状态
Windows提供了一个函数,可以用来查询地址空间中内存地址的某些信息(如大小,存储器类型和保护属性等)。实际上本章中带的VMMap示例应用程序就使用这个函数来生成第13章所附的虚拟内存表交换信息。这个函数称为VirtualQuery:
DWORD VirtualQuery(
LPCVOID pvAddress,
PMEMORY_BASIC_INFORMATION pmbi,
DWORD dwLength);
Windows还提供了另一个函数,它使一个进程能够查询另一个进程的内存信息:
DWORD VirtualQueryEX(
HANDLE hProcess,
LPCVOID pvAddress,
PMEMORY_BASIC_INFORMATION pmbi,
DWORD dwLength);
这两个函数基本相同,差别在于使用VirtualQueryEx时,可以传递你想要查询的地址空间信息的进程的句柄。调试程序和其他实用程序使用这个函数最多,几乎所有的应用程序都只需要调用VirtualQuery函数。当调用VirtualQuery(Ex)函数时,pvAddress参数必须包含你想要查询其信息的虚拟内存地址。Pmbi参数是你必须分配的MEMORY_BASIC_INFORMATION结构的地址。最后一个参数是dwLength,用于设定MEMORY_BASIC_INFORMATION结构的大小。
VirtualQuery(Ex)函数返回拷贝到缓存中的字节的数量。
根据在pvAddress参数中传递的地址,VirtualQuery(Ex)函数将关于共享相同状态、保护属性和类型的相邻页面的范围信息填入MEMORY_BASIC_INFORMATION结构中。表14-3描述了该结构的成员。
表14-3MEMORY_BASIC_INFORMATION结构的成员函数
成员名 描述
BaseAddress 与pvAddress参数的值相同,但是四舍五入为页面的边界值
AllocationBase 用于指明包含在pvAddress参数中设定的地址区域的基地址
AllocationProtect 用于指明一个地址空间区域被初次保留时赋予该区域的保护属性
RegionSize 用于指明从基地址开始的所有页面的大小(以字节为计量单位)这些页面与含有用pvSddress参数设定的地址的页面拥有相同的保护属性、状态和类型
State 用于指明所有相邻页面的状态(MEM_FREE、MEM_RESERVE或MEM_COMMIT)。这些页面与含有用pvAddress参数设定的地址的页面拥有相同的保护属性、状态和类型.如果它的状态是空闲,那么AllocationBase、AllocationProtect、Protect和Type等成员均未定义,如果状态是MEM_RESERVE,则Protect成员未定义
Protect 用于指明所有相邻页面的保护属性(PAGE_*)。这些页面与含有用pvAddress 参数设定的地址的页面拥有相同的保属性、状态和类型
Type 用于指明支持所有相邻页面的物理存储器的类型(MEM_IMAGE,MEM_MAPPED或MEM_PRIVATE)。这些相邻页面与含有用pvAddress参数设定的地址的页面拥有相同的保护属性、状态和类型。如果是Windows98,那么这个成员将总是MEM_PRIVATE
第15章在应用程序中使用虚拟内存
Windows提供了3种进行内存管理的方法,它们是:
•虚拟内存,最适合用来管理大型对象或结构数组。
•内存映射文件,最适合用来管理大型数据流(通常来自文件)以及在单个计算机上运行的多个进程之间共享数据。
•内存堆栈,最适合用来管理大量的小对象。
本章将要介绍第一种方法,即虚拟内存。内存映射文件和堆栈分别在第17章和第18章介绍。
用于管理虚拟内存的函数可以用来直接保留一个地址空间区域,将物理存储器(来自页文件)提交给该区域,并且可以设置你自己的保护属性。
15.1在地址空间中保留一个区域
通过调用VirtualAlloc函数,可以在进程的地址空间中保留一个区域:
PVOID VirtualAlloc(
PVOID pvAddress,
SIZE_T dwSize,
DWORD fdwAllocationType,
DWORD fdwProtect);
第一个参数pvAddress包含一个内存地址,用于设定想让系统将地址空间保留在什么地方。在大多数情况下,你为该参数传递MULL。它告诉VirtualAlloc,保存着一个空闲地址区域的记录的系统应该将区域保留在它认为合适的任何地方。系统可以从进程的地址空间的任何位置来保留一个区域,因为不能保证系统可以从地址空间的底部向上或者从上面向底部来分配各个区域。可以使用MEM_TOP_DOWN标志来说明该分配方式。这个标志将在本章的后面加以介绍。对大多数程序员来说,能够选择一个特定的内存地址,并在该地址保留一个区域,这是个非同寻常的想法。当你在过去分配内存时,操作系统只是寻找一个其大小足以满足需要的内存块,并分配该内存块,然后返回它的地址。但是,由于每个进程有它自己的地址空间,因此可以设定一个基本内存地址,在这个地址上让操作系统保留地址空间区域。
例如,你想将一个从50MB开始的区域保留在进程的地址空间中。这时,可以传递52428800(50×1024×1024)作为pvAddress参数。如果该内存地址有一个足够大的空闲区域满足你的要求,那么系统就保留这个区域并返回。如果在特定的地址上不存在空闲区域,或者如果空闲区域不够大,那么系统就不能满足你的要求,VirtualAlloc函数返回NULL。
注意,为pvAddress参数传递的任何地址必须始终位于进程的用户方式分区中,否则对VirtualAlloc函数的调用就会失败,导致它返回NULL。
第13章讲过,地址空间区域总是按照分配粒度的边界来保留的(迄今为止在所有的Windows环境下均是64KB)。因此,如果试图在进程地址空间中保留一个从19668992(300×5536+8192)这个地址开始的区域,系统就会将这个地址圆整为64KB的倍数,然后保留从19660800(300×65536)这个地址开始的区域。
如果VirtualAlloc函数能够满足你的要求,那么它就返回一个值,指明保留区域的基地址。如果传递一个特定的地址作为VirtualAlloc的pvAddress参数,那么该返回值与传递给VirtualAlloc的值相同,并被圆整为(如果需要的话)64KB边界值。
VirtualAlloc函数的第二个参数是dwSize,用于设定想保留的区域的大小(以字节为计量单位)。由于系统保留的区域始终必须是CPU页面大小的倍数,因此,如果试图保留一个跨越62KB的区域,结果就会在使用4KB、8KB或16KB页面的计算机上产生一个跨越64KB的区域。
VirtualAlloc函数的第三个参数是fdwAllocationType,它能够告诉系统你想保留一个区域还是提交物理存储器(这样的区分是必要的,因为VirtualAlloc函数也可以用来提交物理存储器)。
若要保留一个地址空间区域,必须传递MEM_RESERVE标识符作为FdwAllocationType参数的值。
如果保留的区域预计在很长时间内不会被释放,那么可以在尽可能高的内存地址上保留该区域。这样,该区域就不会从进程地址空间的中间位置上进行保留。因为在这个位置上它可能导致区域分成碎片。如果想让系统在最高内存地址上保留一个区域,必须为pvAddress参数和fdwAllocationType参数传递NULL,还必须逐位使用OR将MEM_TOP_DOWN标志和MEM_RESERVE标志连接起来。
注意在Windows98下,MEM_TOP_DOWN标志将被忽略。
最后一个参数是fdwProtect,用于指明应该赋予该地址空间区域的保护属性。与该区域相关联的保护属性对映射到该区域的已提交内存没有影响。无论赋予区域的保护属性是什么,如果没有提交任何物理存储器,那么访问该范围中的内存地址的任何企图都将导致该线程引发一个访问违规。
当保留一个区域时,应该为该区域赋予一个已提交内存最常用的保护属性。例如,如果打算提交的物理存储器的保护属性是PAGE_READWRITE(这是最常用的保护属性),那么应该用PAGE_READWRITE保护属性来保留该区域。当区域的保护属性与已提交内存的保护属性相匹配时,系统保存的内部记录的运行效率最高。
可以使用下列保护属性中的任何一个:PAGE_NOACCESS、PAGE_READWRITE、PAGE_READONLY、PAGE_EXECUTE、PAGE_EXECUTE_READ或PAGE_EXECUTE_READWRITE。但是,既不能设定PAGE_WRITECOPY属性,也不能设定PAGE_EXECUTE_WRITECOPY属性。如果设定了这些属性,VirtualAlloc函数将不保留该区域,并且返回NULL。
另外,当保留地址空间区域时,不能使用保护属性标志PAGE_GUARD,PAGE_NOCACHE或PAGE_WRITECOMBINE,这些标志只能用于已提交的内存。
注意Windows98只支持PAGE_NOACCESS、PAGE_READONLY和PAGE_READWRITE保护属性。如果试图保留使用PAGE_EXECUTE或PAGE_EXECUTE_READ两个保护属性的区域,将会产生一个带有PAGE_READONLY保护属性的区域。同样,如果保留一个使用PAGE_EXECUTE_READWRITE保护属性的区域,就会产生一个带有PAGE_READWRITE保护属性的区域。
15.2在保留区域中的提交存储器
当保留一个区域后,必须将物理存储器提交给该区域,然后才能访问该区域中包含的内存地址。系统从它的页文件中将已提交的物理存储器分配给一个区域。物理存储器总是按页面边界和页面大小的块来提交的。
若要提交物理存储器,必须再次调用VirtualAlloc函数。不过这次为fdwAllocationType参数传递的是MEM_COMMIT标志,而不是MEM_RESERVE标志。传递的页面保护属性通常与调用VirtualAlloc来保留区域时使用的保护属性相同(大多数情况下是PAGE_READWRITE),不过也可以设定一个不同的保护属性。
在已保留的区域中,你必须告诉VirtualAlloc函数,你想将物理存储器提交到何处,以及要提交多少物理存储器。为了做到这一点,可以在pvAddress参数中设定你需要的内存地址,并在dwSize参数中设定物理存储器的数量(以字节为计量单位)。注意,不必立即将物理存储器提交给整个区域。
下面让我们来看一个如何提交物理存储器。比如说,你的应用程序是在x86CPU上运行的,该应用程序保留了一个从地址5242880开始的512KB的区域。你想让应用程序将物理存储器提交给已保留区域的6KB部分,从2KB的地方开始,直到已保留区域的地址空间。为此,可以调用带有MEM_COMMIT标志的VirtualAlloc函数,如下所示:
VirtualAlloc((PVOID)(5242880+(2*1024)),6*1024,
MEM_COMMIT,PAGE_READWRITE);
在这个例子中,系统必须提交8KB的物理存储器,地址范围从5242880到5251071(5242880+8KB-1字节)。这两个提交的页面都拥有PAGE_READWRITE保护属性。保护属性只以整个页面为单位来赋予。同一个内存页面的不同部分不能使用不同的保护属性。然而,区域中的一个页面可以使用一种保护属性(比如PAGE_READWRITE),而同一个区域中的另一个页面可以使用不同的保护属性(比如PAGE_READONLY)。
15.3同时进行区域的保留和内存的提交
有时你可能想要在保留区域的同时,将物理存储器提交给它。只需要一次调用VirtualAlloc
函数就能进行这样的操作,如下所示:
PVOID pvMem=VirtualAlloc(NULL,99*1024,
MEM_RESERVE | MEM_COMMIT,PAGE_READWRITE);
这个函数调用请求保留一个99KB的区域,并且将99KB的物理存储器提交给它。当系统处理这个函数调用时,它首先要搜索你的进程的地址空间,找出未保留的地址空间中一个地址连续的区域,它必须足够大,能够存放100KB(在4KB页面的计算机上)或104KB(在8KB页面的计算机上)。
最后需要说明的是,VirtualAlloc将返回保留区域和提交区域的虚拟地址,然后该虚拟地址被保存在pvMem变量中。如果系统无法找到足够大的地址空间,或者不能提交该物理存储器,VirtualAlloc将返回NULL。
当用这种方式来保留一个区域和提交物理存储器时,将特定的地址作为pvAddress参数传递给VirtualAlloc当然是可能的。否则就必须用OR将MEM_TOP_DOWN标志与fdwAllocationType参数连接起来,并为pvAddress参数传递NULL,让系统在进程的地址空间的顶部选定一个适当的区域。
15.4何时提交物理存储器
假设想实现一个电子表格应用程序,这个电子表格为200行x256列。对于每一个单元格,都需要一个CELLDATA结构来描述单元格的内容。若要处理这种二维单元格矩阵,最容易的方法是在应用程序中声明下面的变量:CELLDATA CellData[200][256];
如果CELLDATA结构的大小是128字节,那么这个二维矩阵将需要6553600(200x256x128)个字节的物理存储器。对于电子表格来说,如果直接用页文件来分配物理存储器,那么这是个不小的数目了,尤其是考虑到大多数用户只是将信息放入少数的单元格中,而大部分单元格却空闲不用,因此显得有些浪费。内存的利用率非常低。
传统上,电子表格一直是用其他数据结构技术来实现的,比如链接表等。使用链接表,只需要为电子表格中实际包含数据的单元格创建CELLDATA结构。由于电子表格中的大多数单元格都是不用的,因此这种方法可以节省大量的内存。但是这种方法使得你很难获得单元格的内容。如果想知道第5行第10列的单元格的内容,必须遍历链接表,才能找到需要的单元格,因此使用链接表方法比明确声明的矩阵方法速度要慢。
虚拟内存为我们提供了一种兼顾预先声明二维矩阵和实现链接表的两全其美的方法。运用虚拟内存,既可以使用已声明的矩阵技术进行快速而方便的访问,又可以利用链接表技术大大节省内存的使用量。
如果想利用虚拟内存技术的优点,你的程序必须按照下列步骤来编写:
1)保留一个足够大的地址空间区域,用来存放CELLDATA结构的整个数组。保留一个根本不使用任何物理存储器的区域。
2)当用户将数据输入一个单元格时,找出CELLDATA结构应该进入的保留区域中的内存地址。当然,这时尚未有任何物理存储器被映射到该地址,因此,访问该地址的内存的任何企图都会引发访问违规。
3)就CELLDATA结构来说,只将足够的物理存储器提交给第二步中找到的内存地址(你可以告诉系统将物理存储器提交给保留区域的特定部分,这个区域既可以包含映射到物理存储器的各个部分,也可以包含没有映射到物理存储器的各个部分)。
4)设置新的CELLDATA结构的成员。
现在物理存储器已经映射到相应的位置,你的程序能够访问内存,而不会引发访问违规。这个虚拟内存技术非常出色,因为只有在用户将数据输入电子表格的单元格时,才会提交物理存储器。由于电子表格中的大多数单元格是空的,因此大部分保留区域没有提交给它的物理存储器。
虚拟内存技术存在的一个问题是,必须确定物理存储器在何时提交。如果用户将数据输入一个单元格,然后只是编辑或修改该数据,那么就没有必要提交物理存储器,因为该单元格的CELLDATA结构的内存在数据初次输入时就已经提交了。
另外,系统总是按页面的分配粒度来提交物理存储器的。因此,当试图为单个CELLDATA结构提交物理存储器时(像上面的第二步那样),系统实际上提交的是内存的一个完整的页面。这并不像它听起来那样十分浪费:为单个CELLDATA结构提交物理存储器的结果是,也要为附近的其他CELLDATA结构提交内存。如果这时用户将数据输入邻近的单元格(这是经常出现的情况),就不需要提交更多的物理存储器。
有4种方法可以用来确定是否要将物理存储器提交给区域的一个部分:
•始终设法进行物理存储器的提交。每次调用VirtualAlloc函数的时候,不要查看物理存储器是否已经映射到地址空间区域的一个部分,而是让你的程序设法进行内存的提交。系统首先查看内存是否已经被提交,如果已经提交,那么就不要提交更多的物理存储器。
这种方法最容易操作,但是它的缺点是每次改变CELLDATA结构时要多进行一次函数的调用,这会使程序运行得比较慢。
•(使用VirtualQuery函数)确定物理存储器是否已经提交给包含CELLDATA结构的地址空间。如果已经提交了,那么就不要进行任何别的操作。如果尚未提交,则可以调用
VirtualAlloc函数以便提交内存。这种方法实际上比第一种方法差,它既会增加代码的长度,又会降低程序运行的速度(因为增加了对VirtualAlloc函数的调用)。
•保留一个关于哪些页面已经提交和哪些页面尚未提交的记录。这样做可以使你的应用程
序运行得更快,因为不必调用VirtualAlloc函数,你的代码能够比系统更快地确定内存是否已经被提交。它的缺点是,必须不断跟踪页面提交的信息,这可能非常简单,也可能非常困难,要根据你的情况而定。
•使用结构化异常处理(SEH)方法,这是最好的方法。SEH是一个操作系统特性,它使系统能够在发生某种情况时将此情况通知你的应用程序。实际上可以创建一个带有异常处理程序的应用程序,然后,每当试图访问未提交的内存时,系统就将这个问题通知应用程序。然后你的应用程序便进行内存的提交,并告诉系统重新运行导致异常条件的指令。这时对内存的访问就能成功地进行了,程序将继续运行,仿佛从未发生过问题一样。
这种方法是优点最多的方法,因为需要做的工作最少(也就是说要你编写的代码比较少),同时,你的程序可以全速运行。关于SEH的全面介绍,请参见第23、24和25章。第25章中的电子表格示例应用程序说明了如何按照上面介绍的方法来使用虚拟内存。
15.5回收虚拟内存和释放地址空间区域
若要回收映射到一个区域的物理存储器,或者释放这个地址空间区域,可调用VirtualFree
函数:BOOL VirtualFree(
LPVOID pvAddress,
SIZE_T dwSize,
DWORD fdwFreeType);
首先让我们观察一下调用VirtualFree函数来释放一个已保留区域的简单例子。当你的进程不再访问区域中的物理存储器时,就可以释放整个保留的区域和所有提交给该区域的物理存储器,方法是一次调用VirtualFree函数。
就这个函数的调用来说,pvAddress参数必须是该区域的基地址。此地址与该区域被保留时VirtualAlloc函数返回的地址相同。系统知道在特定内存地址上的该区域的大小,因此可以为dwSize参数传递0。实际上,必须为dwSize参数传递0,否则对VirtualFree的调用就会失败。对于第三个参数fdwFreeType,必须传递MEM_RELEASE,以告诉系统将所有映射的物理存储器提交给该区域并释放该区域。当释放一个区域时,必须释放该区域保留的所有地址空间。例如不能保留一个128KB的区域,然后决定只释放它的64KB。必须释放所有的128KB。
当想要从一个区域回收某些物理存储器,但是却不释放该区域时,也可以调用VirtualFree函数,若要回收某些物理存储器,必须在VirtualFree函数的pvAddress参数中传递用于标识要回收的第一个页面的内存地址,还必须在dwSize参数中设定要释放的字节数,并在fdwFreeType参数中传递MEM_DECOMMIT标志。
与提交物理存储器的情况一样,回收时也必须按照页面的分配粒度来进行。这就是说,设定页面中间的一个内存地址就可以回收整个页面。当然,如果pvAddress+dwSize的值位于一个页面的中间,那么包含该地址的整个页面将被回收。因此位于pvAddress至pvAddress+dwSize范围内的所有页面均被回收。
如果dwSize是0,pvSddress是已分配区域的基地址,那么VirtualFree将回收全部范围内的已分配页面。当物理存储器的页面已经回收之后,已释放的物理存储器就可以供系统中的所有其他进程使用,如果试图访问未回收的内存,将会造成访问违规。
15.5.1何时回收物理存储器
在实践中,知道何时回收内存是非常困难的。让我们再以电子表格为例。如果你的应用程序是在x86计算机上运行,每个内存页面是4KB,它可以存放32个(4096/128)CELLDATA结构。如果用户删除了单元格CellData[0][1]的内容,那么只要单元格CellData[0][0]至CellData[0][31]也不被使用,就可以回收它的内存页面。那么怎么能够知道这个情况呢?可以用下面3种方法来解决这个问题。
•毫无疑问,最容易的方法是设计一个CELLDATA结构,它的大小只有一个页面。这时,由于始终都是每个页面使用一个结构,因此当不再需要该结构中的数据时,就可以回收该页面的物理存储器。即使你的数据结构是x86CPU上的8KB或12KB页面的倍数(通常这是非常大的数据结构),回收内存仍然是非常容易的。当然,如果要使用这种方法,必须定义你的数据结构,使之符合你针对的CPU的页面大小而不是我们通常编写程序所用的结构。
•更为实用的方法是保留一个正在使用的结构的记录。为了节省内存,可以使用一个位图。这样,如果有一个100个结构的数组,你也可以维护一个100位的数组。开始时,所有的位均设置为0,表示这些结构都没有使用。当使用这些结构时,可以将对应的位设置为1。然后,每当不需要某个结构,并将它的位重新改为0时,你可以检查属于同一个内存页面的相邻结构的位。如果没有相邻的结构正在使用,就可以回收该页面。
•最后一个方法是实现一个无用单元收集函数。这个方案依赖于这样一种情况,即当物理存储器初次提交时,系统将一个页面中的所有字节设置为0。若要使用该方案,首先必须在你的结构中设置一个BOOL(也许称为fInUse)。然后,每次你将一个结构放入已提交的内存中,必须确保该fInUse被置于TRUE。
当你的应用程序运行时,必须定期调用无用单元收集函数。该函数应该遍历所有潜在的数据结构。对于每个数据结构,该函数首先要确定是否已经为该结构提交内存。如果已经提交,该函数将检查fInUse成员,以确定它是否是0。如果该值是0,则表示该结构没有被使用。如果该值是TRUE,则表示该结构正在使用。当无用单元函数检查了属于既定页面的所有结构后,
如果所有结构都没有被使用,它将调用VirtualFree函数,回收该内存。
当一个结构不再被视为“在用”(InUse)后,就可以立即调用无用单元收集函数,不过这项操作需要的时间比你想像的要长,因为该函数要循环通过所有可能的结构。实现该函数的一个出色方法是让它作为低优先级线程的一部分来运行。这样,就不必占用执行主应用程序的线程的时间。每当主应用程序运行空闲时,或者主应用程序的线程执行文件的I/O操作时,系统就可以给无用单元收集函数安排运行时间。
在上面列出的所有方法中,前面的两种方法是我个人喜欢使用的方法。不过,如果你的结构比较小(小于一个页面),那么建议你使用最后一种方法。
15.6改变保护属性
虽然实践中很少这样做,但是可以改变已经提交的物理存储器的一个或多个页面的保护属性。例如,你编写了一个用于管理链接表的代码,将它的节点存放在一个保留区域中。可以设计一些函数,以便处理该链接表,这样,它们就可以在每个函数开始运行时将已提交内存的保护属性改为PAGE_READWRITE,然后在每个函数终止运行时将保护属性重新改为PAGE_NOACCESS。
通过这样的设置,就能够使链接表数据不受隐藏在程序中的其他错误的影响。如果进程中的任何其他代码存在一个迷失指针,试图访问你的链接表数据,那么就会引发访问违规。当试图寻找应用程序中难以发现的错误时,利用保护属性是极其有用的。
若要改变内存页面的保护属性,可以调用VirtualProtect函数:
BOOL VirtualProtect(
PVOID pvAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD pfloldProtect);
这里的pvAddress参数指向内存的基地址(它必须位于进程的用户方式分区中),dwSize参数用于指明你想要改变保护属性的字节数,而flNewProtect参数则代表PAGE_*保护属性标志中的任何一个标志,但PAGE_WRITECOPY和PAGE_EXECUTE_WRITECOPY这两个标志除外。
最后一个参数pflOldProtect是DWORD的地址,VirtualProtect将用原先与pvAddress位置上的字节相关的保护属性填入该DWORD。尽管许多应用程序并不需要该信息,但是必须为该参数传递一个有效地址,否则该函数的运行将会失败。
当然,保护属性是与内存的整个页面相关联的,而不是赋予内存的各个字节的。Windows98Windows98只支持PAGE_READONLY和PAGE_READWRITE两个保护属性。如果试图将页面的保护属性改为PAGE_EXECUTE或PAGE_EXECUTE_READ,该页面可得到PAGE_READONLY保护属性。同样,如果试图将页面的保护属性改为PAGE_EXECUTE_READWRITE,那么该页面将得到PAGE_READWRITE保护属性。
VirtualProtect函数不能用于改变跨越不同保留区域的页面的保护属性。如果拥有相邻的保留区域并想改变这些区域中的一些页面的保护属性,那么必须多次调用VirtualProtect函数。
15.7清除物理存储器的内容
Windows98Windows98不支持物理存储器内容的清除。
当你修改物理存储器的各个页面的内容时,系统将尽量设法将修改的内容保存在RAM中。但是,当应用程序运行时,从.exe文件、DLL文件和/或页文件加载页面就可能需要占用系统的RAM。由于系统要查找RAM的页面,以满足当前加载页面的需求,因此系统必须将RAM的已修改页面转到系统的页文件中。
Windows2000提供了一个特性,使得应用程序能够提高它的性能,这个特性就是对物理存储器内容进行清除。清除存储器意味着你告诉系统,内存的一个或多个页面上的数据并没有被修改。如果系统正在搜索RAM的一个页面,并且选择一个已修改的页面,系统必须将RAM的这个页面写入页文件。这个操作的速度很慢,而且会影响系统的运行性能。对于大多数应用程序来说,可以让系统将你修改了的页面保留在系统的页文件中。
然而,有些应用程序使用内存的时间很短,然后就不再要求保留该内存的内容。为了提高性能,应用程序可以告诉系统不要将内存的某些页面保存在系统的页文件中。这是应用程序告诉系统数据页面尚未修改的一个基本方法。因此,如果系统选择将RAM的页面用于别的目的,那么该页面的内容就不必保存在页文件中,从而可以提高应用程序的运行性能。若要清除内存的内容,应用程序可以调用VirtualAlloc函数,在第三个参数中传递MEM_RESET标志。
如果在调用VirtualAlloc函数时引用的页面位于页文件中,系统将删除这些页面。下次应用程序访问内存时,便使用最初被初始化为0的RAM页面。如果清除了当前RAM中的页面内容,那么它们将被标上未修改的标记,这样它们将永远不会被写入页文件。注意,虽然RAM页面的内容没有被置0,但是不应该继续从内存的该页面读取数据。如果系统不需要RAM的这个页面,它将包含其原始内容。但是如果系统需要RAM的这个页面,系统就可以提取该页面。然后当你试图访问该页面的内容时,系统将给你一个已经删除内容的新页面。由于你无法控制这个行为特性,因此,当清除页面的内容后,你必须假定该页面的内容是无用信息。
当清除内存的内容时,有两件事情必须记住。首先,当调用VirtualAlloc函数时,基地址通常圆整为一个页面边界的值,而字节数则圆整为一个页面的整数。当清除页面的内容时,用这种办法圆整基地址和字节数是非常危险的,因此,当传递MEM_RESET标志时,VirtualAlloc将按反方向对这些值进行圆整。
要记住的第二件事情是,MEM_RESET标志始终必须自己单独使用,不能用OR将它与任何其他标志连接起来使用。将MEM_RESET标志与任何其他标志连接起来确实没有任何意义。
最后请注意,带有MEM_RESET标志的VirtualAlloc函数要求传递一个有效的页面保护属性,即使该函数不使用这个值,也必须传递该值。
15.8地址窗口扩展—适用于Windows2000
随着时间的推移,应用程序需要的内存越来越多。对于服务器应用程序来说,情况更是如此。由于越来越多的客户机对服务器提出访问请求,服务器的运行性能就会降低。为了提高运
行性能,服务器应用程序必须在RAM中保存更多的数据,并且缩小磁盘的页面。其他类别的应用程序,比如数据库、工程设计和科学应用程序,也需要具备处理大块内存的能力。对于所有这些应用程序来说,32位地址空间是不够使用的。
为了满足这些应用程序的需要,Windows2000提供了一个新特性。称为地址窗口扩展(AWE)。Microsoft创建AWE是出于下面两个目的:
•允许应用程序对从来不在操作系统与磁盘之间交换的RAM进行分配。
•允许应用程序访问的RAM大于进程的地址空间。
AWE基本上为应用程序提供了一种分配一个或多个RAM块的手段。当分配RAM块时,在进程的地址空间中是看不见这些RAM块的。后来,应用程序(使用VirtualAlloc函数)保留一个地址空间区域,这个区域就成为地址窗口。这时应用程序调用一个函数,每次将一个RAM块赋予该地址窗口。将一个RAM块赋予地址窗口的速度是非常快的(通常只需要几个毫秒)。显然,通过单个地址窗口,每次只能访问一个RAM块。这使得你的代码很难实现,因为,当需要时,必须在你的代码中显式调用函数,才能将不同的RAM块赋予地址窗口。
对VirtualAlloc函数的调用保留了一个1MB的地址窗口。通常该地址窗口要大得多。你必须选定一个适合于应用程序需要的RAM块大小的窗口大小。当然,你创建的最大窗口取决于你的地址空间中可用的最大相邻空闲地址块。MEM_RESERVE标志用于指明我正在保留一个地址区域。MEM_PHYSICAL标志用于指明这个区域最终将受RAM物理存储器的支持。AWE的局限性是,映射到地址窗口的所有内存必须是可读的和可写入的,因此PAGE_READWRITE是可以传递VirtualAlloc函数的唯一有效的保护属性。此外,不能使用VirtualProtect函数来修改这个保护属性。
RAM物理存储器的分配是非常简单的,只需要调用AllocateUserPhysicalPages:
BOOL AllocateUserPhysicalPages(
HANDLE hprocess,
PULONG_PTR pulRamPages,
PULONG_PTR aRAMPages);
该函数负责分配pulRAMPages参数指明的值设定的RAM页面的数量,并且将这些页面赋予hProcess参数标识的进程。
每个RAM页面由操作系统赋予一个页框号。当系统选择供分配用的RAM页面时,它就将每个RAM页面的页框号填入aRAMPages参数指向的数组。页框号本身对应用程序没有任何用处,不应该查看该数组的内容,并且肯定不应该修改该数组中的任何一个值。注意,你不知道哪些RAM页面已经被分配给该内存块,也不应该去关注这个情况。当地址窗口显示RAM块中的页面时,它们显示为一个相邻的内存块。这使得RAM非常便于使用,并且使你可以不必了解系统内部的运行情况。
该函数返回时,pulRAMPages参数中的值用于指明该函数成功地分配的页面数量。这个数量通常与传递给函数的值是相同的,但是它也可能是个较小的值。
只有拥有页面的进程才能使用已经分配的RAM页面,AWE不允许RAM页面被映射到另一个进程的地址空间。因此不能在进程之间共享RAM块。
注意当然,物理RAM是一种非常宝贵的资源,并且应用程序只能分配尚未指定用途的RAM。应该非常节省地使用AWE,否则你的进程和其他进程将会过分地在内存与磁盘之间进行页的交换,从而严重影响系统的运行性能。此外,如果可用RAM的数量比较少,也会对系统创建新进程、线程和其他资源的能力产生不利的影响。应用程序可以使用GlobalMemoryStatusEx函数来监控物理存储器的使用情况。
为了保护RAM的分配,AllocateUserPhysicalPages函数要求调用者拥有LockPagesinMemory(锁定内存中的页面)的用户权限,并且已经激活该权限,否则该函数的运行将会失败。按照默认设置,该权限不被赋予任何用户或用户组。该权限被赋予LocalSystem(本地系统)帐户,它通常用于服务程序。如果想要运行一个调用AllocateUserPhysicalPages函数的交互式应用程序,那么管理员必须在你登录和运行应用程序之前为你赋予该权限。
Windows2000在Windows2000中,可以执行下列步骤,打开LockPagesinMemory用户权限:
1)单击Start按钮,选定Run菜单项,打开ComputerManagementMMC控制台。在
Run框中,键入“Compmgmt.msc/a”,再单击OK按钮。
2)如果在左边的窗格中没有显示LocalComputerPolicy(本地计算机政策)项,那么在控制台菜单中选定Add/RemoveSnap-ins(添加/删除咬接项(snap-in))。在Standalone选项卡上,从Snap-insAddedTo(咬接项添加到)组合框中选定ComputerManagement(local)。现在单击Add按钮,显示AddStandaloneSnap-in(添加独立咬接项)对话框。从AvailableStandaloneSnap-ins(可用独立咬接项)中选定GroupPolicy(组政策)。并单击Add按钮。在SelectGroupPolicyObject(选定组政策对象)对话框中,保留默认值,并单击Finish按钮。单击AddStandaloneSnap-in对话框上的Close按钮,再单击Add/RemoveSnap-in对话框上的OK按钮。这时,在ComputerManagement控制台的左窗格中就可以看到LocalComputerPolicy项。
3)在控制台的左窗格中,双击下列项目,将它们展开:LocalComputerPolicy(本地计算机政策)、ComputerConfiguration(计算机配置)、WindowsSettings(窗口设置)、SecuritySettings(安全性设置)和LocalPolicy(本地政策)。然后选定UserRightsAssignment(用户权限赋值)项。
4)在右窗格中,选定LockPagesinMemory属性。
5)从Action(操作)菜单中选定Security,显示LockPagesinMemory对话框,单击Add按钮。使用SelectUsersorGroup对话框,添加你想为其赋予LockPagesinMemory用户权限的用户和/或用户组。单击OK按钮,退出每个对话框。当用户登录时,他将被赋予相应的用户权限。如果你只是将LockPagesinMemory权限赋予你自己,那么必须在该权限生效前退出并重新登录。
现在我们已经创建了地址窗口并且分配了一个RAM块,可以通过调用MapUserPhysicalPages函数将该RAM块赋予该地址窗口:
BOOL MapUserPhysicalPages(
PVOID pvAddressWindow,
PULONG_PTR pulRamPages,
PULONG_PTR aRAMPages);
第一个参数pvAddressWindow用于指明地址窗口的虚拟地址,第二和第三个参数ulRAMPages和aRAMPages用于指明该地址窗口中可以看到多少个RAM页面以及哪些页面可以看到。如果窗口小于你试图映射的页面数量,那么函数运行就会失败。Microsoft设置这个函数的主要目的是使它运行起来非常快。一般来说,MapUserPhysicalPages函数能够在几个微秒内映射该RAM块。
注意也可以调用MapUserPhysicalPages函数来取消对当前RAM块的分配,方法是为aRAMPages参数传递NULL。
一旦RAM块被分配给地址窗口,只需要引用相对于地址窗口的基地址(在我的示例代码
中是pvWindow)的虚拟地址,就可以很容易地访问该RAM内存。
当不再需要RAM块时,应该调用FreeUserPhysicalPages函数将它释放:
BOOL FreeUserPhysicalPages(
HANDLE hprocess,
PULONG_PTR pulRamPages,
PULONG_PTR aRAMPages);
第一个参数hProcess用于指明哪个进程拥有你试图释放的RAM页面。第二和第三个参数用于指明你要释放多少个页面和这些页面的页框号。如果该RAM块目前已经被映射到该地址窗口,那么它将被取消映射并被释放。
最后,为了彻底清除页面,我仅仅调用了VirtualFree函数,传递窗口的虚拟基地址,为区域大小传递0,再传递MEM_RELEASE,将地址窗口释放掉。
我的简单的示例代码创建了单个地址窗口和单个RAM块。这使得我的应用程序能够访问没有与磁盘进行数据交换的RAM。但是,应用程序也能够创建若干个地址窗口,并且可以分配若干个RAM块。虽然这些RAM块可以分配给任何一个地址窗口,但是系统不允许单个RAM块同时出现在两个地址窗口中。
64位Windows2000全面支持AWE。对使用AWE的32位应用程序进行移植是非常容易和简单的。不过对于64位应用程序来说,AWE的用处比较小,因为进程的地址空间太大了。但是AWE仍然是有用的,因为它使得应用程序能够分配不与磁盘进行数据交换的物理RAM。