Как написать потокобезопасный код С# для Unity3D?
Я хотел бы понять, как писать потокобезопасный код.
Например, у меня есть этот код в моей игре:
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;
}
Если я правильно понимаю, может случиться так, что основной игровой поток и мой _thread
могут иметь свою собственную кешированную версию _done
, и один поток никогда не увидит, что _done
изменено в другом потоке?
И если это возможно, как его решить?
-
Можно ли разрешить, только применяя ключевое слово volatile
.
-
Или можно читать и записывать значения с помощью методов Interlocked
, таких как Exchange
и Read
?
-
Если я окружаю _done
операцию чтения и записи с lock (_someObject)
, мне нужно использовать Interlocked
или что-то, чтобы предотвратить кеширование?
Изменить 1
- Если я определяю
_done
как volatile
и вызывать метод Update
из нескольких потоков. Возможно ли, что 2 потока войдут в оператор if
, прежде чем присваивать _done
false?
Ответы
Ответ 1
-
да, но технически это не то, что делает ключевое слово volatile
; он имеет этот результат как побочный эффект, хотя - и большинство использования volatile
для этого побочного эффекта; на самом деле документация MSDN volatile
теперь перечисляет только этот сценарий побочного эффекта (ссылка). Я предполагаю, что фактическая оригинальная формулировка (о инструкциях по переупорядочению) было просто слишком запутанным? так что, возможно, это официальное использование?
-
для Interlocked
нет методов bool
; вам нужно использовать int
со значениями, такими как 0
/1
, но это в значительной степени то, что a bool
в любом случае - обратите внимание, что Thread.VolatileRead
также будет работать
-
lock
имеет полный забор; вам не нужны никакие дополнительные конструкции, lock
сам по себе достаточно, чтобы JIT понял, что вам нужно
Лично я использую volatile
. Вы легко указали свой 1/2/3 при увеличении накладных расходов. volatile
будет самым дешевым вариантом здесь.
Ответ 2
Хотя вы можете использовать ключевое слово volatile
для вашего флага bool, он не всегда гарантирует потокобезопасный доступ к полю.
В вашем случае я мог бы создать отдельный класс Worker
и использовать события для уведомления, когда фоновая задача завершает выполнение:
// 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();
}
}
Не стесняйтесь изменять код в соответствии с вашими потребностями
P.S.
Волатильность хороша по производительности, и в вашем сценарии она должна работать так, как будто вы читаете и записываете в правильном порядке. Возможно, барьер памяти достигается именно при чтении/записи свеже - но нет гарантии по спецификациям MSDN. Вам решать, рисковать ли использовать volatile
или нет.
Ответ 3
Возможно, вам даже не нужна переменная _done, так как вы можете добиться такого же поведения, если используете метод IsAlive(). (учитывая, что у вас только 1 фоновый поток)
Вот так:
if(_thread == null || !_thread.IsAlive())
{
_thread = new Thread(Work);
_thread.Start();
}
Я не тестировал этот бит... это всего лишь предложение:)
Ответ 4
Использовать MemoryBarrier()
System.Threading.Thread.MemoryBarrier()
- это правильный инструмент. Код может выглядеть неуклюжим, но он быстрее, чем другие жизнеспособные альтернативы.
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
volatile
не препятствует чтению более старого значения, поэтому он не соответствует цели дизайна здесь. См. объяснение Джона Скита или Threading in С# для более подробной информации.
Обратите внимание, что volatile
может работать во многих случаях из-за поведения undefined, особенно сильной модели памяти во многих общих системах. Однако зависимость от поведения undefined может привести к появлению ошибок при запуске вашего кода в других системах. Практическим примером этого может быть, если вы используете этот код на Raspberry Pi (теперь это возможно из-за .NET Core!).
Изменить. После обсуждения утверждения о том, что "volatile
здесь не будет работать", неясно, что именно гарантирует спецификация С#; возможно, volatile
может быть гарантированно работать, хотя и с большей задержкой. MemoryBarrier()
по-прежнему является лучшим решением, поскольку обеспечивает более быстрое коммит. Это поведение объясняется в примере из "С# 4 в двух словах", обсуждаемом в "Зачем мне нужен барьер памяти?.
Не используйте блокировки
Замки - более тяжелый механизм, предназначенный для более сильного контроля процесса. Они бесполезны в таком приложении.
Достижение производительности достаточно мало, чтобы вы, вероятно, не заметили его при использовании света, но оно все еще не оптимально. Кроме того, он может внести вклад (даже если немного) в сторону более крупных проблем, таких как голодание и блокировка потоков.
Сведения о том, почему volatile не работает
Чтобы продемонстрировать эту проблему, здесь .NET исходный код от Microsoft (через 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;
}
}
Итак, скажем, что один поток устанавливает _done = true;
, затем другой читает _done
, чтобы проверить, является ли он true
. Как это выглядит, если мы встраиваем его?
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 set and
// Thread #2 read, so we're not guaranteed that Thread #2
// got Thread #1 set.
}
}
Короче говоря, проблема с volatile
заключается в том, что, в то время как она вставляет MemoryBarrier()
's, она не вставляет их там, где они нам нужны в этом случае.
Ответ 5
Новички могут создавать потокобезопасный код в Unity, выполняя следующие действия:
- Скопировать данные, которые они хотят, чтобы рабочий поток работал.
- Сообщите рабочему потоку о работе над копией.
- Из рабочего потока, когда работа выполнена, отправьте вызов в основной поток, чтобы применить изменения.
Таким образом, вам не нужны блокировки и летучие компоненты в вашем коде, всего два диспетчера (которые скрывают все блокировки и летучие компоненты).
Теперь это простой и безопасный вариант, который должны использовать новички. Вам, наверное, интересно, что делают эксперты: они делают то же самое.
Вот код из метода Update
в одном из моих проектов, который решает ту же проблему, которую вы пытаетесь решить:
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();
});
});
Обратите внимание, что единственное, что мы должны сделать, чтобы обеспечить безопасный поток данных, - это не редактировать block
или ID
после вызова enqueue. Не задействован волатильный или явный блокировка.
Вот соответствующие методы из 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);
}
И вот ссылка на реализацию пула потоков, которую вы можете использовать до тех пор, пока Unity, наконец, не поддержит .NET ThreadPool: fooobar.com/questions/98101/...
Ответ 6
Я не думаю, что вам нужно много добавить к вашему коду. Если Unity-версия Mono действительно не делает что-то чрезвычайно и несовместимо с обычной версией .NET Framework (что, я уверен, это не так), у вас не будет разных копий _done в разных потоках в вашем сценарии. Таким образом, нет необходимости в блокировке, нет необходимости в изменчивости, без черной магии.
То, что вы действительно можете встретить с довольно высокой вероятностью, - это ситуация, когда ваш основной поток устанавливает _done в false в тот же самый момент, когда ваш фоновый поток устанавливает его в true, что определенно плохо. Для этого вам необходимо окружить эти операции "блокировкой" (не забудьте заблокировать их на SAME-объекте синхронизации).
Пока вы используете _done, как описано в примере кода, вы можете просто "блокировать" его каждый раз, когда вы пишете переменную, и все будет хорошо.