《C++应用程序性能优化::第四章操作系统的内存管理》学习和理解

说明:《C++应用程序性能优化》 作者:冯宏华等 2007年版。

2010-8-24开始

cs_wuyg@126.com

       听说不关注内存的C++高手,是伪高手。对C++的内存学习非常重要,继续学习理解。

  长期以来,在计算机系统中,内存都是一种紧缺和宝贵的资源,应用程序必须在载入内存后才能执行。以前,在内存空间不够大时,同时运行的应用程序的数量就会受到很大的限制。甚至当某个应用程序在某个运行时所需内存超过物理内存时,该应用程序就会无法运行。现代操作系统的内存管理都能解决这个问题,解决方法就是虚拟内存的引入。

  本质上虚拟内存就是要让一个程序的代码和数据在没有全部载入内存时即可运行。当要访问尚未载入物理内存的数据时,虚拟内存管理器动态地将这部分代码或数据从磁盘载入到内存中,通常要先把内存中的某些代码或数据置换到硬盘中,为即将载入的代码或数据腾出空间。

  系统在将数据置换到调页文件时,要考虑整个系统的效率问题,选择最不可能会再被访问的内存,将它的数据换到调页文件中。使得磁盘I/O操作次数降到尽可能低,以尽可能提高程序的运行性能。

一、             Windows内存管理

1、虚拟内存

  Win32虚拟内存管理器为每一个Win32进程提供了进程私有且基于页的4GB(32位)大小的线性虚拟地址空间。

(1)       每个进程都有4GB大小的虚拟地址空间,且只能访问自己的空间(父子进程例外)。dll没有属于自己的虚拟地址空间,它所在的空间属于调用它的进程。

(2)       X86中页大小为4KB。虚拟内存地址空间的申请和释放,以及内存和磁盘的数据传输或置换都是以页为最小单位进行。

(3)       进程可使用的地址空间只有低区的2GB,高区的2GB归系统使用。

  Win32中用来辅助实现虚拟内存的硬盘文件称为“调页文件”,可以有16个,调页文件用来存放被虚拟内存管理器置换出内存的数据。当数据再次被访问时会从调用文件置换进内存。但是,exe和dll文件包含只读数据,系统采用内存映射文件的方式实现,而不使用调页文件。

当访问不在物理内存中的数据时,会发生缺页中断,虚拟内存管理器会把数据调入内存,对于开发人员来说这是透明的,但是这涉及磁盘I/O操作,会降低程序的总体性能。

2、虚拟地址空间页面的三种状态

   进程的虚拟地址空间中的页有三种状态:自由(free),预留(reserved),提交(committed)。

(1)       自由,表示此页尚未被分配,可以用来满足新的内存分配请求。

(2)       预留,是从虚拟地址空间中划出一块区域(region,页的整数倍数大小),划出之后这个区域中的页不能用来满足新的内存分配请求,而是用来供要求“预留”此段区域的代码以后使用。预留时并没有分配物理存储,只是增加了一个描述进程虚拟地址空间使用状态的数据结构(VAD,虚拟地址描述符),用来记录这段区域已被预留。“预留”相比“提交”快,因为没有涉及到真正的物理存储。因为没有物理存储,所以当访问“预留”的空间时会发生“内存访问违例”(这会导致整个进程退出)。

(3)       提交,会从调页文件中开辟空间,并修改VAD中的相应项。提交时获取的空间是调页文件的空间,而不是物理内存空间,用来作为以后的置换备份空间。当程序第一次访问已提交的空间时,出现缺页中断,虚拟内存管理器处理这个错误,这时候才真正的分配物理内存。注意到,提交操作仅仅是开辟调页文件区域的空间而已,物理内存没有分配,虚拟内存跟物理内存映射所必须的页目录和页表也没有建。必须到第一次访问时才全部完善。这是Win32虚拟内存管理中的demand-paging策略的一个体现,不到真正访问时不会为虚拟地址分配真正的物理内存。

  为什么会有demand-paging策略?

  因为:程序在提出“提交”/“预留”,这只是一种潜在的需求,不一定会被立即访问,也有可能到程序结束都不会被访问。如果立即给分配物理内存,是一种浪费。

  另一方面,如果完全不采用提交和预留,每次都随需分配内存,这样对于一个要频繁申请内存的代码来说,可能会导致它在不同时间点申请的空间是分散的,这样的话就无法利用空间局部性原理(locality),会导致缺页次数增加,性能下降。

在Win32中预留和提交都是使用VirtualAlloc函数完成的。该函数使用MEM_RESERVE参数则表明预留。使用MEM_COMMIT参数则表明提交。

3、创建线程时的预留和提交

       创建线程栈时,只是一个预留的虚拟地址区域,默认是1MB,初始时只有前两页是提交的。当线程栈因为函数的嵌套调用需要更多的提交页时,虚拟内存管理器会动态的提交该虚拟地址中的后续页以满足其需求,直到达到1MB上限。当达到上限时,虚拟内存管理器不会增加预留区域大型,而是在提交最后一页是抛出一个栈溢出异常,此时还有一个页空间可用,程序可继续运行。当用完最后一页时,还需要空间,则导致进程直接退出。

4、访问虚拟内存时的流程

       虚拟内存管理器维护一个称为“页帧数据库(page-frame database)”的数据结构,此数据结构是操作系统全局的,当windows启动时被初始化,用来跟踪和记录物理内存中每一个页的状态,它会用一个链表将所有空闲页连接起来,当需要空闲页时,直接查找此空闲页链表,如果有,直接使用;否则根据调页算法置换之。另外,操作系统充分利用了局部性原理,每一次发生缺页中断时,调入内存的页面是需求页面相邻的几个页面。当要有了空闲的物理内存页面后,要检测是否是第一次访问,如果是则清0使用即可,不需要从调页文件的备份页中读取备份内容。

       线程访问某块数据时,需要首先判断是否在物理内存中,如果不在,则判断是否在调页文件中,如果还不在,则出错,进程退出。如果在内存中则直接访问。如果在调页文件中,则要确保有空闲的页面供使用。

虚拟内存跟物理内存存在映射关系。通过二级页表实现。第一层表称为“页目录”,第二层表称为“页表”。当要访问某地址时,通过该地址的高10位在页目录中寻找页目录项,然后再通过中间的10位在页表中查找页表项,找到页表,最后再根据地址的第12位寻址到对应的物理内存。页目录有1024项,页表也有1024项,页目录占4KB的空间,1024个页表占1024*4KB的空间。

       在发生缺页错误之后,虚拟内存管理器要去判断该地址是否在调页文件中,在调页文件中的哪个地方。这些是通过查找虚拟地址描述符树(Virtual Address Descriptor, VAD)做到的。每个进程都有一个VAD树,是一颗平衡二叉树,记录了每一块被预留/提交的区域。当程序需要申请内存时,虚拟内存管理器通过访问VAD树,可以从两个相邻的VAD中获取获取所需的虚拟地址空间,增加一项VAD。当发生缺页中断后,遍历VAD树,查找到相应的VAD,如果是提交了的空间,则根据其中的信息分配物理内存,建立页表,然后再进行映射。如果查到的只是预留的地址空间,则会导致访问违例,进程退出。当访问自由的内存时,没有查到相应的VAD,同样是访问违例,进程退出。

5、进程工作集

       频繁的调页操作引起的大量磁盘I/O操作降低了程序的运行效率,因此每一个进程都会将一定量的内存驻留在物理内存中,并跟踪其执行的性能,动态调整这个数量。Win32驻留在物理内存中的内存页称为进程的“工作集(working set)”。

       如果执行的代码或访问的数据不在工作集中,则会引发额外的磁盘I/O操作,从而降低程序的运行效率。一个极端的情况就是发送所谓的颠簸或抖动(thrashing),即是大量的执行时间花在了调页操作上,而不是代码执行上。

       每当虚拟内存管理器调页时,不仅仅是调入需要的页,同时还将附近的页一起调入。提高程序的运行效率,应该考虑下边两个因素:

(1)       对于代码来说。编写紧凑的代码。根据时间局部性原理,程序80%的时间用在了20%的代码。如果将20%的代码尽量集中在一起,可以减少缺页次数,减少磁盘I/O操作提高程序性能。

(2)       对于数据来说。尽量将那些会一起访问的数据放在一起。可以利用win32提供的预留和提交两步机制,可以为那些会一同访问的数据预留出空间,此处并没有开辟空间,没有内存浪费,在后边数据生成的时候,再提交空间,这样能充分利用局部性原理。

6、Win32内存相关的API

(1)传统的CRT函数(malloc/free系列):因为这组函数的平台无关性,如果程序会被移植到其他非windows平台,则这组函数是首选。

(2)global heap/local heap(GlobalAlloc和LocalAlloc系统):这组函数是为了向后兼容而保留的。不建议使用

(3)虚拟内存函数(VirtualAlloc/VirtualFree系列):这组函数直接通过保留(reserve)和提交(commit)虚拟内存地址空间来操作内存,因此他们为开发人员提供最大的自由度,但相应地也为开发人员内存管理工作增加了更多的负担。这组函数适合于为大型连续的数据结构数组开辟空间。

(4)内存映射文件函数(CreateFileMapping/MapViewOfFile系列):系统使用内存映射文件函数系列来加载.exe或者.dll文件。而对于开发人员而言,一方面通过这组函数可以方便地操作硬盘文件,而不用考虑繁琐的文件I/O操作;另一方面,运行在同一台机器上的多个进程可以通过内存映射文件函数来共享数据(这也是同一台机器上进程间进行数据共享和通信的最有效率和最方便的方法)。

(5)堆内存函数(HeapCreate/HeapAlloc系列):Win32平台中的每个堆都是各进程私有的,每个进程除了默认的进程堆,还可以另外创建用户自定义堆。当程序需要动态创建多个小数据结构时,堆函数系列最为适合。一般来说CRT函数(malloc/free)就是基于堆内存函数实现的。

7、虚拟内存函数

       VirtualAlloc/VirtualFree,VirtualLock/VirtualUnlock,VirtualQuery/VirtualQueryEx及VirtualProtect/VirtualProtectex。其中最重要的是第一对。

       当通过VirtualAlloc保留虚拟内存地址空间时,只是增加了VAD树中的一项,并没有分配其他资源,如:调页文件、物理内存、页表等都没有,因而非常快捷。因为没有分配实际空间,所以访问保留的地址会发生访问违例。

       通过VirtualAlloc提交虚拟内存时,内存管理器必须从系统调页文件中开辟实际的存储空间,并修改/增加相应的VAD项,因此速度会比保留操作慢。但是此时也没有分配物理内存空间,页表也没有被创建。当首次访问这段虚拟内存地址空间时,由于缺页中断,虚拟内存管理器会查找VAD,接着根据VAD内存,动态创建页表(page table entry,PTE),然后根据PTE信息分配物理内页页,并实际访问该内存。可见,真正花费时间的操作不是提交内存,而是对提交内存的第一次访问。这种lazy-evalutation机制对程序运行的性能非常有益。这样,即便是用户提交了大段内存,而只零星访问某些页面而已,对程序性能也不会造成太大影响。

       VirtualFree有两种选择:可以将提交的内存释放,但是只释放内存而不释放保留的虚拟地址空间;可以将内存和虚拟地址空间一并释放。释放提交的内存比释放预留的内存要慢。因为释放预留的内存,只需要修改VAD树即可。

8、内存映射文件

       内存映射文件有三个用途:(1)windows利用它来有效使用exe和dll文件;(2)开发人员利用它来方便的访问硬盘文件;(3)实现不同进程间的内存共享。

       内存映射文件提供的方便访问硬盘文件的机制:一旦通过这种机制将一个硬盘文件(部分或全部)映射到进程的一段虚拟地址空间中,读写该文件的内容就像通过指针访问变量一样。

       跟实现虚拟内存一样,内存映射文件保留了一个地址空间的区域,并根据需要将物理存储器提交给该区域。它们之间的区别在于,当内存映射文件用来存取一个磁盘文件的时候,它提交的物理存储器就来自这个文件。

       (1)通过内存映射文件访问磁盘文件

使用步骤:

       CreateFile,获取文件句柄

       CreateFileMapping,创建一个文件映射内核对象,返回内存映射文件对象的句柄

       MapViewOfFile,映射文件的内容到进程虚拟地址空间,返回指针。之前的两个函数操作并没有预留或提交虚拟地址空间。

       通过该指针修改文件内容时,因为性能的考虑,减少磁盘I/O操作次数,修改的结果常常不会立即反映到文件中,可以调用FlushViewOfFile函数,实现强制同步。

       UnmapViewOfFile函数,系统回收对应的MapViewOfFile调用时预留并提交的虚拟内存地址空间区域。

       CloseHandle函数,关闭内存映射文件内核对象和文件内核对象。

注意:MapViewOfFile并不会触发物理内存分配,只有发生数据访问时才会因缺页(page fault)引发物理内存分配。

(2)通过内存映射文件实现进程间数据共享

  进程之间通过内存映射文件进行数据共享时,内存映射文件使用的磁盘空间是从调页文件中开辟的。

  使用步骤:

  因为不是基于某磁盘文件,所以不需要CreateFile

  CreateFileMapping,其中文件句柄参数为-1,表明这是在调页文件中开辟的空间。同时里边的一个参数要指出名称,这样另一个进程就能打开该内存映射内核对象。

       OpenFileMapping,文件映射内核对象已存在,则打开,获取内存映射文件对象句柄

  MapViewOfFile,同(1)

9、堆

       分配多个小块内存一般会选择使用堆函数,比如链表节点和树节点等。

       每个进程都有一个默认的堆,其初始区域大小为1MB。

       创建自定义堆:HeapCreate

       从堆中分配内存:HeapAlloc

       释放内存:HeapFree

       销毁自定义堆:HeapDestroy

       获取所有进程堆:GetProcessHeaps

       修改分配内存的大小:HeapReAlloc

       查询某块分配内存的大小:HeapSize

       堆合并:HeapCompact

       对堆的销毁的说明:

  一是销毁时,所有从该堆分配的内存全部被回收,而不必对那些内存一一进行释放,同时该堆占用物理存储以及虚拟地址空间区域也会被系统回收;        二是如果没有显式销毁自定义堆,这些堆会在程序退出时被系统销毁;三是进程默认对堆只能在程序退出时被销毁。另外,当线程退出时,系统是不会自动销毁线程的私有堆的,必须手动销毁。

  使用自定义堆的好处:

(1)       减少碎片,节省内存;由于大多数自定义堆是为某些特定的数据结构创建的,所有这些数据结果大小相同,从而使得上次释放的空间有更大的机会刚好可以满足下一次的内存申请,从而减少内存碎片。

(2)       由于局部性原理获得的性能提高:自定义堆上的内存块大多是某些特定的数据结构,这些数据结构有着强烈的时间局部性。如果这些数据定义在同一个堆里,这种空间局部性就会极大的减少数据整体访问引起的缺页中断。

(3)       由避免线程同步获得性能提高:如果只有一个堆,那么多线程就必须串行申请空间,如果多个线程都有各自的私有堆,那么就可以并行的去申请空间,性能得到提高。

 

二、             Linux内存管理

讲得很简单,只看了一点点,没感觉,大概跟windows下的有类似之处。因为尚未学习Linux下的开发,所以不做笔记。

三、             比较学习

  这本书对windows下的内存管理,缺少实例,不如罗云彬书的第十章。但是,讲了VAD的概念,之前并不知道有这个东西。对于预留和提交操作的理解也讲得比较深入,也只有深入理解这两个操作的本质,才能真正体现“性能”。

对于为什么要创建额外的堆,windows核心编程里边是这样说的:

(1)       对组件的保护

(2)       更有效的内存管理

(3)       局部访问

(4)       避免线程同步的开销

(5)       快速释放

  本书里边没有提到(1),不过,这一点跟性能提升无关。

cs_wuyg@126.com

2010.8.27结束

附上学习时的一点代码:

 1、内存映射文件实现进程数据共享

//内存映射文件测试之进程共享.cpp
//coder:cs_wuyg@126.com
//2010.8.23
//测试说明:同时运行两个相同的可执行文件(本程序),也就是两个进程,然后在其中一个DOS界面中输入,在另一个DOS界面中可以看到相同的显示。
//由于没有采用窗口界面,所以输入和输出有点麻烦。
//Code::Blocks 10.05 IDE,VS2005/2008
#include <windows.h>
#include <iostream>
#include <string>
using namespace std;
///////////////////////////////////////////////////////
int main()
{
	HANDLE m_hFileMap = OpenFileMapping(FILE_MAP_READ | FILE_MAP_WRITE, 0, TEXT("ShareMem"));
	if (!m_hFileMap)
	{
		m_hFileMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, 4096, TEXT("ShareMem"));
	}
	if (!m_hFileMap)
	{
		cout << "测试失败!!" << endl;
		return -1;
	}
	PVOID pview = MapViewOfFile(m_hFileMap, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);
	if (!pview)
	{
		cout << "测试失败!!" << endl;
		CloseHandle(m_hFileMap);
		return -1;
	}
	((char*)pview)[0] = 0;
	string strout;
	string strpre;
	cout << "利用内存映射文件进行进程间数据共享测试:" << endl;
	while(true)
	{
		cin >> (char*)pview;
		/*由于在控制台程序下测试,所以没法输出和显示同时,采用隔一段时间输入一次*/
		for (int i = 0; i != 5; ++i)
		{
			Sleep (2000);
			strout = (char*)pview;
			/*如果共享区字符串改变了,则显示*/
			if (strout != strpre)
			{
				cout << strout << endl;
				strpre = strout;
			}
		}
	}
	CloseHandle(m_hFileMap);
    return 0;
}

2、文件截取

//截取文件.cpp
//用win32API实现,文件截取
//coder:cs_wuyg@126.com
//2010.8.26
//IDE Code::Blocks compiler:vs2005/2008
#include <windows.h>
#include <string>
#include <iostream>
using namespace std;
int	main()
{
	string filepath;
	cout << "Input filename:" << endl;
	cin >> filepath;

	HANDLE hFile = CreateFile(filepath.c_str(), GENERIC_WRITE, 0, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
	if (hFile == INVALID_HANDLE_VALUE)
	{
		cout << "Err" << endl;
		return -1;
	}
	//输出文件大小
	UINT file_size = GetFileSize(hFile, NULL);
	cout << "File size: " << file_size << endl;
	//设置文件的指针
	SetFilePointer(hFile, 500, NULL, FILE_BEGIN);
	//把当前指针的文章设为文件结束位置
	SetEndOfFile(hFile);
	//新文件大小
	file_size = GetFileSize(hFile, NULL);
	cout << "New File size: " << file_size << endl;
	system("pause");
	return 0;
}
/*
Input filename:
a.txt
File size: 4797
New File size: 500
*/

 

posted on 2010-08-27 09:44  烛秋  阅读(3505)  评论(4编辑  收藏  举报