1 апреля 2012 г.

Ответ на задачку №13

Ответ на задачку №13.

Проблема с указанным кодом заключается в этой строчке:
for i := 0 to Count - 1 do
  Items[i].Done;
Задайте себе вопрос: как выполняется эта строчка?

Items - это индекс-свойство. Получение значения Items[i] - это вызов метода GetItem, который возвращает IPlugin.

Чтобы воспользоваться возвращённым значением, его нужно куда-то сохранить. Куда? Ну, если бы у нас было бы написано так:
for i := 0 to Count - 1 do
begin
  P := Items[i];
  P.Done;
end;
то ответ очевиден: в переменную P. Но поскольку никакой переменной у нас в коде нет, компилятор вынужден создать внутреннюю невидимую переменную. Подобные вещи он делает постоянно, незаметно для вас. Всякий раз, когда компилятору необходимо сохранить промежуточный результат вычислений или выполнения кода - он заводит временную переменную. Конечно, почти всегда для этой временной переменной не выделяется место на стеке, а она располагается прямо в регистре процессора. И исчезает (перезаписывается) во время выполнения следующих действий.

Ну, завёл компилятор переменную, казалось бы, и завёл. Но ведь у нас не простая переменная, а переменная автоуправляемого типа. Это значит, что её нужно удалять компилятору. В данном случае - компилятору нужно вызвать для неё метод _Release. Тогда возникает вопрос: а где он будет это делать?

Наивному читателю может показаться, что компилятору стоит сделать это в той же строке: выделил переменную, использовал её и тут же удалил.

Поначалу это кажется логичным, пока вы не посмотрите на такой код:
var
  SEI: TShellExecuteInfo;
begin
  FillChar(SEI, SizeOf(SEI), 0);
  SEI.cbSize := SizeOf(SEI);
  SEI.Wnd := Application.MainForm.Handle;
  {$IFDEF UNICODE}SEI.fMask := SEE_MASK_UNICODE;{$ENDIF}
  SEI.lpVerb := 'open';
  SEI.lpFile := PChar(ExtractFilePath(ParamStr(0)) + 'Help.chm');
  SEI.nShow := SW_SHOWMAXIMIZED;
  Win32Check(ShellExecuteEx(@SEI));
Обратите внимание, что в строке
SEI.lpFile := PChar(ExtractFilePath(ParamStr(0)) + 'Help.chm')
создаётся временная переменная, значение которой сохраняется в поле SEI.lpFile, а затем используется в вызове функции ShellExecuteEx в строке ниже:
Win32Check(ShellExecuteEx(@SEI))
Поэтому, если бы компилятор удалял бы временные переменные в той же строке - вы бы получили указатели, указывающие на мусор, и, как следствие, access violation при выполнении кода выше.

Поэтому, компилятор сохраняет все временные переменные до конца подпрограммы, где и удаляет их. Это можно увидеть в машинном коде для данной процедуры:
; Unit1.pas: Items[i].Done;
005117A0 8D4DF0           lea ecx,[ebp-$10]
005117A3 8B55F8           mov edx,[ebp-$08]
005117A6 8B45FC           mov eax,[ebp-$04]
005117A9 E896FFFFFF       call TPluginManager.GetItem
005117AE 8B45F0           mov eax,[ebp-$10]
005117B1 8B10             mov edx,[eax]
005117B3 FF520C           call dword ptr [edx+$0c]
005117B6 FF45F8           inc dword ptr [ebp-$08]

...

; Unit1.pas: end;
00511813 33C0             xor eax,eax
00511815 5A               pop edx
00511816 59               pop ecx
00511817 59               pop ecx
00511818 648910           mov fs:[eax],edx
0051181B 6830185100       push $00511830
00511820 8D45F0           lea eax,[ebp-$10]
00511823 E82C87EFFF       call @IntfClear
00511828 C3               ret 
00511829 E98E49EFFF       jmp @HandleFinally
0051182E EBF0             jmp $00511820
Как видите, временная переменная автоматического типа сохраняется в стеке (в нашем случае - в [ebp-$10]), а в конце подпрограммы (в end;) располагается (скрытый) finally-блок, который удаляет все временные переменные.

Иными словами, наш код:
procedure TPluginManager.UnloadAll;
var
  i: Integer;
begin
  // Вызываем Done, чтобы плагины корректно финализировались
  for i := 0 to Count - 1 do
    Items[i].Done;
 
  // Чистим записи
  for i := 0 to Count - 1 do
  begin
    FItems[i].Plugin := nil;
    FreeLibrary(FItems[i].Handle);
  end;
 
  Finalize(FItems);
end;
будучи скомпилированным в машинный код, станет выглядеть так:
procedure TPluginManager.UnloadAll;
var
  i: Integer;
  T: IPlugin;
begin
  Integer(T) := 0;
  try
    // Вызываем Done, чтобы плагины корректно финализировались
    for i := 0 to Count - 1 do
    begin
      T := Items[i];
      T.Done;
    end;
 
    // Чистим записи
    for i := 0 to Count - 1 do
    begin
      FItems[i].Plugin := nil;
      FreeLibrary(FItems[i].Handle);
    end;
 
    Finalize(FItems);
  finally
    T := nil; // Finalize(T)
  end;
end;
Тут уже несложно увидеть проблему: ссылка на интерфейс плагина (в скрытой временной переменной) живёт дольше, чем сама библиотека плагина. Мы выгружаем библиотеку плагина до того, как будут отпущены все ссылки на плагин. Поэтому, когда в finally-блоке компилятор делает финализацию временной переменной, он вызывает код уже выгруженной DLL - что, как несложно сообразить, и приводит к access violation.

Решение

Ну, есть три варианта решения таких проблем:
  1. Сделать временную переменную явной
  2. Не допускать появления временной переменной
  3. Не допускать влияния временных переменных на код
Лично я предпочитаю способ номер три, но давайте по порядку.

Способ №1

Вы можете просто вручную управлять временной переменной, сделав её явной, обычной переменной:
var
  P: IPlugin;
begin
  ...
    // Вызываем Done, чтобы плагины корректно финализировались
    for i := 0 to Count - 1 do
    begin
      P := Items[i];
      P.Done;
      P := nil;
    end;
  ...
end;
Тогда, хотя компилятор всё ещё будет создавать скрытый finally-блок в конце подпрограммы для авто-удаления переменной P, но, поскольку мы удаляем переменную явно, сами (P := nil), то код очистки компилятора просто ничего не будет делать. Это будет просто вроде как "подстелить соломку" на случай если очистка переменной не произойдёт (например - будет исключение при вызове метода Done).

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

Способ №2

Если вы посмотрите на реконструированный машинный код подпрограммы выше, то увидите, что временная переменная не используется во втором цикле. Хотя, казалось бы, ситуация там аналогичная. В чём разница?

Разница в том, что в первом цикле мы используем свойство Items, а во втором - поле FItems. Получение значение свойства - это вызов Get-акцессора. Получение значения поля - это ровно само поле и есть. Именно поэтому в первом случае нам нужна переменная для сохранения результата вызова метода-акцессора, а во втором случае переменная у нас уже есть - это само поле FItems.

Поэтому, вы можете исправить этот код так:
for i := 0 to Count - 1 do
  FItems[i].Plugin.Done;
Здесь у нас нет вызовов методов, тут везде используются переменные (поля), так что нет никакой нужды создавать временные переменные. И, действительно, компилятор в этом случае даже не будет создавать скрытый finally-блок в конце подпрограммы, потому что удалять уже нечего.

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

Способ №3

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

В нашем примере к первой группе относятся операции с интерфейсами, а ко второй - удаление DLL.

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

В нашем примере:
procedure TPluginManager.UnloadAll;

  procedure FinalizePlugins;
  var
    i: Integer;
  begin
    // Вызываем Done, чтобы плагины корректно финализировались
    for i := 0 to Count - 1 do
      Items[i].Done;
 
    // Чистим записи
    for i := 0 to Count - 1 do
      FItems[i].Plugin := nil;
  end;

var
  i: Integer;
begin
  FinalizePlugins; 
 
  for i := 0 to Count - 1 do
    FreeLibrary(FItems[i].Handle);
 
  Finalize(FItems);
end;
А в общем случае этот метод выглядит так:
procedure DoSomething;

  procedure DoMaliciousThings;
  begin
    // тут - все потенциально опасные операции
  end;

begin
  DoMaliciousThings;
 
  // тут - все операции, где вам важно отсутствие неявных ссылок
end;
Этот способ группировки по типам операций в отдельные подпрограммы крайне полезен и в других ситуациях. И лично мне он очень нравится как раз по той причине, что вам не нужно искать временные переменные в коде. Вы просто выделяете отдельно операцию удаление библиотеки - вот и всё. Вы делаете это, потому что вы знаете "ну, я же точно не хочу, чтобы в этот момент на неё были какие-то ссылки, которые я не заметил". Но при этом вам не нужно думать, действительно создаются ли компилятором такие скрытые ссылки или нет (и в каких именно строках кода). Вы просто отделяете весь прочий код так, чтобы он не приносил проблем - вне зависимости от того, что там происходит.

Диагностика

Как диагностировать такие проблемы:
  • На самом деле, проще всего их не допускать. Как бы банально это ни звучало. Как вы увидели выше, в разделе решений, вам достаточно просто следовать несложным правилам при разработке кода, работающего с интерфейсами в DLL (способ №3 выше) - и таких проблем у вас возникнуть не может в принципе.
  • Вы не можете диагностировать эту проблему в ядре, но вы можете добавить отладочную проверку в сами плагины. Вам достаточно убедиться, что в момент выгрузки библиотеки плагина его объект "плагин" был удалён. Заведите глобальную переменную-флажок. Установите её в True при создании объекта "плагин", сбросьте её в False при его удалении. А в finalization модуля убедитесь, что переменная = False. Если это не так - то вас выгружают в то время, пока на вас держится ссылка.

P.S. Меня несколько удивил вот этот вариант ответа:
Предполагаю, что проблема может быть, если есть зависимости между плагинами. Тогда сначала нужно финализировать все плагины, а только потом в цикле освобождать указатели и выгружать пакеты.
Гм, вообще-то код в задачке делает именно это: сначала отдельный цикл финализации, затем освобождает указатели и пакеты.

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

  1. А вот такой вопрос. Если а качестве типа FItems используется не динамический массив, а TList. Как я понимаю в этом случае всегда будет access violation, т.к. получение элемента дженерика - это вызов геттера, в котором произойдет увеличение счетчика ссылок, я правильно понимаю?

    ОтветитьУдалить
  2. Думаю, что да. Ведь в этом случае FItems будет, по сути, эквивалентно Items в коде из задачки.

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

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

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

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

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

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

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