Почему 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, у которого две критические проблемы:
- Паузы «stop-the-world» — когда запускается GC, игра зависает. Пауза в 2 мс при 60 fps съедает 12% бюджета кадра; при 120 fps (VR) — 24%.
- Непредсказуемые моменты срабатывания — GC может запуститься во время битвы с боссом, кат-сцены или соревновательного матча.
Чем Valkarn Tasks отличается
| Сценарий | System.Task | UniTask | Valkarn 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
| Бенчмарк | ValueTask | UniTask | Valkarn Tasks | vs ValueTask |
|---|---|---|---|---|
| Пакет 100 задач | 956 нс | 508 нс | 489 нс | 1.95× |
| Пакет 1000 задач | 9 697 нс | 5 016 нс | 4 728 нс | 2.05× |
| С CancellationToken | 38.8 нс | 36.2 нс | 29.6 нс | 1.31× |
| Обработка исключений | 10 399 нс | 8 247 нс | 9 248 нс | 1.12× |
Все пути: 0 байт выделения.
Комбинаторы — до 9.6 раз быстрее Task
| Бенчмарк | Task | UniTask | Valkarn Tasks | vs 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× |
| Пулированный Promise | 59.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 ValkarnTaskAwaitable.NextFrameAsync()→ValkarnTask.NextFrame()Awaitable.BackgroundThreadAsync()→ValkarnTask.SwitchToThreadPool()Awaitable.MainThreadAsync()→ удалено (Valkarn по умолчанию работает в основном потоке)
Матрица сравнения
| Функция | System.Task | UniTask | Awaitable | Valkarn Tasks |
|---|---|---|---|---|
| Синхронный путь без выделений | Нет | Да | Нет | Да |
| Комбинаторы без выделений | Нет | Нет | Нет | Да (генерация кода) |
| Основан на структуре | Нет | Да | Нет | Да |
| Автоматическая отмена по жизненному циклу | Нет | Вручную | Частично | Автоматически |
| Нет отмены родственных задач | Нет | Нет | Нет | Да |
| Критические секции | Нет | Нет | Нет | Да |
| Result<T> (без исключений) | Нет | Частично | Нет | Да |
| TestClock | Нет | Нет | Нет | Да |
| Мост Job System | Нет | Нет | Нет | Да |
| Диагностика времени компиляции | Нет | Нет | Нет | Да (17 правил) |
| Ограниченные пулы + обрезка | Нет | Нет | Н/П | Да |
| Детерминированный отчёт об ошибках | Нет | Нет (финализатор) | Частично | Да (при возврате в пул) |
| Полные каналы | Да (.NET) | Минимально | Нет | Да |
| Мост Awaitable | Н/П | Минимально | Нативно | Прозрачно |
| Пул с оптимизацией IL2CPP | Нет | Нет (Volatile на каждую оп.) | Н/П | Да (ноль атомарных) |
| Безопасность коллизии токенов | Н/П | 18 мин (short) | Н/П | Никогда (uint поколение) |
| Автомиграция из UniTask | Н/П | Н/П | Н/П | Да (15 исправлений) |
| Автомиграция из Awaitable | Н/П | Н/П | Н/П | Да (8 исправлений) |
Ваша игра заслуживает async без рывков.