web-dev-qa-db-fra.com

Comment écrire du code C # thread-safe pour Unity3D?

J'aimerais comprendre comment écrire du code thread-safe.

Par exemple, j'ai ce code dans mon jeu:

bool _done = false;
Thread _thread;

// main game update loop
Update()
{
    // if computation done handle it then start again
    if(_done)
    {
        // .. handle it ...
        _done = false;
        _thread = new Thread(Work);
        _thread.Start();
    }
}

void Work()
{
     // ... massive computation

     _done = true;
}

Si je comprends bien, il se peut que le thread principal du jeu et mon _thread Puissent avoir leur propre version en cache de _done, Et un thread ne verra jamais que _done A changé dans un autre fil?

Et si c'est le cas, comment le résoudre?

  1. Est-il possible de résoudre en appliquant uniquement le mot clé volatile.

  2. Ou est-il possible de lire et d'écrire des valeurs via les méthodes de Interlocked comme Exchange et Read?

  3. Si j'entoure _done D'opérations de lecture et d'écriture avec lock (_someObject), dois-je utiliser Interlocked ou quelque chose pour empêcher la mise en cache?

Modifier 1

  1. Si je définis _done Comme volatile et que j'appelle la méthode Update à partir de plusieurs threads. Est-il possible que 2 threads entrent dans l'instruction if avant d'assigner _done À false?
23
Stas BZ
  1. oui, mais techniquement ce n'est pas ce que fait le mot clé volatile; il a ce résultat comme effet secondaire, cependant - et la plupart des utilisations de volatile sont pour cet effet secondaire; en fait, la documentation MSDN de volatile ne répertorie désormais que ce scénario d'effets secondaires ( link ) - Je suppose que l'original réel la formulation (sur les instructions de réorganisation) était tout simplement trop confuse? alors peut-être que est maintenant l'usage officiel?

  2. il n'y a pas de méthodes bool pour Interlocked; vous devez utiliser un int avec des valeurs comme 0/1, mais c'est à peu près ce qu'est un bool de toute façon - notez que Thread.VolatileRead fonctionnerait également

  3. lock a une clôture complète; vous n'avez besoin d'aucune construction supplémentaire, le lock en lui-même suffit au JIT pour comprendre ce dont vous avez besoin

Personnellement, j'utiliserais le volatile. Vous avez commodément répertorié votre 1/2/3 dans l'ordre croissant des frais généraux. volatile sera l'option la moins chère ici.

15
Marc Gravell

Bien que vous pourrait utilisez le mot clé volatile pour votre indicateur booléen, il ne garantit pas toujours un accès thread-safe au champ.

Dans votre cas, je créerais probablement une classe distincte Worker et utiliserais des événements pour notifier la fin de l'exécution de la tâche en arrière-plan:

// Change this class to contain whatever data you need

public class MyEventArgs 
{
    public string Data { get; set; }
}

public class Worker
{
    public event EventHandler<MyEventArgs> WorkComplete = delegate { };
    private readonly object _locker = new object();

    public void Start()
    {
        new Thread(DoWork).Start();
    }

    void DoWork()
    {
        // add a 'lock' here if this shouldn't be run in parallel 
        Thread.Sleep(5000); // ... massive computation
        WorkComplete(this, null); // pass the result of computations with MyEventArgs
    }
}

class MyClass
{
    private readonly Worker _worker = new Worker();

    public MyClass()
    {
        _worker.WorkComplete += OnWorkComplete;
    }

    private void OnWorkComplete(object sender, MyEventArgs eventArgs)
    {
        // Do something with result here
    }

    private void Update()
    {
        _worker.Start();
    }
}

N'hésitez pas à changer le code selon vos besoins

P.S. La volatilité est bonne en termes de performances, et dans votre scénario, cela devrait fonctionner car il semble que vous obtenez vos lectures et écritures dans le bon ordre. Il est possible que la barrière de la mémoire soit atteinte précisément en lisant/écrivant fraîchement - mais il n'y a aucune garantie par les spécifications MSDN. C'est à vous de décider de prendre le risque d'utiliser volatile ou non.

11
Fabjan

Peut-être que vous n'avez même pas besoin de votre variable _done, car vous pourriez obtenir le même comportement si vous utilisez la méthode IsAlive () du thread. (étant donné que vous n'avez qu'un seul fil d'arrière-plan)

Comme ça:

if(_thread == null || !_thread.IsAlive())
{
    _thread = new Thread(Work);
    _thread.Start();
}

Je n'ai pas testé ce btw .. c'est juste une suggestion :)

6
Stefan Woehrer

Utilisez MemoryBarrier ()

System.Threading.Thread.MemoryBarrier() est le bon outil ici. Le code peut sembler maladroit, mais il est plus rapide que les autres alternatives viables.

bool _isDone = false;
public bool IsDone
{
    get
    {
        System.Threading.Thread.MemoryBarrier();
        var toReturn = this._isDone;
        System.Threading.Thread.MemoryBarrier();

        return toReturn;
    }
    private set
    {
        System.Threading.Thread.MemoryBarrier();
        this._isDone = value;
        System.Threading.Thread.MemoryBarrier();
    }
}

Ne pas utiliser volatile

volatile n'empêche pas la lecture d'une valeur plus ancienne, donc elle ne répond pas à l'objectif de conception ici. Voir explication de Jon Skeet ou Threading en C # pour en savoir plus.

Notez que volatile mai semble fonctionner dans de nombreux cas en raison d'un comportement indéfini, en particulier d'un modèle de mémoire forte sur de nombreux systèmes courants. Cependant, la dépendance à un comportement non défini peut entraîner l'apparition de bogues lorsque vous exécutez votre code sur d'autres systèmes. Un exemple pratique de cela serait si vous exécutez ce code sur un Raspberry Pi (désormais possible grâce à .NET Core!).

Edit : Après avoir discuté de l'affirmation selon laquelle "volatile ne fonctionnera pas ici", on ne sait pas exactement ce que la spécification C # garantit; sans doute, volatile pourrait être garanti de fonctionner, mais avec un délai plus long. MemoryBarrier() est toujours la meilleure solution car elle garantit un commit plus rapide. Ce comportement est expliqué dans un exemple de "C # 4 en bref", décrit dans " Pourquoi ai-je besoin d'une barrière de mémoire? ".

N'utilisez pas de verrous

Les verrous sont un mécanisme plus lourd destiné à un meilleur contrôle du processus. Ils sont inutilement maladroits dans une application comme celle-ci.

La performance est suffisamment petite pour que vous ne la remarquiez probablement pas avec une utilisation légère, mais elle est toujours sous-optimale. En outre, il peut contribuer (même légèrement) à des problèmes plus importants tels que la famine de fil et le blocage.

Détails sur les raisons pour lesquelles la volatilité ne fonctionne pas

Pour illustrer le problème, voici le code source .NET de Microsoft (via ReferenceSource) :

public static class Volatile
{
    public static bool Read(ref bool location)
    {
        var value = location;
        Thread.MemoryBarrier();
        return value;
    }

    public static void Write(ref byte location, byte value)
    {
        Thread.MemoryBarrier();
        location = value;
    }
}

Donc, disons qu'un thread définit _done = true;, Puis un autre lit _done Pour vérifier s'il s'agit de true. À quoi cela ressemble-t-il si nous l'intégrons?

void WhatHappensIfWeUseVolatile()
{
    // Thread #1:  Volatile write
    Thread.MemoryBarrier();
    this._done = true;           // "location = value;"

    // Thread #2:  Volatile read
    var _done = this._done;      // "var value = location;"
    Thread.MemoryBarrier();

    // Check if Thread #2 got the new value from Thread #1
    if (_done == true)
    {
        //  This MIGHT happen, or might not.
        //  
        //  There was no MemoryBarrier between Thread #1's set and
        //  Thread #2's read, so we're not guaranteed that Thread #2
        //  got Thread #1's set.
    }
}

En bref, le problème avec volatile est que, alors qu'il fait insérer MemoryBarrier(), c'est ne fait pas insérer les où nous en avons besoin dans ce cas.

5
Nat

Les novices peuvent créer du code thread-safe dans Unity en procédant comme suit:

  • Copiez les données sur lesquelles travailler un thread de travail.
  • Dites à un thread de travail de travailler sur la copie.
  • À partir du thread de travail, lorsque le travail est terminé, envoyez un appel au thread principal pour appliquer les modifications.

De cette façon, vous n'avez pas besoin de verrous et de volatiles dans votre code, juste deux répartiteurs (qui masquent tous les verrous et volatiles).

Maintenant, c'est la variante simple et sûre, celle que les novices devraient utiliser. Vous vous demandez probablement ce que font les experts: ils font exactement la même chose.

Voici du code de la méthode Update dans l'un de mes projets, qui résout le même problème que vous essayez de résoudre:

Helpers.UnityThreadPool.Instance.Enqueue(() => {
    // This work is done by a worker thread:
    SimpleTexture t = Assets.Geometry.CubeSphere.CreateTexture(block, (int)Scramble(ID));
    Helpers.UnityMainThreadDispatcher.Instance.Enqueue(() => {
        // This work is done by the Unity main thread:
        obj.GetComponent<MeshRenderer>().material.mainTexture = t.ToUnityTexture();
    });
});

Notez que la seule chose que nous devons faire pour sécuriser le thread ci-dessus est de ne pas modifier blockou ID après avoir appelé la file d'attente. Aucun verrou volatil ou explicite impliqué.

Voici les méthodes pertinentes de UnityMainThreadDispatcher:

List<Action> mExecutionQueue;
List<Action> mUpdateQueue;

public void Update()
{
    lock (mExecutionQueue)
    {
        mUpdateQueue.AddRange(mExecutionQueue);
        mExecutionQueue.Clear();
    }
    foreach (var action in mUpdateQueue) // todo: time limit, only perform ~10ms of actions per frame
    {
        try {
            action();
        }
        catch (System.Exception e) {
            UnityEngine.Debug.LogError("Exception in UnityMainThreadDispatcher: " + e.ToString());
        }
    }
    mUpdateQueue.Clear();
}

public void Enqueue(Action action)
{
    lock (mExecutionQueue)
        mExecutionQueue.Add(action);
}

Et voici un lien vers une implémentation de pool de threads que vous pouvez utiliser jusqu'à ce que Unity prenne enfin en charge le ThreadPool .NET: https://stackoverflow.com/a/436552/161274

4
Peter