Проблема с указанным кодом заключается в этой строчке:
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 в коде из задачки.
ОтветитьУдалить