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?
Est-il possible de résoudre en appliquant uniquement le mot clé volatile
.
Ou est-il possible de lire et d'écrire des valeurs via les méthodes de Interlocked
comme Exchange
et Read
?
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
_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?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?
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
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.
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.
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 :)
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();
}
}
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? ".
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.
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.
Les novices peuvent créer du code thread-safe dans Unity en procédant comme suit:
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 block
ou 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