近来工作比较空闲,所以就上111cn.net看看帖子什么的,两个多月前,我在VC/MFC板块中发了这么一个帖子:dll占的究竟是谁的空间?详细参考:
http://topic.111cn.net/u/20080123/16/310330cd-e262-4534-b8c8-9bff892c7f21.html
关于这个帖子,我后面作了个总结,意思是说:dll占用的空间不属于某一个调用它的进程,dll是属于系统的,我得出这个“标准答案”的时候自己算是比较满意了,于是结了贴,事情并没这样结束,我昨天闲来无事,翻看以前的帖子,发现我结贴后还有一位朋友在帖子后留了言,我很感兴趣,正好也看到他在线,于是通过111cn.net的web在线交流跟他(或者“她”?)聊了挺久,之后又上网查了不少资料,然后结合自己实际看到的情况,对这个问题又有了新的认识,于是写这篇文章,当然我不敢说现在就是“标准答案”了,做blog的一个目的就是,share自己的知识,以便和众朋友交流。有什么不对,我再改进。
不过得在此先声明一下,这篇文章也不是final version,因为我对很多东西的认识也只停留在表面层次,所谓知其然不知其所以,因此我还打算在以后有了进一步研究之后重写这篇文章。我不喜欢太理论化的东西,诸如《XXXX原理》,整本书连一行代码都看不到,完全没有操作性可言,我希望我写的东西足够通俗易懂并且可以很简单地去实践,试验。
先说个概念的事情,就是我们经常说的一个词,“价格”,这一点都不陌生,很熟悉的词,我们去买东西的时候都喜欢讨价还价,最后以某个价格成交,比如一个冷饮一块五毛钱,一条鱼十块钱,再复杂一点的情况是买数码产品,比如一台数码相机,你问价,奸商会反问你:“你要的是带的还是不带的?”带不带可能有一两百的价差,这时候价格就被赋予不同的含义。如果买更大件些的东西,价格就不仅仅是一个数字,比如买房,就比较复杂了,你说的价格究竟是上家的到手价,还是是合同价,还是成交价,抑或是你要支付的金额?如果需要房贷,那办理贷款的手续费,甚至以后要偿还的利息算不算在房价内?因为这也算是你为了这个房子的“付出”啊,所以这个时候“价格”就比较复杂了,实际情况可能比这更复杂一些,得多费些工夫才能完全理解其意义。内存的占用,也是这样的道理,如果是单片机,我想没什么好说的,是多少就多少,但“虚拟内存”这个概念一出现,情况就变得复杂了,再加上“段”“页”这些概念,再加上DLL,“这个程序会占用多少内存?”这个问题就不简单了。
我们最直截了当地了解进程所占用内存的方法是通过Windows自带的任务管理器(Task Monitor),有两列是最值得我们关心的,一列是内存使用(Mem Usage),一列是虚拟内存大小(VM Size),在从事Windows开发以前,我一直认为Mem Usage是进程所占用的物理内存,而VM Size是程序所占用的虚拟内存(物理内存不够就把硬盘模拟为内存,然后把该存放在物理内存中的数据存到硬盘上去),所以占用的总的内存大小应该是两者的和,这种理解明显这是不对的。不过也不能完全怪我,其实Mem Usage这个名称本身就有其误导性,准确的解释:Mem Usage指的是该进程的Working Set Size,什么是Working Set Size?Working Set Size就是一个进程能直接不发生缺页错误(Page Faults)地访问的物理内存的大小。也许你会想:“嗯?搞什么文字游戏?这不就是占用的物理内存大小么?”我说那未必,不信就看下面这个图。
图中一共打开了11个App1.exe程序,如果Mem Usage指的是各个进程所占用的物理内存的话,那11个App1.exe的实例一共占据的物理内存就是就是574112K,约561M,可我的电脑的物理内存只有512M啊,这怎么可能?再看这11个App1.exe的VM Size,344K,明显Mem Usage可以大于VM Size,当然,从图中也能看到有VM Size大于Mem Usage的情况。这究竟是为什么?
对于这两个值,我目前的理解是这样的,Mem Usage就如前面所说,指的是一个进程能直接不发生Page Faults地访问的物理内存的大小,(本文为了方便起见,之后还是把Mem Usage称为物理内存占用,但读者必须清楚它的实际意义)VM Size是进程本身已经commit的虚拟内存。
关于Mem Usage:上述的11个App1.exe看起来肯定共同拥有了一些物理内存,它们可以共同访问这些物理内存而不发生Page Faults,如何来实现这个“共同拥有”?聪明的你一定想到了,用dll,我给出关键代码段,然后读者你自己去试试看。
这是dll中的代码:
#pragma comment(linker,"/section:.SharedDataName,rws")
#pragma data_seg (".SharedDataName")
__declspec(dllexport) char szDataSegTest[50*1024*1024]="";
/* 注意:后面的“=""”不能去除,否则就跟非共享段的没什么差别,至于为什么,得去问Microsoft */
#pragma data_seg()
这是App1中的代码:
extern char __declspec(dllimport) szDataSegTest[];
int main(int argc, char* argv[])
{
for(int i=0; i<50*1024*1024; i+=4096)
{
sprintf(&szDataSegTest[i], "%u", time(NULL));
}
getchar();
return 0;
}
实验1:运行多个App1的实例,观察Task Monitor。
通过DLL的“共享段”来共享数据,这是Microsoft提供的一种不错的功能,这不是C++的功能,算是Microsoft的扩展功能,我是觉得这个功能不错,在预先知道数据长度的情况下轻松实现进程间数据共享。也许你要问,main函数中的那个for循环是什么意思?问得好,现在你不妨再来做个实验。
实验2:把那段for循环注释掉,再运行程序,观察Task Monitor。
你会发现这时候的Mem Usage才几百K,完全没有50M那么大,为什么?原因是这样,如果这段内存你没用到,Windows是不会预先去映射的,当你的程序需要访问这段内存,就发生缺页,然后Windows才会去映射,所以通过这个for去使用了这段内存,你才能看到这50M的Mem Usage。那现在问题又来了,是不是我只需要:
szDataSegTest[0] = '''';
这么一个操作,就可以看得到这50M的Mem Usage呢?答案是否定的,实验当然你可以马上做一下,因为Windows映射内存的单位是页,发生缺页的时候,就产生Page Faults,Windows才会映射一个页,那一个页究竟多大?也许你已经看到了,就是代码中的4096,当然并不是所有的Windows系统的页大小(Page Size)都是4096,但我们目前能接触的应该是4096,获取Page Size的方法是GetSystemInfo,具体参考MSDN。顺便提一下这个Page Size是操作系统写死的,不能随便改变的。由于这个共享的机制,同时运行11个App1.exe并不会真的占用561M内存。
另外,Task Monitor中也是能看Page Faults的数量的,上面这个App1运行一次所发生的Page Faults大概是50*1024*1024/4096=12800次,事实上肯定会比这个数字大一些,因为程序Load到内存的时候或多或少都会发生缺页的。
OK,我们进入下一步实验。
实验3:程序还是上面的程序,取消刚才的注释,现在,运行了这个程序后,把它最小化,然后再观察Task Monitor。
在我的机器上,Mem Usage由50多M变成了156K,我估计在你的机器上也差不多,50M的物理内存占用突然不见了,为什么?其实这个程序不是个特例,只不过我用一个50M的数组来把这种现象夸张放大了,你观察下别的程序也会这样的,最小化之后Mem Usage小了不少,然后再把程序窗口还原,也许Mem Usage能恢复变大,但通常没有原来的大了。这其实并不是程序本身作了什么特殊的处理,这完全是Windows的功能。Windows的设计者考虑到这么种现象,就是我们用户在最小化一个程序之后,往往是暂时不想再使用这个程序,所以有必要把这个程序占用的一些物理内存释放出来,以便供别的程序使用,所以这个时候Windows就把最小化程序的Working Set Size弄小了,其实用户也可以“手动地”把Working Set Size弄小,通过这个API:SetProcessWorkingSetSize,具体参考MSDN,但手动调整这个是完全没有必要的,Windows足够智能去处理这些事情了,所以什么所谓“内存整理工具”都是画蛇添足。
现在,我们来改一下程序,DLL部分不变,改App1:
int main(int argc, char* argv[])
{
for(int i=0; i<50*1024*1024; i+=4096)
{
sprintf(&szDataSegTest[i], "%u", time(NULL));
}
getchar();
for(i=0; i<50*1024*1024; i+=4096)
{
sprintf(&szDataSegTest[i], "%u", time(NULL));
}
getchar();
return 0;
}
实验4:运行程序,最小化,观察Task Monitor中的Mem Usage和Page Faults,然后恢复程序窗口,按一下回车键,让程序继续运行,再观察Task Monitor,然后再把程序最小化,再观察一次Task Monitor。
我想你已经发现了,Page Faults几乎翻倍。“错误”本身不是什么好东西,页面错误亦然,但Windows在这里为什么竟敢纵容那么多的Page Faults?我想那是因为这种所谓“错误”其实并不怎么严重的缘故,为什么这么说?我们可以再做个实验。
实验5:运行两个App1,把其中一个最小化,观察Task Monitor。
你会发现最小化的那个的Mem Usage变成了156K,而另一个还是50M。它们共享的是同样的的一段物理内存,这段物理内存明显还在使用中,而最小化的那个要继续访问这段物理内存的话就得发生Page Faults,这就有点不太合情理了,内存既然还在使用,为什么Windows要取消最小化的App1对这段物理内存的映射关系?也许这只是个字面游戏,虽然这里发生了大量的Page Faults,可程序速度几乎不受影响,你可以在两个for循环的前后加个GetTickCount来测试一下耗时。代码类似这样:
unsigned int dwBegin = GetTickCount();
for(int i=0; i<50*1024*1024; i+=4096)
{
sprintf(&szDataSegTest[i], "%u", time(NULL));
}
unsigned int dwEnd = GetTickCount();
printf("elapsed[%d]n", dwEnd-dwBegin);
实验6:运行App1,按回车看结果,关闭;再运行App1,最小化,恢复,按回车看结果。
两者差别很小,几乎不会影响什么性能,但我这里说的只是这种形式的Page Faults不影响性能,并不是说所有类型的Page Faults不影响性能,为什么?这样说下去可能话就长了,加上我对Windows内部的运作还不是很了解,所以只能大概地说:这种类型的Page Faults导致的额外开销只是重新建立App1对共享段内存的映射关系,而无需将页面文件的数据Load到内存中,所以对性能影响不大。也许敏捷的你马上要问:“你刚才把App1最小化了,那它使用的物理内存不是被系统收回了么?为什么恢复它只是‘重新建立映射关系’而已?”很好,能问这个问题真是了不起,关于这个,我们马上再做个实验。(读者:怎么这么多实验?作者:本人没什么水平,只能用实验来说明问题,呵呵。)
现在打开Task Monitor,但现在看的是“性能”这个标签页,而不是“进程”这个标签页,这里我们能看到CPU,物理内存,页面文件的总体使用情况:
也许你注意到了,我的电脑有两个CPU,准确说一个双核CPU,Windows把它当成两个了,现在已经进入了双核时代,还有我的物理内存,514088K,不足512M,嗯?到底怎么回事?不能怪我啊,公司的电脑都没有独立显卡的,只能把一部分内存用作显存了,嗯,要求不能太高。(老板:嗯?你小子居然在上班时候写blog!)
实验7:先观察一下Task Monitor,注意页面文件(PF)使用情况和物理内存使用情况,运行App1,看Task Monitor,然后把App1最小化,继续观察Task Monitor,恢复App1,按回车键,让它继续跑,再最小化,再观察Task Monitor,最后把App1关掉,再观察Task Monitor。
这是一个非常有趣的现象,在程序运行的时候,PF使用突然增加50M,物理内存可用量降低50M,把程序最小化之后,按道理说物理内存很大一部分就被系统收回(只运行一个App1的实例情况下),前面你也看到了,程序最小化后的Mem Usage瞬间剧减至156K,所以我们应该看到的物理内存可用量会剧增50M,可这里不会,你会发现,物理内存的可用量会以大约每秒500K左右的速度,增加50M,这个过程不是瞬间完成的,也就是说Windows对物理内存的回收不是一下子完成的,为什么?我想最主要的原因是性能问题,一次性将巨大的物理内存的数据写入到页面文件中去以腾出物理内存空间的这种做法是低效的,假如物理内存中的数据有200M,(现在物理内存动辄2G的硬件配置,这不算什么啦)你可以尝试一次性在磁盘上写入200M的内容看看需要花费多少时间,虽然Windows有高速缓冲机制,但对于巨大的数据,这种缓冲仍然是不足的,如果用户最小化程序窗口之后,紧接着将程序窗口恢复并运行(这种操作还是比较多见的),那Windows又得从页面文件中调出这200M的数据,效率就可想而知,所以Windows对内存的回收是一点点的,偷偷摸摸地进行的,这样确保了我们的系统看起来很流畅。如果运行了两个App1的实例,只是最小化其中一个,那这种内存回收的现象将观察不到。好,回到这个实验当中,到了最后,你把App1关掉,但我还要求你继续观察Task Monitor,为什么呢?我还是相信你的观察力的,你会看到:App1所占据的物理内存会被马上释放,而页面文件使用量并没有马上降低50M,并且,你看不到页面文件使用量的明显变化,甚至观察了几分钟都没有什么动静,冷静冷静,你要有足够的耐心,为了探求真理,等个10来分钟,如何?还是没动静?好吧,别等了,我承认这个情况是比较复杂的,这依赖于Windows对页面文件的管理算法,这个页面文件使用是会释放的,但我也说不清什么时候会,你如果还愿意再等,那么现在停止手头的工作,出去跑两圈,回来看看也许就释放掉这50M了。什么?你问我Windows关于页面文件管理的的算法?我最怕算法这些东西了,我大学时候数学都是混着过去的。
这里再插入一个相对单独一些的论题,就是:DLL会不会在引用它的进程全部结束时候马上被移除出内存?我们可以写个App2来试试看,DLL代码保持不变:
extern char __declspec(dllimport) szDataSegTest[];
int main(int argc, char* argv[])
{
printf("[%s]", szDataSegTest);
sprintf(szDataSegTest, "%u", time(NULL));
return 0;
}
这个代码的意思是,App2全部退出后,如果DLL还继续有效驻留内存的话,再运行一次App2,应该能看到前面一个App2给这个DLL的共享段赋的那个值。
实验:运行App2,再运行App2,观察两个App2的运行情况;运行App2,按回车结束App2的运行,紧接着马上再运行一个App2,观察两个App2的运行情况。
实验结果证明了引用DLL的程序全部结束后,DLL将立即被移出内存,不再有效,也许你认为这个证据还不够充分。好吧,我这里提供个别的门路去检验:“超级兔子任务管理器”就提供了查看驻留内存的所有模块的这个功能,大家可以尝试用类似的工具检验一下。
写了这么多,大家应该多Mem Usage,VM Size,Page File这些东西有概念了吧。我们准备转入下个主题了。
×××××××× 不 怎 么 华 丽 的 分 割 线 ××××××××
在前面,我主要提了3个概念,一个Mem Usage,一个VM Size,一个Page File,对于Mem Usage,大家都很清楚了吧;Page File,估计也没什么问题了。这两个宝一个是物理内存,一个是一直被广大用户误会的虚拟内存(磁盘上的页面文件,在Linux中,叫swap分区)。那么VM Size号称虚拟内存,究竟大小怎么来定呢?我前面提到,VM Size是进程本身已经commit的虚拟内存,那什么叫commit?OK,先别急,我们来改一下前面用来做实验的那个DLL的代码,以事实来说明问题。
#pragma comment(linker,"/section:.SharedDataName,rws")
#pragma data_seg (".SharedDataName")
#pragma data_seg()
__declspec(dllexport) char szDataSegTest[50*1024*1024];
其实就是把szDataSegTest的定义移出共享段,App1代码不变。
实验8:运行5个App1的实例。(注意:这个实验有一定“危险性”,确保你的电脑的物理内存不低于256M)
运行结果大致如下图所示:
比同前面齐刷刷的52192K,这个结果多少有些“不和谐”,46848,46848,9896,46848,47216,这是我其中一次的运行情况,你的情况跟我的不太可能相同,你如果完成了这个实验一次,再做一次,会发现跟上次的结果也不太可能相同,但它们的VM Size却完全一样51544。在做这个实验的时候,如果你电脑的内存跟我的一样,只有512M,那你会明显感觉有点卡,硬盘嘎吱嘎吱地响,如果内存只有256M,我想那卡得会更明显了,硬盘也更累了,这就是内存不足,Windows不断地写页面文件的结果,如果你在观察页面文件的使用量,你会发现页面文件使用激增,完全不是刚才使用共享段的那样,增加了一次50M就完。而这次的Mem Usage也明显比上次的少了,你敢不敢多运行几个实例?好,我相信你有胆量,大不了就多听听硬盘交响曲。运行了之后你把它们所有的Mem Usage加起来,看看有没有可能突破561M?不管你运行了多少,都不可能,运行更多只会让系统缩减其它进程的Working Set Size。由于这次访问的DLL的内容不属于共享段,所以每次对这个大数组进行写操作,都得基于一个Copy-On-Write的规则,也就是在App1自己的进程空间里分配一块内存,复制大数组中相应的内容,将要写向大数组的东西写到自己这块内存里,当然这个分配同前面的一样,也是基于页的分配规则。由于这是进程在自己的空间里分配的,所以我们清楚地看到VM Size是一点都不含糊的50M,而不是像之前的例子那样反映出来的344K,(之前由于是共享段,只需要映射过去,不需要独另分配)VM Size和它的名字并不怎么相符,它不是一个虚无飘渺的数字,它代表了实际上进程commit的内存,所以它是必须有页面文件或者物理内存作为支持的,通常物理内存有限的情况下,就是增加页面文件来支持这个内存分配。另外你会看到这次结束每个App1都能导致页面文件使用量的显著减少,如图:
大家可以顺便做做最小化后观察内存变化的实验,看看跟前面的有什么不同,原理是一样的。这里略过。
前面反反复复提到一个词——commit,这是什么意思呢?比较贴近的意思是“提交”,“确认”,使用过数据库都知道,commit之后数据的更改才会真正生效,版本管理软件svn也是一样,用commit来提交更改确认。而内存分配翻译为“提交”却不太通顺,其实这个词来自虚拟内存分配函数VirtualAlloc。(我想的,不知道正确否)关于VirtualAlloc,大家可以看看MSDN,它的第三个参数是分配类型,分配类型一共有两种,分别是MEM_RESERVE和MEM_COMMIT,MEM_RESERVE是“预留”,在进程空间里预留出一块区域,但不对它进行实际的分配,这块区域可以不对应任何实际的物理内存或者页面文件,而MEM_COMMIT则是真实的分配,这么说大家都应该知道commit的意思了吧。当然你也可以reserve一块相当大的内存出来,看看VM Size有没有变化,实验结果会告诉你:只reserve的话是不会增加VM Size的。C++中这样的语句很多:
char *p = new char[100];
这其实就是commit了一块sizeof(char[100])大小的内存,执行了这个语句你会看到VM Size中会多出了100个字节……厄,太少了,可能你看不到。
到此为止,你认为观察一个程序所占用的内存,是看Mem Usage准确一些呢,还是看VM Size准确一些呢?我看还是看VM Size更准确一些。但这个“更准确”也不代表绝对,这只是相对而言。对于共享段,你说这么多个App1的实例为什么都不承认这个段属于它们的?但如果所有的App1的实例都结束运行之后,这个DLL就将被移出内存,因此回答这篇文章的标题问题的话,应该这样说吧:DLL是共同属于引用它的进程的,这比“DLL是属于系统的”来得更准确些。
写到这里,我也有点累了,我很多问题都没有完全搞懂,比如Windows的内存回收机制,VM Size的具体计算方法等。
在此得感谢111cn.net的朋友cnzdgs,希望他/她看到这篇文章的时候能留言,交流。还有另一篇很不错的文章,给了我很多启示,大家可以通过下面这个链接看到:
http://blog.joycode.com/qqchen/archive/2004/03/17/16434.aspx