Перейти к основному содержимому

Почему Valkarn Tasks

Async/await с нулевым выделением памяти для Unity. Быстрее UniTask. Умнее Awaitable.


Проблема: async в Unity сломан

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

ВариантПроблема
КорутиныНет возвращаемых значений, нет обработки ошибок, нет отмены, нет композиции
System.Threading.TasksВыделяет память при каждом вызове async (~144–232 байт), вызывает GC, не знает о жизненном цикле Unity
UniTaskХорош — но: коллизия токенов через 18 минут, нет диагностики времени компиляции, отчёт об ошибках зависит от финализатора
Unity Awaitable (2023+)Основан на классе (выделяет память), нет комбинаторов, нет каналов, нет поддержки тестирования

Valkarn Tasks решает все эти проблемы в едином пакете с генерацией кода и нулевым выделением памяти.


Нулевое выделение — почему каждый байт важен

Что выделение памяти означает для игр

Каждый вызов new object(), new List<T>() или async Task выделяет память на управляемой куче, которую отслеживает сборщик мусора. Unity использует Boehm GC, у которого две критические проблемы:

  1. Паузы «stop-the-world» — когда запускается GC, игра зависает. Пауза в 2 мс при 60 fps съедает 12% бюджета кадра; при 120 fps (VR) — 24%.
  2. Непредсказуемые моменты срабатывания — GC может запуститься во время битвы с боссом, кат-сцены или соревновательного матча.

Чем Valkarn Tasks отличается

СценарийSystem.TaskUniTaskValkarn Tasks
async Method() — завершается синхронно144 байт0 байт0 байт
async Method() — приостанавливается один раз232+ байт0 байт (пул)0 байт (пул)
WhenAll(a, b)232 байт0 байт (пул)0 байт (пул с генерацией кода)
WhenAny(a, b)144 байт72 байт0 байт
Promise (ручное завершение)144 байт104 байт88 байт
Пулированный Promise (многоразовый)144 байт0 байт0 байт

В типичном игровом кадре с 50–100 асинхронными операциями System.Task генерирует 7–23 КБ мусора. Valkarn Tasks генерирует ноль.

Что это значит для вашей игры

  • Никаких рывков от GC — заблокированная частота кадров без заиканий от асинхронных операций
  • Безопасно для VR — цели 90/120 fps без пиков GC
  • Удобно для мобильных — меньше давления на память на устройствах с ограниченным ОЗУ
  • Сертификация на консолях — предсказуемое поведение памяти помогает пройти сертификационные требования

Производительность — бенчмарки против лучших

Бенчмарки: BenchmarkDotNet v0.14.0, .NET 9.0, Intel Core i7-10875H.

Основной async/await — в 2 раза быстрее ValueTask

БенчмаркValueTaskUniTaskValkarn Tasksvs ValueTask
Пакет 100 задач956 нс508 нс489 нс1.95×
Пакет 1000 задач9 697 нс5 016 нс4 728 нс2.05×
С CancellationToken38.8 нс36.2 нс29.6 нс1.31×
Обработка исключений10 399 нс8 247 нс9 248 нс1.12×

Все пути: 0 байт выделения.

Комбинаторы — до 9.6 раз быстрее Task

БенчмаркTaskUniTaskValkarn Tasksvs Task
WhenAll (2 задачи)117 нс / 232 Б13.6 нс / 0 Б12.1 нс / 0 Б9.6×
WhenAll (5 задач)156 нс / 272 Б25.1 нс / 0 Б25.3 нс / 0 Б6.2×
WhenAny (2 задачи)39.0 нс / 144 Б60.0 нс / 72 Б11.6 нс / 0 Б3.4×
Пулированный Promise59.5 нс / 144 Б53.6 нс / 0 Б38.3 нс / 0 Б1.55×

WhenAny в 5.2 раза быстрее UniTask и выделяет ноль байт.

Пул объектов — 4.3 наносекунды

ОперацияВремяВыделение
Аренда + возврат быстрого слота в основном потоке4.3 нс0 байт
Кросс-поточный стек Treiber~15 нс0 байт

Ноль атомарных операций в основном потоке — критично для IL2CPP, где Volatile.Read работает в 9.2 раза медленнее.

Реальное влияние (50 асинхронных операций/кадр при 60 fps)

БиблиотекаВремя/кадрБюджет кадраGC/сек
System.Task~48 мкс0.29%~430 КБ/с
UniTask~25 мкс0.15%~3.6 КБ/с
Valkarn Tasks~24 мкс0.14%0 КБ/с

За 10 минут System.Task генерирует ~258 МБ асинхронного мусора. Valkarn Tasks генерирует ноль.


Функции, которых нет ни в одной другой библиотеке

Автоматическая отмена по жизненному циклу

public partial class EnemySpawner : MonoBehaviour
{
async ValkarnTask Start()
{
while (true)
{
await ValkarnTask.Delay(3000);
SpawnWave();
}
// Автоматически отменяется при уничтожении этого GameObject.
// Никаких CancellationToken. Никаких утечек памяти. Никаких задач-зомби.
}
}

Больше никаких MissingReferenceException от асинхронных методов, работающих после уничтожения. Никакой ручной очистки в OnDestroy. Никакого забытого освобождения CancellationTokenSource.

Критические секции

async ValkarnTask SaveProgress()
{
var data = await LoadPlayerData(); // можно отменить

await using (ValkarnTask.Critical())
{
await db.Insert(data); // завершится, даже если GO уничтожен
await db.Commit();
} // отложенная отмена применяется здесь

await SendNotification(); // снова можно отменить
}

Запись в базу данных, сетевые запросы и сохранение файлов завершаются, даже если игрок выходит или сцена выгружается. Никаких повреждённых сохранений. Никаких незавершённых аналитических данных. Никаких потерянных чеков.

Диагностика времени компиляции

Код диагностикиЧто обнаруживает
TT001Двойной await ValkarnTask (баг использования после освобождения)
TT002Забытый await задачи (молчаливый сбой)
TT012Асинхронные циклы без проверки отмены (циклы-зомби)
TT013Возвращённая, но не awaited задача (баг «выстрелить и забыть»)
TT016Асинхронный метод без await (ненужные накладные расходы)
TT017[FireAndForget] на ValkarnTask<T> (отбрасывание результата)

Баги обнаруживаются в IDE как красные волнистые подчёркивания — а не как сбои во время выполнения через 20 минут тестирования.

Result<T> — обработка ошибок без try/catch

var result = await loadTask.AsResult();

if (result.Succeeded) Use(result.Value);
else if (result.IsCanceled) ShowRetryDialog();
else Debug.LogError(result.Error);

Каждый путь ошибки явный. Никаких проглоченных исключений. Никаких пропущенных обработчиков.

Каналы с обратным давлением

var channel = ValkarnTask.Channel.CreateBounded<EnemySpawnRequest>(capacity: 10);

// Производитель (игровая логика)
await channel.Writer.WriteAsync(new EnemySpawnRequest { type = EnemyType.Dragon });

// Потребитель (система спавна)
await foreach (var request in channel.Reader.ReadAllAsync())
await SpawnEnemy(request);

Чисто разделяйте системы. Ограничивайте скорость спавна. Ставьте в очередь сетевые сообщения. Буферизуйте события ввода. Производитель замедляется, когда потребитель не успевает — предотвращая пики памяти.

Детерминированное тестирование с TestClock

[Test]
public void Respawn_WaitsThreeSeconds()
{
var clock = new TestClock();
var task = spawner.RespawnEnemy();

clock.Advance(TimeSpan.FromSeconds(2));
Assert.IsFalse(task.IsCompleted);

clock.Advance(TimeSpan.FromSeconds(1));
Assert.IsTrue(task.IsCompleted);
}

Тестируйте логику, зависящую от времени, мгновенно. Никакого yield return new WaitForSeconds(3) в тестах. Никаких нестабильных CI-таймингов.

Безопасность поколенческих токенов

UniTask использует токен типа short (16 бит). После 65 536 циклов пула (~18 минут активной асинхронной работы) устаревшая ссылка молча читает результат другой задачи — баг использования после освобождения, который практически невозможно воспроизвести.

Valkarn Tasks использует счётчик поколений типа uint для каждого слота пула: 4 294 967 296 циклов на слот до коллизии. В любом реальном сценарии это невозможно.


Миграция за минуты, а не недели

Из UniTask

Шаг 1: Установите Valkarn Tasks
Шаг 2: На местах использования UniTask в IDE появятся жёлтые лампочки
Шаг 3: Правый клик → «Исправить все вхождения в решении» (Ctrl+.)
Шаг 4: Удалите ссылку на пакет UniTask

15 диагностик миграции (MIG001–MIG015) автоматически охватывают каждый API UniTask. Типичный проект с 500–2 000 асинхронными методами мигрирует менее чем за 5 минут. Более 95% полностью автоматизировано.

Из Unity Awaitable

Та же миграция в один клик:

  • async Awaitableasync ValkarnTask
  • Awaitable.NextFrameAsync()ValkarnTask.NextFrame()
  • Awaitable.BackgroundThreadAsync()ValkarnTask.SwitchToThreadPool()
  • Awaitable.MainThreadAsync() → удалено (Valkarn по умолчанию работает в основном потоке)

Матрица сравнения

ФункцияSystem.TaskUniTaskAwaitableValkarn Tasks
Синхронный путь без выделенийНетДаНетДа
Комбинаторы без выделенийНетНетНетДа (генерация кода)
Основан на структуреНетДаНетДа
Автоматическая отмена по жизненному циклуНетВручнуюЧастичноАвтоматически
Нет отмены родственных задачНетНетНетДа
Критические секцииНетНетНетДа
Result<T> (без исключений)НетЧастичноНетДа
TestClockНетНетНетДа
Мост Job SystemНетНетНетДа
Диагностика времени компиляцииНетНетНетДа (17 правил)
Ограниченные пулы + обрезкаНетНетН/ПДа
Детерминированный отчёт об ошибкахНетНет (финализатор)ЧастичноДа (при возврате в пул)
Полные каналыДа (.NET)МинимальноНетДа
Мост AwaitableН/ПМинимальноНативноПрозрачно
Пул с оптимизацией IL2CPPНетНет (Volatile на каждую оп.)Н/ПДа (ноль атомарных)
Безопасность коллизии токеновН/П18 мин (short)Н/ПНикогда (uint поколение)
Автомиграция из UniTaskН/ПН/ПН/ПДа (15 исправлений)
Автомиграция из AwaitableН/ПН/ПН/ПДа (8 исправлений)

Ваша игра заслуживает async без рывков.