65.9K
CodeProject 正在变化。 阅读更多。
Home

第四部分: Windows 调试技术 - 调试内存泄漏( Perfmon)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (22投票s)

2014 年 2 月 12 日

CPOL

7分钟阅读

viewsIcon

52728

这是 Windows 应用程序调试系列文章的第 4 部分,重点介绍内存泄漏。

引言

本文以简单易懂的方式解释了如何调试 Windows 应用程序中的内存泄漏。本文的范围仅限于用户模式调试,并介绍了使用 Perfmon 进行非常基础的调试。

注意:这是一个系列文章,分为 5 个部分。
第一部分:Windows 调试技术 - 应用程序崩溃调试 (Windbg)
第二部分:Windows 调试技术 - 应用程序崩溃调试 (DebugDiag, AppVerifier)
第三部分:Windows 调试技术——调试应用程序崩溃(自动生成转储)
第四部分: Windows 调试技术 - 调试内存泄漏( Perfmon)
第五部分: Windows 调试技术 - 调试内存泄漏( CRT API)

背景

原生代码最常见的问题之一是内存泄漏,主要原因是内存管理被交给了应用程序自身。分配和释放内存是应用程序的责任。当一个应用程序动态分配了内存,但在使用完毕后没有释放它,这个程序就存在内存泄漏。这部分内存虽然不再被应用程序使用,但也不能被系统或任何其他程序使用。

定义

首先,让我们试着理解内存的基础知识。我们从“虚拟地址空间”开始。一个进程的虚拟地址空间是它可以使用的虚拟内存地址的集合。每个进程的地址空间都是私有的,除非共享,否则其他进程无法访问。进程中进行的任何操作,如分配内存、创建静态对象等,都在其中进行。

所有东西都存在于虚拟地址空间(VAS)内。内存的四个段:代码段、栈段、数据段和堆都位于 VAS 内部。请看下图:

我们在这里看到,地址空间被分为用户模式和内核模式两部分。用户模式和内核模式各有 2GB 的虚拟地址空间。区别在于,2GB 的内核模式空间在系统范围内的所有运行进程之间共享。而用户模式空间是进程专用的,即所有的内存分配都在这个空间内进行。这是进程私有的。例如,使用“new”分配内存会增加用户模式空间。创建一个事件对象、互斥体对象等会增加内核模式空间,因为这些是内核模式对象。

现在让我们讨论如何判断某个特定的 API 会占用用户模式空间还是内核模式空间。如果一个 API 的参数中有 SECURITY_ATTRIBUTES,那么这意味着它将创建一个内核模式对象,而 SECURITY_ATTRIBUTES 将决定其对其他进程的可访问性。

这些 API 的一些例子是:

HANDLE WINAPI CreateEvent(
  _In_opt_  LPSECURITY_ATTRIBUTES lpEventAttributes,
  _In_      BOOL bManualReset,
  _In_      BOOL bInitialState,
  _In_opt_  LPCTSTR lpName
);
HANDLE WINAPI CreateMutex(
  _In_opt_  LPSECURITY_ATTRIBUTES lpMutexAttributes,
  _In_      BOOL bInitialOwner,
  _In_opt_  LPCTSTR lpName
);
在这种情况下,CreateThread 有点不同。
HANDLE WINAPI CreateThread(
  _In_opt_   LPSECURITY_ATTRIBUTES lpThreadAttributes,
  _In_       SIZE_T dwStackSize,
  _In_       LPTHREAD_START_ROUTINE lpStartAddress,
  _In_opt_   LPVOID lpParameter,
  _In_       DWORD dwCreationFlags,
  _Out_opt_  LPDWORD lpThreadId
);

尽管线程是一个内核模式对象,但当它被创建时,默认会在用户模式中占用 1MB 的栈空间。这意味着在 32 位操作系统上,我们最多可以拥有 2048 个线程,因为这将占用全部 2GB 的用户模式空间。默认的栈大小可以通过 Visual Studio 中的项目设置来修改,这样就可以创建更多的线程。这是 32 位操作系统上虚拟地址空间的一般分布情况。

此外,虚拟地址空间被划分为页,每页大小为 4KB。所以一个虚拟地址空间最多可以拥有的页数是 1024*1024=1048576。所以现在我们明白了,当我们超过这个限制时,我们的应用程序将停止工作,因为没有更多的内存可用。当我们接近这个限制时,系统会提示“虚拟内存不足”。

在 64 位系统上,用户模式空间是 6TB,内核模式空间是 2TB。所以进程有比 32 位大得多的增长空间。这只是内存的基础知识,要了解更多细节,请参考《Windows Internals》一书。

是什么导致了内存泄漏?

有三个非常基本的原因:

1. 堆增长:分配了内存但没有释放,即调用了 malloc 但没有调用 free / 调用了 new 但没有调用 delete。这间接意味着堆在增长但没有被释放,从而导致虚拟内存不断增加,如果超过了指定的限制,应用程序肯定会崩溃。

int main()
{
	for(int i = 0; i < 10; i++)
	{
		char* p = new char[100];
	}
	return 0;
}
2. 句柄泄漏:这发生在创建了各种句柄但没有释放的情况下,例如文件句柄、线程句柄、进程句柄等。
int main()
{
	HANDLE hEvent = NULL;
	hEvent = CreateEvent(NULL, TRUE, FALSE, TEXT("WriteEvent"));
	return 0;
}
3. 线程数量:这发生在线程被持续创建,但在完成任务后没有真正退出的情况下。如前所述,这默认会占用 1MB 的用户模式空间,所以如果线程不断增加而不被释放,最终会导致虚拟内存的增加。

调试技术

调试内存泄漏是最复杂的问题之一。有一些可用的工具,如 Rational Purifier、Insure++ 等。但要找到确切的问题,需要大量地审阅代码和对整个场景的理解。除此之外,大多数内存泄漏分析工具都是付费的,并非所有组织都真的提倡使用。因此,应该可以用 Windows 操作系统自带的免费工具来修复这类问题。

第一个也是最方便的工具,仅仅用来判断是否存在泄漏,就是“性能监视器”。性能监视器是 Windows 操作系统内置的工具,用于测量应用程序或系统级的各种参数,如:内存、CPU、磁盘等。

以下面的代码为例来说明内存泄漏:

#include <iostream>
#include <Windows.h>
#include <tchar.h>
#include <string>

using namespace std;

bool GetComputerName(wstring& wstrCompName);
bool fileread(const wstring& filepath);
bool GetUserName(wstring& wstrUserName);

int main()
{
	int nChoice = 0;
	wstring wstrCompName;
	wstring wstrUserName;
	char ccontinue = '\0'; 

	do
	{
		wcout<<L"Enter your choice"<<endl;
		wcout<<L"1.Read Computer name"<<endl;
		wcout<<L"2.Read User Name"<<endl;
		cin>>nChoice;

		switch(nChoice)
		{
		case 1:
			GetComputerName(wstrCompName);
			wcout<<L"Computer name read is"<<wstrCompName.c_str()<<endl;
			break;
		case 2:
			GetUserName(wstrUserName);
			wcout<<L"user name read is"<<wstrUserName.c_str()<<endl;
			break;
		default:
			wcout<<L"Invalid option";
		}
		wcout<<"Do you want to continue Y or N";
		cin>>ccontinue;
	}while(ccontinue == 'y' || ccontinue == 'Y');
	return 0;
}



bool GetComputerName(wstring& wstrCompName)
{
	wchar_t* pwszCompName = NULL;
	DWORD dwSize = 0;
	bool bRet = false;

	if (!GetComputerNameEx(ComputerNameDnsHostname, pwszCompName, &dwSize))
	{
		if(GetLastError() != ERROR_MORE_DATA)
		{
			wcout<<L"GetComputerNameEx Failed with Error Code"<<GetLastError();
			return false;
		}
		pwszCompName = new(std::nothrow) wchar_t[dwSize + 1];
		if(pwszCompName == NULL)
		{
			wcout<<L"unable to allocate memory"<<endl;
			return false;
		}
		memset(pwszCompName, L'\0', dwSize + 1);
		if (!GetComputerNameEx(ComputerNameDnsHostname, pwszCompName, &dwSize))
		{	
			wcou<<L"GetComputerNameEx Failed with Error Code"<<GetLastError();
			return false;
		}
	}
	char* test = new char[1024*1024];
	wstrCompName.assign(pwszCompName);
	return true;			
}


bool GetUserName(wstring& wstrUserName)
{
	wchar_t* pwszUserName = NULL;
	DWORD dwSize = 0;

	if(!GetUserName(pwszUserName, &dwSize))
	{
		if(GetLastError() != ERROR_INSUFFICIENT_BUFFER)		
		{
			wcout<<"GetUserName returned with Error"<<GetLastError();
			return false;
		}
		pwszUserName = new(std::nothrow)wchar_t[dwSize + 1]();
		if(pwszUserName == NULL)
		{
			return false;
		}
		if(!GetUserName(pwszUserName, &dwSize))
		{
			if(pwszUserName)
			{
				delete [] pwszUserName;
				pwszUserName = NULL;
			}
			wcout<<"GetUserName returned with Error"<<GetLastError();
			return false;
		}
	}
	wstrUserName.assign(pwszUserName);
	if(pwszUserName)
	{
		delete [] pwszUserName;
		pwszUserName = NULL;
	}
	return true;
}

看一下代码,我们可以发现函数 GetComputerName 中存在泄漏,应该被修复。这里有两个泄漏:一个是变量 pwszCompName 没有被释放,另一个是变量 test 被分配了但没有释放。但情况并非总是如此,我们将要处理的代码会复杂得多。

让我们按部就班地识别并解决问题。

步骤1:在性能监视器工具中添加性能计数器。

  1. 如下图所示启动性能监视器:

  2. 通过选择“进程”部分的参数,为应用程序添加性能计数器。

  3. 查看已添加参数的图表。

步骤2:运行用例并监控图表。

根据我们当前的实现,选择选项 1,然后观察“私有字节(private bytes)”的增长。选择 Y 继续,然后再次选择选项 1,观察“私有字节(private bytes)”的增长。

现在选择选项 2 并观察增长情况。私有字节仅在选择选项 1 时增长。私有字节的增长表明发生了动态分配但没有被释放。换句话说,执行了 new/malloc 但没有调用 free/delete。

下面显示的是当前用例的图表,私有字节是那条持续增长的红线。

步骤3:追踪代码流程并修复问题。

既然我们已经确定问题出在选择选项 1 时,这意味着我们应该开始朝这个方向追踪代码。让我们进入函数 GetComputerName。我们看到变量 pwszCompName 没有被释放,变量 test 也没有被释放。让我们将上面的代码修改为下面的样子,这最终会修复问题。

bool GetComputerName(wstring& wstrCompName)
{
	wchar_t* pwszCompName = NULL;
	DWORD dwSize = 0;
	bool bRet = false;

	if (!GetComputerNameEx(ComputerNameDnsHostname, pwszCompName, &dwSize))
	{
		if(GetLastError() != ERROR_MORE_DATA)
		{
			wcout<<L"GetComputerNameEx Failed with Error Code"<<GetLastError();
			return false;
		}
		pwszCompName = new(std::nothrow) wchar_t[dwSize + 1];
		if(pwszCompName == NULL)
		{
			wcout<<L"unable to allocate memory"<<endl;
			return false;
		}
		memset(pwszCompName, L'\0', dwSize + 1);
		if (!GetComputerNameEx(ComputerNameDnsHostname, pwszCompName, &dwSize))
		{  
			if(pwszCompName)
			{
				delete [] pwszCompName;
				pwszCompName = NULL;
			}				
			wcout<<L"GetComputerNameEx Failed with Error Code"<<GetLastError();
			return false;
		}
	}
	char* test = new char[1024*1024];	
	wstrCompName.assign(pwszCompName);
		
	if(test)
	{
		delete[] test;
		test = NULL;
	}
	if(pwszCompName)
	{
		delete [] pwszCompName;
		pwszCompName = NULL;
	}
	return true;			
}

摘要

在上面的例子中,我们观察到私有字节的增长。当它超过 4KB 的限制时(在 32 位操作系统上),这最终会增加虚拟字节。同样地,我们也可以监视线程数和句柄数,并以类似的方式修复它们。

这只是使用性能监视器找出问题的一种非常基本的方法,但它非常有效。我在我的职业生涯中多次使用过它,并成功地定位和解决了问题。其他一些可以使用的工具是 DebugDiag 和 UMDH——这些是微软的免费工具。使用 CRT API 是另一种技术,将在下一篇文章中讨论。

参考文献

历史

  • 2014-02-10: 文章上传
© . All rights reserved.