Разработка графического движка на Vulkan API с нуля (проект Shuttle Engine) требует ручного управления многопоточностью. В современном движке необходимо параллелить три основных направления:

  • Рендеринг: подготовка и отправка командных буферов.

  • Стриминг ресурсов: загрузка текстур и мешей в VRAM без микрофризов кадра.

  • Игровые системы: расчет трансформаций, физики, анимаций и видимости объектов.

Стандартная синхронизация через мьютексы неэффективна — из‑за постоянных блокировок многопоточное исполнение превращается в узкую трубу. Подход с цепочками коллбэков также неудобен, так как усложняет отладку и поддержку кода.

Для решения этих проблем я разрабатываю shuttle_async — легковесный фреймворк корутин и планировщик задач, который станет вычислительным фундаментом для Shuttle Engine.

Результаты финального Release‑бенчмарка (‑O3)

Профили стресс‑тестирования

Стресс‑тест проводился на процессоре Intel Core i9-14900KF (24 ядра, 32 потока) в конфигурации Release. Для чистоты замеров lock‑free алгоритмов из кода полностью удален диагностический вывод.

Профиль теста

Конфигурация

Что проверяет (Цель)

? 1. Много малых задач(Small tasks)

50 000 корутин по 10 000 атомарных итераций.

Издержки планировщика при высокой частоте запросов к очередям. Проверяет пропускную способность lock‑free алгоритмов.

? 2. Большие задачи(Large tasks)

5 000 корутин по 4 000 000 математических итераций.

Чистую вычислительную мощность (Compute‑heavy). Обращения к диспетчеру минимальны. Проверяет способность рантайма утилизировать 100% CPU.

? 3. Неравномерные задачи(Uneven tasks)

10 000 корутин:• 10% тяжелых (8 000 000 итераций)• 90% легких (50 000 итераций).

Балансировку нагрузки (Work Stealing) при сильном дисбалансе. Проверяет, забирают ли свободные потоки задачи из деков Chase‑Lev занятых потоков.

Рис.1. Сводная таблица эффективности параллелизма (Все профили нагрузки). Медианное время выполнения в секундах и коэффициент ускорения относительно однопоточного режима.
Рис.1. Сводная таблица эффективности параллелизма (Все профили нагрузки). Медианное время выполнения в секундах и коэффициент ускорения относительно однопоточного режима.

Поток управления

В C++ бесстековые корутины по умолчанию не привязаны к конкретным потокам ОС. Чтобы управлять их выполнением в рамках игрового цикла Shuttle Engine, используется абстракция IExecutor. Она полностью контролирует поток управления (control flow).

Связующим звеном между планировщиком и потоками операционной системы выступает структура ThreadContext, объявленная как thread_local. Каждый рабочий поток инициализирует свой TLS‑контекст. За безопасность и корректный жизненный цикл этого состояния отвечает RAII‑обертка ThreadContextGuard, которая восстанавливает предыдущий контекст при уничтожении объекта.

Интерфейс ядра выглядит следующим образом:

class IExecutor {
public:
    virtual ~IExecutor() = default;
    
    // Ставит корутину на исполнение в lock-free очередь
    [[nodiscard]] virtual Status execute(std::coroutine_handle<> coro) = 0;

    // TLS-интроспекция контекста текущего потока
    [[nodiscard]] static IExecutor* getThreadPinnedExecutor() noexcept;
    [[nodiscard]] static IExecutor* getCurrentSharedExecutor() noexcept;
    [[nodiscard]] static std::coroutine_handle<> getCurrentDispatcherCoroutine() noexcept;

protected:
    // RAII-область для безопасного переключения и восстановления TLS-контекста
    class [[nodiscard]] ThreadContextGuard { /* ... */ };

private:
    inline static thread_local ThreadContext currentThreadContext = {};
};

Анатомия передачи управления потоком

Как корутины выполняются внутри системы без тяжелых блокировок? Процесс построен на прямой передаче управления между корутинами:

  • Точка приостановки: Когда корутина доходит до co_await (например, ожидая готовности ресурса) и приостанавливается, она передает управление служебной корутине‑диспетчеру текущего потока. Хэндл диспетчера извлекается через getCurrentDispatcherCoroutine().

  • Выборка задачи: Диспетчер просыпается на этом же потоке ОС, опрашивает контекст исполнителя и забирает следующую готовую задачу из локального дека Chase‑Lev или глобальной очереди.

  • Переключение контекста: Управление переносится напрямую в тело следующей корутины без возврата в ядро ОС. Поток операционной системы выполняет роль непрерывной вычислительной среды, а задачи сменяют друг друга через механизм симметричной передачи управления (Symmetric Transfer).

Кооперативная многозадачность

Для управления сложным жизненным циклом асинхронного графа в библиотеке реализована концепция Structured Concurrency, разделенная на следующие компоненты:

Иерархия владения: CoroScope и JoinableScope

Внутренние задачи игрового движка не должны жить в режиме «запустил и забыл» (fire‑and‑forget). CoroScope предоставляет контекст и древовидную иерархию для запуска и отслеживания дочерних корутин внутри асинхронного графа.

В свою очередь, JoinableScope выступает в роли корневого (root) скоупа. Он создается непосредственно в обычном блокирующем коде (синхронном контексте движка) и служит входной точкой, которая позволяет развернуть асинхронное дерево вычислений и гарантированно дождаться завершения всех порожденных в нем ветвей.

Защита локальных переменных: служебная корутина joinTask

Одна из главных опасностей в C++ — завершение родительской корутины до того, как отработают дочерние. Если родитель уничтожается раньше своих «детей», все захваченные по ссылке локальные переменные его фрейма пропадают. Это приводит к появлению висячих ссылок и указателей, вызывая неопределенное поведение (UB) и падения рантайма.

Для защиты от этой проблемы в архитектуру заложен внутренний предохранитель. При завершении родительской задачи в методах return_void или return_value её promise‑объекта неявно запускается служебная корутина joinTask, которая блокирует уничтожение фрейма, пока все дочерние задачи не завершат выполнение.

Логика работы предохранителя:

  • Замораживание фрейма: Если при завершении родителя счетчик активных задач в его CoroScope больше нуля, joinTask приостанавливает выполнение. Фрейм родителя и его локальные переменные остаются в памяти, а управление через диспетчер возвращается потоку.

  • Возобновление: Последняя завершившаяся дочерняя задача атомарно сбрасывает счетчик до нуля и отправляет joinTask обратно в оригинальный поток родителя для финальной очистки памяти.

Только после этого происходит безопасное, каскадное удаление всей иерархии фреймов.

Безопасный финал: joinAll в деструкторе

Что происходит, когда объект JoinableScope выходит из области видимости (например, при уничтожении игровой подсистемы движка)? Чтобы предотвратить утечки «зомби‑корутин», в деструкторе JoinableScope принудительно вызывается метод joinAll.

joinAll осуществляет блокирующее ожидание всех незавершенных корутин, зарегистрированных в данном скоупе. Пока пул потоков полностью не докрутит оставшиеся задачи, деструктор не завершится, что гарантирует абсолютную безопасность очистки ресурсов Vulkan API на стыке уничтожения систем.

Режимы запуска: LaunchMode

Для гибкого управления потоком задач в рамках CoroScope я спроектировал три базовые стратегии планирования:

  • LaunchMode::Async — корутина сразу ставится в очередь указанного исполнителя (экзекутора) ещё до того, как метод launch вернет управление.

  • LaunchMode::Lazy — рантайм подготавливает и возвращает созданную задачу в приостановленном состоянии (initially suspended). Её фактический запуск и отправка на пул потоков происходят позже, по явному требованию.

  • LaunchMode::Immediate — Корутина начинает выполняться сразу, на текущем потоке, прямо в контексте вызывающего кода.

? ПРЕДУПРЕЖДЕНИЕ ПО БЕЗОПАСНОСТИ

Жесткое правило контракта shuttle_async: Использовать оператор co_await внутри Immediate‑задач разрешается строго в рамках одного и того же потока (например, совместно с привязанными к потоку ThreadPinnedExecutor или OneThreadExecutor). Смена пула или потока возобновления внутри тела корутины гарантированно ведет к неопределенному поведению (UB).

Связующее звено: AsyncSharedState, Promise и Future

Асинхронные задачи используют AsyncSharedState<T> — объект общего состояния для передачи результата выполнения от продюсера (Promise) к потребителю (Future).

Жизненный цикл и Lock‑Free координация

Взаимодействие построено на базе конечного автомата, который координирует потоки без использования глобальных мьютексов:

  • Выделение памяти: После успешного выделения памяти через mimalloc инициализируется объект состояния и связываются конечные точки. В случае сбоя аллокации рантайм возвращает ошибку через std::expected в рамках профиля AsyncResult.

  • Публикация результата: Продюсер атомарно переводит автомат в состояние Signaled и записывает вычисленное значение.

  • Нотификация: Если корутина‑потребитель зарегистрировала свой хэндл до публикации значения, завершающий поток извлекает этот хэндл и планирует его выполнение через выбранный экзекутор.

Управление ресурсами и деаллокация

Рантайм задействует внутренний счетчик ссылок общего состояния (reference count). Этот счетчик атомарно учитывает активные конечные точки, выполняющиеся операции ожидания и обратные вызовы завершения.

Ключевая особенность: фабрика создания состояния жестко сохраняет внутри стейта операцию освобождения памяти конкретного пользовательского аллокатора. Когда счетчик ссылок падает до нуля, AsyncSharedState самостоятельно и корректно освобождает занимаемое хранилище.

Пул потоков

Как же нам удалось так утилизировать потоки? Секрет кроется в полностью децентрализованной lock‑free архитектуре планировщика ThreadPoolExecutor. Мы ушли от классической схемы с одной глобальной очередью под мьютексом, разделив потоки данных на два независимых уровня:

  • Локальные деки Chase‑Lev: Каждый воркер пула имеет собственную lock‑free деку, работающую по принципу Work Stealing. Поток забирает задачи из головы своей деки (pop()) и добавляет новые задачи в её хвост (push()) без блокировок и конкуренции с другими ядрами.

  • Глобальная воронка: Внешние задачи от сторонних потоков (или при редком переполнении локальных дек) агрегируются в потокобезопасной очереди ConcurrentQueue от moodycamel. Воркер обращается к ней, только если его собственная дека опустела.

Если работы нет ни в локальной, ни в глобальной очереди, воркер переходит к рандомизированному сканированию дек своих соседей и пытается «украсть» ровно одну задачу. Если и кража не удалась, поток уходит в кратковременный спин‑лок через инструкцию процессора mmpause.

Для минимизации избыточных пробуждений потоков ОС используется 64-битное упакованное атомарное поле состояния сна (Packed sleep state). Оно координирует идентификаторы спящих воркеров и счетчик активных пробуждений.

Воркер выполняет системный вызов нотификации только при необходимости, что устраняет проблему Thundering Herd (эффект «дикой стаи»).

План дальнейшего развития (Roadmap)

  • Асинхронный ввод‑вывод для Win32: реализация неблокирующего стриминга текстур и мешей с диска в VRAM через Win32 Threadpool API с полной интеграцией в текущий Executor API.

  • Тестирование сценариев отказа: расширение покрытия для отмены задач через CancellationToken и проверка устойчивости автомата AsyncSharedState в условиях экстремальных гонок данных.

Послесловие

Проект shuttle_async создается как экспериментальное и специализированное решение под задачи Shuttle Engine, поэтому мне важна обратная связь.

Если вы сталкивались с похожими архитектурными вызовами при разработке игровых движков или рантаймов корутин, буду рад конструктивной критике и обсуждению. Вопросы по lock‑free очередям, балансировке задач или обработке сценариев отмены также приветствуются.

Поддержите проект лайком или звездой на GitHub, если вам интересна тема низкоуровневой асинхронности в C++. Весь исходный код планировщика, тесты, примеры и puml‑схемы переходов состояний открыты под лицензией MIT.

⭐ Ссылка на GitHub: ://github.com

Комментарии (0)