Aller au contenu principal

Pourquoi Valkarn Tasks

Async/await sans allocation pour Unity. Plus rapide qu'UniTask. Plus intelligent qu'Awaitable.


Le problème : l'async dans Unity est cassé

Les développeurs Unity ont besoin d'opérations asynchrones partout : chargement de scènes, téléchargement d'assets, attente d'animations, délai de spawning, communication avec des serveurs. Aujourd'hui, les options sont :

OptionProblème
CoroutinesPas de valeurs de retour, pas de gestion d'erreurs, pas d'annulation, pas de composition
System.Threading.TasksAlloue à chaque appel async (~144–232 octets), déclenche le GC, pas de gestion du cycle de vie Unity
UniTaskBien — mais : collision de tokens après 18 minutes, pas de diagnostics à la compilation, rapport d'erreurs dépendant des finaliseurs
Unity Awaitable (2023+)Basé sur une classe (alloue), pas de combinateurs, pas de canaux, pas de support de tests

Valkarn Tasks résout tous ces problèmes en un seul package généré par source, sans allocation.


Zéro allocation — pourquoi chaque octet compte

Ce que signifie l'allocation dans les jeux

Chaque new object(), new List<T>() ou appel async Task alloue sur le tas managé, suivi par le Garbage Collector. Unity utilise le GC Boehm qui présente deux problèmes critiques :

  1. Pauses stop-the-world — lorsque le GC s'exécute, le jeu se fige. Une pause de 2 ms à 60 fps coûte 12% du budget d'image ; à 120 fps (VR) elle coûte 24%.
  2. Timing imprévisible — le GC peut se déclencher pendant un combat de boss, une cinématique ou un match compétitif.

Ce que fait Valkarn Tasks différemment

ScénarioSystem.TaskUniTaskValkarn Tasks
async Method() — complète de façon synchrone144 octets0 octet0 octet
async Method() — se suspend une fois232+ octets0 octet (poolé)0 octet (poolé)
WhenAll(a, b)232 octets0 octet (poolé)0 octet (source-gen poolé)
WhenAny(a, b)144 octets72 octets0 octet
Promise (complétion manuelle)144 octets104 octets88 octets
Promise poolée (réutilisable)144 octets0 octet0 octet

Dans une image de jeu typique avec 50–100 opérations async, System.Task génère 7–23 Ko de déchets. Valkarn Tasks en génère zéro.

Ce que cela signifie pour votre jeu

  • Pas de saccades GC — framerate stable sans à-coups causés par les opérations async
  • Compatible VR — objectifs 90/120 fps sans pics de GC
  • Optimisé mobile — moins de pression mémoire sur les appareils à RAM limitée
  • Certifié console — le comportement mémoire prévisible aide à satisfaire les exigences de certification

Performance — comparé aux meilleurs

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

Async/await de base — 2× plus rapide que ValueTask

BenchmarkValueTaskUniTaskValkarn Tasksvs ValueTask
100 tâches en bloc956 ns508 ns489 ns1.95×
1 000 tâches en bloc9 697 ns5 016 ns4 728 ns2.05×
Avec CancellationToken38,8 ns36,2 ns29,6 ns1.31×
Gestion des exceptions10 399 ns8 247 ns9 248 ns1.12×

Tous les chemins : 0 octet alloué.

Combinateurs — jusqu'à 9,6× plus rapide que Task

BenchmarkTaskUniTaskValkarn Tasksvs Task
WhenAll (2 tâches)117 ns / 232o13,6 ns / 0o12,1 ns / 0o9.6×
WhenAll (5 tâches)156 ns / 272o25,1 ns / 0o25,3 ns / 0o6.2×
WhenAny (2 tâches)39,0 ns / 144o60,0 ns / 72o11,6 ns / 0o3.4×
Promise poolée59,5 ns / 144o53,6 ns / 0o38,3 ns / 0o1.55×

WhenAny est 5,2× plus rapide qu'UniTask et n'alloue aucun octet.

Pool d'objets — 4,3 nanosecondes

OpérationTempsAllocation
Emprunt + retour sur slot rapide du thread principal4,3 ns0 octet
Pile Treiber inter-threads~15 ns0 octet

Zéro opération atomique sur le thread principal — critique pour IL2CPP où Volatile.Read est 9,2× plus lent.

Impact en conditions réelles (50 opérations async/image à 60 fps)

BibliothèqueTemps/imageBudget d'imageGC/seconde
System.Task~48 µs0,29%~430 Ko/s
UniTask~25 µs0,15%~3,6 Ko/s
Valkarn Tasks~24 µs0,14%0 Ko/s

En 10 minutes, System.Task génère ~258 Mo de déchets async. Valkarn Tasks en génère zéro.


Fonctionnalités qu'aucune autre bibliothèque ne possède

Annulation automatique du cycle de vie

public partial class EnemySpawner : MonoBehaviour
{
async ValkarnTask Start()
{
while (true)
{
await ValkarnTask.Delay(3000);
SpawnWave();
}
// Annulé automatiquement quand ce GameObject est détruit.
// Pas de CancellationToken. Pas de fuites mémoire. Pas de tâches zombies.
}
}

Plus de MissingReferenceException causées par des méthodes async qui s'exécutent après la destruction. Plus de nettoyage manuel dans OnDestroy. Plus de CancellationTokenSource oubliés.

Sections critiques

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

await using (ValkarnTask.Critical())
{
await db.Insert(data); // se termine même si le GO est détruit
await db.Commit();
} // l'annulation en attente s'applique maintenant

await SendNotification(); // annulable à nouveau
}

Les écritures en base de données, les requêtes réseau et les sauvegardes de fichiers se terminent même lorsque le joueur quitte ou qu'une scène se décharge. Plus de sauvegardes corrompues. Plus d'analytics à moitié écrites. Plus de reçus perdus.

Diagnostics à la compilation

DiagnosticCe qu'il détecte
TT001Double-await sur un ValkarnTask (bug use-after-free)
TT002Oubli d'attendre une tâche (échec silencieux)
TT012Boucles async sans vérification d'annulation (boucles zombies)
TT013Tâche retournée mais non attendue (bug fire-and-forget)
TT016Méthode async sans await (surcharge inutile)
TT017[FireAndForget] sur ValkarnTask<T> (perte d'un résultat)

Bugs détectés dans l'IDE comme soulignements rouges — pas des crashes à l'exécution 20 minutes après le début des tests.

Result<T> — gestion d'erreurs sans try/catch

var result = await loadTask.AsResult();

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

Chaque chemin d'erreur est explicite. Pas d'exceptions avalées. Pas de gestionnaires manquants.

Canaux avec contre-pression

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

// Producteur (logique de jeu)
await channel.Writer.WriteAsync(new EnemySpawnRequest { type = EnemyType.Dragon });

// Consommateur (système de spawn)
await foreach (var request in channel.Reader.ReadAllAsync())
await SpawnEnemy(request);

Découple les systèmes proprement. Limite le taux de spawn. Met en file d'attente les messages réseau. Tamponne les événements d'entrée. Le producteur ralentit quand le consommateur ne peut pas suivre — évitant les pics de mémoire.

Tests déterministes avec 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);
}

Testez la logique dépendante du temps instantanément. Plus de yield return new WaitForSeconds(3) dans les tests. Plus de timing CI instable.

Sécurité des tokens générationnels

UniTask utilise un token short (16 bits). Après 65 536 cycles de pool (~18 minutes de travail async actif), une référence obsolète lit silencieusement le résultat d'une autre tâche — un bug use-after-free pratiquement impossible à reproduire.

Valkarn Tasks utilise un compteur de génération uint par slot de pool : 4 294 967 296 cycles par slot avant collision. Impossible dans tout scénario réaliste.


Migration en minutes, pas en semaines

Depuis UniTask

Étape 1 : Installez Valkarn Tasks
Étape 2 : Des ampoules jaunes apparaissent sur les usages UniTask dans l'IDE
Étape 3 : Clic droit → "Corriger toutes les occurrences dans la solution" (Ctrl+.)
Étape 4 : Supprimez la référence au package UniTask

15 diagnostics de migration (MIG001–MIG015) couvrent automatiquement toute l'API UniTask. Un projet typique avec 500–2 000 méthodes async migre en moins de 5 minutes. 95%+ entièrement automatisé.

Depuis Unity Awaitable

La même migration en un clic :

  • async Awaitableasync ValkarnTask
  • Awaitable.NextFrameAsync()ValkarnTask.NextFrame()
  • Awaitable.BackgroundThreadAsync()ValkarnTask.SwitchToThreadPool()
  • Awaitable.MainThreadAsync() → supprimé (Valkarn s'exécute sur le thread principal par défaut)

Matrice de comparaison

FonctionnalitéSystem.TaskUniTaskAwaitableValkarn Tasks
Chemin sync sans allocationNonOuiNonOui
Combinateurs sans allocationNonNonNonOui (source gen)
Basé sur structNonOuiNonOui
Annulation automatique du cycle de vieNonManuelPartielAutomatique
Pas d'annulation de tâches sœursNonNonNonOui
Sections critiquesNonNonNonOui
Result<T> (sans exception)NonPartielNonOui
TestClockNonNonNonOui
Pont avec Job SystemNonNonNonOui
Diagnostics à la compilationNonNonNonOui (17 règles)
Pools bornés + écrêtageNonNonN/AOui
Rapport d'erreurs déterministeNonNon (finaliseur)PartielOui (retour au pool)
Canaux completsOui (.NET)MinimalNonOui
Pont avec AwaitableN/AMinimalNatifTransparent
Pool optimisé IL2CPPNonNon (Volatile à chaque op)N/AOui (zéro atomique)
Sécurité contre les collisions de tokensN/A18 min (short)N/AJamais (uint gen)
Migration automatique depuis UniTaskN/AN/AN/AOui (15 corrections)
Migration automatique depuis AwaitableN/AN/AN/AOui (8 corrections)

Votre jeu mérite un async qui ne bégaie pas.