关于DLL加载和运行的性能优化总结

本文从三个方面总结了加快DLL加载和运行速度的方法,他们分别是:

  1. 使用Rebase和Bind。
  2. 按序号的方式导入函数。
  3. 使用预读取技术(chromium加载dll时使用了此项)。

首先,讨论一下使用Rebase和Bind,提高DLL加载和运行性能的方法。我们知道,在编译链接DLL的时候,连接器会给DLL一个加载基址,而这个加载基址对于DLL模块中的使用了硬编码地址的代码和数据是至关重要的。因为这些硬编码的地址都是连接器通过DLL加载基址计算出来的。

这样,当我们的DLL被加载到进程空间当中的时候,如果足够幸运的话,DLL正好被加载到连接器所指定的地址,那么那些硬编码的地址就是完全正确的,加载过程不需要其他额外的工作就能让DLL正确运行了。

但是,真实的情况并不是这么理想,因为我们的进程可能不得不加载许许多多DLL,这样就会导致,某些DLL预定的基址可能已经被其他DLL使用。因此,前者就不得不加载到其他地址。由此带来的影响也是显而易见的,硬编码的地址就会出错。为了不让这种错误发生,加载器也就不得不多做一些工作,就是重新矫正这些硬编码。这样,如果DLL里包含的硬编码地址越多,加载的速度就会越慢,从而导致DLL加载性能下降。

另一方面,由于DLL加载基址是不可预见的,所以DLL加载的时候,加载器会根据导入表搜索所有的导入函数,计算并且确定导入函数的正确地址。这个过程也会对DLL加载性能有一定影响。

微软已经考虑到了这个问题的优化方法,在SDK中为我们提供了Rebase和Bind工具。其中Rebase工具,可以合理安排进程中DLL的加载地址,并且修改到PE中,从而避免DLL加载时Rebase操作。这样,一旦我们确认了DLL的加载基址是不会发生改变,那么我们就可以使用Bind工具将导入表进行绑定,这样的好处就是加载器不需要在加载的时候去计算确定导入函数地址,因为这些地址以及被预设了。

需要注意的一点,即使你做了这么多的事情,也不能完全避免加载基址的冲突,例如使用ASLR的DLL,其加载的地址是会发生变化的。所以就不能保证Rebase和Bind的有效性。具体能优化多少性能,在不同的案例中可能结果不同,需要具体实验才能知道。

第二种优化DLL运行性能的方案就是使用序号而非名称的方式导入函数。这一点也就非常容易解释了,如果通过函数名确定导入函数地址,那么加载器就不得不对字符串进行比较,从而确定正确的函数地址,虽然DLL的导出表是按顺序字母排列的,并且查找方式也是二分查找,但是如果函数很多,这依然是个耗时的工作。例如,我机器上的MFC100.dll有14000多个导出函数,如果进程需要按照名称确定自己需要的函数是哪一个,那么工作量还是不小的吧。所以MFC100.dll很明智的使用了导出序号的方法,这样加载器计算导入函数的时候,就能够使用序号来确定函数地址了,也就是简单使用数组搜索,从而得到目标函数地址。

这种方法的优化效果同样也要根据具体情况而定,如果导入函数少,目标DLL导出的函数也非常少,那么这种优化应该是没有什么意义的。相反,如果需要导入函数很多,而且目标DLL也导出了很多函数,那么在想提高程序加载性能的时候,不妨试一试这个方案。

最后一个方法是Pre-Read技术,使用在chromium中的。这种方法的原理是将DLL预先存入系统缓存,从而减少Page Fault来达到提高性能的目的。这种优化主要针对的是进程冷启动加载DLL的情况。进程第一次启动的时候,加载所需的DLL,DLL会被MAP到内存空间,虽然如果查询这片MAP的内存,会发现确实是COMMIT状态。但是,实际上系统并没要保证这些内存在Working Set中。一般情况下,系统只会把你想要用到的内存加载到Working Set中,以节约物理内存。这样,当我们每次用到这个并没有对应的Working Set的虚拟内存的时候,就发生了Page Fault,系统这个时候才会把这些内存加载到Working Set。而Page Fault对性能的影响很是比较大的。所以Pre-Read技术就有了用武之地。

Chromium对Pre-Read实现的非常好,代码的具体位置是http://src.chromium.org/viewvc/chrome/trunk/src/chrome/app/image_pre_reader_win.cc 。代码中,分别针对XP和XP以上的系统使用了不同的方法让系统缓存目标DLL。在XP以上的系统中,代码简单的通过ReadFile将文件读取到内存中,然后释放内存,关闭文件句柄,就可以达到缓存目标DLL的目的。而在XP系统下,做法有些不同,它使用LoadLibraryEx函数,将文件Load到内存空间,然后尝试对每个Page进行读取操作,已达到让数据载入Working Set的目的。

以上是我所知道的三种加载DLL的优化方法,也可能还有更多更好的方法,有兴趣的可以一起讨论下。不过无论什么性能优化方法,都必须建立在实际项目的基础上,并且有科学的依据和论证,切不可只从理论上下结论来优化程序,纸上谈兵,这样很可能适得其反,让性能变得更糟。(例如这个例子里所提到的,看上去不错的优化可能还不如什么都不做:http://blogs.msdn.com/b/oldnewthing/archive/2004/12/17/317157.aspx)。至于说,如何得到程序运行数据用于总结出有效的优化方案,这里强烈推荐一款神器XPerf。实际上,也是因为使用了XPerf,才让我对性能优化产生了浓厚的兴趣!

DebuggingNTInternals

gdi_handle_study —— 查看进程GDI资源情况的工具

gdi_handle_study 是一个用于查看进程中gdi句柄资源的工具。可以用于监控gdi资源是否泄露,已经对gdi资源的使用情况。使用方法非常简单:

1
2
3
4
5
6
<blockquote>usage: gdi_handle_study.exe [-c] [-v [-f <filter>]] [processname|pid]
processname    List GDI handles loaded by process (partial name accepted)
pid                   List GDI handles associated with the specified process id
-c                    Show GDI count information.
-v                    Show GDI handle information.
-f                    Filter the GDI handle type.</blockquote>

在不用任何参数的情况下,工具会显示所有进程的gdi资源使用概况,如图所示:
20130504154700

值得注意的是,GDI Total和GDI All的区别在于,GDI Total统计出来的数量,是通过工具本身枚举可统计GDI资源后得出统计值,而GDI All是通过系统API直接获得的值,有些的情况下,GDI Total的值是小于GDI All的值的。这种情况可能因为某些GDI资源是系统保留的。另外一个要注意的是,如果要显示所有进程的gdi情况,需要有管理员权限运行该工具。

processname和pid参数能让我们指定需要查看的进程名或者进程ID。参数-c能查看更为详细的gdi资源的统计情况。如下图所示:
20130504155533

从上图可以看出,qq这种DirectUI程序,用的Bitmap资源何其的多啊。。。

-v参数是用来查看更为详细的GDI资源信息,其中就包括额资源的句柄,资源的种类以及资源的内核对象地址。如图所示:
20130504160314

最后工具还能利用-f filter,来查看想看到的资源情况,例如上图中,bitmap不是自己想看的资源,但是却占据了大量的视野。这个时候filter就能用上了。如图:
20130504160837

上图就是利用filter,显示的Brush资源的详细情况了。

下载gdi_handle_study

NTInternals