如何为Unity3D编写线程安全的C#代码?

时间:2017-04-07 09:39:18

标签: c# multithreading 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已更改另一个线程?

如果可能,如何解决?

  1. 是否可以解决,仅应用volatile关键字。

  2. 或者可以通过InterlockedExchange之类的Read方法读取和写入值吗?

  3. 如果我使用_done包围lock (_someObject)读写操作,是否需要使用Interlocked或其他内容来阻止缓存?

  4. 修改1

    1. 如果我将_done定义为volatile并从多个线程调用Update方法。在将if指定为false之前,2个线程是否可能会输入_done语句?

6 个答案:

答案 0 :(得分:15)

  1. 是的,但从技术上讲,这不是volatile关键字所做的;然而,它有这种结果作为副作用 - volatile的大部分用法都是 这种副作用;实际上volatile的MSDN文档现在只列出了这个副作用场景(link) - 我想实际的原始措辞(关于重新排序指令)太混乱了?那么这个现在是官方用法吗?

  2. bool没有Interlocked个方法;您需要使用int / 0之类的值1,但这几乎是bool / em> - 请注意Thread.VolatileRead也可以使用

  3. lock有一个完整的围栏;你不需要任何额外的结构,lock本身足以让JIT了解你的需要

  4. 就个人而言,我使用volatile。您已经方便地列出了1/2/3增加的开销顺序。 volatile将是最便宜的选择。

答案 1 :(得分:11)

虽然可能使用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的风险。

答案 2 :(得分:6)

也许您甚至不需要_done变量,因为如果使用线程的IsAlive()方法,您可能会遇到相同的行为。 (假设你只有1个后台线程)

像这样:

img

我没有测试过这个......这只是一个建议:)

答案 3 :(得分:5)

使用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不会阻止读取较旧的值,因此它不符合此处的设计目标。有关详情,请参阅Jon Skeet's explanationThreading in C#

请注意volatile 可能在很多情况下由于未定义的行为而出现,特别是在许多常见系统上都是强大的内存模型。但是,当您在其他系统上运行代码时,依赖未定义的行为可能会导致出现错误。一个实际的例子就是你在Raspberry Pi上运行这个代码(现在可能是因为.NET Core!)。

修改:在讨论了“volatile无法在此处发挥作用”的说法之后,目前还不清楚C#规范究竟保证了什么;可以说,volatile 可能可以保证工作,尽管它有更大的延迟。 MemoryBarrier()仍然是更好的解决方案,因为它可以确保更快的提交。在“Why do I need a memory barrier?”中讨论的“果壳中的C#4”中的示例中解释了此行为。

不要使用锁

锁是一种较重的机制,用于加强过程控制。他们在这样的应用程序中不必要地笨拙。

性能打击足够小,你可能不会注意到光线使用,但它仍然是次优的。此外,它可以(如果稍微)对线程饥饿和死锁等较大问题做出贡献。

有关挥发性无效的详细信息

为了证明这个问题,这里是.NET source code from 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;
    }
}

所以,假设一个线程设置_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's set and
        //  Thread #2's read, so we're not guaranteed that Thread #2
        //  got Thread #1's set.
    }
}

简而言之,volatile的问题在于,当 插入MemoryBarrier()时,它不会将它们插入在这种情况下我们需要它们。

答案 4 :(得分:4)

Novices可以通过执行以下操作在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();
    });
});

请注意,要使上述线程安全,我们唯一要做的就是在调用enqueue后不要编辑blockID。不涉及易失性或显式锁定。

以下是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:https://stackoverflow.com/a/436552/1612743

答案 5 :(得分:-2)

我认为您不需要在代码中添加太多内容。除非Unity的Mono版本真的做了大量的事情并且与常规的.Net Framework(我确定它没有)有着不同的代码,你在场景中的不同线程中不会有_done的不同副本。所以,不需要互锁,不需要挥发,不需要黑魔法。

当你的主线程将后台线程设置为true的同一时刻你的主线程将_done设置为false时,你真正可能会遇到的情况非常高,这绝对是一件坏事。这就是为什么你需要用'lock'来包围那些操作(不要忘记将它们锁在SAME同步对象上)。

只要您按照代码示例中的描述使用_done,就可以在每次编写变量时“锁定”它,一切都会好的。