Система асинхронных задач, планировщик и менеджер потоков.
Базовый интерфейс для всех асинхронных задач.
Хранит статус выполнения задачи, зависимости, тип очереди и тд.
Виртуальный метод Run()
гарантированно выполнится только после того как все входные зависимости завершены - их методы Run
или OnCancel
были вызваны.
Перед вызовом Run()
кэш ЦП загружается (acquire), а после вызова - выгружается (release), дополнительные синхронизации не требуются.
Внутри реализации метода Run()
можно вызвать:
- метод
OnFailure()
- ошибка при выполнении задачи, меняет статус и вызывает методOnCancel()
после завершенияRun()
. - метод
Continue()
- возвращает задачу в очередь, чтобы повторно вызвать методRun()
. Может принимать список зависимых задач.
Исходник: AsyncTask.h
Корутины из C++20 сделанные поверх AsyncTask.
Интерфейс сделан через неблокирующие co_await
:
bool co_await Coro_IsCanceled
EStatus co_await Coro_Status
ETaskQueue co_await Coro_TaskQueue
Исходник: AsyncTask.h
Корутины из C++20 сделанные поверх AsyncTask, могут хранить значение внутри и возвращать его через co_await
после выполнения задачи.
Исходник: Coroutine.h, Тесты
Повторяет функционал корутин на C++17, который поддерживается многими компиляторами.
MakePromiseFromValue()
- если аргумент dependsOn пустой, то задача не добавляется в очередь.
MakePromiseFrom() и MakePromiseFromArray()
- объединяют результаты промисов в один.
- Main - единственный поток, который обрабатывает вызовы ОС (окно, ввод и тд).
- PerFrame - поток с высоким приоритетом, предназначен для коротких задач в пределах кадра, это физика, логика, графика и тд. Разрешен доступ к Vulkan и Metal API.
- Renderer - аналогично PerFrame, но используется чтобы ограничить количество потоков с доступом к командному пулу (Vulkan). Должны использоваться только
RenderTask
иRenderTaskCoro
. Память для записи команд в буфер выделяется под каждый поток (VkCommandPool). - Background - рабочий поток с низким приоритетом. Рекомендуется использовать для тяжелых задач:
- Графическое АПИ: компиляция пайплайнов, выделение памяти и тд.
- Доступ к файлам.
- Работа с сетью.
- FileIO - не является очередью, нужен для передачи управления в ОС, чтобы обработать завершенные асинхронные команды чтения/записи в файл.
Исходник: EThread.h
Поддерживаются сильные и слабые зависимости.
Если слабая зависимость отменяется или завершается с ошибкой, то зависящая от него задача все равно запустится. Явно указывается через WeakDep{task}
.
Если сильная зависимость отменяется или завершается с ошибкой, то зависящая от него задача также отменяется. Неявно используется сильная зависимость, явно указывается через StrongDep{task}
.
Метод Run()
используется для добавления задач в очередь, в случае ошибки задача помечается как отмененная.
Если не получилось создать задачу (исключение в конструкторе и тд), то возвращается задача-заглушка с отмененным состоянием.
Метод ProcessTask()
вызывается потоком, чтобы найти задачу в указанных очередях и выполнить ее - вызывается AsyncTask::Run()
, либо если задача отменена, то вызывается AsyncTask::OnCancel()
и продолжается поиск задачи.
Исходник: TaskScheduler.h
У каждой задачи есть битовое поле на 64 бита, которое меняется атомарно. Когда все входные зависимости для задачи выполнены, планировщик может достать задачу из очереди и начать выполнение.
Менеджер зависимостей хранит указатель на задачу и номер бита, к которому привязана зависимость.
Исходник: TaskScheduler.h
Встроенная в движок реализация потоков для обработки задач, пользователи могут сделать свою реализацию IThread
и добавить ее в планировщик (TaskScheduler::AddThread
).
Стандартная реализация потоков принимает список типов очередей, поток будет выполнять задачи только из этого списка и в том порядке, в котором они расположены в списке.
Например поток с EThreadArray{ ETaskQueue::PerFrame, ETaskQueue::Renderer }
будет выполнять PerFrame
задачи с большим приоритетом.
ThreadManager также распределяет потоки по ядрам ЦП.
Если ЦП содержит энергоэффективные ядра, то потоки с Background, FileIO
будут привязаны к этим ядрам, а потоки с Main, PerFrame, Renderer
будут привязаны к производительным ядрам ЦП.
Параметр bindThreadToPhysicalCore
определяет будет ли привязка к физическим ядрам или к логическим (2 потока на ядро), потоки с Background, FileIO
всегда привязываются к логическим ядрам, так как могут дольше простаивать.
Потоки сами решают когда засыпать, если нет задач на выполнение. В стандартной реализации потоки постепенно увеличивают время сна, если задачи не поступают.
Исходник: ThreadManager.h
Блокирующие примитивы синхронизации не должны использоваться, за исключением коротких блокировок.
Используется по аналогии с Mutex
, но эксклюзивная блокировка достигается за счет зависимостей между задачами.
Недостатки:
- Разблокируется только после выполнения всего метода
IAsyncTask::Run()
. - Планировщик потоков решает когда запустить следующую задачу, поэтому между ними может быть большой интервал бездействия.
Исходник: AsyncMutex.h
Производительность планировщика зависит от скорости добавления и поиска задачи в очереди. Очередь работает без блокировок (lock-free), используется несколько внутренних очередей и распределение поиска по ним в зависимости от потока (EThreadSeed).
Производительность не зависит от количества потоков, алгоритм отлично масштабируется и выдает стабильный результат на слабых мобилках и на мощных ПК.
Пример худшего случая 1
Всего задач: 350k
Максимальное количество задач в очереди: 25k
Время выполнения задачи: 7мкс
Потеря времени в планировщике: 30%
Пример худшего случая 2
Всего задач: 350k
Максимальное количество задач в очереди: 32k
Время выполнения задачи: 22мкс
Потеря времени в планировщике: 12%
Пример хорошего случая
Всего задач: 350k
Максимальное количество задач в очереди: 32k
Время выполнения задачи: 110мкс
Потеря времени в планировщике: 2.7%