Подтвердить что ты не робот

Как написать потокобезопасный код С# для 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

  1. Если я определяю _done как volatile и вызывать метод Update из нескольких потоков. Возможно ли, что 2 потока войдут в оператор if, прежде чем присваивать _done false?
4b9b3361

Ответ 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, как описано в примере кода, вы можете просто "блокировать" его каждый раз, когда вы пишете переменную, и все будет хорошо.