Проблема с указанным кодом заключается в этой строчке:
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
Вы можете просто вручную управлять временной переменной, сделав её явной, обычной переменной: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. Меня несколько удивил вот этот вариант ответа:
Предполагаю, что проблема может быть, если есть зависимости между плагинами. Тогда сначала нужно финализировать все плагины, а только потом в цикле освобождать указатели и выгружать пакеты.Гм, вообще-то код в задачке делает именно это: сначала отдельный цикл финализации, затем освобождает указатели и пакеты.
А вот такой вопрос. Если а качестве типа FItems используется не динамический массив, а TList. Как я понимаю в этом случае всегда будет access violation, т.к. получение элемента дженерика - это вызов геттера, в котором произойдет увеличение счетчика ссылок, я правильно понимаю?
ОтветитьУдалитьДумаю, что да. Ведь в этом случае FItems будет, по сути, эквивалентно Items в коде из задачки.
ОтветитьУдалить