Saltar al contenido principal

Por qué Valkarn Tasks

Async/await sin asignaciones para Unity. Más rápido que UniTask. Más inteligente que Awaitable.


El problema: async en Unity está roto

Los desarrolladores de Unity necesitan operaciones asíncronas en todas partes: cargar escenas, descargar assets, esperar animaciones, retrasar spawns, comunicarse con servidores. Hoy, las opciones son:

OpciónProblema
CoroutinesSin valores de retorno, sin manejo de errores, sin cancelación, sin composición
System.Threading.TasksAsigna en cada llamada async (~144–232 bytes), activa el GC, sin conciencia del ciclo de vida de Unity
UniTaskBuena opción — pero: colisión de tokens tras 18 minutos, sin diagnósticos en tiempo de compilación, reporte de errores dependiente de finalizadores
Unity Awaitable (2023+)Basada en clases (asigna), sin combinadores, sin canales, sin soporte de pruebas

Valkarn Tasks resuelve todos estos problemas en un único paquete generado por código fuente y sin asignaciones.


Sin asignaciones — por qué cada byte importa

Qué significa asignar memoria en juegos

Cada new object(), new List<T>() o llamada async Task asigna en el heap administrado, rastreado por el Recolector de Basura. Unity usa el GC Boehm, que tiene dos problemas críticos:

  1. Pausas stop-the-world — cuando el GC se ejecuta, el juego se congela. Una pausa de 2 ms a 60 fps consume el 12% del presupuesto de frame; a 120 fps (VR) consume el 24%.
  2. Temporización impredecible — el GC puede activarse durante un combate de jefe, una cinemática o una partida competitiva.

Qué hace diferente a Valkarn Tasks

EscenarioSystem.TaskUniTaskValkarn Tasks
async Method() — completa síncronamente144 bytes0 bytes0 bytes
async Method() — suspende una vez232+ bytes0 bytes (pooled)0 bytes (pooled)
WhenAll(a, b)232 bytes0 bytes (pooled)0 bytes (source-gen pooled)
WhenAny(a, b)144 bytes72 bytes0 bytes
Promise (completado manual)144 bytes104 bytes88 bytes
Promise en pool (reutilizable)144 bytes0 bytes0 bytes

En un frame de juego típico con 50–100 operaciones async, System.Task genera 7–23 KB de basura. Valkarn Tasks genera cero.

Qué significa esto para tu juego

  • Sin stuttering por el GC — framerate estable sin interrupciones causadas por operaciones async
  • Seguro para VR — objetivos de 90/120 fps sin picos del GC
  • Optimizado para móviles — menor presión de memoria en dispositivos con RAM limitada
  • Certificado para consolas — el comportamiento de memoria predecible ayuda a superar los requisitos de certificación

Rendimiento — comparado con los mejores

Benchmarks: BenchmarkDotNet v0.14.0, .NET 9.0, Intel Core i7-10875H.

Async/await básico — 2× más rápido que ValueTask

BenchmarkValueTaskUniTaskValkarn Tasksvs ValueTask
100 tareas en bloque956 ns508 ns489 ns1.95×
1.000 tareas en bloque9.697 ns5.016 ns4.728 ns2.05×
Con CancellationToken38,8 ns36,2 ns29,6 ns1.31×
Manejo de excepciones10.399 ns8.247 ns9.248 ns1.12×

Todos los caminos: 0 bytes asignados.

Combinadores — hasta 9,6× más rápido que Task

BenchmarkTaskUniTaskValkarn Tasksvs Task
WhenAll (2 tareas)117 ns / 232B13,6 ns / 0B12,1 ns / 0B9.6×
WhenAll (5 tareas)156 ns / 272B25,1 ns / 0B25,3 ns / 0B6.2×
WhenAny (2 tareas)39,0 ns / 144B60,0 ns / 72B11,6 ns / 0B3.4×
Promise en pool59,5 ns / 144B53,6 ns / 0B38,3 ns / 0B1.55×

WhenAny es 5,2× más rápido que UniTask y no asigna ningún byte.

Pool de objetos — 4,3 nanosegundos

OperaciónTiempoAsignación
Alquiler + devolución en slot rápido del hilo principal4,3 ns0 bytes
Pila Treiber entre hilos~15 ns0 bytes

Sin operaciones atómicas en el hilo principal — crítico para IL2CPP, donde Volatile.Read es 9,2× más lento.

Impacto en el mundo real (50 operaciones async/frame a 60 fps)

BibliotecaTiempo/framePresupuesto de frameGC/segundo
System.Task~48 µs0,29%~430 KB/s
UniTask~25 µs0,15%~3,6 KB/s
Valkarn Tasks~24 µs0,14%0 KB/s

En 10 minutos, System.Task genera ~258 MB de basura async. Valkarn Tasks genera cero.


Características que ninguna otra biblioteca tiene

Cancelación automática de ciclo de vida

public partial class EnemySpawner : MonoBehaviour
{
async ValkarnTask Start()
{
while (true)
{
await ValkarnTask.Delay(3000);
SpawnWave();
}
// Cancelado automáticamente cuando este GameObject se destruye.
// Sin CancellationToken. Sin fugas de memoria. Sin tareas zombie.
}
}

No más MissingReferenceException por métodos async que se ejecutan después de la destrucción. Sin limpieza manual en OnDestroy. Sin CancellationTokenSource olvidados.

Secciones críticas

async ValkarnTask SaveProgress()
{
var data = await LoadPlayerData(); // cancelable

await using (ValkarnTask.Critical())
{
await db.Insert(data); // se completa aunque el GO sea destruido
await db.Commit();
} // la cancelación pendiente se aplica ahora

await SendNotification(); // cancelable de nuevo
}

Las escrituras en base de datos, peticiones de red y guardados de archivos se completan aunque el jugador salga o una escena se descargue. Sin guardados corruptos. Sin analíticas a medias. Sin recibos perdidos.

Diagnósticos en tiempo de compilación

DiagnósticoQué detecta
TT001Doble await sobre un ValkarnTask (bug de uso tras liberación)
TT002Olvidar awaitar una tarea (fallo silencioso)
TT012Bucles async sin comprobación de cancelación (bucles zombie)
TT013Tarea devuelta pero no esperada (bug de fire-and-forget)
TT016Método async sin await (sobrecarga innecesaria)
TT017[FireAndForget] en ValkarnTask<T> (descarta un resultado)

Errores detectados en el IDE como líneas rojas — no como crashes en tiempo de ejecución 20 minutos después de empezar las pruebas.

Result<T> — manejo de errores sin try/catch

var result = await loadTask.AsResult();

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

Cada camino de error es explícito. Sin excepciones silenciadas. Sin manejadores omitidos.

Canales con contrapresión

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

// Productor (lógica de juego)
await channel.Writer.WriteAsync(new EnemySpawnRequest { type = EnemyType.Dragon });

// Consumidor (sistema de spawn)
await foreach (var request in channel.Reader.ReadAllAsync())
await SpawnEnemy(request);

Desacopla sistemas de forma limpia. Limita la tasa de spawning. Encola mensajes de red. Almacena eventos de entrada en buffer. El productor se ralentiza cuando el consumidor no puede seguir el ritmo — evitando picos de memoria.

Pruebas deterministas con 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);
}

Prueba la lógica dependiente del tiempo al instante. Sin yield return new WaitForSeconds(3) en pruebas. Sin temporización de CI inestable.

Seguridad de tokens generacionales

UniTask usa un token short (16 bits). Tras 65.536 ciclos de pool (~18 minutos de trabajo async activo), una referencia obsoleta lee silenciosamente el resultado de otra tarea — un bug de uso tras liberación prácticamente imposible de reproducir.

Valkarn Tasks usa un contador de generación uint por slot de pool: 4.294.967.296 ciclos por slot antes de colisión. Imposible en cualquier escenario realista.


Migración en minutos, no semanas

Desde UniTask

Paso 1: Instala Valkarn Tasks
Paso 2: Aparecen bombillas amarillas en los usos de UniTask en el IDE
Paso 3: Clic derecho → "Corregir todas las ocurrencias en la solución" (Ctrl+.)
Paso 4: Elimina la referencia al paquete UniTask

15 diagnósticos de migración (MIG001–MIG015) cubren automáticamente toda la API de UniTask. Un proyecto típico con 500–2.000 métodos async migra en menos de 5 minutos. 95%+ completamente automatizado.

Desde Unity Awaitable

La misma migración con un clic:

  • async Awaitableasync ValkarnTask
  • Awaitable.NextFrameAsync()ValkarnTask.NextFrame()
  • Awaitable.BackgroundThreadAsync()ValkarnTask.SwitchToThreadPool()
  • Awaitable.MainThreadAsync() → eliminado (Valkarn se ejecuta en el hilo principal por defecto)

Matriz de comparación

CaracterísticaSystem.TaskUniTaskAwaitableValkarn Tasks
Ruta síncrona sin asignaciónNoNo
Combinadores sin asignaciónNoNoNoSí (source gen)
Basado en structNoNo
Cancelación automática de ciclo de vidaNoManualParcialAutomático
Sin cancelación de hermanosNoNoNo
Secciones críticasNoNoNo
Result<T> (sin lanzar)NoParcialNo
TestClockNoNoNo
Puente con Job SystemNoNoNo
Diagnósticos en tiempo de compilaciónNoNoNoSí (17 reglas)
Pools acotados + recorteNoNoN/A
Reporte de errores deterministaNoNo (finalizador)ParcialSí (retorno al pool)
Canales completosSí (.NET)MínimoNo
Puente con AwaitableN/AMínimoNativoTransparente
Pool optimizado para IL2CPPNoNo (Volatile en cada op)N/ASí (sin atómicos)
Seguridad contra colisión de tokensN/A18 min (short)N/ANunca (uint gen)
Migración automática desde UniTaskN/AN/AN/ASí (15 correcciones)
Migración automática desde AwaitableN/AN/AN/ASí (8 correcciones)

Tu juego merece un async que no se entrecorte.