10 февраля 2010 г.

Ищем утечки памяти, redux

В этот пост я попытался собрать всё, что не было сказано в первой части.

Чтение отчётов о утечках памяти (или: как мне найти причину утечки памяти)

Ну, мы уже говорили про то, как надо читать отчёты. Хотя в общих словах я сказал там все слова, но думаю, что не лишним будет пересказать их в прицеле на отчёты об утечках памяти, поскольку, кажется, не все осознают сказанное.

Okей, первой ошибкой начинающих является бегство к месту, указанному в стеке вызовов отчёта, и долгое и задумчивое его созерцание. Дело в том, что отчёт об утечке памяти никогда не указывает на место проблемы.

Например, отчёт об утечке String в большинстве случаев будет указывать на такой тривиальный код:
S := IntToStr(I);
И что вы надеетесь тут увидеть? :)

И это не какое-то ограничение инструментов - просто эти инструменты не обладают телепатическими способностями и не могут (пока) читать ваши мысли. Давайте подумаем сами: что такое утечка? Это когда мы что-то выделили, но не освободили. Значит: в отчёте об ошибке у нас есть "что" - сам ресурс, и "выделили" - стек вызова на выделения ресурса. А проблема-то сидит в моменте "освобождения" ресурса. Но ведь инструмент не может знать: а где же вы (ваш код) собирались освободить выделенный ресурс? Поэтому в отчёте есть только информация по выделению ресурса. Информации о реальной проблеме там нет.

Что значит, что "проблема заключается в освобождении"? Ну, либо мы потеряли ссылку на объект, либо же ссылка у нас есть, но сама процедура очистки не была вызвана. Вот это и надо искать.

Что нужно сделать, когда вы получили отчёт об утечке памяти? Ну, вам нужно проследовать по стеку вызовов к месту выделения ресурсов. Но дальше (в отличие от отчёта об исключении) вам не нужно анализировать эту строчку. Вам нужно:
  1. Отметить, что за ресурс в ней создаётся.
  2. Найти точку, где этот ресурс должен удаляться "по плану".
  3. Найти причину, почему ресурс в этом месте не был удалён.
Как я уже сказал, причин для пункта три может быть всего две: либо мы посеяли ссылку на объект, либо ссылка есть, но сам вызов процедуры удаления был пропущен.

Ещё новички часто любят постить на форумы отчёты об утечках, говоря: "помогите! Не могу понять, в чём проблема!". Надеюсь, теперь вы понимаете, как глупо это выглядит: никто кроме автора кода не может это знать! Потому что только он может сказать, где и что он хотел сделать (читай: где он планировал освободить ресурс). Особенно феерично это смотрится на фоне полного отсутствия кода в вопросе, кроме кода выделения ресурса (который, как мы выяснили выше, не имеет к проблеме утечки никакого отношения) - видимо, подразумевается поголовное наличие телепатических способностей у отвечающих :)

Поэтому более корректно будет запостить вместе с логом и сам код, а ещё лучше - предварительно провести исследование самому (как это делать - я сейчас и описываю) и только потом, если не получилось найти проблему, задавать вопрос. Или немного иначе задать вопрос: например, попросить подсказать, какие вообще причины могли потенциально привести к утечке в этом месте? Вам назовут несколько вариантов, а вы сможете проверить их по своему коду.

И если с чистокровными указателями всё более-менее понятно: забыли вызов, присвоили указатель, не удалив старый ресурс и т.п., то проблемы с автофинализируемыми ресурсами (строки, интерфейсы и т.п.) обычно ставят начинающих в тупик: как это вообще возможно? Разве автоматическое управление временем жизни не было введено именно для этого, чтобы не было утечек?

Ну, вообще-то да - но это не означает, что вы не можете всё испортить ;)

К примеру, самое очевидное, что может произойти при утечке строки/интерфейса - не был удалён объект, полем которого является строка или интерфейс. Иными словами, это утечка дочерняя по отношению к другой. Понятно, что в этом случае в отчёте должно быть несколько утечек.

И парочка примеров на менее очевидные и более коварные ситуации (примечание: все примеры ниже являются специально сконструированными; тем не менее, они показывают некоторые ситуации, аналогичные которым реально встречаются на практике; из-за того, что генерируемый машинный код может отличаться в разных версиях Delphi, иногда такие примеры нужно чуть-чуть подкрутить, но факт в том, что это всегда можно сделать; примеры ниже делались на Delphi 14.0.3513.24210).

Пример 1: порча памяти (memory corruption).
В этой ситуации вы вообще не имеете проблем с кодом, который работает с ресурсом. Но вы имеете проблему где-то ещё. Вы перезаписываете память (скорее всего, используя низко-уровневые процедуры), затирая ссылку на ресурс, в результате чего объект оказывается забыт и становится утечкой:
{$O-} // отключаем оптимизацию, чтобы компилятор не ругался на бессмысленный код

procedure TForm1.FormCreate(Sender: TObject);

  procedure Test;
  const
    ArrLen = 5;
  var
    A: array[1..ArrLen - 1] of Integer; 
    S: String;
  begin
    // Тут какие-то действия
    S := IntToStr(5);              // <- утечка памяти в этой строке!
    // Тут какие-то действия
    FillChar(A, ArrLen * SizeOf(Integer), 0);
    // Тут какие-то действия
  end;

begin
  Test;
end;

initialization
  ReportMemoryLeaksOnShutdown := True;

end.
В данном примере будет отчёт об утечке памяти. Стек вызовов будет указывать на помеченную строку, которая не содержит ошибок. Более того, вся работа с S также не содержит ошибок вовсе. Реальная проблема лежит в работе с массивом A: FillChar затирает на один элемент больше, чем есть места в массиве (проблема, кстати, вполне реальная - она возникла из-за необычной размерности массива). Поскольку следом за массивом в памяти лежит S (которая является строкой - т.е. неявным указателем), то получается, что FillChar обнуляет и её тоже. Оопс! Мы только что потеряли ссылку на память строки.

Пример 2: порча стека.
Ну, в некотором роде это тоже порча памяти. Но если в первом примере это могла быть любая память (не обязательно стек), то здесь - пример, специфичный именно для стека:
{$O-} // отключаем оптимизацию, чтобы компилятор не ругался на бессмысленный код

procedure SomeProc(I1: Pointer; I2: Integer); stdcall;
begin
  // Тут какие-то действия
end;

procedure TForm1.FormCreate(Sender: TObject);

  procedure Test;
  var
    P: procedure(I: Pointer); stdcall;
    D: Pointer;
    S: Pointer;
  begin
    P := @SomeProc;
    D := nil;
    // Тут какие-то действия
    GetMem(S, 512);              // <- утечка памяти в этой строке!
    P(S);
    SomeProc(nil, 0);
    FreeMem(S);
    // Тут какие-то действия
  end;

begin
  Test;
end;

initialization
  ReportMemoryLeaksOnShutdown := True;

end.
В данном примере проблема в несоответствии прототипов P и SomeProc. Поэтому при вызове процедуры P (на самом деле - SomeProc) она забирает из стека (при очистке стека перед возвратом управления) на один элемент больше, и стек оказывается "смещённым" в сторону, поэтому дальнейшая работа со стеком (вызов SomeProc сразу после вызова P) обнуляет ссылку на данные указателя S. Как это вообще работает, спросите вы? Почему нет исключения? Ну, как-то работает, да :) В этом примере у нас тоже оказывается забыта ссылка. О том, как бороться с такими проблемами, - в следующий раз.

Утечки других видов ресурсов

Указанным способом (подменой менеджера памяти) вы можете найти утечки только памяти и только, выделенной через менеджер памяти Delphi. Любые другие виды ресурсов вы таким образом не поймаете. Обычно об этом забывают - и когда программа начинает хапать под себя кучу памяти, а "отловщик" утечек молчит как рыба - впадают в ступор.

Например, ресурсы GDI - они создаются и удаляются вызовами WinAPI функций, потому все запросы на них идут в обход менеджера памяти Delphi и, соответственно, никак не учитываются при поиске утечек. Впрочем, довольно часто обращение с ресурсами "заворачивается" в класс. Например, обычно в Delphi приложениях вся работа с HBITMAP обёрнута в класс TBitmap. В этом случае, поскольку устанавливается взаимно-однозначное соответствие между описателями HBITMAP и объектами TBitmap, то утечка одного ресурса автоматически означает утечку второго вида ресурса - и наоборот. В этом случае вы можете поймать утечку косвенно: не как её саму, а как утечку родственного ей ресурса. Впрочем, иногда в этом правиле есть исключения - например, с тем же TBitmap: метод ReleaseHandle позволяет "отпустить" описатель HBITMAP, отвязав его от объекта и, таким образом, нарушив взаимно-однозначное соответствие.

Второй пример - всё та же память, но выделенная любым другим способом, в обход стандартного менеджера памяти. В основном - это выделение памяти при использовании WinAPI функций. К примеру, если вы замените тип String в примере №1 выше на тип WideString - то утечка "исчезнет". Почему? Ну, на самом деле она не исчезнет, просто теперь у нас будет утечка другого ресурса. WideString является обёрткой над системным типом BSTR, у которого есть требование: все операции на выделение/освобождение памяти должны идти через системный менеджер памяти. Раз используется системный менеджер памяти - значит, наш учёт памяти и поиск утечек идут лесом.

Замечу, что вы можете вести учёт этих самых других ресурсов, но задача эта на порядок сложнее: т.к. нет единого центрального менеджера, а есть куча разрознённых функций (кроме того, нет официального способа "вклиниться" в функции или сделать подмену - только хакерскими хуками). В качестве примера программ, которые могут ловить такие утечки я уже упоминал утилиты MemProof и AQTime. Но обычно это применимо только на стадии тестирования/отладки на машине разработчика (как в FastMM), а не использовании в боевых условиях на машинах клиентов (как это имеет место для поиска утечек памяти в EurekaLog).

Но на самом деле, эти примеры показывают важность классовых оболочек. Не используйте низко-уровневые вещи напрямую - заключите работу с ними в класс. Вы можете оттестировать класс и безбоязненно использовать его. Собрав все действия в одно место (класс), вы упростите код и утечку ресурсов будет проще найти.

Как я могу найти утечку памяти до выхода из программы?

Отчёт по любой утечке ресурсов может быть создан только при выходе из программы - вы не можете создать его "по запросу". И это не ограничение какой-либо методики или инструмента, а просто здравый смысл.

Что такое утечка? Утечка - это когда вы выделили ресурс (память), но не освободили его. Как вы можете обнаружить утечку? Ну, утечка - это когда у вас остаются не удалённые ресурсы. А как вы можете узнать, что ресурс не был удалён? Все ресурсы, которые остались к моменту выхода программы, (определённо) являются не удалёнными. Если бы их кто-то удалил - они не висели бы тут до завершения работы.

Но как вы можете создать отчёт об утечках по запросу, в момент работы программы? Ну, для этого вам нужно знать, не утёк ли ресурс. Как вы можете это узнать? Никак. Вы не знаете, сколько ссылок есть на этот ресурс, и если они есть, то будет ли производится очистка/удаление ресурса хотя бы по одной ссылке.

Простой пример:
function GetWorkFilePath: String;
begin
  // много-много сложных действий
end;
Вы хотите знать, нет ли в алгоритме GetWorkFilePath утечки памяти. Поэтому вы пишете что-то вроде:
function GetWorkFilePath: String;
begin
  MemState := RememberAllAllocatedMemory;
  try
    // много-много сложных действий
  finally
    CreateMemLeakReport(MemState);
  end;
end;
Здесь: функция RememberAllAllocatedMemory запоминает всю выделенную память, а функция CreateMemLeakReport сравнивает текущую память с сохранённой ранее. Для каждого несоответствия функция CreateMemLeakReport создаёт отчёт об утечке памяти.

Вы думаете: ну, вся выделенная память должна же удаляться, верно? Поэтому я запомню, сколько я выделил памяти, потом выполню действия, которые я проверяю на утечку памяти, и посмотрю: не стало ли памяти больше после вызова. Каждый блок памяти, который не имеет соответствия в "снимке памяти" будет утечкой.

Но память не следует такому строгому шаблону! (выделил-использовал-удалил)

В примере выше ваша функция выделяет память под строку и возвращает её. С указанной выше логикой эта строка будет определена как утечка памяти, потому что она осталась "висеть" при выходе из функции. Но на самом деле произошло это не потому, что у нас есть утечка памяти, а потому, что функция передаёт право владения строкой вызывающей стороне, делая её ответственной за удаление ресурса.

Более того: строки (и не только строки) имеют счётчик ссылок. Что означает, что вы вообще не можете применять к ним какую-либо логику, основывающуюся на соответствии вызовов функций менеджера памяти - потому что в большинстве случаев по управлению памятью эти функции вообще не вызываются, поскольку вся работа заканчивается на увеличении/уменьшении счётчика ссылок! Код примера выше мог и не выделять память под строку, если он просто возвращает уже готовую строку (или даже константу), просто увеличивая её счётчик ссылок (для константы - не изменяя).

Поиск неявных утечек памяти

На самом деле, предыдущий вопрос задают не ради ответа на него самого (это ещё один пример X, Y и Z). С его помощью пытаются диагностировать т.н. неявные утечки памяти.

Что это такое? Ну, например: по ходу работы вы создаёте объекты и помещаете их в глобальный список (кэш) с передачей права владения. Но вы забываете их удалять. Т.е. с течением времени объектов в списке становится всё больше и больше - потребление памяти программой растёт, создавая все признаки утечки памяти. Однако это не является утечкой памяти в явном виде/строгом смысле, т.к. объекты будут в списке - они не утекли: на них есть ссылка (из кэша) и при закрытии программы список (кэш) освобождается вместе со всеми находящимися в нём объектами. Формально, утечки нет.

Вот для этого народ и пытается сделать отчёт об утечках "по запросу", до выхода из программы.

На самом деле, тут надо действовать по-другому. Для поиска таких неявных утечек есть один хитрый способ. Дайте поработать своей программе много часов. Пусть она сожрёт всю доступную ей память. Ткните теперь в любое место памяти в программе. Что вы увидите? Неявную утечку. Почему так происходит? Представим, что реальные данные программы занимают 50 Мб памяти. Пусть программа поработала и занимает 60 Мб. Из них 50 Мб — это наши данные и 10 Мб — объекты в нашем списке, которые мы забыли освободить. Пусть теперь программа занимает 2 Гб памяти. Среди них 50 Мб — это данные нашей программы. Всё остальное место занимают данные забытых объектов. Ткните в любой адрес (разумеется, тыкать нужно не в абсолютно произвольный адрес, а в случайный среди адресов, выделенных менеджером памяти). Есть вероятность в (2048 — 50) / 2048 = 98%, что вы попадёте в память для потерянного объекта. Стек вызовов каждого выделения хранится менеджером памяти (вы ведь запускали его в отладочном режиме, да?). Осталось только вывести его. Чем больше памяти займёт программа, тем больше в ней будет мусора, тем меньше полезных данных, и тем проще будет найти утечку. Даже 50% шанс (мусора ровно столько же, сколько и полезных данных) уже неплох — просто нужно будет проверить несколько значений.

Для выполнения этих задач можно воспользоваться FastMM и его функцией LogAllocatedBlocksToFile — функцией сохранения информации о выделенных блоках в файл. Возможно, вам пригодятся и другие отладочные функции FastMM (типа FastGetHeapStatus, GetMemoryManagerState, GetMemoryManagerUsageSummary или GetMemoryMap) — просто посмотрите секцию interface его главного модуля. Если под ваш сценарий не нашлось готового решения, всегда можно по-быстрому написать менеджер-заглушку для сбора нужной информации.

Чем плохи утечки и всегда ли надо освобождать память?

Вообще говоря, часто утечка памяти не приводит к какой-либо заметной проблеме для пользователя: программа как работала, так и работает. Утечка? Ну и что - задачи-то программа выполняет! Это особенно справедливо для клиентский программ: поскольку они работают ограниченное время, то утечка памяти не слишком страшна, поскольку при закрытии программы вся память освобождается (подробнее - к Рихтеру), и утечки будут удалены. Нет, я не имею ввиду, что с утечками памяти не нужно бороться: утечки памяти - это всегда плохо, но убить неделю на исправление утечки в 4 байта на клиентской программе - не самое разумное времяпровождение. Разумеется, это не применимо к серверным программам. Поскольку они работают постоянно, то периодическая утечка даже одного байта почти наверняка будет фатальной.

С этим вопросом близко связан и такой вопрос: раз вся память, занятая программой, автоматически освобождается при её выходе, то нельзя ли не удалять глобальные ресурсы, понадеявшись на автоматическую очистку? (Это называется "разовая утечка памяти" - в отличие от "периодической", которая накапливается)

Ну, формально ответ: можно. Это корректно, и вы действительно можете так делать. Однако, "можете" - не значит "должны". Понятно, что никаких чисто технических проблем это не создаст. Так почему это плохо?

Потому что при такой логике вы не сможете найти настоящие утечки ресурсов. Если вы не будете педантично удалять каждый ресурс до выхода из программы, понадеявшись на автоочистку, то у вас получится куча отчётов об утечках ресурсов. Ну, это, так сказать, утечка ресурса "by design": формально удовлетворяя признаку утечки (не освобождена при выходе), она не является логической утечкой. И проблема тут в том, что если таких отчётов станет на один больше или меньше (добавится настоящая утечка) - вы вполне можете этого не заметить. Это касается, как автоматического, так и ручного поиска.

Вот почему хорошим тоном является удаление ресурсов всегда, вне зависимости от того, будут ли они удалены системой. К сожалению, иногда бывают случаи, когда просто нельзя сделать по-другому - ну нет в программе места, где можно было бы сделать очистку. Что делать в таких ситуациях? Об этом чуть ниже. Но общее правило таково: всегда освобождайте ресурсы, если вы можете это сделать. Этим вы сильно упростите себе жизнь в будущем.

Глюки Delphi

Уж куда без них :)

Прежде, чем ломиться решать проблемы с утечкой памяти - убедитесь, что проблема есть вообще: запустите программу в свободном прогоне, без отладчика. Это призвано избавить вас от лже-утечек, типа этой.

Помимо багов IDE, бывают баги и в RTL/VCL Delphi: например. Обычно это либо баги (и тогда есть шанс на исправление в следующей версии Delphi - например), либо вещи, которые по каким-то причинам нельзя исправить. В любом случае они проявляют себя как утечки памяти, не зависящие от вашего кода.

Что вы тут можете сделать? Ну, вы должны воспользоваться возможностями своей утилиты по поиску утечек памяти. Некоторые утилиты предоставляют функции типа RegisterExpectedMemoryLeak - вы можете вызвать её при старте программы, передать в неё указатель - и тогда, при выходе из программы: если утечка памяти совпадёт с таким "зарегистрированным" указателем, то отчёт создан не будет. Да, такое вот обходное решение. Хотя проблему этим вы не убираете, но хотя бы скрываете её, что позволяет сосредоточиться на проблемах, исправление которых в ваших силах. Главная опасность тут - не переусердствовать: не нужно добавлять все подряд утечки в "зарегистрированные" - ведь этим вы не исправляете их!

С другой стороны, вы должны уже понимать, что плохи именно периодические утечки (сейчас я говорю уже про ваш код и вообще это относится скорее к предыдущему пункту "чем же плохи утечки и надо ли их освобождать"). Если утечка разовая - то ничего страшного в ней абсолютно нет (как в клиентских, так и в серверных приложениях). Да, утечку, определённо, надо исправить. Но разовую утечку можно "исправить" и добавлением в RegisterExpectedMemoryLeak - и это, в принципе, абсолютно нормально, если бы не одно но: а где гарантия, что она разовая? А вы точно уверены? А вдруг не разовая, и тем самым вы скроете реальную утечку? Поэтому лучше бы её исправить как полагается - освобождением, а не регистрации как ожидаемой (ну, если в ваш вариант RegisterExpectedMemoryLeak передаётся конкретный адрес - то, определённо, периодическую утечку вы так не скроете, так что можно смело использовать такой подход).

1 комментарий :

  1. Внимательно прочитал, очень интересно, спасибо. С некоторыми тезисами не очень согласен. Например имел слабо периодичный лик(1leak/10min) на 60-100М. Что создавало определенные проблемы. Считаю, что полемику убирать-не убирать лики лучше свести к жесткому руслу - взять за привычку УБИРАТЬ всегда.
    Вопрос можно? На XP моя app не имела ликов. После переноса 1:1 на win7 огреб не только массу memory leak (по логу и всякого рода менеджерам) но и GDI resource leak. Как с ними бороться, если явно стоит Getmem-Freemem без каких либо промежуточных операций или GetObject-DeleteObject? Такое же вижу на висте
    Спасибо

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.