Как я решил проблему утечки памяти в Delphi-приложении
Кросс-платформенный клиент CRM, написанный на Delphi с FireMonkey, шёл в релиз на Windows, macOS, iOS и Android из одной кодовой базы. Через полгода эксплуатации пользователи стали присылать отзывы: приложение со временем «тяжелеет», через пару часов работы вкладка занимает 70–100 мегабайт, а на старых Android-устройствах интерфейс ощутимо подтормаживает.
Сначала я списал это на кэш и особенности устройств. Цифры в диспетчере задач продолжали расти даже при пустом кэше, а после перезапуска процесса возвращались к исходному. Стало понятно, что речь идёт не о кэшировании, а о настоящей утечке памяти.
Расскажу, как методично искал источник проблемы, какие инструменты помогли быстрее всего и какие архитектурные решения пришлось пересмотреть. Разбор пригодится разработчикам на Delphi, Object Pascal и тем, кто ведёт смежные проекты на C++Builder.
Симптомы, с которых всё началось
Первый сигнал — медленный, но неуклонный рост потребления памяти между перезапусками окна. В диспетчере задач Windows график ползёт вверх каждую минуту, в Instruments на macOS объём private memory растёт ровной лестницей. На Android система начинает предупреждать о превышении лимита 256 МБ и принудительно завершает фоновые сервисы.
Второй симптом — деградация скорости списков и анимаций. Субъективный признак, но он хорошо коррелирует с фрагментацией кучи. Когда менеджер памяти не может выделить непрерывный блок, он тратит больше времени на поиск свободных участков, и любые операции с динамическими массивами становятся заметно медленнее.
Третий индикатор пришёл из отчётов о сбоях. Количество non-fatal termination выросло в четыре раза после одной из накопительных сборок. Я связал это с OOM-условиями: FireMonkey не успевает корректно освободить ресурсы при нехватке памяти и аварийно завершает процесс.
Инструменты диагностики утечек
Прежде чем чинить, нужно точно понять, кто виноват. В экосистеме Delphi нет единственного «серебряного» профайлера, поэтому я комбинировал несколько инструментов и сопоставлял их показания.
| Инструмент | Платформа | Что показывает | Когда полезен |
|---|---|---|---|
| FastMM4 в режиме отладки | Windows | Отчёт об утечках с именами классов | Отладка на dev-машине |
| ReportMemoryLeaksOnShutdown | Windows | Булев флаг и базовый лог | Быстрый sanity check |
| madExcept / EurekaLog | Windows | Стек вызовов на момент утечки | Когда нужна трассировка |
| Instruments (Allocations) | macOS, iOS | Объекты по классу, время жизни | Кросс-платформенный код |
| Android Studio Profiler | Android | Heap dump, удерживаемые ссылки | Мобильные сборки |
FastMM — стандартный менеджер памяти Delphi начиная с версии 2007. Включив в проекте FullDebugMode и LogMemoryUsage, я получал при завершении процесса подробный отчёт со списком неосвобождённых блоков. madExcept добавлял стек вызовов на момент выделения. Сочетание двух инструментов давало картину почти как в C++ с _CrtDumpMemoryLeaks, но с привязкой к конкретным строкам кода.
Когда нужны более глубокие проверки, имеет смысл обратиться в Lion Studio для независимого аудита. Сторонний ревью особенно ценен, когда собственная команда «замыливает» проблему и перестаёт замечать повторяющиеся паттерны.
Первая гипотеза: интерфейсы и подсчёт ссылок
Любой разбор утечек в Delphi стоит начинать с интерфейсов. Система подсчёта ссылок на IInterface работает прозрачно, и именно эта прозрачность часто становится ловушкой. Если объект реализует интерфейс, его время жизни контролируется ссылками, а не владельцем. Достаточно передать такой объект в обработчик события и забыть обнулить ссылку — она останется до конца жизни подписчика.
В моём случае проблему создавал TInterfacedPersistent в связке с анонимными методами. Лямбда захватывала контекстный объект, передавалась в шину событий и сохранялась там на весь сеанс. Поскольку форма подписывалась многократно при каждом пересоздании панели, в шине накапливались десятки идентичных делегатов, и каждый удерживал свой собственный экземпляр объекта в памяти.
Решение оказалось двойным. Я ввёл правило явной отписки в деструкторе формы с FreeAndNil для всех полей, реализующих IInterface. Параллельно переписал шину событий на TMultiCastEvent со слабыми ссылками, чтобы подписчики автоматически выпадали после разрушения. После этого график потребления памяти стал плоским даже в стресс-сценарии.
Слабые ссылки обработчиков событий
С интерфейсами тесно связаны обычные события и методы. В Delphi любой TNotifyEvent удерживает жёсткую ссылку на target-объект через поле Sender. Если вы подписались на OnChange объекта A из объекта B, а потом потеряли ссылку на B (но не на A), объект B продолжит жить в памяти до тех пор, пока жив A.
Такие утечки накапливаются медленно и трудно поддаются ручному поиску. Я добавил в код хелпер HookEventManager, который перехватывает вызовы AddHandler и RemoveHandler и ведёт журнал соответствий «target → список подписанных компонентов». Любое расхождение сигнализирует о потенциальной утечке. В production-сборке хелпер отключается условной директивой.
Отдельная история — TThread.CreateAnonymousThread. Анонимные потоки с self-references часто приводят к циклическим зависимостям. Переход на именованные классы с явным методом Execute и пул TThreadPool сделал потоки детерминированно завершаемыми. Вместо WaitFor теперь используется TEvent.WaitFor с тайм-аутом и принудительным Terminate в крайнем случае.
Контейнеры и владение объектами
После интерфейсов и потоков следующий крупный источник — контейнеры. Я по-прежнему люблю TObjectList<T>, но создаю его с параметром AOwnsObjects := True ровно один раз, в коде, который владеет этими объектами. Передавать такой список наружу нельзя: получатель не знает про владение, и при его уничтожении объекты уйдут вместе с ним. Если нужна «только коллекция», использую TList<T> либо сразу же отдаю объект наружу через интерфейс.
Второй частый контейнер — TStringList и его наследники. Он владеет строками, но не владеет объектами, привязанными через Objects. Если делать TStringList.AddObject('key', TObj.Create), список освободит строку, но не сам объект. Получается асимметрия, которая ломает ожидания. Лечится либо специальным классом-наследником, либо собственным TObjectMap на основе TDictionary<TKey,TPair<TObject,Boolean>>, где булево поле явно фиксирует факт владения.
Наконец, TDictionary<TKey,TValue> со ссылочными типами. Утечка приходит через ключи, если те — record-обёртки над интерфейсами. Изменение ключа помечает bucket как пустой, но не очищает запись и не освобождает интерфейс. Для больших кэшей словарей добавил периодический вызов Compact либо ротацию сегментов с явной очисткой.
Неуправляемые ресурсы
Даже без утечек на уровне RTL можно наделать бед с неуправляемыми ресурсами. Самые каверзные — растровые изображения и GDI-объекты на Windows. TBitmap под капотом скрывает и managed-память для пиксельного буфера, и GDI-handle для HBitmap. Free освобождает оба, но если вы делали Assign или копировали canvas в третий объект, ничто не мешает получить handle дважды и словить «утечку» в одном из мест.
Я добавил обёртку TScopedBitmap с интерфейсом IBitmapDisposable и RAII-конструкцией на record с операторами Initialize/Finalize. Это превратило все вызовы try ... finally TBitmap.Free в однострочные безопасные блоки. В Free Pascal тот же приём работает через try ... finally с Dispose, что удобно для переносимого кода под Linux-серверы.
Коварный ресурс — сетевые сокеты. TIdTCPClient от Indy при экстренном обрыве связи не закрывал underlying TIdIOHandler, и при каждой реконнекте накапливались неотмененные операции ввода-вывода. Переход на TNetHttpClient частично решил проблему — он использует managed-обёртки и connection pooling, — но в коде появился явный Client.Cancel, вызываемый в блоке finally. На Android при этом осталась утечка SSL-handle, которую пришлось лечить отдельным патчем через SSL_set_shutdown.
Системные меры и культура разработки
Технические правки сняли остроту, но без процессных изменений утечки возвращались бы в каждом релизе. Параллельно с рефакторингом кода я внедрил три практики, и со временем дефекты этого класса практически исчезли из bug tracker.
Первая — обязательный запуск FastMM с полным отчётом в CI на каждый pull request. Если коммит увеличивает потребление памяти эталонного сценария более чем на 5%, сборка падает. Это болезненно для разработчиков, зато быстро приучает писать парные Create/Free. Вторая — code review с обязательной проверкой TInterfaceList, TQueue и любых анонимных методов: ревьюер либо одобряет отписку, либо просит автора её добавить. Третья — ежемесячный «memory budget» отчёт, где график потребления ресурсов у реальных пользователей сравнивается с лабораторным эталоном.
Когда культура меняется, утечки становятся редким исключением, а не привычным шумом в release notes. Delphi даёт для этого все инструменты: встроенный FastMM с подробными отчётами, IDE-плагины CnPack и DLangExtensions для статического анализа, модули DUnit и DUnitX для интеграционных проверок. Достаточно один раз настроить конвейер и не отступать от него.