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

Почему 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 Awaitable → async 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 без рывков.