Поиск потерянных блоков памяти с помощью ascLib
Несколько реже допускаются ошибки логического характера (их тяжелее найти простым просмотром кода). Примером такой ошибки может быть широко известное зацикливание ссылок в COM-объектах (circular references, иногда называемое deadlock), когда первый объект создает второй, который в результате каких-то промежуточных действий получает ссылку на первый. Поскольку обычно такие ссылки образуются в результате запроса одного из интерфейсов (QueryInterface), то счетчик ссылок первого объекта увеличивается. Первый объект больше не может быть удален своим создателем через вызов метода Release до тех пор, пока не будет удален второй объект. А второй объект может быть удален только при удалении первого (то есть никогда).
Отслеживать такие ошибки непросто: нет момента для установки точки прерывания (breakpoint) – управление не приходит на деструктор класса (если речь идет о создании объекта).
Самый простой способ определить, какой именно объект не был удален по завершению программы – это во время выделения памяти запомнить некоторую информацию, уникально идентифицирующую создаваемый объект. При удалении этого объекта информация о нем должна быть удалена из списка созданных объектов. В результате после завершения программы (или по выходу из контролируемого фрагмента кода) в списке созданных объектов останутся только те элементы, которые забыли удалить.
Примерно такой подход реализован в стандартной отладочной библиотеке времени выполнения DCRT (debug CRT). Все функции выделения памяти и операторы new переопределяются на работу со специально создаваемым для этого heap’ом. Каждому выделенному блоку памяти присваивается уникальный номер, на который впоследствии можно поставить точку прерывания (breakpoint).
Чтобы начать получать отчёты о потерях памяти, можно включить режим _CRTDBG_LEAK_CHECK_DF или вызвать функцию _CrtDumpMemoryLeaks перед завершением программы. В случае обнаружения потери памяти в окно отладчика будет выдан рапорт, начинающийся сообщением “Detected memory leaks!”, в котором будут указаны имена файлов исходного кода, номера строк в них, уникальные номера не освобожденных блоков памяти (в фигурных скобках) и некоторые другие параметры.
На распределение блока с серийным номером, выданным в списке утечек памяти, можно установить точку прерывания. Для установки точки прерывания можно использовать функцию _CrtSetBreakAlloc (из кода программы) или переменную _crtBreakAlloc (ее значение можно устанавливать прямо в окне watch). В качестве параметра упомянутой функции (или как значение переменной) выступает тот самый уникальный номер блока памяти.
У этого способа есть большое достоинство – такая проверка работает очень быстро. Кроме того, получается «живой» стек вызова функций (со значениями переменных и т.п.).
Но есть и недостатки:
- нельзя гарантировать, что уникальные номера блоков памяти совпадут с номерами, получившимися при следующем запуске программы
- библиотека DCRT используется только в отладочной версии, а что делать в release?
- если по какой-то причине программист использует свой heap, на него не распространяется стандартный контроль над работой с памятью
- все выделения памяти через COM (например, CoTascMemAlloc) не контролируются
- не проверяется соответствие способа освобождения памяти способу ее выделения. Например, если память занималась через new, то освобождать ее надо через delete, а не CoTascMemFree. Если память выделялась через new[] (с вызовом конструкторов у элементов массива), то и освобождать ее желательно через delete[] (с вызовом деструкторов у элементов массива)
- отлаживать программу все же не очень удобно. Надо запустить программу, завершить ее, запомнить уникальный номер выделения памяти, заново запустить программу, вовремя остановить ее выполнение, вставить в окно “watch” переменную _crtBreakAlloc и установить ее значение...
В дополнение к сказанному хотелось бы упомянуть MFC-функцию AfxDumpStack, которая позволяет получить стек вызовов и вывести его в окно отладчика или в буфер обмена в любой момент выполнения программы (эта функция реализована в “DumpStak.cpp”).
Несмотря на все недостатки, нет нужды усложнять себе жизнь в простых случаях. Если есть возможность использовать отладочные функции DCRT, надо это делать.
Контроль над освобождением памяти можно реализовать и самостоятельно (без использования DCRT). Для этого нужно реализовать список загружаемых объектов аналогично тому, как это сделано в DCRT. Чтобы не привязываться к порядку выделения памяти, можно использовать значения указателей на полученную память в качестве уникальных идентификаторов. Таким образом, после завершения работы программы непустой список будет содержать в себе указатели на не освобожденные фрагменты памяти.
Однако знания того, какой именно объект не был удален, еще не достаточно для полноценного поиска ошибки. Большинство объектов похожи друг на друга, и по их содержимому непросто разобраться, что это за объект. В некоторых приложениях, особенно написанных с использованием ATL, вся память выделяется в одном-двух местах. Крайне желательно знать, из какой именно функции (или метода класса) вызывался код, выполняющий заем памяти. Очевидно, что удобнее всего было бы иметь информацию о стеке вызовов (call stack): в момент выделения памяти запоминать состояние стека и указатель на полученную область памяти, а при освобождении памяти сравнивать указатели и удалять информацию из списка созданных объектов.
Последовательность действий, приведших к потере памяти, прояснится, если выводить стек вызовов в текстовом виде в окно отладчика (debug window). Удобнее всего было бы показывать имя исходного файла с кодом и номером строки в нем, как это делает MS VC++ при показе ошибки компиляции. Если данные в этом формате вывести в окно отладчика, то можно будет перейти на точку вызова функции в коде простым двойным щелчком мыши на строке в окне отладчика (даже после завершения отладки).
К сожалению, это возможно только при наличии отладочной информации (debug symbols). Иначе можно получить только название модуля с полным путем к нему и величину смещения точки вызова относительно начала ближайшей public-функции (но и это уже не так мало).
Понятно, что для контроля над моментами занятия и освобождения памяти необходимо получить доступ к коду всех функций и операторов работы с выделением памяти (new, delete, free, malloc, calloc, realloc, _expand). Сделать это проще всего переопределением их на свои функции работы с памятью (лучше всего также создать свой heap). Разумеется, не нужно переопределять функции, занимающие память в стеке (например, alloca).
В ascLib в результате такого переопределения вся память (кроме используемой для COM) выделяется через функцию ascMemAlloc, а освобождается через функцию ascMemFree.
Получение стека вызовов на каждое занятие памяти (с последующим освобождением) является операцией медленной и прожорливой в отношении оперативной памяти. Даже тысяча созданных объектов может заметно замедлить выполнение программы. Поэтому для контроля над освобождением памяти в ascLib используется счетчик ссылок g_iAscMemCount. В случае потери памяти, сообщение об этом выдается в окно отладчика (через макрос ATLTRACE).
В принципе, можно заменить ATLTRACE на ATLASSERT или на выдачу диалога с сообщением, но это вызовет очевидные проблемы при работе на серверной стороне в случае отладки клиентского приложения. Если процесс на сервере был запущен под “Interactive User”, появится модальный диалог, который остановит работу серверного приложения. Иначе (если не “Interactive User”) серверное приложение остановится молча (без выдачи диалога), и можно будет потратить немало времени, ломая голову над причиной его зависания.
Режим запоминания состояния стека вызовов при контроле над выделением памяти лучше всего включать с помощью специального объявления в настройке проекта (preprocessor definitions). В ascLib этот контроль включается с помощью объявления в настройке проекта одного из вариантов ASC_MEM_FREE_CHECK. Таких вариантов три:
ASC_MEM_FREE_CHECK – запоминается информация для всех точек вызова, имеющихся в стеке.
ASC_MEM_FREE_CHECK_ONLY_SOURCE – запоминается информация только для точек вызова, по которым удалось получить имя файла источника кода и номер строки в нем (для перехода в код из окна отладчика). Этот способ контроля является оптимальным для отладки.
ASC_MEM_FREE_CHECK_NO_CALLSTACK – только контроль за выделением и освобождением памяти (с учетом соответствия способа освобождения способу выделения). Информация для точек вызова при этом не запоминается вовсе, стек не разбирается. Это самый быстрый способ контроля.
До тех пор, пока счетчик g_iAscMemCount в момент выполнения _Module.Term() равен нулю (то есть потерь памяти не обнаруживается), нет необходимости объявлять какой-либо из вариантов ASC_MEM_FREE_CHECK, чтобы не замедлять работу приложения.
Как это реализовано в ascLib
Все, что необходимо для поддержки контроля над освобождением памяти с хранением стека вызовов, находится в файлах: “ascLibInit.h”, “ascLib.cpp”, “ascCheckMemFree.h”, “ascCheckMemFreePre.h”, “ascDump.h”, “ascMap.h”.
Перед инициализацией модуля (внутри метода _Module.Init()) вызывается функция ascMemCheckStart, которая инициализирует список создаваемых объектов. Этот список реализован на основе класса CascMap, что позволяет существенно ускорить поиск элемента (по сравнению с массивом). Класс CascMap (файл “ascMap.h”) является модификацией MFC-класса CMap (файл “afxtempl.h”), документация на который есть в MSDN.
Основной код этой функции реализован в ascMemCheckStartInternal и выглядит так:
inline HRESULT ascMemCheckStartInternal()
{
// Убедиться, что еще не существует heap и список для хранения контрольной информации
ATLASSERT(!g_heapCheckMem && !g_pMapCheckMem);
g_heapCheckMem = HeapCreate(0, g_cdwCheckMemHeapSize, 0);
ASC_RET_OUTOFMEMORY_IF_NULL(g_heapCheckMem);
// Создать через New, чтобы вызвался конструктор
g_pMapCheckMem = new CascCheckMemFreeMap(g_cdwCheckMemFreeMapSize);
ATLASSERT(g_pMapCheckMem);
}
Затем при каждом новом выделении памяти в список добавляется еще один элемент, причем в качестве ключа для поиска устанавливается значение указателя на занятую область памяти, а в качестве значения – строка со стеком вызовов. Для этого вызывается функция ascMemCheckAppend, основной код которой реализован в ascMemCheckAppendInternal и выглядит так:
inline HRESULT ascMemCheckAppendInternal(
VOID * pAddress, ascMemCheckAllocTypeEnum amcatType)
{
// Добавить ключ в Map
CascMemCheckElemInfo *pMemCheckElemInfo = (CascMemCheckElemInfo *)HeapAlloc(g_heapCheckMem, 0, sizeof(CascMemCheckElemInfo));
pMemCheckElemInfo->Init();
#ifndef ASC_MEM_FREE_CHECK_NO_CALLSTACK
// Создать и заполнить строку szCallStack
HRESULT hr = ascDumpStack2(&pMemCheckElemInfo->m_szStrCallStack);
ASC_RETURN_FAILED(hr);
pMemCheckElemInfo->m_iStrCallStackLen = ascStrLenA(pMemCheckElemInfo->m_szStrCallStack);
#endif //ASC_MEM_FREE_CHECK_NO_CALLSTACK
pMemCheckElemInfo->m_amcatType = amcatType;
pMemCheckElemInfo->m_pAddress = pAddress;
g_pMapCheckMem->SetAt((DWORD)pAddress, pMemCheckElemInfo);
return S_OK;
}
Для получения строки, содержащей стек вызовов, используются функции из библиотеки imagehlp.dll.
В MFC есть функция AfxDumpStack, которая была взята за основу функции ascDumpStack2. Принципы ее работы таковы: с помощью функции SymGetModuleInfo получается список адресов для каждого вызова в стеке.
Затем с помощью функции SymGetLineFromAddr делается попытка получить имя файла с исходным текстом и номер строки в нем для каждого адреса.
Если это не удается, и не объявлено ASC_MEM_FREE_CHECK_ONLY_SOURCE, делается попытка получить имя и полный путь к модулю, а также строку с отладочными символами с помощью функции SymGetSymFromAddr.
Полученные строки накапливаются в одной большой строке, которая потом передается в новый элемент map’a в качестве значения (rValue). Значение указателя на выделенную область памяти устанавливается в качестве ключа (rKey).
Основной код выглядит так (для краткости исключены некоторые непринципиальные детали, функции ResolveSourceLine и ResolveSymbol вызываются из ascDumpStack2):
inline BOOL ResolveSourceLine(
HANDLE hProcess, DWORD dwAddress, SYMBOL_INFO &siSymbol)
{
DWORD dwSymOptions = SymGetOptions();
if(!(dwSymOptions & SYMOPT_LOAD_LINES))
SymSetOptions(SYMOPT_LOAD_LINES);
IMAGEHLP_LINE ilLine;
ascMemFillZeroObj(ilLine);
ilLine.SizeOfStruct = sizeof(IMAGEHLP_SYMBOL);
ilLine.Address = dwAddress;
if(SymGetLineFromAddr(hProcess, dwAddress, &(siSymbol.dwOffset), &ilLine))
{ // Удалось получить номер строки и имя исходного файла
if(ilLine.FileName)
{
lstrcpynA(siSymbol.szModule, ilLine.FileName, ascStrLenA(ilLine.FileName) + 1);
wsprintfA(siSymbol.szSymbol, " (%d): ", ilLine.LineNumber);
}
return TRUE;
}
else
return FALSE;
}
inline void ResolveSymbol(
HANDLE hProcess, DWORD dwAddress, SYMBOL_INFO &siSymbol)
{
memset(&siSymbol, 0, sizeof(SYMBOL_INFO));
mi.SizeOfStruct = sizeof(IMAGEHLP_MODULE);
if (SymGetModuleInfo(hProcess, dwAddress, &mi))
{
LPSTR pszModule = strchr(mi.ImageName, '\\');
if (pszModule == NULL)
pszModule = mi.ImageName;
else
pszModule++;
lstrcpynA(siSymbol.szModule, pszModule, ascCountOf(siSymbol.szModule));
lstrcatA(siSymbol.szModule, "! ");
}
sym.SizeOfStruct = sizeof(IMAGEHLP_SYMBOL);
sym.Address = dwAddress;
sym.MaxNameLength = 255;
if (SymGetSymFromAddr(hProcess, dwAddress, &(siSymbol.dwOffset), &sym))
{
pszSymbol = sym.Name;
if (UnDecorateSymbolName(sym.Name, szUndec, _countof(szUndec),
UNDNAME_NO_MS_KEYWORDS | UNDNAME_NO_ACCESS_SPECIFIERS))
pszSymbol = szUndec;
else if (SymUnDName(&sym, szUndec, _countof(szUndec)))
pszSymbol = szUndec;
if (siSymbol.dwOffset != 0)
{
wsprintfA(szWithOffset, "%s + %d bytes", pszSymbol, siSymbol.dwOffset);
pszSymbol = szWithOffset;
}
}
else
pszSymbol = "<no symbol>";
lstrcpynA(siSymbol.szSymbol, pszSymbol, _countof(siSymbol.szSymbol));
}
inline HRESULT ascDumpStack2(
LPSTR *pszCallStack,
const int ciMaxCallCount = g_ciCheckMemFreeMaxCallCount,
const int ciMaxLenElem = g_ciCheckMemFreeMaxLenElem
)
{
//Занять память в стеке
int * arrAddress = (int *)alloca(sizeof(int) * ciMaxCallCount);
HANDLE hProcess = ::GetCurrentProcess();
int iAddresses = ascDumpStackFillAddressArray(hProcess, arrAddress, ciMaxCallCount);
// Возможно, что IMAGEHLP.DLL не найдена
ASC_RETURN_FAILED_ASSERT((HRESULT)iAddresses);
// Занять память под CallStack из расчета ciMaxLenElem байт на 1 строку (включая \r\n)
ATLASSERT(g_heapCheckMem);
int iBufLen = sizeof(CHAR) * ciMaxLenElem * iAddresses + 1; // + 1 для null
*pszCallStack = (LPSTR)HeapAlloc(g_heapCheckMem, 0, iBufLen);
for(int nAddress = 0; nAddress < iAddresses; nAddress++)
{
SYMBOL_INFO info;
BOOL bOk = ResolveSourceLine(hProcess, arrAddress[nAddress], info);
if(!bOk)
#ifdef ASC_MEM_FREE_CHECK_ONLY_SOURCE
continue;
#<kw>else</kw> //ASC_MEM_FREE_CHECK_ONLY_SOURCE
ResolveSymbol(hProcess, dwAddress, info);
#endif //ASC_MEM_FREE_CHECK_ONLY_SOURCE
}
(*pszCallStack)[iPos] = 0; // Закрыть строку
if(!ascStrLen(*pszCallStack))
{ // По каким-то причинам не удалось получить call stack
*pszCallStack = NULL;
return S_FALSE;
}
return S_OK;
}
Непосредственно перед освобождением памяти выполняется поиск элемента списка, ключевое значение которого совпадает со значением указателя на освобождаемую память. Если элемент найден, он удаляется из списка. Для этого вызывается функция ascMemCheckRemove, основной код которой реализован в ascMemCheckRemoveInternal и выглядит так:
inline HRESULT ascMemCheckRemoveInternal(VOID * pAddress, ascMemCheckAllocTypeEnum amcatType)
{
// Удалить ключ из Map'a
CascMemCheckElemInfo * pMemCheckElemInfo = NULL;
BOOL bOk = g_pMapCheckMem->Lookup((DWORD)pAddress, pMemCheckElemInfo);
if(bOk)
g_pMapCheckMem->RemoveKey((DWORD)pAddress);
if(!bOk)
return E_FAIL;
if(pMemCheckElemInfo)
{
// Способы выделения и освобождения памяти должны совпадать
ATLASSERT(amcatType == pMemCheckElemInfo->m_amcatType);
// Освободить память
pMemCheckElemInfo->Clear();
HeapFree(g_heapCheckMem, 0, (VOID *)pMemCheckElemInfo);
}
return S_OK;
}
Во время завершения работы модуля (_Module.Term()) вызывается функция ascMemCheckFinish, внутри которой выполняется проверка состояния списка. Пустой список означает, что вся выделенная для работы память была освобождена. Наличие хотя бы одного элемента в списке означает, что память для него не была освобождена, то есть программа малость протекает (memory leak detected).
Основной код функции ascMemCheckFinish реализован в ascMemCheckFinishInternal и выглядит так:
inline HRESULT ascMemCheckFinishInternal(LPTSTR szModuleName = NULL)
{
// Определить имя файла модуля и статистику занятий/освобождений памяти
...
// Проверить и очистить map
if(!g_pMapCheckMem->IsEmpty())
{ // Map не пуст, значит, какая-то память не была освобождена
ascMemCheckOnFinishNoEmpty();
// Очистить память, на которую ссылаются значения всех записей
POSITION pos = g_pMapCheckMem->GetStartPosition();
while (pos != NULL)
{
CascMemCheckElemInfo *pMemCheckElemInfo = NULL; DWORD dwAddress = 0;
g_pMapCheckMem->GetNextAssoc(pos, dwAddress, pMemCheckElemInfo);
if(pMemCheckElemInfo && pMemCheckElemInfo->m_szStrCallStack)
{ // Если есть значение, то освободить память
pMemCheckElemInfo->Clear();
HeapFree(g_heapCheckMem, 0, (VOID *)pMemCheckElemInfo);
}
}
}
// Уничтожить map и heap
free(g_pMapCheckMem);
HeapDestroy(g_heapCheckMem);
return S_OK;
}
В случае обнаружения утечки памяти вызывается inline-функция ascMemCheckOnFinishNoEmpty, которую можно переопределить на свою реализацию, если угодно. В реализации по умолчанию эта функция перебирает список и выводит все его содержимое в окно отладчика (debug window).
Чтобы не смешивать контролирующее и контролируемое, для хранения всей информации списка созданных объектов выделяется отдельный heap (глобальная переменная g_heapCheckMem).
Как заставить это работать?
Файл ascLibInit.h должен включаться в файл проекта stdafx.h сразу после включения “atlbase.h” (если используется WTL, то после AtlApp.h).
Файл ascLib.h должен включаться в файл проекта stdafx.h после объявления модуля (_Module) и atlcom.h.
Нужно исключить atlimpl.cpp из файла проекта stdafx.cpp, иначе при компиляции release-версии произойдет ошибка при попытке переопределить реализацию функций работы с памятью (тех, что переопределены макросами в файле ascLibInit.h). Измененный аналог файла atlimpl.cpp включен в ascLib.cpp.
Контроль потерь памяти без определения места практически не отнимает времени и выполняется всегда с помощью счетчика m_lMemRefCount в классе CascModule.
Если в окно отладчика поступило сообщение о потере памяти, нужно в настройках того проекта, от модуля которого получено сообщение, включить ASC_MEM_FREE_CHECK_ONLY_SOURCE или ASC_MEM_FREE_CHECK (лучше первый) и перекомпилировать отладочную версию (_DEBUG).
После повторного запуска и завершения приложения окно отладчика будет содержать список указателей, память по которым не была освобождена, и стеки вызовов для них.
Очевидно, что в release-версии объявление ASC_MEM_FREE_CHECK_ONLY_SOURCE может выдать пустую строку вместо строки с информацией о стеке вызовов.
Функция ascMemCheckOnFinishNoEmpty специально реализована отдельно для того, чтобы можно было легко изменить реакцию на обнаружение потери памяти (например, перенаправить вывод информации о стеке вызовов в файл или буфер обмена). Можно переопределить ее при отладке release-версии (для перенаправления вывода в файл), поскольку окно отладчика будет недоступно. Можно также воспользоваться перенаправлением вывода ATLTRACE (как это сделать, описано в уже упоминавшейся статье по отладке в Visual C++).
Основной код этой функции реализован в ascMemCheckMapToDebugWindow и выглядит так:
inline HRESULT ascMemCheckMapToDebugWindow()
{
ascOutputDebugStringA("\nINFO: начало списка…\n");
int iObjNum = 0; // Счетчик номеров объектов в Map'е
POSITION pos = g_pMapCheckMem->GetStartPosition();
while (pos != NULL)
{
++iObjNum; DWORD dwAddress = 0;
CascMemCheckElemInfo *pMemCheckElemInfo = NULL;
g_pMapCheckMem->GetNextAssoc(pos, dwAddress, pMemCheckElemInfo);
CHAR ch[256];
if(pMemCheckElemInfo && pMemCheckElemInfo->m_szStrCallStack)
{
wsprintfA(ch, "\nОбъект %d (0x%0X).”
“Способ занятия памяти: \"%s\". В момент создания имеет CallStack:\n",
iObjNum, dwAddress, amcatArrNameByIndex(pMemCheckElemInfo->m_amcatType));
ascOutputDebugStringA(ch);
// Вывести CallStack, если он есть
ascOutputDebugStringA(pMemCheckElemInfo->m_szStrCallStack);
}
}
ascOutputDebugStringA("INFO: конец списка …\n\n");
return S_OK;
}
С помощью функции ascDumpStack можно в любом месте программы по любому условию получить стек вызовов. В режиме отладки информация будет выведена в окно отладчика, а в release-версии – в буфер обмена.
Таким образом, использование новых возможностей библиотеки ascLib позволяет существенно упростить отладку кода (даже без подключения DCRT). Теперь можно получить список стеков вызова для каждой утечки памяти и просматривать код этих вызовов простым двойным щелчком мыши на строке в окне отладчика. Можно контролировать соответствие способа выделения памяти способу ее освобождения.