4 апреля 2023 г.

Многие библиотеки/компоненты не тестируются на ошибки памяти

С нами связался клиент, который заявил, что EurekaLog вызывает исключение Access Violation в простом демонстрационном приложении.

В частности, приложение работает нормально при компиляции без EurekaLog и даёт ожидаемые результаты. Но приложение вылетает с ошибкой "Access violation at address 00410759 in module 'DemoApp.exe'. Read of address 83EC8B59" при компиляции с EurekaLog.

К сожалению, клиент не сообщил никаких дополнительных сведений, таких как: версия EurekaLog, версия IDE, версия ОС, файл отчета об ошибке, стек вызовов и т.д. Однако мы знали, что исключение Access Violation произошло внутри функции System._IntfClear.

Что ж, не нужно быть гением, чтобы понять, что в коде примера приложения, скорее всего, есть ошибка памяти. И, вероятно, она связана со смешиванием ручного и автоматического управления жизненным циклом объекта.

Мы установили демо-версию рассматриваемых компонентов и скомпилировали пример приложения с помощью EurekaLog. Запуск приложения и простой выход из него вызывали следующую ошибку:

"Application made attempt to call method of already deleted object: $0430B610 OBJECT [TContosoDoc] 340 bytes"

а стек вызовов из баг-репорта выглядел так:

System._IntfClear
Система._FinalizeRecord
System.TObject.CleanupInstance
System.TObject.FreeInstance
Система._ClassDestroy
Contoso.VCL.TContosoPropertiesForm.Destroy 2893[2]
System.TObject.Free
System.Classes.TComponent.DestroyComponents
Vcl.Forms.DoneApplication
System.SysUtils.DoExitProc
Система._Halt0

Ну, сообщение об ошибке другое, но место сбоя то же самое. Почему так?

Причин может быть несколько:
  1. Клиент не сообщил, когда и как возникла ошибка. Возможно, он видел другую ошибку в другом месте.
  2. Клиент мог (правильно) предположить, что где-то возникла ошибка памяти, поэтому он попытался "исправить" ошибку, отключив некоторые параметры проверки памяти EurekaLog. Поэтому конфигурация клиентского приложения может отличаться от настроек по умолчанию. А мы проверяли, используя настройки по умолчанию.
  3. Код, обнаруживающий/отображающий сообщение "вызов метода уже удаленного объекта" работает благодаря тому, что освобожденная память остается нетронутой. Однако, если какой-то код выделяет память поверх этой удаленной памяти, проверка уже не может работать, и вместо этого вы получаете обычное исключение Access Violation. Таким образом, в зависимости от того, как память выделяется/распоряжается, поведение может меняться.

Во всяком случае, EurekaLog смогла показать второй стек вызовов для того же объекта: в частности, второй стек вызовов показывает, где объект был изначально уничтожен:

ContosoDoc.TContosoCustomDoc.Destroy
System.Classes.TComponent.DestroyComponents
Vcl.Forms.DoneApplication
System.SysUtils.DoExitProc
Система._Halt0

Просто взглянув на эти два стека вызовов, вы уже можете увидеть проблему:
  1. Во втором стеке вызовов (фактическое удаление) упоминается, что рассматриваемый объект был удален "вручную" путём вызова его деструктора при очистке компонентов при завершении работы приложения.
  2. В первом стеке вызовов (доступ к уже удаленному объекту) упоминается, что этот же объект также пытались удалить автоматически с помощью ссылки на интерфейс.
К счастью, нам не нужен исходный код компонента/библиотеки (которого у нас нет, потому что мы используем демо/пробную версию), чтобы подтвердить это. Номер строки из первого стека вызовов приводит нас непосредственно к проблеме:
constructor TContosoPropertiesForm.Create(AOwner: TComponent);
begin
  inherited;
  FDoc := TContosoDoc.Create(Self);
end;

destructor TContosoPropertiesForm.Destroy;
begin
  inherited;
end;
где поле FDoc объявлено как:
private
  FDoc: IContosoDoc;
Вы видите проблему?

TContosoDoc будет удалён TContosoPropertiesForm, так как TContosoPropertiesForm (Self) была передана в качестве владельца (owner) в TContosoDoc. Таким образом, когда TContosoPropertiesForm удаляет себя, она также удаляет все принадлежащие ей подкомпоненты, включая TContosoDoc.

Но ссылка на TContosoDoc также была сохранена в поле FDoc. Это не должно быть проблемой, если бы поле имело тип TContosoDoc. Но оно имеет тип IContosoDoc. Другими словами, это интерфейс! Когда интерфейс выходит за пределы области видимости, он разыменовывается, и объект удаляется, когда счетчик ссылок достигает нуля.

Возможно, вы знаете, что компоненты (потомки от TComponent) переопределяют автоматическое управление интерфейсом, говоря "счётчика ссылок нет". Другими словами, увеличение и уменьшение счетчика ссылок интерфейса абсолютно ничего не делает.

Если это так - то почему тогда возникает ошибка? Причина в том, что даже простое поведение "нет счетчика ссылок" требует вызова виртуального метода! Действительно, вспомните, что IInterface/IUnknown объявляется как:
type
  IInterface = interface
    ['{00000000-0000-0000-C000-000000000046}']
    function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
    function _AddRef: Integer; stdcall;
    function _Release: Integer; stdcall;
  end;
  
  IUnknown = IInterface;
Другими словами, любой интерфейс в Delphi обязан реализовывать методы _AddRef и _Release, потому что все интерфейсы в Delphi происходят от IInterface.

Еще одна часть головоломки: поведение "нет счетчика ссылок" реализуется не как "не вызывать методы _AddRef/_Release", а как "методы _AddRef/_Release ничего не делают". Таким образом, код всё ещё будет вызвать методы _AddRef/_Release.

Если так - их надо реализовать (как пустые методы). И как вы реализуете методы интерфейса? Используя виртуальные методы:
type
  TInterfacedObject = class(TObject, IInterface)
  protected
    FRefCount: Integer;
    function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
    function _AddRef: Integer; stdcall;
    function _Release: Integer; stdcall;
  end;
Да, методы _AddRef/_Release не являются виртуальными методами для объекта (класса). Однако помните, что интерфейс по сути является абстрактным классом, что означает, что все его методы являются чисто виртуальными. Это означает, что упомянутые методы будут виртуальными методами для интерфейса после его реализации.

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

Итак, почему это не было проблемой в приложении без EurekaLog?

Всё просто: удаление объекта означает пометку его памяти как "пустой". Сама память никуда не делась. И её содержание остаётся прежним. Следовательно, любые дальнейшие вызовы методов _AddRef/_Release будут успешными, поскольку таблица виртуальных методов всё ещё может быть найдена.

Вывод: это ошибка в демонстрационном коде библиотеки/компонента, которую необходимо исправить. Самый простой способ — обходной путь: установите для поля FDoc значение nil в качестве первого действия в деструкторе TContosoPropertiesForm. Один правильный способ исправить ошибку - изменить тип поля на объект (класс), поэтому интерфейсы будут создаваться/удаляться только при использовании. Другой способ — удалить владельца и внедрить подсчет ссылок, чтобы время жизни объекта регулировалось только полем интерфейса.

Мораль истории: используйте либо интерфейсы, либо объекты, но не смешивайте их! Например, если вы используете интерфейсы - не храните ссылки на реализующие объекты. Если вы используете объекты - не храните ссылки на интерфейсы.

Как вы понимаете, многие библиотеки и компоненты содержат ошибки, связанные с памятью, потому что в Delphi нет встроенных инструментов для диагностики таких проблем. Вам нужен сторонний инструмент: отладочный менеджер памяти. Не каждый разработчик библиотек/компонентов будет делать всё возможное, чтобы использовать сторонний инструмент для тестирования своего кода. Это верно даже для самой Delphi, поскольку и VCL, и FMX имеют схожие ошибки памяти, которые обычно остаются скрытыми. Например: RSP-38694, RSP-30403, RSP-28294, RSP-10308, ...

Итак, что, если вы не можете исправить сторонний код? Вы можете скрыть ошибку, отключив проверки памяти в EurekaLog. Мы рекомендуем оставить опцию "Enable extended memory manager" включённой и отключить все остальные подпараметры. Не забудьте только установить опцию "When memory is released" в положение "Do nothing". Обратите внимание, что тем самым вы скрываете ошибку, а не исправляете её!

P.S. Для кого-то это может быть контринтуитивно, но если вы хотите исправить ошибку с памятью, вам нужно включить опцию "Catch memory leaks" (убедитесь, что опция "Active only when running under debugger" выключена, если вы запускаете программу вне отладчика). Дело в том, что включение проверок на утечки памяти позволяет EurekaLog выделять дополнительные блоки памяти с информацией о выделенной памяти. В этих дополнительных блоках памяти EurekaLog может сохранять в том числе дополнительные стеки вызова и информацию о типе данных блока памяти. Вся эта дополнительная информация может помочь EurekaLog показывать более точную диагностическую информацию, если проблема будет обнаружена.

Комментариев нет :

Отправить комментарий

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

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

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

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

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

Примечание. Отправлять комментарии могут только участники этого блога.