导语:最近我在研究VirtualAlloc时,碰到了一个新问题。如果你在这个函数中使用了错误的标志,那么,根据你分配的内存大小,Windows运行速度可能会比平时慢1000倍,甚至可能更糟。
前言
最近我在研究VirtualAlloc时,碰到了一个新问题。如果你在这个函数中使用了错误的标志,那么,根据你分配的内存大小,Windows运行速度可能会比平时慢1000倍,甚至可能更糟。
VirtualAlloc是一个Windows API函数,该函数的功能是在调用进程的虚地址空间,预定或者提交一部分页面。简而言之就是申请内存空间,用于低级内存分配。它允许你保留虚拟地址空间区域,并在这些区域内提交页面。
我最近收到了一份客户的反馈报告,说在一台内存非常大的服务器上运行应用程序时,设备的启动速度反而比在内存小的设备上要慢得多。具体来说,就是当内存升级到288GB时,启动时间反而从平时的3分钟增加到了25分钟。
后来在分析中,我发现这个应用程序为缓存保留了大部分内存,它以1MBchunk编码(分块传输编码)来完成,所以在288GB的设备上,它对VirtualAlloc进行了250000次调用。正是这些内存分配花费了25分钟。老实说,花3分钟,我都嫌太长了。
通过使用Windows事件跟踪 (ETW)工具,我发现解决方案其实很简单,就是不要使用VirtualAlloc MEM_TOP_DOWN标志。
MEM_TOP_DOWN标志会告诉VirtualAlloc从地址空间的顶部开始进行分配。这对于查找刚刚出现在64位的应用程序中的漏洞利用很有帮助,因为它确保了原有漏洞利用所必需的前32位地址。
没有任何地方警告说,使用此标志的VirtualAlloc不能很好的扩展。它似乎使用了O(n ^ 2)算法, 其中'n'与你使用此标志进行的分配数相关。从此标志的说明概要分析来看,它似乎是缓慢且有条不紊地扫描保留存储器的列表,试图确保它找到具有最高地址的漏洞。
当这个标志被删除时,就会从25分钟启动时间变成瞬间启动。
减速测试
为此,我决定做一些测试。由于我没有288GB内存的服务器,所以我不得不在我的8GB笔记本电脑上运行测试。经过一些测试,我最终得到的代码是一堆64 KB地址范围的VirtualAlloc,然后释放它们,然后重新执行。所有分配都使用MEM_COMMIT | MEM_RESERVE | MEM_TOP_DOWN标志完成的。它释放的一次比一次多,从1000最终增加到64000。我将我的测试程序设计成一个64位的应用程序,这样它就可以轻松的处理所需的4 GB的地址空间。
我将数据放入Excel中,它创造了我见过的最完美的O(n ^ 2)图:
然后我在不使用MEM_TOP_DOWN标志的情况下尝试了一下,结果完全不同。
图形不仅从二次曲线变成了线性曲线,而且还可以看到尺度的变化。使用MEM_TOP_DOWN VirtualAlloc,对于大量的分配来说,速度要慢一千倍,而且一直都在变慢!
将两个结果放在一个图表上,你可以看到蓝色的图(VirtualAlloc没有MEM_TOP_DOWN)与横轴没有区别。
为了更彻底得进行一次分析,我抓了一个ETW样本。ETW对这类检测非常有效,因为它可以从VirtualAlloc的实现一直到我的测试代码,一直抓取完整的调用堆栈。下面是CPU使用率(抽样)表的相关摘录。
在我所观察的8.23秒的抽样中,显示大约有8155毫秒(8.155秒)花费在了用来寻找下一个地址,而不是用来调整页表和分配内存上。
除了测试外,请不要使用MEM_TOP_DOWN。以下是测试代码:
// Copyright Bruce Dawson const size_t MaxAllocs = 64000; __int64 GetQPC() { LARGE_INTEGER time; QueryPerformanceCounter( &time ); return time.QuadPart; } double GetQPF() { LARGE_INTEGER frequency; QueryPerformanceFrequency( &frequency ); return (double)frequency.QuadPart; } void* pMem[MaxAllocs]; void TimeAllocTopDown(bool topDown, const size_t pageSizeBytes, size_t count) { DWORD flags = MEM_COMMIT | MEM_RESERVE; if (topDown) flags |= MEM_TOP_DOWN; assert(count <= _countof(pMem)); __int64 start = GetQPC(); for (size_t i = 0; i < count; ++i) { pMem[i] = VirtualAlloc( NULL, pageSizeBytes, flags, PAGE_READWRITE ); assert(pMem[i]); } __int64 elapsed = GetQPC() – start; if (topDown) printf(“Topdown”); else printf(“Normal “); printf(” allocs took %7.4f s for %5lld allocs of %lld KB blocks (total is %lld MB).\n”, elapsed / GetQPF(), (long long)count, (long long)pageSizeBytes / 1024, (long long)(count * pageSizeBytes) / (1024 * 1024)); // Free the memory. for (size_t i = 0; i < _countof(pMem); ++i) { if (pMem[i]) VirtualFree( pMem[i], 0, MEM_RELEASE ); pMem[i] = 0; } } int _tmain(int argc, _TCHAR* argv[]) { bool topDown = true; for (size_t blockSize = 64 * 1024; blockSize <= 64 * 1024; blockSize += blockSize) { for (int i = 0; i <= 64; ++i) { TimeAllocTopDown(topDown, blockSize, i * 1000); } } return 0; }
文件访问情况下的减速问题
Windows一直以文件操作缓慢和进程创建缓慢著称。但你可曾想过,如何让这些操作过程变得更慢吗?以下我会介绍一种技术,你可以使用它让Windows上的所有文件操作以十分之一的正常速度(或更慢)运行,这对大多数用户来说是无法追踪的。
当然,本文还将介绍如何发现并避免速度减缓的问题。
这篇文章解释了我是如何发现速度减缓的问题并修复它的。
我是如何发现速度减缓问题的?
有时,在尝试更快的完成某个进程时,你可以获得的最重要的信息就是知道“它可以更快”。当你拥有所有代码时,这可能涉及包络估计,竞争性基准测试或经验调整。
在处理像微软的NTFS文件系统这样的封闭程序时,要知道程序运行速度是否比实际运行速度快就比较困难了。但也有一些提示:
1.如果你注意到文件操作比以前慢了,那么这就暗示着它们可能会再次变快;
2.如果你注意到文件操作有时快,有时慢,那么这就表明你可以让它们始终保持快速;
3.如果文件操作的分析显示了奇怪的缓解方法,那么这是一个提示,可能会引导你找到缓解的方法;
在我最近的经历中,所有这三个线索我都看到过,这让我明白了造成文件系统大范围放缓的原因是我使用的一个工具。然后,我与工具的作者一起进行了修复。
文件删除缓慢
当从源代码中重复构建Chromium时,我注意到在构建之间清理输出目录需要耗时几分钟,这占了构建时间的一大部分。我有理由相信这个过程会更快。我还注意到,如果Visual Studio不运行,这种减速就不会发生。
注意:这不是一个Visual Studio远程执行代码漏洞,Microsoft Visual Studio(简称VS)是美国微软公司的开发工具包系列产品,它包括了整个软件生命周期中所需要的大部分工具。当软件不检查未构建项目的文件的源标记时,Visual Studio软件中存在远程执行代码漏洞,即“Visual Studio远程执行代码漏洞”。这会影响Microsoft Visual Studio,Expression Blend 4。
通过ETW追踪我并没有发现明显的问题所在,但它确实给了我一些提示。我意识到我使用的新版本的VsChromium Visual Studio扩展可能就是原因所在。然后我与其开发者合作,创建了一个解决方案。
VsChromium是一个Visual Studio扩展,它将所有源代码保存在一个被监控的目录中,并加载到RAM中。当应用到Chromium源代码时,这只需要几GB RAM,但好处是你可以在几毫秒内搜索整个代码库。我用VsChromium在Chromium上工作时,文件操作是以正常速度的十分之一运行的。
重新模拟问题进行分析
在本文中,我用Python脚本模拟了这个问题,该脚本创建并删除了VsChromium正在监控的目录中的1000个文件,然后记录了该活动的ETW跟踪。WPA (Windows性能分析器)中的CPU使用率(精确)图显示了CPU使用率占系统8核的百分比,该表以毫秒为单位显示。CPU使用情况显示了我的测试脚本运行大约5秒。
运行我的脚本的python.exe占用了大部分CPU时间,这似乎是合理的,因为当我添加和删除文件时,系统进程将进行一些簿记。VsChromium以及Visual Studio,可能也会做一些工作,因为VsChromium会注意到需要添加和删除索引的新文件。最后,SearchIndexer.exe占用了一些CPU时间来索引新文件。所以,没有什么是明显错误的。但是。我知道这段代码运行得太慢了。
相比之下,当相同的脚本在VsChromium未监控到的目录中创建和删除文件时,CPU使用情况如下所示,耗费的时间(在图表下面以秒为单位显示)几乎缩短了10倍! 从VsChromium.Server.exe和devenv.exe中获取的CPU时间应该消失似乎是合理的,但这并没有改变。Python脚本本身使用的CPU时间要少得多(从4888毫秒减少到561毫秒),系统进程使用的CPU时间也减少了(从2604毫秒减少到42毫秒),到底发生了什么?
基于上下文切换数据的CPU使用(精确)图很好的告诉了你一个进程需要消耗了多少CPU时间(包括调查为什么它是空闲的)。但是,如果你想要查看进程花费的时间,那么你需要查看CPU使用率(抽样)图。这是基于CPU采样数据的,默认为1 kHz,具有完整的调用堆栈。
采样数据的堆栈视图并不能立即清楚的显示在哪里浪费了时间,所以我换了另一种视图,这种视图能消除分析噪音。
此视图(包含在UIforETW中,但由于某种原因仍然不是WPA默认视图集的一部分)按进程分组采样数据,依次是线程,然后是模块,最后是函数。在本文所列举的案例中,大家可以看到python进程中的4882个样本中绝大多数都在ntoskrnl.exe模块中,在python27.dll中运行Python代码所花费的时间非常小。
深入研究ntoskrnl.exe样本,我可以看到样本中函数的进程。
其中绝大多数是分配内存(MiAllocatePagedPoolPages)或清除内存(memset,MiCompletePrivateZeroFault和相关的页面错误)。
在文件系统测试中花费这么多时间处理内存似乎很奇怪,但事实上,还有比这更糟糕的。系统中第二繁忙的进程是内核进程,如果它将最近释放的内存(内存分配的另一个隐藏成本)的时间归零,那么它将花费绝大部分时间,这发生了什么?
回到我搜索memset的python进程的调用堆栈视图,发现它在调用堆栈中大约有70个级别,难怪我没有注意。右键单击它并选择View Callers-> By Function我可以看到调用memset的隐藏进程(包括它调用的“子函数”)占我的python进程CPU成本的一半以上,4904个样本中,占了2971个。
由于调用者几乎总是FsRtlNotifyFilterReportChangeLiteEx,我右键单击它并选择View Callees-> By Function。这表明该函数正在分配内存,在其上调用memset,并在python进程中消耗83%的CPU时间!
注:我认为memset触发的页面错误(在内核中处理)以memset的子函数的形式显示出来。ETW可以跟踪跨内核边界的用户。
有时也可能注意到有一些错误的线索,比如我注意到关键调用堆栈上的wcifs.sys!WcGenerateFileName,这让我怀疑8.3 filename生成是否会减慢速度,但禁用该特性没有帮助。最终,我放弃了仔细检查那些难以理解的调用堆栈,转而思考VsChromium是如何工作的。在启动时,它只需将所有源文件加载到RAM中。此后,它必须监控对源文件的更改。因此,我有理由假设它可能有一个文件系统监控器。另外,我碰巧知道作者最近把文件通知缓冲区的大小从16KB更改为2MiB,看起来Windows不喜欢这样。实际上,返回缓冲区大小增加之前的版本可以避免这个问题。
于是,我得出的结论是,文件通知缓冲区是在池内存(ExAllocatePoolWithTag)之外分配的,然后用可用的通知数据填充。为了避免将内核数据泄漏到监控进程,缓冲区的其余部分将归为零。如果缓冲区足够大,那么缓冲区的归零将占用大部分时间。我将ALL_FAULTS提供程序(我通过查看“xperf -providers k”找到)添加到我的ETW跟踪中,以查看触发了多少页面错误。结果令我有些震惊,总共有2544578个DemandZero页面错误,相当于9.7 GiB的数据,或大约4970个MiB缓冲区。这是我创建然后删除的数千个文件中每个文件的4.97通知缓冲区,对于每个创建和删除的文件,VsChromium应该含有五个通知事件,这意味着几乎所有通知缓冲区都只包含一个通知记录。以下是按进程和类型划分页面错误:
为什么缓冲区这么大?
FileSystemWatcher建议不要使用大缓冲区,但它没有详细说明这将导致什么样的问题,而且Chrome开发人员的设备有大量内存。因此,当极其复杂的文件操作发生时,FileSystemWatcher推荐的小缓冲区不能做到在不丢失事件的情况下处理通知速率。此时尝试更大的缓冲区大小是很有诱惑力的。而且更大的缓冲区可以避免缓冲区溢出,至少部分是通过减慢极其复杂的文件操作来实现的。
当VsChromium的开发者注意到这个问题时,他们决定缩小缓冲区,让VsChromium更轻松地处理丢失的事件,即当通知事件丢失时,临时暂停监控。
具有讽刺意味的是,这其中的大部分耗时进程:memset调用、页面错误和相关的页面归零,是因为内核的两个部分没有真正地进行交互。通知系统请求内存,它被赋予零内存,然后再将内存归零,这会触发页面错误,并使释放内存更耗时。如果通知系统知道传入的内存已经为零,那么它就不需要为它归零,也不会出现故障。
如何通过文件访问让正常的Windows使用变得非常缓慢?
其实不应该花费太多的C#代码来创建一个监控所有驱动器根目录的应用程序,使用从小开始逐渐变大的缓冲区大小。那你想知道缓冲区大小为10 MiB会产生什么影响?什么样的缓冲区大小会使这种用户模式技巧让正常的Windows使用速度变得非常缓慢?
ETW跟踪将很容易检测到通知缓冲区是问题所在,因为执行文件操作的每个进程都将显示在ntoskrnl.exe中花费的大量CPU时间!FsRtlNotifyFilterReportChangeLiteEx。这表明有人正在使用很大的通知缓冲区,但你如何找到正在执行此操作的进程?我的测试中的python.exe进程花费了大量时间来分配和清除内存,但是也有人需要释放内存。由于内存分配了ntoskrnl.exe!ExAllocatePoolWithTag,内存将被释放ntoskrnl.exe!ExFreePoolWithTag,所以你只需要在堆栈中搜索该函数,使用视图对通过它的所有堆栈进行分组,并找到在此函数中,1039个样本耗费时间的过程。因为我希望在进行原始调查时,顺便找到让正常的Windows使用变得非常缓慢的方法。
你可以点此链接找到c# FileSystemWatcher类的源代码,该类是使用ReadDirectoryChangesW实现的。这是一个使用起来很复杂的功能,点击这里看使用说明,你也可以点此查看使用示例代码。
VsChromium通知缓冲区的大小增加就发生在这个变更中,这个在0.9.26版中有相关说明。但在发布的0.9.27版中却有所变化,原因如下:
将文件系统监控缓冲区大小从2MB减少到16KB。这是因为当高速SSD上的许多文件发生变化时,2MB缓冲区会对操作系统产生明显的性能影响。因此降低缓冲区的大小,不但仍然支持快速构建场景,而且对性能的影响几乎为零。
在此,我建议使用最新版本的VsChromium(0.9.27版)。
你可以在这里,找到ETW跟踪的详细信息和Python脚本。脚本创建和删除文件的次数是两次,一次在被监控的目录中,一次在非监控的目录中,中间有半秒钟的停顿。如果试图重新创建此目录,则需要配置VsChromium以监控其中一个目录。
有两个ETW跟踪版本,一个为0.9.26版(比较慢),该版本记录了所有页面错误;另一个版本为0.9.30(修复版)。
接下来,我会向你展示如何让Windows上的流程创建速度变慢的方法。其中我会举一个实际发生的样本,该样本发生在2017年11月。
进程创建情况下的减速问题
以下是我在构建Chrome时,在Windows上遇到的五个主要问题:
1.导致全系统UI挂起的混乱序列化:24核CPU和我无法移动鼠标;
2.Microsoft的一个附加组件中的进程句柄泄漏:僵尸进程正在吞噬内存;
3.Windows文件缓存中长期存在的看似合理的漏洞:编译器漏洞?链接器漏洞?还是Windows内核漏洞;
4.如果误用了文件通知,则会出现性能故障:使Windows速度降低(在以上的文件访问部分已经讲到);
5.一个奇怪的设计决策,使得进程创建的速度随着时间的推移而变慢;
不错,本文讲的就是第5个问题。
跟踪罕见的崩溃
计算机的所有正常进程应该都是可靠和可预测的,如果不能,则代表出现了问题。如果我连续构建Chrome几百次,那么我希望每一次构建都能成功。所以,当我的分布式编译器进程(gomac .exe)偶尔崩溃时,我就想研究一下。我已经配置了崩溃转储的自动记录程序,以便能够看到当检测到堆损坏时所发生的崩溃。一个简单的研究方法就是打开pageheap,这样Windows堆就可以将每个分配放在一个单独的页面上。这意味着释放后使用和缓冲区溢出会立即造成崩溃,我曾经写过关于使用App Verifier(应用程序验证器)启用pageheap的文章。
应用验证程序使程序运行得更慢,这一方面是因为分配现在更昂贵,另一方面是因为页面对齐的分配意味着CPU的缓存大部分是中性的。所以,我期望我的构建运行得慢一些,但不会太多,而且构建看起来运行得很好。
但当我稍后检查时,构建似乎已经停止。在大约7000个建造步骤之后,没有明显的进展迹象。
O(n ^ 2)通常不好用
O(n^2)是比较基础的排序算法,效率较低,编码简答,易于实现,是一些简单情景的首选。
事实证明,应用程序验证器喜欢创建日志文件,它并不介意是否有人要查看这些日志文件,它只是为了以防万一而创建的。这些日志文件需要有唯一的名称。我给这些日志文件起的名称如下:gomacc.exe.0.dat,gomacc.exe.1.dat等,其中的数字是递增的。
要获得数字递增的那些名称,你需要找到接下来应该使用的数字,最简单的方法就是尝试可能的名称或数字,直到找到没有使用过的名称。也就是说,尝试创建一个名为gomacc.exe.0.dat的新文件时。如果已经有了,那就试试gomacc.exe.1.dat,依此类推,可能发生的最坏情况是什么?
进程创建缓慢
事实证明,如果你在创建进程时对未使用的文件名进行正常的排序搜索,则需要启动N个进程执行O(N ^ 2)操作。根据我的经验,O(N ^ 2)算法太慢,除非你能保证N总是保持很小。
至于速度有多慢,则具体取决于查看已经存在的文件名需要多长时间。通过我的测试,在这种情况下,Windows似乎需要大约80微秒(80μs或0.08 ms)来检查文件是否存在。通常情况下,启动第一个进程非常快,但是启动第1000个进程需要扫描已经创建的1000个日志文件,这需要80毫秒,而且越往后情况越糟。
典型的Chrome版本需要运行编译器3万次,每次启动编译器都需要扫描以前创建的N个日志文件,每次存在检查的时间为0.08 ms。线性搜索下一个可用的日志文件的名称,则意味着需要启动N个进行,在 (N ^ 2)/ 2文件里进行检查,所以30000 * 30000 / 2是 4.5亿。由于每个文件的存在检查需要0.08毫秒,即总共需要3600万毫秒,即36000秒。这意味着我的Chrome构建,通常需要5到10分钟,将需要额外多花费10个小时。
在写这篇文章时,我通过启动一个空的可执行文件大约7000次,重现了这个漏洞,并得到了一个完美的O(n ^ 2)曲线,如下图所示。
奇怪的是,在这次测试中,如果我获取ETW跟踪样本并只查看在这些许多不同文件名上调用CreateFile的平均时间,那么结果表明每个文件需要不到5微秒(平均为4.386微秒)。
看起来这只是揭示了ETW文件I/O跟踪的部分现象。文件I/O事件只跟踪文件系统的最低级别,Ntfs.sys上面还有许多层,包括FLTMGR.SYS和ntoskrnl.exe。然而,耗时过程并不能完全隐藏,CPU时间都显示在CPU使用(抽样)图中。下图显示了一个548毫秒的时间段,表示一个进程的创建过程,主要只是扫描了大约6850个可能的日志文件名。
那如果使用运行更快的磁盘,会解决这个问题吗?答案是否定的。
处理的数据量很小,写入磁盘的数据量甚至更小。我在本次测试时,也对此进行了测试,当时我的磁盘几乎完全空闲。这是一个CPU-bound(计算密集型)问题,因为所有相关的磁盘数据都被缓存了。而且,即使运行减少了一个数量级,它仍然会太慢。因此最佳的做法就是你不要使用O(N ^ 2)算法。
如何检测运行放慢的问题
你可以通过在%userprofile%\ appverifierlogs中查找.dat文件来检测此问题,你可以通过获取ETW跟踪,很容易地检测进程创建中的减速问题,现在你又知道了一件需要寻找的事情。
解决方案
最简单的解决方案是禁用日志文件的生成,这还可以防止磁盘被大量日志文件所填充。为此,你可以用这个命令: appverif.exe -logtofile disable。
由于日志文件创建被禁用,我发现我的跟踪的进程速度比测试开始时快了大约三倍,并且完全不会减速。这就允许在1.5分钟内生成7000个应用程序验证器监控进程,而不是40分钟。在我简单地测试批处理文件和流程后,我看到了进程创建效率有了以下的提高:
1.通常为每秒200个(每个进程耗时5毫秒);
2.每秒启用应用程序验证器75次,但会禁用日志记录(每个进程耗时13 ms);
3.在启用了应用程序验证器和启用了日志记录的情况下,最初的速度是每秒40毫秒(每个进程耗时25毫秒);
4.一旦成功建立一次Chrome,则每秒会发生0.4次;
微软还可以通过使用其他方法来解决这个问题,而不是一直想着如何在减少日志文件的数量上想办法。如果他们使用当前的日期和时间(毫秒或更高分辨率)作为文件名的一部分,那么他们将得到语义更有意义的日志文件名,并且可以非常快速地创建,几乎没有唯一文件搜索逻辑。
但是,此时不再需要应用程序验证器,而且日志文件也没有任何价值,所以请禁用它们。
https://randomascii.wordpress.com/2011/08/05/making-virtualalloc-arbitrarily-slower/
https://randomascii.wordpress.com/2018/04/17/making-windows-slower-part-1-file-access/
https://randomascii.wordpress.com/2018/10/15/making-windows-slower-part-2-process-creation/