среда, 30 апреля 2014 г.

Интерфейсы и исключения в деструкторах

Часто можно встретить рекомендацию, что финализация должна быть всегда успешна.
Ну, в том смысле, что в процессе финализации не должно случаться исключений. А если встречаются, они там же (в процессе финализации) должны обрабатываться.
Пост о безуспешной попытке выйти за рамки ограничений...

Проблема

Всем известно, что стартованная транзакция должна быть так или иначе завершена: подтверждена (COMMIT), либо отменена (ROLLBACK). К сожалению, если алгоритм изменения данных достаточно сложен и разветвлён,а главное - поскольку все мы люди, гарантировать завершение транзакции нельзя.
Если транзакция не завершена, она может стать причиной разного рода проблем, среди которых можно назвать и потерю данных и deadlock.
Соответственно, возникает желание выявлять такие "провисшие" транзакции в приложении, протоколировать обнаруженную проблему и, разумеется информировать о ней пользователя.
В свете этого возникло желание воспользоваться свойством интерфейсов, состоящим в том, что соответствующие им объекты (например, потомки TInterfacedObject) автоматически освобождаются при выходе за рамки области их определения.
Если таким объектом сделать транзакцию, то в случае, если она стартована, но до её освобождения к ней не применён Commit или RollBack, можно возбудить исключение, что код, в котором она оказалась задействована, написан некорректно.
Пост не о транзакциях, а о том, что технику основанную на исключениях, вероятно, не следует использовать для подобных целей.

Код

Рассмотрим следующий, предельно упрощённый пример.
{$apptype console}
{$define TestTransactionLeak}
 
program Project1;
 
uses
  System.SysUtils;
 
type
  ETransaction_LeakDetected = class(Exception);
 
  IDBTransaction = interface
    ['{DBFA2E38-22FA-4DBD-A849-2DA71FEE522A}']
    procedure Commit;
    procedure Rollback;
  end;
 
  TDBTransaction = class(TInterfacedObject, IDBTransaction)
  private
    FName: String;
    FFinished: Boolean;
    procedure Log(const AMessage: String);
  public
    // <-- IDBTransaction
    procedure Commit;
    procedure Rollback;
    // IDBTransaction -->
    constructor Create(const AName: String);
    destructor Destroy; override;
  end;
 
constructor TDBTransaction.Create(const AName: String);
begin
  inherited Create;
  FName := AName;
  Log('created');
end;
 
resourcestring
  STransactionWithName = 'Transaction "%s": %s.';
 
destructor TDBTransaction.Destroy;
begin
  Log('destroy');
  inherited;
  if not FFinished then
    raise ETransaction_LeakDetected.CreateFmt(STransactionWithName, [FName, 'leak detected']);
end;
 
procedure TDBTransaction.Log(const AMessage: String);
begin
  WriteLn(Format(STransactionWithName, [FName, AMessage]));
end;
 
procedure TDBTransaction.Commit;
begin
  FFinished := True;
end;
 
procedure TDBTransaction.Rollback;
begin
  FFinished := True;
end;
 
procedure Execute;
var
  trs1, trs2, trs3: IDBTransaction;
begin
  trs1 := TDBTransaction.Create('1');
  trs2 := TDBTransaction.Create('2');
  trs3 := TDBTransaction.Create('3');
  trs1.Commit;
  {$ifndef TestTransactionLeak}
    trs2.Commit;
  {$endif}
  trs3.Rollback;
end;
 
begin
  try
    Execute;
    WriteLn('Done.');
  except
    on E: Exception do
      Writeln(E.ClassName, ': ', E.Message);
  end;
  ReadLn;
end.

Как видно, в процедуре Execute создаётся три связанных с интерфейсными переменными объектов, после чего для каждого из них вызывается Commit или Rollback - не существенно, поскольку реализация этих методов идентична - в True выставляется поле данных FFinished, на значение которого есть реакция в деструкторе: если FFinished = False - возбуждается исключение.
trs2.Commit обложено условной компиляцией, что позволит легко увидеть поведение как в штатном случае, когда Commit/Rollback вызваны для всех объявленных транзакций, так и в случае, когда это требование нарушено (trs2 в случае включённого определения TestTransactionLeak окажется "провисшей").
Теперь давайте рассмотрим, как программа сработает в обычной ситуации, когда определение TestTransactionLeak отсутствует. - Не произойдёт ничего интересного, вывод будет таким:
Transaction "1": created.
Transaction "2": created.
Transaction "3": created.
Transaction "3": destroy.
Transaction "2": destroy.
Transaction "1": destroy.
Done.

Всё ожидаемо, созданные потомки TInterfacedObject закрываются в порядке, обратном последовательности их определения в блоке локальных переменных.
Теперь посмотрим, как будет выглядеть вывод программы в случае, если определение TestTransactionLeak включено:
Transaction "1": created.
Transaction "2": created.
Transaction "3": created.
Transaction "3": destroy.
Transaction "2": destroy.
ETransaction_LeakDetected: Transaction "2": leak detected.

Здесь уже интереснее. При освобождении trs2 ожидаемо произошло исключение, но оно привело к тому, что экземпляр trs1 оказался не освобождён. Т.е. в данном случае мы имеем утечку памяти.

Предварительные выводы

Что мы имеем. В случае исключения в деструкторе объекта, освобождаемого интерфейсной ссылкой, в общем случае мы получим утечку памяти для других интерфейсных ссылок. Причём, не видится пути устранить утечку, кроме как писать "лесенку" try..finally и помещать в блок finally присваивание nil интерфейсным ссылкам.
Утечку памяти мы устраним, но это очевидно неудобно, да и логика работы с интерфейсными ссылками подразумевает управление ими на уровне компилятора.
Получается, компилятор подставляет код, обеспечивающий финализацию интерфейсных ссылок, но не гарантирует её, поскольку любое необработанное исключение приведёт прекращению процесса финализации.
Не знаю как Вам, но мне такое поведение не представляется правильным, поскольку компилятор выполняет только часть работы, и на то что он обеспечивает, нельзя полагаться.

В сущности, описанное поведение наводит на некоторые размышления.

1. Поскольку необработанные исключения в деструкторах столь разрушительны, что их нельзя допускать ни при каких обстоятельствах, это накладывает на разработчика обеспечение гарантий, что необработанные исключения в деструкторе не возникнут.
Это, в свою очередь, "легитимизирут" такую технику работы с ресурсами, в правильности которой я долго сомневался, хотя и использовал её очень часто. Обратите внимание на последовательные вызовы Free в блоке finally:
instance1 := nil;
instance2 := nil;
try
  instance1 := TSomeClass.Create;
  ... ... ...
  instance1 := TSomeClass.Create;
  ... ... ...
finally
  instance2.Free;
  instance1.Free;
end;
 
и даже более радикальный вариант:
var
  lvr: record
    instance1, instance2: TSomeClass;
    ... ... ...
  end;
begin
  FillChar(lvr, SizeOf(lvr), 0);
  with lvr do
    try
      instance1 := TSomeClass.Create;
      ... ... ...
      instance1 := TSomeClass.Create;
      ... ... ...
    finally
      instance2.Free;
      instance1.Free;
    end;
end;
Действительно, если исключений в деструкторах быть не должно ни при каких обстоятельствах, если даже механизмы уровня компилятора не избавляют от утечек ресурсов при необработанных исключениях в деструкторах, если разработчик должен гарантировать отсутствие таких необработанных исключений, значит - вполне можно "не заморачиваться" насчёт того, что два вызова Free идут последовательно в блоке finally.

2. Интерфейсные ссылки вполне подходят для того, чтобы контролировать инспекцию важных объектов, но надеяться на возможность генерации исключения о проблеме в самом, казалось бы, удобном для этого месте - не приходится.
Действительно, возвращаясь к примеру с транзакциями, ввиду невозможности безопасного (с точки зрения освобождения ресурсов) возбуждения исключения в деструкторе касса TDBTransaction, приходится заниматься разработкой инфраструктуры, которая должна будет обеспечить выдачу пользователю сообщения, а также генерации исключения при попытке продолжить изменение данных.
А это - не самая простая задача.

3. При написании деструкторов приходится постоянно помнить о том, что необработанные исключения в них неприемлемы. А забыть об этом очень легко.
Например, если бросить взгляд на TCustomConnection.Destroy, можно обнаржить там такой код:
destructor TCustomConnection.Destroy;
begin
  inherited Destroy;
  SetConnected(False);
  FreeAndNil(FConnectEvents);
  FreeAndNil(FClients);
  FreeAndNil(FDataSets);
end;
 
SetConnected - виртуальный метод, который может быть перекрыт в потомках, кроме того, этот метод может вызываться и вне контекста Destroy.
В сущности, упоминание его в деструкторе без соответствующего окружения try..except соответствует контракту, что этот метод не вызовет исключения, что очевидно, некорректное ограничение.
Системная проблема в том, что когда мы пишем деструктор, мы можем не знать, какое наше действие вызовет исключение. Подавлять все исключения без разбора - понятное дело - малоприемлемо.
Таким образом, недоработка в финализации объектов приводит к необходимости "городить огороды", чего по соображениям эргономики никто не станет, что снижает стабильность приложений, разработанных в Delphi.

Как должно было бы быть

Если говорить о финализации интерфейсных ссылок, оптимальным мне представлялся бы вариант, когда компилятором обеспечивалось гарантированное разрушение всех соответствующих объектов. Приблизительно так, как обрабатываются исключения в контсрукторах.
Конечно же, возникающие при финализации исключения ни в коем случае не должны "замалчиваться", просто их следует возбудить уже после того, как финализация завершена.

3 комментария :

  1. !!! "В сущности, упоминание его в деструкторе без соответствующего окружения try..except соответствует контракту, что этот метод не вызовет исключения, что очевидно, некорректное ограничение." :-)

    ОтветитьУдалить
    Ответы
    1. Да, Александр :-) Таково моё мнение.
      Спасибо, что обратили внимание на это сообщение.
      Возможно, Вас позабавит следующий фрагмент из модуля System, который работает в наших условиях уже лет 15:
      <code>
      threadvar THREAD_VAR_ObjectBeforeFree: tObjectFunc;

      function TObject.Free: Boolean;
      var
        CallDestroy: procedure(Self: Pointer; Options: Longint);
        E: Pointer;
      begin
        Result := False;
        E := nil;
        if Self = nil then
          Exit;
        if Assigned(THREAD_VAR_ObjectBeforeFree) then
          begin
            try
              Result := THREAD_VAR_ObjectBeforeFree(Self);
            except
              E := AcquireExceptionObject;
            end;
            SuspendException(E);
            if not Result then
              Exit;
          end
        else
          Result := True;
        asm
          PUSH ECX
          MOV ECX, Self
          MOV ECX, [ECX]
          MOV ECX, DWORD PTR [ECX] + VMTOFFSET TObject.Destroy
          MOV CallDestroy, ECX
          POP ECX
        end;
        try
          try
            try
              BeforeDestruction;
            finally
              __FinallyCall;
              CallDestroy(Self, $81);
            end;
          finally
            __FinallyCall;
            FreeInstance;
          end;
        except
          E := AcquireExceptionObject;
        end;
        SuspendException(E);
      end;
      <\code>
      Это не пример того, как следует поступать в данном случае - скорее, попытка защититься от реального положения дел. Тщетная к тому же...
      Код писал не я, хотя в конце 90-х я оказал влияние на его появление. Увы, от исключений в деструкторах он, возможно, до какой-то степени и защищает, но это - не системное решение проблемы. Системно же, может оказаться, что от такого решения больше вреда чем пользы...

      Удалить
    2. "Да, Александр :-) Таково моё мнение"

      Так ОЧЕНЬ правильное МНЕНИЕ :-) Его бы где-нибудь "в камне высечь". Например на уровне компилятора :-)

      Удалить