Обычная системная куча обязана предполагать худшее: что в неё в любой момент постучатся из десятка параллельных потоков. Отсюда неизбежные блокировки, атомарные операции и накладные расходы. Но в GUI‑приложениях на WinUI 3 этого сценария просто не бывает — все контролы и элементы разметки создаются и умирают строго в одном‑единственном STA‑потоке. Зачем же платить за потокобезопасность, которую мы не используем?
В этой статье мы разберем устройство sta_memory_pool — кастомного аллокатора для библиотеки wxl, работающего в 15–16 раз быстрее общей кучи. Вы узнаете, как с помощью инструкции lzcnt вычислить класс размера за один такт, как избавиться от лишних if‑проверок на горячем пути с помощью самоперестраивающихся таблиц функций и как заставить линейную аллокацию работать на максимальную отзывчивость приложения при старте.
Непростой лейаут в памяти
В предыдущей статье был дан обзор общих принципов построения проекций WinRT в wxl. Каждый прикладной объект WinUI 3 проецируется по принципу pImpl.
Например, класс wxl::RadioButton имеет следующий схематичный лейаут в памяти:
RadioButton { Impl* } -> Impl { IInspectable*, IDependencyObject*, IUIElement*, IFrameworkElement*, IControl*, IContentControl*, IButtonBase*, IToggleButton*, IRadioButton* }
В конструкторе wxl::RadioButton выделяется динамическая память для wxl::RadioButton::Impl. Поля этого объекта — слоты для интерфейсов, кэшируемых в ленивой манере. Они необходимы для шустрого обращения к свойствам: ведь описание GUI для нас — это вызов конструкторов элементов, установка их свойств, подписка на события и связывание элементов в иерархию вложенных сцен.
Объекты вроде RadioButton имеют ссылочную семантику. Все копии переменной делят общий экземпляр Impl, а сам RadioButton выступает аналогом умного указателя. В WinRT все объекты тоже являются умными указателями на COM‑объекты, для которых парные вызовы AddRef/Release — это виртуальные вызов «снаружи» и тяжелое Interlocked‑спотыкание «внутри».
Заменив непосредственное удержание COM‑интерфейса на свой инлайный и однопоточный прокси‑объект refcounted, wxl сделала операции манипулирования общим состоянием дешевле. Да, мы ввели лишний уровень косвенности. Но он в любом случае необходим для обслуживания схемы ленивого кэширования интерфейсов.
Объекты‑призраки
Стоит принять ту концепцию, что декларативное описание GUI в своём идиоматически чистом варианте не держит ссылок на контролы. Держаться за контролы было принято, скажем, в MFC, Windows Forms или в Qt, где контролы создавались в переменных класса формы, а оперирование ими шло в ручном режиме через имена этих переменных. Сейчас от этого можно задешево уйти через инфраструктуру двустороннего биндинга — пример можно подсмотреть в samples/Calculator.
Из этого следует парадоксальный вывод: получается, wxl создаёт объекты, которые никому не нужны? Весь граф из десятков или сотен элементов с 5–10 вызовами для установки свойств каждого (включая подписку на события) работает примерно как морской прибой — накатила волна, намочила песок и отступила, не оставив после себя ничего?
Да, именно так!
Целевое декларативное описание можно воспринимать как короткоживущую фабрику/билдер для конструирования графа визуальной сцены. Фабрика должна быстро построить строительные леса, удобным для нас образом возвести целевое здание с их помощью и так же быстро прибрать за собой.
Видится так, что без специальных приёмов работы с памятью этот сценарий не взлетит, можно даже не пытаться. За основу кастомного аллокатора для GUI была взята популярная схема пул‑аллокатора по степеням двойки.
🛑 Правило, на котором всё держится
Объект sta_memory_pool объявляет жесткое правило: все обращения к нему идут строго из одного потока. Это не рекомендация, а условие существования. Списки блоков внутри пула — обычные указатели, которыми орудуют без единой атомарной операции. Конкурентные вызовы из разных потоков гарантированно разрушат структуру данных.
В wxl идентификатор потока читается напрямую из TEB (Thread Environment Block), в обход тяжеловесного std::this_thread::get_id(). При доступе в пул выполняется легковесная проверка идентификатора текущего потока через assert. Несмотря на её дешевизну, эта проверка происходит только в дебажной сборке. В релизе испаряются любые проверки. Это просто не то место в программе, которое можно обыграть по ветвлению из‑за неверных входных данных.
Потокобезопасность здесь — не свойство класса, а дисциплина использования. Чужой поток гарантирует assert в отладке и UB в релизе, третьего не дано.
⚡ Класс размера за одну инструкцию или даже даром
Пул раскладывает запросы по классам‑степеням двойки: от 4/8 байт до 32 КБ. Самый быстрый способ найти класс размера — через специальную функцию:
static constexpr uint32_t pool_index(uint32_t size) noexcept { return std::countl_zero(size - 1); }
std::countl_zero вычисляет число ведущих нулей. На архитектуре x86/x64 компилятор развернет этот код в инструкцию lzcnt, а на ARM64 — в нативную clz.
Посмотрим, как это работает в числах:
Размер |
| Ведущих нулей | Класс размера |
|---|---|---|---|
1 | 0 | 32 | 8 байт |
8 | 7 | 29 | 8 байт |
9 | 8 | 28 | 16 байт |
16 384 | 16 383 | 18 | 16 КБ |
32 768 | 32 767 | 17 | 32 КБ |
Одно вычитание и один lzcnt одновременно округляют размер запрошенного блока вверх до степени двойки и превращают этот размер в индекс. Ни ветвлений, ни делений, ни циклов.
Удивительно, но в сети полно похожих реализаций, где результат инструкции lzcnt потом еще вычитают из константы, чтобы получить прямой порядок элементов... Иногда хочется позвонить авторам такого кода и спросить — а зачем машине прямой порядок?
Массив пулов внутри нашего аллокатора расположен «в обратную сторону» — от больших размеров к меньшим, в точности как выдаёт инструкция lzcnt, исполняющаяся за один такт. Сам факт «обратности» индексов нас совершенно не беспокоит.
🗺️ Таблица функций вместо ветвлений
Полученный индекс адресует две таблицы указателей на статические функции:
using alloc_fn = void* (*)() noexcept; using free_fn = void (*)(void*) noexcept; static alloc_fn s_alloc_table[33]; static free_fn s_free_table[33];
Размер массивов равен 33, так как инструкция lzcnt для 32-битного аргумента возвращает значения в диапазоне от 0 (нет ведущих нулей) до 32 (все нули).
Выделение памяти превращается в диспетчеризацию по таблице:
return s_alloc_table[pool_index(size)]();
Посчитать индекс, загрузить указатель из таблицы, вызвать функцию. В этом месте ноль дополнительных ветвлений в рантайме. Конвейер процессора отлично справляется с предвыборкой команд задолго до выполнения косвенного вызова. На разогретых кэшах L1 инструкций и данных такой вызов обходится практически бесплатно.
Из 33 доступных слотов полезной нагрузкой заняты 16 — по числу поддерживаемых классов размера (от 17-го слота для 32 КБ до 32-го для 8 байт). Самый последний, 32-й слот (соответствующий lzcnt от нуля, то есть ошибочному запросу размера 0) занят функцией обработки ошибок. Благодаря этому даже невалидный запрос своим ходом прилетает прямиком в обработчик. И снова — без лишнего if в рантайме.
Именно поэтому для вычисления индекса используется именно uint32_t, а не size_t. Нет смысла раздувать таблицы до 65 слотов на архитектуре x64, если старшая половина индексов никогда не будет задействована.
🔄 Слот, который переписывает сам себя
Простой трюк: у каждого класса размера есть две аллоцирующие функции:
bump_alloc<N>()— просто сдвигает курсор линейного аллокатора (bump allocator) в текущей странице памяти, возвращая указатель на зарезервированный участок памяти. Это экстремально дешево.pool_<N>::get()— возвращает блок из списка ранее освобожденных блоков (free list), реализуя концепцию пула свободных блоков памяти. Здесь происходит манипуляция с головой списка, возвращается последний помещённый в аллокатор блок памяти (по дисциплине LIFO). То есть выдаются самые «разогретые» в кэше участки памяти, что отлично показывает себя в бенчмарках. Это чуть дороже bump‑аллокации на несколько тактов процессора, но окупается эффективностью последующего доступа к самой памяти блока.
Популярный подход в реализации таких аллокаторов совершает лишнюю проверку: «Пул свободных блоков исчерпан? Если да, двигаем курсор на странице, иначе берем из списка». Но любая проверка — это ветвление, а регулярные ветвления на горячем пути аллокации неизбежно приводят к промахам предсказателя переходов процессора. Самое досадное, что тут бесполезно расставлять атрибуты [[likely]] и [[unlikely]]: мы не можем знать заранее мгновенную ситуацию в нашем пул‑аллокаторе.
А что если сделать алгоритм самоадаптирующимся, чтобы тот «знал» наиболее вероятную ветку ветвления заранее? Сказано — сделано. В wxl ответ хранится в самом слоте таблицы, который меняет свой указатель динамически.
Итак, у нас есть две аллоцирующие функции — bump_alloc<N> и pool_<N>::get, а также функция возврата блока памяти в пул pool_<N>::put.
Весь алгоритм целиком:
По умолчанию в слоте выставлен адрес функции
bump_alloc<N>. Пока память только выделяется, она идет через дешевое скольжение курсора.Вызов
pool_<N>::putвозвращает блок памяти в список свободных блоков и переключает адрес аллоцирующей функции в таблицеs_alloc_table[index]наpool_<N>::get, чтобы при следующем обращении отдать этот же блок.Когда свободные блоки из пула полностью заканчиваются, то
pool_<N>::getпереключает адрес в таблицеs_alloc_table[index]обратно наbump_alloc<N>.
template <unsigned N> struct sta_memory_pool::pool_ { inline static constexpr unsigned index = pool_index(1u << N); static void* get() noexcept { if (void* node = top_) [[likely]] return (top_ = *static_cast<void**>(node)), node; s_alloc_table[index] = bump_alloc<N>; // Список опустел — слот возвращается к курсору return bump_alloc<N>(); } static void put(void* node) noexcept { (*static_cast<void**>(node) = top_), top_ = node; s_alloc_table[index] = &pool_::get; // Появился свободный блок — слот идет к списку } static void* top_; };
Основное тело bump_alloc<N>:
if (byte* c = s_cursor, *c1 = c + size; c1 <= s_end) [[likely]] return (s_cursor = c1), c;
Итог: вопрос «а есть ли что‑то в пуле свободных блоков?» задается тогда, когда там с большой вероятностью что‑то есть. Всё остальное время ответ закодирован в самой таблице функций. На практике вероятность верного предсказанного ветвления оказалась большой — этому подыгрывает групповая природа операций в нашем сценарии, когда выделения и уничтожения редко идут по одному блоку друг за другом, а накатывают и откатываются обратно большими волнами.
📈 Битва бенчмарков или «сказано — сделано»
Такая схема была выбрана не за красоту, а потому что она единственная выжила на поле боя бенчмарков. Тестировались разные подходы: от классических проверок списка до архитектуры вообще без таблиц, где функции вызывались напрямую, а классы размеров выражались через CRTP и наследование специализаций. Мы доходили вплоть до хардкода пулов под конкретные прикладные типы, которые ими обслуживаются. Всего были тщательнейшим образом протестированы 8 разновидностей однопоточного аллокатора с минимальными отличиями вдоль градиента вариаций подходов — чтобы случайно не пропустить «экстремум».
Каждое сочетание прогонялось на сценариях горячего пути выделения (sta_allocator_get_benchmark.cpp) и на «настоящей» нагрузке — дереве вложенных контейнеров, которое строится, перетряхивается и рушится (sta_allocator_scenario_benchmark.cpp).
На сценариях, где освобождения чередуются с выделениями (balanced), ведущие 4 вариации оказались практически неразличимы. На масштабе единиц наносекунд то, как компилятор выровнял код в памяти, значит больше, чем то, что этот код фактически делает.
А вот сценарий fresh (сплошные выделения и ни одного освобождения) разделил кандидатов сразу и с заметным отрывом. В нём решается единственный вопрос: сколько стоит выделение, когда список свободных блоков пуст. У самоперестраивающихся таблиц ответ очевиден — ноль сверх самого выделения. Как только список свободных блоков пула опустел, аллокатор переключается на функцию bump_alloc<N>. После этого каждое следующее выделение попадает в скользящий курсор.
И это ровно тот профиль нагрузки, в котором живет запуск любого GUI‑приложения. Построение дерева интерфейса при старте — это тысячи аллокаций подряд (чтение конфигурации, элементы управления, коллекции, строковые операции) и редкие освобождения. Массовое выделение памяти при инициализации GUI — главный сценарий для этого аллокатора, и механика автоматического переключения аллокатора на безусловный путь bump‑курсора окупается с запасом.
Текущие показатели пула (Release, класс размера 32 байта, время в наносекундах):
Сценарий | Что делает | Время (нс) |
|---|---|---|
| Снятие блока с непустого списка, без дилемм | 2.30 |
| Освобождение + выделение | 2.45 |
| Только выделения, свободный список пуст | 1.95 |
Продвинуть курсор линейного аллокатора (fresh) дешевле, чем снять узел со списка и прочитать из него указатель на следующий. Выбранная схема подыгрывает отзывчивости приложения на старте, за это она и была назначена победителем.
На самом деле, почти любой из однопоточных пул‑аллокаторов даёт резкий буст производительности, то есть позволяет схеме проецирования WinRT в wxl в принципе существовать в выбранном варианте. Но раз уж вышло так, что вся схема критично зависит от аллокатора, то почему бы не довести вопрос до абсурда идеала? Сказано — сделано.
🗄️ Откуда берётся память и где пул заканчивается
Память запрашивается страницами через VirtualAlloc. Аллокатор берёт у системы крупный кусок — не меньше 256 КБ (по умолчанию — один мегабайт, размер страницы настраивается однократно при инициализации пула). Когда текущая страница заканчивается, аллокатор выделяет следующую. Хвост старой страницы не пропадает зря — он рубится на максимальные выровненные блоки‑степени двойки и раздаётся по свободным спискам обычным вызовом pool_<N>::put.
Пул идеален для мелких и многочисленных объектов, но для крупных аллокаций он вреден, ведь эта машинерия удерживает память до конца работы программы. Пул хорош тогда, когда непрерывно совершает работу: выдаёт блоки, получает их обратно и снова выдаёт. И плох, если он однажды выделил большой кусок, забрал его назад и больше никому не отдаёт.
Именно поэтому для специфического сценария парсинга XML в читалке книг «Буквица» реализован брат‑близнец описанного аллокатора. Он оперирует исключительно блоками по 64 КБ (то есть там нет таблицы слотов, а есть единственный слот). Такова узкая специфика: типичные размеры книг попадают в диапазон от сотни килобайт до мегабайта, а сам XML‑документ имеет «кучерявую» структуру. В этой ситуации лучше всего подошла концепция «арены», где вся память освобождается разом без вызовов деструкторов у объектов. Да, вы правильно поняли: решение по месту порой лучше даже самого крутого общего решения (особенно если это частное решение взяло у общего всё самое лучшее).
Из этих соображений запросы памяти к sta_memory_pool разбиты на три уровня:
До 32 КБ: Свободные списки свободных блоков поверх страниц — то, ради чего всё и задумывалось.
От 32 КБ до половины страницы: Приватная Windows‑куча STA‑потока. Она обслуживается компонентом
thread_heapнад системным Win32 API куч, инициализированным с флагомHEAP_NO_SERIALIZE. Так мы просим операционную систему полностью отключить внутренние блокировки кучи за ненадобностью.Больше половины страницы: Обычный
std::mallocи общая куча процесса. После возврата эта память может быть переиспользована другими потоками приложения.
Таким образом, использовать sta_memory_pool для выделения больших блоков памяти будет означать лишь удлинение цепочки аллокации, потому что он не для этого сценария.
⚙️ Когда размер известен компилятору
Благодаря инлайнингу до честного выполнения инструкции lzcnt в рантайме дело доходит редко. При вызове new T выражение sizeof(T) является константой времени компиляции, и от всей адресации в релизной сборке остается лаконичное:
s_alloc_table[28](); // Адрес таблицы и индекс известны на этапе компиляции
Но если хочется быть окончательно уверенным — у функций alloc и free есть NTTP‑шаблонные двойники (Non‑Type Template Parameters), принимающие размер в качестве параметра шаблона:
template <uint32_t Size> static void* alloc() noexcept;
🚀 Как этим пользоваться
Пул — статическая сущность, его код перемещён прагмой в сегмент lib, то есть будет инициализирован раньше сегмента user, где живёт код целевой программы, и уничтожен после уничтожения последних глобальных объектов программы.
Помимо переопределения операторов new/delete, STA‑пул подхватывается через sta_allocator<T> — обычный std::allocator‑совместимый переходник:
template <typename T> using sta_allocator = allocator_adapter<T, sta_memory_pool>;
Благодаря этому wxl::vector, wxl::wstring и прочие компоненты — это всё те же стандартные std‑контейнеры, у которых просто подменён бэкенд. STL остается привычной STL, но память под ней работает совершенно иначе.
📌 Что стоит помнить
Один поток — нарушение этого правила гарантирует неопределенное поведение (
UB).Удержание памяти — пул не возвращает страницы операционной системе до момента собственной смерти, что является осознанным компромиссом ради аллокации ценой в три процессорные инструкции.
Где еще подменяются слоты функций?
В конструкторах объектов WinUI 3.
Вот последовательность шагов конструирования обычной кнопки стандартными средствами WinRT:
Вызов
RoGetActivationFactory("winrt::очень_длинный_неймспейс::Button")— получаем указатель на фабрику;Вызов
ActivateInstanceу фабрики — получаем базовый объектIInspectable;Запрос целевого интерфейса
IButtonу объектаIInspectableчерезQueryInterface. Полученный экземпляр запрошенного интерфейса присваивается умному указателю, а полученному на предыдущем этапе экземпляруIInspectableвызываетсяReleaseкак сигнализация об окончании владения.
Видно, что получение экземпляра фабрики в этой цепочке обходится дорого. Первое естественное желание — закэшировать экземпляр фабрики при первом же обращении. Именно так и происходит в WinRT. Но делается это в расчёте на общий случай доступа из множества параллельных потоков. Кэширование в проекции cppwinrt реализовано через атомарные операции interlocked cas, ведь несколько потоков могут конкурентно пытаться сохранить фабрику для одного и того же объекта.
Но для GUI‑объектов этот сценарий невозможен! Мы снова платим за потокобезопасность, которой не пользуемся.
Далее, сам факт того, закэширована ли фабрика, нужно постоянно проверять с помощью ветвления. Но и это не конец неприятностей. В WinRT фабрика является шаблонной и инлайновой. В каждом месте создания кнопки, даже если ветвление всегда идёт по горячему пути (где фабрика уже существует), в каждом методе компилятор генерирует else‑ветку. Она запрашивает фабрику по имени, а затем устраивает CAS‑гонки с потенциальными конкурирующими потоками за право установить свой экземпляр в качестве глобального.
Итого, нетривиальный код алгоритма кэширования встраивается компилятором в каждый метод, где создаётся объект WinRT. Если в методе создаётся десяток контролов — значит, внутри него будет жить столько же раздутых вариантов инициализации фабрик.
Можем ли мы спокойно смотреть на этот беспредел? Внутренний перфекционист отвечает однозначное «Нет!».
Как это выглядит в wxl
Никаких ветвлений — слот кэша фабрики не может быть пуст. Его не нужно проверять через
if, вызов происходит сразу.Функция‑заглушка на старте — по умолчанию в этом слоте лежит адрес stub‑функции
init. Она лениво инициализирует фабрику при первом обращении и просто подменяет собственный слот на адрес целевой функции.
«Но ведь фабрика — это COM‑объект, чьи методы обязаны вызываться как виртуальные!» — поправите вы меня и будете абсолютно правы. Это именно COM‑объект, использующий соглашение о вызовах __stdcall. Данное соглашение на бинарном уровне не различает this‑вызовы и вызовы обычных статических функций. Иначе как бы эти интерфейсы вызывались из чистого Си или других языков в интеропе с ними?
Поэтому под слот выделено две ячейки: для самого экземпляра фабрики и для непосредственного адреса функции из 6-го слота интерфейса IActivationFactory. Конструктор WinUI 3‑объекта теперь схематично выглядит так (аргумент this передаётся первым):
static void activate(::IInspectable** result) { slot.fn(slot.fac, result); }
Виртуальный вызов заменяется на вызов функции по указателю. Это экономит не просто такты, а разрушает длинную цепочку зависимостей по данным (Data Dependency Chain) и избавляет от рантайм‑проверок.
В стандартном WinRT процессору на горячем пути приходится сначала прочитать адрес фабрики из статического кэша, проверить его через if на nullptr, затем по полученному адресу прочитать указатель на vtable объекта и только после этого вычислить адрес целевой функции. Каждое следующее чтение физически ждет завершения предыдущего.
В нашем же случае адреса slot.fn и slot.fac лежат рядом в локальной структуре. Процессор загружает их в регистры одновременно и параллельно, минуя и проверку на nullptr, и обращение к vtable фабрики, сокращая время вот такого целевого вызова:
struct IActivationFactory : IInspectable { virtual HRESULT __stdcall ActivateInstance(IInspectable** instance) = 0; };
Почему слот именно шестой и почему он не может измениться в будущем?
Таков строгий ABI‑контракт COM от самого его рождения: когда требуется дополнить или изменить функциональность, разработчикам разрешено только выпускать новые интерфейсы. Изменять однажды опубликованные интерфейсы запрещено. Именно поэтому мы можем позволить себе навсегда захардкодить знание о том, какую по порядковому номеру функцию нужно взять у фабрики и поместить в наш слот (три метода IUnknown + три метода IInspectable— метод ActivateInstance занимает шестую позицию в vtable).
Итого, в момент конструирования WinUI 3‑объекта средствами wxl происходит простой вызов, как показано в листинге выше. И... это всё?
Нет, внимательный читатель уже заметил, что стандартный WinRT после создания объекта сразу же запрашивает дефолтный интерфейс класса. Ведь семантически в коде был вызван конструктор объекта, пусть даже под капотом это тяжеловесный COM. В нашем же случае у Button::Impl инициализируется только базовый указатель на IInspectable, полученный от фабрики единственным вызовом, то есть дефолтный интерфейс на старте не запрашивается. Он будет получен лениво и закэширован в будущем — точно так же, как и любой другой интерфейс из десятка доступных для простой кнопки.
Но вот что забавно: именно дефолтный интерфейс IButton в реальности запрашивается используется крайне редко. Почему?
Размеры и стили — это механика базовых интерфейсов
IUIElementиIFrameworkElement.Событие
Click— зона ответственности базовогоIControl.Текст, иконки или сложные сцены внутри — это присвоение свойству
ContentбазовогоIContentControl.Связывание с родителем — когда наступает пора отдать контрол в качестве дочернего элемента, кнопка запрашивает у себя интерфейс
IUIElement, который с большой вероятностью уже был закэширован на этапе настройки свойств.
Давайте заглянем в winmd‑файл метаданных и посмотрим, что на самом деле скрывает в себе интерфейс IButton:
internal interface IButton { FlyoutBase Flyout { get; [param: In] set; } }
Свойство Flyout не используется практически никогда. Из‑за этого в реальных приложениях ваши объекты wxl::Button при создании класса вообще никогда не будут запрашивать дефолтный интерфейс самой кнопки! Как бы парадоксально это ни звучало для экосистемы COM.
Благодаря ленивому кэшированию интерфейсов с одновременным удержанием их внутри прокси‑объекта, библиотека wxl избегает лишнего дорогого первоначального вызова QueryInterface. Этот вызов по умолчанию совершают все без исключения программы на C++, написанные под современный WinRT, даже если дефолтный интерфейс им никогда не понадобится. Но ещё больше схема wxl экономит ресурсы при последующем активном манипулировании свойствами сложных UI‑компонентов со множеством базовых интерфейсов.
Когда не нужно боксирование
Простой пример: WinRT передаёт свойствам аналог std::optional<bool> через IReference<bool>. При этом IReference<T> — точно такой же тяжеловесный COM‑объект, создающийся с соблюдением всех церемоний. И вся эта тяжеловесная механика нужна только для того, чтобы выставить свойство ToggleButton::IsChecked, которое может принимать три состояния: true, false или «не определено».
Здесь мы отбросили даже наши STA‑трюки. Эксперименты показали, что WinRT никогда не захватывает владение переданными ему в качестве аргументов объектами‑значениями. Исключение составляют лишь расшаренные иммутабельные строки hstring, у которых просто инкрементируется счётчик ссылок.
Динамически конструировать такой объект из STA‑пула — идея рабочая, но не идеальная. Мы пошли дальше: расположили два статических экземпляра по значению прямо в глобальной памяти — winrt_true и winrt_false. Метод установки свойств просто отправляет адреса этих экземпляров. Самое приятное, что эти адреса известны ещё на этапе компиляции. Методы AddRef/Release у данных объектов пусты (да и на практике их никто не вызывает), полей они не имеют, то есть могут безопасно располагаться по значению в глобальной памяти. И даже если WinRT самым честным образом однажды захватит владение таким объектом через AddRef — всё продолжит работать как задумано.
Да, для других типов данных приходится заворачивать их боксированное представление во временные объекты из STA‑пула. Но таких сценариев исчезающе мало (хоть они и полностью покрыты в wxl). Впрочем, именно для таких редких ситуаций мы и проектировали наш STA‑пул — чтобы не испытывать досаду всякий раз, когда требуется временно выделить динамическую память только для того, чтобы тут же её освободить.
Оно того стоило?
Проверить жизнеспособность подхода поможет простой тест: создание и настройка сцены примерно из сотни элементов. Одинаковая сцена строится средствами голого WinRT и средствами wxl. Результаты бенчмарка приведены в таблице.
Тест | wxl | cppwinrt руками |
|---|---|---|
Постройка сцены, лучшая | 2.39–2.48 мс | 2.49–2.51 мс |
Проход записей, на запись | 1626–1681 нс | 1690–1702 нс |
Это несколько порвало шаблон самому автору, ведь изначальная ретроспектива выглядела примерно так:
Ожидание: «За декларативность GUI и удобные хелперы наверняка придётся заплатить какую‑то цену... Надеюсь, что она не окажется слишком высокой».
Реальность: Декларативность и
pImpl‑проекция вышли бесплатными. Они работают не медленнее ручного cppwinrt, а даже немного быстрее.
Итак. Хелперы wxl выполняют дополнительный код. Проекция создает динамические pImpl‑структуры только для того, чтобы сразу же выкинуть их за ненадобностью после завершения сборки GUI. Пресеты тоже образуют динамически выделяемые объекты‑сеттеры, призванные дать удобный механизм декомпозиции описания UI средствами языка C++. Мы однозначно делаем больше на верхнем уровне, чтобы позволить программисту писать меньше и в лаконичном синтаксисе. И в то же самое время мы делаем заметно меньше на самом низком уровне, что и позволило свести баланс в плюс.
Оно того стоило, и результаты продиктовали однозначный вердикт: wxl быть.
Немного анализа. Относительный перевес wxl при построении сцены вышел небольшим — всего 3–4%.
Почему? Стоит знать две важные цифры:
Разница на одну запись свойства — около 40 нс. Это ровно тот
QueryInterface, который cppwinrt делает по умолчанию, а wxl — нет.Сама запись свойства в WinUI 3 стоит около 1.7 мкс.
Стоимость, казалось бы, тривиальной операции записи значения оказалась близка стоимости двух вызовов функций ядра (kernel transition) на моей машине. А ведь мьютекс этого не делает, если на нём не случилось столкновения потоков, то есть дело не только в потенциальной сериализации доступа к графу GUI. Благо исходники WinUI 3 открыты, и однажды можно будет провести детальное исследование ради удовлетворения любопытства: что именно там так тяжело ворочается? Не хочется забегать вперёд, но пока хочется осторожно верить: если решение будет найдено, оно сможет однажды попасть в саму WinUI 3.
Спойлер: в следующей статье мы заглянем в святая святых современного C++ — в корутины и асинхронщину. Поговорим о том, насколько теперь удобно обслуживать классически нелинейную событийную модель UI через уютно‑линейный асинхронный код (мне до сих пор словосочетание «линейный асинхронный код» режет слух и кажется оксюмороном... но таков актуальный сленг) и прочее зуммерское ми‑ми‑ми, которое становится доступным, стоит лишь организовать всё по уму.
Также мы разберем, каким образом наш «хрустальный» STA‑пул запросто обслуживает потребление памяти в произвольных I/O‑потоках и делает это с присущей ему эффективностью, пугая топовые ИИ кульбитами происходящего. Да так, что те отказываются верить своим глазам, упорно ищут ошибки в многопоточной схеме с однопоточным аллокатором, спорят на повышенных тонах (о да, они уже научились и этому!), а когда до них наконец «доходит» — радуются как маленькие дети.
В области корутин получилось не просто выйти на нулевую стоимость в сравнении с cppwinrt, но и куда заметнее убежать вперёд. Одним словом, продолжаем превращать ограничения сценариев в ультимативные бонусы.

