- PVSM.RU - https://www.pvsm.ru -

Данной заметки не должно было быть, но мы живем не в идеальном мире.
Есть много пользователей, что предпочли стандартному проводнику Windows альтернативу и наверное еще больше пользователей архиватора 7-zip. Имеет смысл поделиться с ними, подумал я и вот мы здесь.
Опустим лишние подробности, случилось так, что я заметил в используемом файловом менеджере значительную задержку появления окна контекстного меню при выборе большого количества файлов. Даже учитывая что его код меню написан без учета современных рекомендаций MS, лаг был подозрительно большим. И хоть никого не вдохновляет идея потратить личное время на неоплачиваемую работу с непредсказуемым результатом, я решил немного разобраться в причинах.
Что делает обычный эникейщик в подобной ситуации? Конечно же берет в руки первый подвернувшийся профайлер.

Итак, локальным виновником оказался метод IContextMenu::QueryContextMenu, а по факту все время съедает... 7-zip.dll
Эта библиотека является расширением оболочки и отвечает за добавление команд архиватора 7-zip в контекстное меню. Не то что ожидаешь увидеть по итогу, но стоит проверить.
Отключаем показ меню архиватора в настройках программы.

И убеждаемся, что собака порылась именно тут.
Ну раз такое дело, почему бы не проверить WinRar? Ничего похожего не наблюдается, лаг отсутствует...
Для более чистого эксперимента закинем тестовую директорию на RAMDISK [1], а файловый менеджер заменим на что-то более очевидное, например Total Commander [2].
Сразу стоит отметить, что все изложенное не затрагивает проводник Windows, там своя внутренняя API магия, которая не ограничивается двухступенчатой инициализацией (сначала для первых 16 объектов, чтобы быстро отобразить меню, а после выбора команды, Invoke уже для полного набора). Все остальные файловые менеджеры почти поголовно подвержены проблеме, как впрочем и практически любой другой софт.
Что ж, попробуем вызвать меню в TC для файлов в нашей папке. Для наглядности сделаем количество побольше. Тест на 20k файлах выявил замедление более чем в х20 раз, а именно 3 секунды без 7-zip и более минуты! с ним.
Вообще, по логике вещей, расширения оболочки не должны вмешиваться в процесс создания меню настолько инвазивно.
Как обычно приложения создают контекстные меню.
Приведу упрощенное изложение стандартного алгоритма.
Небольшая отсылка к MSDN
Для большинства не секрет, что ОС для внутренних операций с объектами файловой системы использует не текстовые представления путей к которым мы привыкли, а некие альтернативные идентификаторы которые могут определять и виртуальные объекты.
Идентификатор элемента (функционально эквивалентен имени файлапапки) фактически является структурой [3] SHITEMID. Список таких идентификаторов определяется структурой [4] ITEMIDLIST.
Для API оболочки объекты пространства имен обычно идентифицируются указателем как раз на эту самую структуру - PIDL (Pointer to an ITEMIDLIST [5]).
В общем, изначально, тем или иным образом приложение (например ФМ) должно преобразовать пути в список идентификаторов. Независимо от API (ILCreateFromPath, SHParseDisplayName итп) в конечном счете будет вызван метод интерфейса IShellFolder [6] рабочего стола Desktop.IShellFolder::ParseDisplayName, внутри которого для стандартных путей идентификаторы будут собраны из WIN32_FIND_DATAW [7], тут (на мой взгляд) можно было бы немного оптимизировать, но разработчикам виднее.
На 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-srcCPP7zip
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-srcCPPCommonMyString.h @ 587]
01 00000000`01de7cf0 00007ffb`b3b71897 7_zip!CLang::OpenFromString+0x2d6 [X:7z2201-srcCPPCommonLang.cpp @ 71]
02 00000000`01de7d90 00007ffb`b3b82224 7_zip!CLang::Open+0x1f7 [X:7z2201-srcCPPCommonLang.cpp @ 146]
03 00000000`01de7e20 00007ffb`b3b82493 7_zip!LangOpen+0x24 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 30]
04 00000000`01de7e50 00007ffb`b3b82534 7_zip!OpenDefaultLang+0x143 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 257]
05 00000000`01de7ee0 00007ffb`b3b82339 7_zip!ReloadLang+0x34 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 279]
06 00000000`01de7f40 00007ffb`b3b7fa30 7_zip!LoadLangOneTime+0x39 [X:7z2201-srcCPP7zipUIFileManagerLangUtils.cpp @ 43]
07 00000000`01de7f80 00007ffb`b80cfe1f 7_zip!CZipContextMenu::QueryContextMenu+0x20 [X:7z2201-srcCPP7zipUIExplorerContextMenu.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
Тк отладка у нас с исходным кодом, быстро пробежавшись по строчкам, понимаем что к чему. Все эти аллокации происходят при парсинге файла локализации (srcCPPCommonLang.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-srcCPPWindowsShell.h @ 68]
03 00000000`01de8950 00007ffb`b3b7a18c 7_zip!NWindows::NShell::CDrop::QueryFileName+0x79 [X:7z2201-srcCPPWindowsShell.cpp @ 114]
04 00000000`01de89a0 00007ffb`b3b7e16d 7_zip!NWindows::NShell::CDrop::QueryFileNames+0x5c [X:7z2201-srcCPPWindowsShell.cpp @ 130]
05 00000000`01de89f0 00007ffb`b3b7e7f1 7_zip!CZipContextMenu::GetFileNames+0x11d [X:7z2201-srcCPP7zipUIExplorerContextMenu.cpp @ 146]
06 00000000`01de8aa0 00007ffb`b80cfc44 7_zip!CZipContextMenu::Initialize+0xd1 [X:7z2201-srcCPP7zipUIExplorerContextMenu.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); 🡰
}
🡻
HRESULT CZipContextMenu::GetFileNames(LPDATAOBJECT dataObject, UStringVector &fileNames)
{
fileNames.Clear();
if (!dataObject)
return E_INVALIDARG;
#ifndef UNDER_CE
FORMATETC fmte = {CF_HDROP, NULL, DVASPECT_CONTENT, -1, TYMED_HGLOBAL};
NCOM::CStgMedium stgMedium;
HRESULT result = dataObject->GetData(&fmte, &stgMedium);
if (result != S_OK)
return result;
stgMedium._mustBeReleased = true;
NShell::CDrop drop(false);
NMemory::CGlobalLock globalLock(stgMedium->hGlobal);
drop.Attach((HDROP)globalLock.GetPointer());
drop.QueryFileNames(fileNames); 🡰
#endif
return S_OK;
}
🡻
void CDrop::QueryFileNames(UStringVector &fileNames)
{
UINT numFiles = QueryCountOfFiles();
fileNames.ClearAndReserve(numFiles);
for (UINT i = 0; i < numFiles; i++)
{
const UString s2 = QueryFileName(i); 🡰
if (!s2.IsEmpty())
fileNames.AddInReserved(s2);
}
}
🡻
UString CDrop::QueryFileName(UINT fileIndex)
{
UString fileName;
{
UINT bufferSize = QueryFile(fileIndex, (LPWSTR)NULL, 0); 🡰 вызов чтобы узнать размер буфера
const unsigned len = bufferSize + 2;
QueryFile(fileIndex, fileName.GetBuf(len), bufferSize + 1); 🡰 реальный вызов
fileName.ReleaseBuf_CalcLen(len);
}
return fileName;
}
🡻
UINT QueryFile(UINT fileIndex, LPWSTR fileName, UINT fileNameSize)
{ return ::DragQueryFileW(m_Object, fileIndex, fileName, fileNameSize); }
🡻
SHELL32!DragQueryFileW
🡻
┌─────────────────────────────────────────────────────┐
│ 4 0 [ 0] SHELL32!DragQueryFileW │
│ 17 0 [ 1] SHELL32!DragQueryFileAorW │
│ 15 0 [ 2] KERNEL32!GlobalLock │
│ 28 0 [ 3] KERNEL32!IsBadReadPtr │
│ 22 28 [ 2] KERNEL32!GlobalLock │
│ 40 50 [ 1] SHELL32!DragQueryFileAorW │
│ 1 0 [ 2] KERNEL32!lstrlenWStub │
│ 654 0 [ 2] KERNELBASE!lstrlenW │
│ 60 705 [ 1] SHELL32!DragQueryFileAorW │
│ 2181 0 [ 2] SHELL32!StringCchCopyW │
│ 68 2886 [ 1] SHELL32!DragQueryFileAorW │
│ 15 0 [ 2] KERNEL32!GlobalUnlock │
│ 73 2901 [ 1] SHELL32!DragQueryFileAorW │
│ 6 0 [ 2] SHELL32!_security_check_cookie │
│ 83 2907 [ 1] SHELL32!DragQueryFileAorW │
│ 6 2990 [ 0] SHELL32!DragQueryFileW │
└─────────────────────────────────────────────────────┘
Коротко по сути - мы кликаем ПКМ на файлах (например хотим посмотреть свойства, или удалить), TC инициализирует процесс создания меню и подгружает расширения, дело доходит до 7-zip...
И тут 7-zip такой:
- Минуточку! Есть мнение, что вы недостаточно страдаете от оверинжиниринга реализации контекстного меню MS и я буду в буфер получать строки файловых путей для каждого выбранного объекта. Мне ведь надо проверить, что вы там выбрали и соответствующие пункты меню показать, чтобы все "по красоте".
- Кто вам сделал удобное меню?
- Кто молодец? А?... вот то-то же!
Безусловно стояла задача сделать пользователю "удобно", но стоило бы потестить и конечную реализацию удобняшек. А по факту мы имеем много бесполезной работы съедающей время.
Также в CDrop::QueryFileName вместо того чтобы один раз выделить буфер достаточного размера, вся цепочка с DragQueryFileW для каждого объекта вызывается дважды, умножая и мою печаль.
В общем, стоит все таки иногда прислушиваться к рекомендациям MS, как это делает WinRar и не пытаться объять "необъятное".
ObjCount = DragQueryFileW(hDrop, 0xFFFFFFFF, 0i64, 0);
if ( !ObjCount )
return E_FAIL;
sub_39D20(a1 + 24);
Count = 16; ✓
if ( ObjCount < 16 ) ✓
Count = ObjCount; ✓
for ( i = 0; i < Count; ++i ) ✓
{
szFile[0] = 0;
DragQueryFileW(hDrop, i, szFile, 0x800u);
sub_39DC0(a1 + 24, szFile);
}
Причины найдены. Делаем выводы.
Ситуация неоднозначная, поскольку с одной стороны это проблема подавляющего большинства софта, мало кто следует гайдлайнам и мы имеем медленные меню. Но с другой, 7-zip забивший на рекомендации, вносит дополнительные задержки.
Как и где поправить код для 7zip (тем кто захочет это сделать), надеюсь понятно.
Сам я еще тот гуманитарий и поэтому оставлю поиск и разбор прочих недоработок в сырках архиватора тем кто разбирается в вопросе и кому это интересно.
Тестирование производилось на
последнем ванильном билде 7-zip v22.01 от 2022-07-15
Windows 10 Pro 21H2 19044.1320 x64, CPU 3800МГц
При желании быстро воспроизвести похожий результат (задумайтесь о жизни, если оно возникло), можете воспользоваться командным файлом для создания тестовой директории.
@echo off
cd /d "%~dp0"
set TestDir=%~dp0TEST
md "%TestDir%"
echo "Working..."
for /L %%N in (1,1,20000) do REM. >"%TestDir%%%N.txt"
При запуске создаст рядом с собой папку TEST с 20k текстовыми файлами нулевого размера.
Затем открываете любой ФМ кроме штатного проводника, Ctrl+A и ПКМ.
Многие пользователи TC скорее всего не используют архиватор "классическим" образом, но это совершенно не важно в рамках рассматриваемой проблемы.
Можно было бы обойтись без отладки и сразу смотреть исходники, но часто ситуация бывает не столь однозначна, как выглядит на первый взгляд и в попытке сэкономить десяток минут, можно потратить больше, или что-то упустить.
Кто-то может сказать, что все это ерунда и можно в общем-то игнорировать при современных мощностях железа, а с действительно большими количествами файлов мало кто работает. Это ваш выбор, пусть этой зимой вам будет чуть теплее, чем остальным, просто чаще вызывайте меню 😜
Мораль проста - не все динамические расширения одинаково полезны, а если вы их пишите, надеюсь, умеете "в оптимизацию".
Ну и убирайте всё неиспользуемое из меню (ShellExView [8] в помощь).
Автор: Влад Волков
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/windows/381482
Ссылки в тексте:
[1] RAMDISK: https://ru.wikipedia.org/wiki/RAM-%D0%B4%D0%B8%D1%81%D0%BA
[2] Total Commander: https://www.ghisler.com/
[3] структурой: https://learn.microsoft.com/en-us/windows/win32/api/shtypes/ns-shtypes-shitemid
[4] структурой: https://learn.microsoft.com/en-us/windows/win32/api/shtypes/ns-shtypes-itemidlist
[5] Pointer to an ITEMIDLIST: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/cc144089(v=vs.85)#pidls
[6] IShellFolder: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nn-shobjidl_core-ishellfolder
[7] WIN32_FIND_DATAW: https://learn.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-win32_find_dataw
[8] ShellExView: https://www.nirsoft.net/utils/shexview.html
[9] Источник: https://habr.com/ru/post/706790/?utm_source=habrahabr&utm_medium=rss&utm_campaign=706790
Нажмите здесь для печати.