7-zip — нет времени спешить
Преамбула
Данной заметки не должно было быть, но мы живем не в идеальном мире.
Есть много пользователей, что предпочли стандартному проводнику Windows альтернативу и наверное еще больше пользователей архиватора 7-zip. Имеет смысл поделиться с ними, подумал я и вот мы здесь.
Опустим лишние подробности, случилось так, что я заметил в используемом файловом менеджере значительную задержку появления окна контекстного меню при выборе большого количества файлов. Даже учитывая что его код меню написан без учета современных рекомендаций MS, лаг был подозрительно большим. И хоть никого не вдохновляет идея потратить личное время на неоплачиваемую работу с непредсказуемым результатом, я решил немного разобраться в причинах.
Что делает обычный эникейщик в подобной ситуации? Конечно же берет в руки первый подвернувшийся профайлер.
Результат
Итак, локальным виновником оказался метод IContextMenu::QueryContextMenu
, а по факту все время съедает… 7-zip.dll
Эта библиотека является расширением оболочки и отвечает за добавление команд архиватора 7-zip в контекстное меню. Не то что ожидаешь увидеть по итогу, но стоит проверить.
Отключаем показ меню архиватора в настройках программы.
И убеждаемся, что собака порылась именно тут.
Ну раз такое дело, почему бы не проверить WinRar? Ничего похожего не наблюдается, лаг отсутствует…
Присмотримся
Для более чистого эксперимента закинем тестовую директорию на RAMDISK, а файловый менеджер заменим на что-то более очевидное, например Total Commander.
Сразу стоит отметить, что все изложенное не затрагивает проводник Windows, там своя внутренняя API магия, которая не ограничивается двухступенчатой инициализацией (сначала для первых 16 объектов, чтобы быстро отобразить меню, а после выбора команды, Invoke уже для полного набора). Все остальные файловые менеджеры почти поголовно подвержены проблеме, как впрочем и практически любой другой софт.
Что ж, попробуем вызвать меню в TC для файлов в нашей папке. Для наглядности сделаем количество побольше. Тест на 20k фалах выявил замедление более чем в х20 раз, а именно 3 секунды без 7-zip
и более минуты! с ним.
Вообще, по логике вещей, расширения оболочки не должны вмешиваться в процесс создания меню настолько инвазивно.
Скучный спойлер. WinAPI и базовый алгоритм
Как обычно приложения создают контекстные меню.
Приведу упрощенное изложение стандартного алгоритма.
Небольшая отсылка к MSDN
Для большинства не секрет, что ОС для внутренних операций с объектами файловой системы использует не текстовые представления путей к которым мы привыкли, а некие альтернативные идентификаторы которые могут определять и виртуальные объекты.
Идентификатор элемента (функционально эквивалентен имени файла\папки) фактически является структурой SHITEMID
. Список таких идентификаторов определяется структурой ITEMIDLIST
.
Для API оболочки объекты пространства имен обычно идентифицируются указателем как раз на эту самую структуру — PIDL
(Pointer to an ITEMIDLIST).
В общем, изначально, тем или иным образом приложение (например ФМ) должно преобразовать пути в список идентификаторов. Независимо от API (ILCreateFromPath
, SHParseDisplayName
итп) в конечном счете будет вызван метод интерфейса IShellFolder рабочего стола Desktop.IShellFolder::ParseDisplayName
, внутри которого для стандартных путей идентификаторы будут собраны из WIN32_FIND_DATAW, тут (на мой взгляд) можно было бы немного оптимизировать, но разработчикам виднее.
На Win10+ этот метод вызывается из windows.storage.dll (CFSFolder::ParseDisplayName
), как и ряд методов ниже.
Затем обычно следует цепочка вызовов (упрощенный вариант):
Desktop.IShellFolder::BindToObject Получение интерфейса (IShellFolder) родительского объекта (другие подобные API все равно вызовут его)
Parent.IShellFolder::GetUIObjectOf Получение интерфейса (IContextMenu)
IContextMenu::QueryInterface Для получения указателя более высокого уровня (IContextMenu3,IContextMenu2)
CreatePopupMenu Создается само меню
IContextMenu#::QueryContextMenu Заполнение меню
TrackPopupMenuEx Показываем меню
В GetUIObjectOf
мы передаем массив указателей на наши относительные идентификаторы и получаем на выходе интерфейс с дефолтным меню.
При, казалось бы, таком простом действии как создание меню, в действительности «за кулисами» происходит просто огромный объем работы.
Описание всего этого «цирка с конями» потянет на цикл отдельных статей, поэтому сфокусируемся на области используемой 7-zip.
Мы уже выяснили, что инициатор проблемной цепочки это QueryContextMenu.
Внутри CDefFolderMenu::QueryContextMenu
происходит много разного
В зависимости от флагов поведение различается, однако базово, к пунктам дефолтного меню добавляются новые с объединением.
По типам объектов в списке сканируются соответствующие ветки реестра на наличие статических и динамических обработчиков.
Для динамики вызываются: CDefFolderMenu::_AddHandlersToDCA
HDXA_QueryContextMenu
Условный CDefFolderMenu:
*IContextMenu ; 0x00 (00) CDefFolderMenu::vftable{IContextMenu3}
*IServiceProvider ; 0x08 (08) CDefFolderMenu::vftable{IServiceProvider}
*IObjectProvider ; 0x10 (16) CDefFolderMenu::vftable{IObjectProvider}
*IShellExtInit ; 0x18 (24) CDefFolderMenu::vftable{for IShellExtInit}
*IObjectWithSelection ; 0x20 (32) CDefFolderMenu::vftable{IObjectWithSelection}
*IOleWindow ; 0x28 (40) CDefFolderMenu::vftable{IOleWindow}
*IDefaultFolderMenuInitialize ; 0x30 (48) CDefFolderMenu::vftable{IDefaultFolderMenuInitialize}
*IVerbStateTaskCallBack ; 0x38 (56) CDefFolderMenu::vftable{IVerbStateTaskCallBack}
*IContextMenuBaseInfo ; 0x40 (64) CDefFolderMenu::vftable{IContextMenuBaseInfo}
*IContextMenuForProgInvoke ; 0x48 (72) CDefFolderMenu::vftable{IContextMenuForProgInvoke}
*IDefaultFolderMenuGetStateFromWrapperInstances ; 0x50 (80) CDefFolderMenu::vftable{IDefaultFolderMenuGetStateFromWrapperInstances}
*IContextMenuCB ; 0x68 (104) CFSFolder::vftable{IContextMenuCB}
*IDataObject ; 0x70 (112) CFSIDLData::vftable{IDataObject}
*IShellFolder2 ; 0x88 (136) CFSFolder::vftable{IShellFolder2}
*hdsaStatics ; 0xB0 (176) HDSA - массив для статики DSA_Create(sizeof(STATICITEMINFO), 1)
*HDXA ; 0xB8 (184) HDXA - массив для динамики DSA_Create(sizeof(CONTEXTMENUINFO), 5)
*hdsaCustomInfo ; 0xC0 (192) HDSA - массив SEARCHINFO's DSA_Create(sizeof(SEARCHINFO), 1)
*pidlFolder.LPITEMIDLIST ; 0xC8 (200) PIDL родительской директории
*apidl.LPITEMIDLIST ; 0xD0 (208) Указатель на массив структур ITEMIDLIST
cidl ; 0xD8 (216) Размер массива apidl (количество структур)
*paa.IAssociationArray ; 0xE0 (224) CAssocArray::vftable{IQueryAssociations}
*psss.IServiceProvider ; 0xE8 (232) CSafeServiceSite::vftable{IServiceProvider}
nKeys ; 0xF0 (240) Количество хэндлов ключей реестра в массиве hkeyClsKeys
hkeyClsKeys[16] ; 0xF8 (248) HKEY - (#DEF_FOLDERMENU_MAXHKEYS = 16) - Массив открытых хэдлов реестра для класса
При сборе инфы реестра заполняются соответствующие пулы данных, в основном это:
Ассоциативный массив
IAssociationArray
(упорядоченный список путей реестра с информацией о типе, обработчиках, псевдонимах команд, иконках итп)Три динамических массива структур — для статических (hdsaStatics), динамических (HDXA) обработчиков и массив
SEARCHINFO
(hdsaCustomInfo).
Впоследствии при вызове метода CDefFolderMenu::InvokeCommand
, если команда (verb
) не каноническая (из списка дефолтного меню) и не статика, функция HDXA_LetHandlerProcessCommandEx
начнет перебор структур CONTEXTMENUINFO
из массива HDXA и будет пробовать вызвать метод IContextMenu::InvokeCommand
по указателю в начале структуры. И тут, либо какой-то метод успешно отработает, либо, если массив кончился, CDefFolderMenu::InvokeCommand
вернет E_INVALIDARG
На этом этапе нужно подгрузить в адресное пространство процесса библиотеки всех востребованных динамических расширений меню, а в случае 7-zip, это практически всегда. И тут мы подходим к инициализации нашего обработчика.
Стоит держать в уме, что такой обработчик меню для программиста это зона высокой ответственности, ведь данный код будет выполняться при каждом вызове контекстного меню для объектов ФС и писаться он должен с максимальной оптимизацией.
Нужно больше тестов
Проверим сначала на единственном файле.
Создаем пустую папку и в ней пустой текстовый файл, аттачимся к TC и ставим bp 7_zip!DllGetClassObject
Запускаем выполнение процесса и вызываем контекстное меню файла. После остановки делаем трассировку. На данный момент мы увидим лишь портянку со змейкой вызовов много раз повторяющегося кода 7_zip!DllUnregisterServer+offset
Суммарный результат (не полной) трассировки:
Function Name Invocations MinInst MaxInst AvgInst
7_zip 559 6 696 38
7_zip!DllGetClassObject 1 44 44 44
7_zip!DllUnregisterServer 1308 3 2741 23
У меня конечно есть нелюбовь к построению причинно-следственных связей, но даже мне кажется, что в текущем контексте, вызовов для модуля достаточно много. Конечно можно уже пробовать курить исходники и делать какие-то выводы, но мы пока не будем, хотя сырки все равно нужны. Чтобы было красиво и наглядно нам не хватает символов для 7-zip.dll.
Ну, это дело пяти минут.
Забираем с оффсайта архив, в CPP/Build.mak дописываем в CFLAGS
/Zi, в LFLAGS
/DEBUG и проставим -Od в CFLAGS_OX
Выполняем в командной строке студии (x64 Native)cd /d X:\7z2201-src\CPP\7zip
nmake /f makefile CPU=AMD64
(Если сильно спешите, скомпилируйте только dll)
Теперь у нас есть отладочные символы. Добавим путь к ним и сыркам для отладчика.
Также подменим библиотеку в папке архиватора. Повторим трассировку и посмотрим топ.
Результат
Function Name Invocations MinInst MaxInst AvgInst
7_zip!UString::Len 436 4 4 4
7_zip!operator new 380 10 10 10
7_zip!_malloc_base 380 19 19 19
7_zip!malloc 380 1 1 1
7_zip!operator new[] 361 1 1 1
7_zip!free 287 3 3 3
7_zip!_free_base 287 3 13 12
7_zip!operator delete 287 1 1 1
7_zip!operator delete[] 287 1 1 1
7_zip!memcpy 250 11 46 20
7_zip!wmemcpy 234 12 12 12
7_zip!UString::UString 243 20 128 22
7_zip!UString::SetStartLen 131 27 27 27
С символами все стало достаточно наглядно. Посмотрим стек на самой часто вызываемой функции.
стек
7_zip!UString::Len:
00007ffb`b3b71690 48894c2408 mov qword ptr [rsp+8],rcx ss:00000000`01de7cf0=0000000001de7d38
0:000> k
# Child-SP RetAddr Call Site
00 00000000`01de7ce8 00007ffb`b3b71c06 7_zip!UString::Len [X:\7z2201-src\CPP\Common\MyString.h @ 587]
01 00000000`01de7cf0 00007ffb`b3b71897 7_zip!CLang::OpenFromString+0x2d6 [X:\7z2201-src\CPP\Common\Lang.cpp @ 71]
02 00000000`01de7d90 00007ffb`b3b82224 7_zip!CLang::Open+0x1f7 [X:\7z2201-src\CPP\Common\Lang.cpp @ 146]
03 00000000`01de7e20 00007ffb`b3b82493 7_zip!LangOpen+0x24 [X:\7z2201-src\CPP\7zip\UI\FileManager\LangUtils.cpp @ 30]
04 00000000`01de7e50 00007ffb`b3b82534 7_zip!OpenDefaultLang+0x143 [X:\7z2201-src\CPP\7zip\UI\FileManager\LangUtils.cpp @ 257]
05 00000000`01de7ee0 00007ffb`b3b82339 7_zip!ReloadLang+0x34 [X:\7z2201-src\CPP\7zip\UI\FileManager\LangUtils.cpp @ 279]
06 00000000`01de7f40 00007ffb`b3b7fa30 7_zip!LoadLangOneTime+0x39 [X:\7z2201-src\CPP\7zip\UI\FileManager\LangUtils.cpp @ 43]
07 00000000`01de7f80 00007ffb`b80cfe1f 7_zip!CZipContextMenu::QueryContextMenu+0x20 [X:\7z2201-src\CPP\7zip\UI\Explorer\ContextMenu.cpp @ 535]
08 00000000`01de8ae0 00007ffb`b80a7b70 SHELL32!HDXA_QueryContextMenu+0x5e7
09 00000000`01de8d80 00000000`005ed2ce SHELL32!CDefFolderMenu::QueryContextMenu+0x5c0
0a 00000000`01de8ef0 00000000`00000001 TOTALCMD64+0x1ed2ce
Тк отладка у нас с исходным кодом, быстро пробежавшись по строчкам, понимаем что к чему. Все эти аллокации происходят при парсинге файла локализации (src\CPP\Common\Lang.cpp).
Конечно странное решение.
Почему бы не брать строки по указателям из закешированного шаблона?
Зачем каждый раз самоотверженно парсить один и тот же файл локализации (16Кб для ru) при каждом нажатии ПКМ? Медленные строковые операции стоит минимизировать прежде всего. Хотя что я докопался, это даже «не те дройды» которых мы ищем…
Если указать профайлеру путь к полученным символам, мы увидим больше подробностей и там, но ничего принципиально нового, можно сразу открывать отладчик.
Перейдем в TC в тестовую директорию, где файлов побольше.
После срабатывания bp 7_zip!DllGetClassObject
поставим точку остановки на SHELL32!DragQueryFileAorW
и глянем стек.
На третьей остановке мы уже попадаем в цикл функции и можем отследить всю проблемную цепочку.
Breakpoint 1 hit (вызов #3+) SHELL32!DragQueryFileAorW:
0:000> k
# Child-SP RetAddr Call Site
00 00000000`01de88d8 00007ffb`b8212f43 SHELL32!DragQueryFileAorW
01 00000000`01de88e0 00007ffb`b3b7a063 SHELL32!DragQueryFileW+0x13
02 00000000`01de8920 00007ffb`b3b7a0e9 7_zip!NWindows::NShell::CDrop::QueryFile+0x33 [X:\7z2201-src\CPP\Windows\Shell.h @ 68]
03 00000000`01de8950 00007ffb`b3b7a18c 7_zip!NWindows::NShell::CDrop::QueryFileName+0x79 [X:\7z2201-src\CPP\Windows\Shell.cpp @ 114]
04 00000000`01de89a0 00007ffb`b3b7e16d 7_zip!NWindows::NShell::CDrop::QueryFileNames+0x5c [X:\7z2201-src\CPP\Windows\Shell.cpp @ 130]
05 00000000`01de89f0 00007ffb`b3b7e7f1 7_zip!CZipContextMenu::GetFileNames+0x11d [X:\7z2201-src\CPP\7zip\UI\Explorer\ContextMenu.cpp @ 146]
06 00000000`01de8aa0 00007ffb`b80cfc44 7_zip!CZipContextMenu::Initialize+0xd1 [X:\7z2201-src\CPP\7zip\UI\Explorer\ContextMenu.cpp @ 177]
07 00000000`01de8ae0 00007ffb`b80a7b70 SHELL32!HDXA_QueryContextMenu+0x40c
08 00000000`01de8d80 00000000`005ed2ce SHELL32!CDefFolderMenu::QueryContextMenu+0x5c0
09 00000000`01de8ef0 00000000`00004e20 TOTALCMD64+0x1ed2ce
Вся цепочка вызовов с исходным кодом функций
STDMETHODIMP CZipContextMenu::Initialize(LPCITEMIDLIST pidlFolder, LPDATAOBJECT dataObject, HKEY /* hkeyProgID */)
{
_dropMode = false;
_dropPath.Empty();
if (pidlFolder != 0)
{
#ifndef UNDER_CE
if (NShell::GetPathFromIDList(pidlFolder, _dropPath))
{
NName::NormalizeDirPathPrefix(_dropPath);
_dropMode = !_dropPath.IsEmpty();
}
else
#endif
_dropPath.Empty();
}
return GetFileNames(dataObject, _fileNames);