Asynchronisme dans Unity : coroutines et async-await

Brouillon

À venir

0 commentaire

Dans Unity, il peut arriver de vouloir exécuter du code dont l'exécution s'étale sur plusieurs frames : du code gourmand en temps de calcul, ou une opération à durée fixée. Néanmoins, l'exécution d'un tel code ne doit pas ralentir ou bloquer l'exécution de l'application, au risque de détériorer l'expérience. Bien qu'il soit possible d'utiliser des threads à cet effet, l'API de Unity n'est généralement pas thread-safe. On ne peut donc pas interagir directement avec Unity depuis un autre thread que le thread principal, il faut mettre en place des mécanismes de synchronisation. Même si cette solution est envisageable, et parfois préférable (networking par exemple), on préférera bien souvent gérer la concurrence avec les coroutines de Unity.

Source

Cas d’utilisation type d’une coroutine : modification de la couleur d’un GameObject

Prenons l'exemple suivant : on souhaite changer la couleur d'un cube, placé à l'intérieur de la scène. Même s'il est possible d'écrire ce code à la main, c'est à dire de garder l'état de l'exécution (couleur originale, couleur actuelle, couleur cible) et de gérer l'exécution sur plusieurs frames dans la méthode Update, on peut aussi créer la coroutine associée. Une coroutine vous permet de définir un point d'interruption (mot clé yield), qui permet de rendre la main à Unity. Les coroutines permettent d'implémenter un multitasking coopératif, dans lequel chaque coroutine coopère en signalant ses points d'interruption possibles.

Exemple de coroutine pour changer la couleur d'un GameObject :

class ChangingColourCoroutine : MonoBehaviour
{
    public float duration = 5.0f;
    public Color targetColor;

    void Start()
    {
        StartCoroutine(ChangeColour(this.gameObject)); // On démarre la coroutine.
    }

    IEnumerator ChangeColour(GameObject target)
    {
        var material = target.GetComponent<Renderer>().material;
        var elapsedTime = 0.0f;
        while (elapsedTime < duration)
        {
            elapsedTime += Time.deltaTime;
            material.color = Color.Lerp(material.color, targetColor, elapsedTime / duration);
            yield return null; // Signale l'interruption, Unity peut reprendre la main pour ses tâches internes. La valeur null définit une interruption jusqu'à la prochaine frame.
        }
    }
}

L'implémentation repose sur les blocs itérateurs, qui proposent un mécanisme sans callbacks (ou presque ...) d'interruption de l'exécution.

Principales limitations

Les coroutines de Unity reposent entièrement sur les blocs itérateurs introduits dans C# 2. Naturellement, elles héritent des limitations de ceux-ci, dont les principales sont exposées ci-dessous.

Valeur de retour

Les blocs itérateurs ne permettent pas de retourner une valeur. Les coroutines héritent du même problème.

De la même manière qu'avec les exceptions (voir plus loin), les callbacks fournissent une solution à ce problème.

Exemple de lecture d'un fichier avec une coroutine :

class CoroutineExample : MonoBehaviour
{
    public string path = "some/path/file.txt";
    void Start()
    {
        StartCoroutine(CoroutineWithCallback(path, ProcessContent));
    }

    void ProcessContent(string content)
    {
        // Process content...
        Debug.Log(content);
    }

    IEnumerator CoroutineWithCallback(string path, Action<string> resultCallback)
    {
        var builder = new StringBuilder();
        using (var reader = new StreamReader(path))
        {
            while (true)
            {
                var line = reader.ReadLine();
                if (line == null) break;
                builder.Append(line);
                yield return null;
            }
        }
        resultCallback(builder.ToString()); // On appelle le callback pour retourner la valeur lue dans le fichier !
    }
}

Gestion des exceptions

La spécification de C# indique qu'une déclaration yield ne peut pas apparaître dans un bloc finally. Une déclaration yield return ne peut également pas apparaître dans une déclaration try contenant une ou plusieurs déclarations catch (mais pas 0 !). Cela limite par conséquent la gestion des exceptions pour les coroutines.

Il est néanmoins possible, avec des callbacks, de préciser des actions à entreprendre lors de la réussite ou l'échec de l'opération à l'intérieur de la coroutine.

Le code à déployer ici est un peu plus long : quelques ressources intéressantes pour réaliser cette gestion, ici et . Il s'agit de workarounds pour compenser le manque de support des exceptions pour les blocs itérateurs.

Async-await à la rescousse

Le principal défaut des callbacks est qu'il entraîne la fragmentation du code : il devient difficile de suivre l'exécution d'une méthode asynchrone, qui se retrouve fractionnée dans plusieurs corps. La programmation asynchrone par les évènements souffre du même problème, et c'est ce problème que le TAP (Task-based Asynchronous Pattern) propose de résoudre.

Valeur de retour avec Task<T>

Le paramètre générique de Task<T> représente le type de retour que l'on souhaite. Pour récupérer une valeur d'une opération asynchrone, on peut attendre (sans bloquer le thread, en redonnant le contrôle à l'appelant), via le mot-clé await. Le mécanisme de contrôle du flux d'exécution vous paraît familier ? C'est normal, il s'agit presque de la même génération de machine à états que pour les blocs itérateurs :

.NET Compiler Roslyn : AsyncStateMachine, IteratorStateMachine, StateMachineTypeSymbol.

Mono mcs : iterators, async

Un exemple de méthode async :

async Task<string> MeasureContentLengthAsync(string path)
{
    using (var reader = File.OpenText(path))
    {
        return await reader.ReadToEndAsync();
    }
}

Le gain est immédiatement visible : plus de callbacks, tout est au même endroit.

Gestion des exceptions

Depuis C# 6, il n'y a plus de limitations pour le mot clé await dans les blocs catch et finally, ce qui permet de gérer les erreurs de manière classique.

async Task<string> MeasureContentLengthAsync(string path)
{
    using (var reader = File.OpenText(path))
    {
        try 
        {
            await reader.ReadToEndAsync();
        }
        catch (Exception exception)
        {
            // Ici, on peut gérer l'exception comme on le souhaite.
        }
    }
}

Le gain est ici aussi immédiatement visible : le bloc n'est plus séparé en callbacks, comme pour la valeur de retour.

Comment utiliser async-await dans Unity ?

Depuis la sortie de Unity 2017, il existe une option expérimentale qui permet de passer à .NET 4.6, et donc d'utiliser la programmation asynchrone via async-await. Pour l'activer option, aller dans le menu Edit -> Project Settings -> Player, puis changer Scripting Runtime Version à Experimental (.NET 4.6 Equivalent).

Cas d’utilisation de lecture d’un contenu web (base API Unity WWW) avec et sans async-await

Dans cet exemple, nous construirons un script de chargement d'image à partir d'une URL, avec gestion d'exceptions. On utilisera la librairie UniRx pour établir le pont entre les coroutines de Unity et la construction async-await (les équipes de Unity n'ont rien annoncé sur la possible migration vers async-await).

Voici le script sans async-await, avec les coroutines écrites à l'aide de blocs itérateurs :

class WWWExample : MonoBehaviour
{
    public string url = "someUrl.png";

    void Start()
    {
        Renderer renderer = GetComponent<Renderer>();
        Action<Texture2D> resultAction = (tex) => { renderer.material.mainTexture = tex; };
        StartCoroutine(RunThrowingIterator(ImageOf(url, resultAction), (exception) => { /* Gestion des exceptions */ }));
    }

    IEnumerator ImageOf(string url, Action<Texture2D> resultCallback)
    {
        using (WWW www = new WWW(url))
        {
            yield return www;
            if (!string.IsNullOrEmpty(www.error)) throw new Exception("Something horrible just happened.");
            resultCallback(www.texture);
        }
    }

    static IEnumerator RunThrowingIterator(IEnumerator enumerator, Action<Exception> done)
    {
        while (true)
        {
            object current;
            try
            {
                if (enumerator.MoveNext() == false) break;
                current = enumerator.Current;
            }
            catch (Exception ex)
            {
                done(ex);
                yield break;
            }
            yield return current;
        }
        done(null);
    }
}

L’équivalent au code précédent, avec la construction async-await :

using UniRx; // Important pour établir le pont IEnumerator / async-await.

class AwaitExample : MonoBehaviour
{
    public string url = "someUrl.png";

    async void Start()
    {
        Renderer renderer = GetComponent<Renderer>();
        try
        {
            Texture2D texture = await ImageOf(url);
            renderer.material.mainTexture = texture;
        }
        catch (Exception exception)
        {
            Debug.Log(exception);
            // Gestion des exceptions
        }
    }

    async Task<Texture2D> ImageOf(string url)
    {
        WWW www = await new WWW(url);
        if (!string.IsNullOrEmpty(www.error)) throw new Exception("Something horrible just happened.");
        return www.texture;
    }
}

Il y a beaucoup moins de boilerplate, et l'intention du développeur est mieux capturée !

Adrian Lissot

Ingénieur en Systèmes Logiciels et Réseaux diplômé de TELECOM Bretagne

Profil de l'auteur