如何在可能的情况下释放线程堆栈占用的内存页






4.71/5 (24投票s)
2006 年 1 月 8 日
7分钟阅读

104232
给我一个线程,我将向你展示一个堆栈...
开始之前
本文假设读者具备一定的 Win32 API 技能。特别是,在阅读本文之前,应该清楚虚拟内存、线程、线程堆栈等术语。要学习这些主题,我个人建议阅读 J. Richter 的书。
引言
我们将在此讨论 Win32 线程堆栈分配机制。我相信这种机制缺乏一些非常基本的功能,这些功能有时可能很有用。在某些情况下,通过相对简单的方式可以节省大量系统资源。
线程堆栈
Win32 中的每个线程都有自己的堆栈。它用作自动(局部)变量、函数参数、函数返回地址以及其他一些内容的占位符。每当您调用一个函数时,会消耗更多的堆栈。并在函数退出后释放。
Win32 中的堆栈分配机制如下:每个线程都有其最大堆栈大小,该大小必须在创建时指定。对于由系统为应用程序创建的主线程,它由链接器写入 EXE 中;对于其他线程,堆栈大小在 `CreateThread` 函数中作为参数指定。如果您向 `CreateThread` 提供 0,则它将采用用于创建主应用程序线程的相同值。MS 在 EXE 中放入的默认值是 1Mb。可以通过以下方式覆盖
#pragma comment (linker, "/STACK:0x500000") // Default stack size is 5Mb.
那么,您可能会问为什么为堆栈保留了这么多内存?答案是内存是**保留**的,不一定是分配的。创建堆栈时,系统在进程的地址空间中保留指定大小的虚拟内存,只有少量页面被分配。当线程消耗所有已分配页面时,系统会动态分配新页面。直到整个保留范围都被分配。如果线程尝试消耗更多内存,则会引发 `EXCEPTION_STACK_OVERFLOW` 异常。
下一个可能出现的问题是为什么要限制堆栈?答案是线程的堆栈必须是连续的内存块(在虚拟地址空间中)。当您启动一个线程时,系统会在您的地址空间中找到一个足够大小的空闲连续内存范围。该范围被标记为已保留(以便其部分不会用于其他任何用途)。因此,如果您为每个线程保留太多空间,即使系统仍然有更多内存,您也可能会耗尽虚拟地址空间。对于 64 位应用程序,您可以自由地为每个线程提供 10Gigs,这应该没有问题(嗯,我没有检查过),但在 Win32 中,其 4Gb 地址空间,用户模式下可访问 2Gb,您应该小心线程堆栈允许增长多少。
我们说过系统会动态分配堆栈的内存页。这是如何实现的?当线程创建时,为其堆栈保留范围,并初步分配一些页面。最后分配的页面具有 `PAGE_GUARD` 属性。当最终访问它时,系统会引发 `STAUS_GUARD_PAGE` 异常,该异常由系统处理。如果堆栈没有增长空间,则会引发 `EXCEPTION_STACK_OVERFLOW`。否则,下一个连续页面将分配 `PAGE_GUARD` 属性。通过这种方式,线程的堆栈就分配好了。
注意:这意味着线程应该逐页访问其堆栈,没有任何越界行为。否则,可能会错过保护页并落入未分配的页面,这将导致 GPF(`EXCEPTION_ACCESS_VIOLATION` 异常)。例如,您可以在堆栈上声明一个大型数组,并从末尾访问它。为了避免这种情况,当您在堆栈上声明大型变量时,编译器会自动添加所谓的堆栈探测(即按所需顺序对堆栈进行显式访问)。
缺少什么
到目前为止,我们讨论了堆栈如何增长。但是如果我们想缩小堆栈怎么办?考虑一个线程调用一个非常消耗堆栈的函数(一些深层递归等)。在调用期间,会分配内存页(除非堆栈已经足够大)。然后,在函数退出后,堆栈仍然被分配,即使线程可能再也不需要这么多堆栈了。**没有机制可以自动释放不需要的堆栈页**。一旦页面被分配,就没有回头路了!在这种情况下可以做什么?
请看以下示例
void Consume900K() { volatile BYTE pSomeBuf[900*1024]; pSomeBuf[sizeof(pSomeBuf) - 1] = 30; // Otherwise the buffer // may be dismissed during optimization } int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { MessageBox(NULL, "Default stack", "", 0); Consume900K(); MessageBox(NULL, "900K consumed for the 1st time", "", 0); Consume900K(); MessageBox(NULL, "900K consumed for the 2nd time", "", 0); return 0; }
尝试运行此程序,并通过任务管理器检查此程序消耗了多少提交资源(物理内存或页面文件)。您会看到在第二个消息框中,此数量急剧增加,约 900K。然而,在第三个消息框中,它没有显著变化。
我记得 J. Richter 在他的一本书中写道,这种情况是不受欢迎的,应用程序应该创建另一个线程来执行消耗堆栈的操作。在该线程退出后,整个堆栈将由操作系统释放。嗯,不用说,这种方法并不理想。创建另一个线程除了明显的开销(上下文切换等)有时会造成太多的混乱,因为每个线程都有自己的 TLS、APC、消息子系统等。除此之外,有时您一开始并不知道某个特定操作可能会大量消耗堆栈。
显式堆栈释放
一旦我们知道用于分配堆栈的机制,我们就可以自己进行显式更改,不是吗?
让我们开始。x86 32 位处理器上的内存页大小为 4K,Alpha 处理器上为 8K。幸运的是,有一种编程方法可以确定页大小。让我们定义一个全局变量(尽管我讨厌使用全局变量)`g_dwProcessorPageSize`,并通过以下代码对其进行初始化
SYSTEM_INFO stSysInfo; GetSystemInfo(&stSysInfo); ASSERT(stSysInfo.dwPageSize); g_dwProcessorPageSize = stSysInfo.dwPageSize;
接下来,在我们对堆栈进行更改之前,让我们有一个诊断函数来查看堆栈
void DbgDumpStack() { #ifdef _DEBUG OutputDebugString(_T("### Stack Dump Start\n")); PBYTE pPtr; _asm mov pPtr, esp; // Get stack pointer. // Get the stack last page. MEMORY_BASIC_INFORMATION stMemBasicInfo; VERIFY(VirtualQuery(pPtr, &stMemBasicInfo, sizeof(stMemBasicInfo))); PBYTE pPos = (PBYTE) stMemBasicInfo.AllocationBase; do { VERIFY(VirtualQuery(pPos, &stMemBasicInfo, sizeof(stMemBasicInfo))); VERIFY(stMemBasicInfo.RegionSize); TCHAR szTxt[0x100]; wsprintf(szTxt, _T("### Range: 0x%08X - 0x%08X, Protect=%X, State=%X, Pages=%u\n"), pPos, pPos + stMemBasicInfo.RegionSize, stMemBasicInfo.Protect, stMemBasicInfo.State, stMemBasicInfo.RegionSize / g_dwProcessorPageSize); OutputDebugString(szTxt); pPos += stMemBasicInfo.RegionSize; } while (pPos < pPtr); OutputDebugString(_T("### Stack Dump Finish\n")); #endif // _DEBUG }
注意:堆栈是反向使用的。`push` 命令会减少堆栈指针,这就是处理器的工作方式。因此,堆栈的内存也是反向分配的。所以,这个函数会先显示空闲(未分配)的页面范围,然后会有一个保护页,然后你会看到已提交的页面。
现在,最后,让我们尝试对堆栈进行一些更改。创建一个 `StackShrink` 函数,该函数将尽可能多地取消提交堆栈。
void StackShrink() { PBYTE pPtr; _asm mov pPtr, esp; // Get stack pointer. // Round the stack pointer to the next page, // add another page extra since our function itself // may consume one more page, but not more than one, let's assume that. // This will be the last page we want to be allocated. // There will be one more page which will be the guard page. // All the following pages must be freed. PBYTE pAllocate = pPtr - (((DWORD) pPtr) % g_dwProcessorPageSize) - g_dwProcessorPageSize; PBYTE pGuard = pAllocate - g_dwProcessorPageSize; PBYTE pFree = pGuard - g_dwProcessorPageSize; // Get the stack last page. MEMORY_BASIC_INFORMATION stMemBasicInfo; VERIFY(VirtualQuery(pPtr, &stMemBasicInfo, sizeof(stMemBasicInfo))); // By now stMemBasicInfo.AllocationBase must // contain the last (in reverse order) page of the stack. // NOTE - this page acts as a security page, // and it is never allocated (committed). // Even if the stack consumes all // its thread, and guard attribute is removed // from the last accessible page // - this page still will be inaccessible. // Well, let's see how many pages // are left unallocated on the stack. VERIFY(VirtualQuery(stMemBasicInfo.AllocationBase, &stMemBasicInfo, sizeof(stMemBasicInfo))); ASSERT(stMemBasicInfo.State == MEM_RESERVE); PBYTE pFirstAllocated = ((PBYTE) stMemBasicInfo.BaseAddress) + stMemBasicInfo.RegionSize; if (pFirstAllocated <= pFree) { // Obviously the stack doesn't // look the way want. Let's fix it. // Before we make any modification to the stack // let's ensure that pAllocate // page is already allocated, so that // there'll be no chance there'll be // STATUS_GUARD_PAGE_VIOLATION // while we're fixing the stack and it is // inconsistent. volatile BYTE nVal = *pAllocate; // now it is 100% accessible. // Free all the pages up to pFree (including it too). VERIFY(VirtualFree(pFirstAllocated, pGuard - pFirstAllocated, MEM_DECOMMIT)); // Make the guard page. VERIFY(VirtualAlloc(pGuard, g_dwProcessorPageSize, MEM_COMMIT, PAGE_READWRITE | PAGE_GUARD)); // Now the stack looks like we want it to be. DbgDumpStack(); } }
看起来就这些了。
为了测试这项技术,我们还要创建一个探测堆栈以消耗指定堆栈大小的函数。
void StackConsume(DWORD dwSizeExtra) { PBYTE pPtr; _asm mov pPtr, esp; // Get stack pointer. for ( ; dwSizeExtra >= g_dwProcessorPageSize; dwSizeExtra -= g_dwProcessorPageSize) { // Move our pointer to the next page on the stack. pPtr -= g_dwProcessorPageSize; // read from this pointer. If the page // isn't allocated yet - it will be. volatile BYTE nVal = *pPtr; } DbgDumpStack(); }
最后,创建一个测试应用程序
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { SYSTEM_INFO stSysInfo; GetSystemInfo(&stSysInfo); ASSERT(stSysInfo.dwPageSize); g_dwProcessorPageSize = stSysInfo.dwPageSize; DbgDumpStack(); MessageBox(NULL, "Default stack", "", 0); StackConsume(100*1024); MessageBox(NULL, "100K consumed", "", 0); StackShrink(); MessageBox(NULL, "Stack compacted", "", 0); StackConsume(900*1024); MessageBox(NULL, "900K consumed", "", 0); StackShrink(); MessageBox(NULL, "Stack compacted", "", 0); return 0; }
您可以运行此应用程序并使用任务管理器对其进行监控。您可以看到它在使用后会释放堆栈。
使用场景
您可以在每次耗用大量(消耗堆栈)操作后显式调用 `StackShrink`。此外,在调用任何 Win32 *`wait`* 函数之前也应该调用它,因为当您的线程处于休眠状态时,没有必要消耗额外的内存页面。如果您正在实现一个带有消息循环的 GUI 应用程序,那么在消息队列为空时(在 `GetMessage` 之前)调用 `StackShrink` 是一个好主意。对于 MFC 应用程序,有一个 `OnIdle` 回调。如果您正在编写自己的消息循环,那么不要这样写
MSG msg; while (GetMessage(&msg, NULL, 0, 0) > 0) DispatchMessage(&msg);
重新设计成这样
while (true) { MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) DispatchMessage(&msg); StackShrink(); // just before we go asleep if (GetMessage(&msg, NULL, 0, 0) <= 0) break; DispatchMessage(&msg); }
注意事项
我已经在 Windows XP 下测试了这种方法,它似乎运行良好。我相信在任何基于 Win32 的操作系统中都不会有任何问题,因为它们的堆栈分配机制是相同的。我怀疑唯一可能的问题是操作系统理论上可能会在其他地方额外保存有关线程堆栈的信息,从而导致显式修改使堆栈不一致。例如,每个线程都有一个与之关联的 TIB(线程信息块),其中包含 `StackBase`、`StackLimit` 等成员。但到目前为止,我还没有看到这样的问题,所以看起来这不是一个这样的地方。
另一个注意事项:我见过一些代码片段,其中函数返回局部缓冲区的地址。例如
PBYTE StupidFunc() { // blablabla BYTE pVal[0x1000]; // blablabla return pVal; }
当然这样做是不道德的,它与程序员的道德相悖。但事实是,它确实有效!在调用这样的函数后,您可以访问它返回的缓冲区,直到您调用另一个覆盖该堆栈部分的函数。
现在,如果您在调用此类函数后放置 `StackShrink`,您不仅会损坏返回缓冲区中的前几个字节,更可能的是,您将使该缓冲区的一部分无法访问。因此,这种不干净的技术绝不能与显式堆栈收缩混用。