【问题标题】:How to write thread-safe C# code for Unity3D?如何为 Unity3D 编写线程安全的 C# 代码?
【发布时间】:2017-04-07 09:39:18
【问题描述】:

我想了解如何编写线程安全代码。

例如,我的游戏中有这段代码:

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. 或者是否可以通过InterlockedExchangeRead等方法读写值?

  3. 如果我用lock (_someObject)包围_done读写操作,我需要使用Interlocked或其他东西来防止缓存吗?

编辑 1

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

【问题讨论】:

  • 不使用threads,不如试试tasks
  • @MAdeelKhalid,我用Unity3d引擎,它只支持框架2
  • 请注意,线程很昂贵(相对而言)每次启动一个新线程可能并不理想
  • 我明白你在做什么。您只是想从另一个Thread 在主Thread 中执行某些操作。您现在拥有的是一种基本方法,但不应在生产代码中使用它。你需要一个队列系统。在this 发帖了解正确的方法。我为此做了一个UnityThread 课程。
  • 如果你想使用多线程,我建议使用库。例如,UniRX 为 Unity 提供了一个简洁的线程池和任务替换,并且还附带了所有的 Rx 工具集(顾名思义,嗯),这有助于获得正确的异步性。

标签: c# multithreading unity3d


【解决方案1】:
  1. 是的,但从技术上讲,这不是 volatile 关键字所做的;但是,它具有该结果作为副作用-volatile 的大多数用途用于该副作用;实际上 volatile 的 MSDN 文档现在只列出了这种副作用场景 (link) - 我猜 实际原件 措辞(关于重新排序说明)太混乱了?所以也许这现在正式使用了吗?

  2. Interlocked 没有 bool 方法;您需要使用具有 0/1 之类的值的 int,但这几乎就是 bool 的含义无论如何 - 请注意 Thread.VolatileRead 也可以使用 p>

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

就个人而言,我会使用volatile。您方便地按递增的间接费用顺序列出了您的 1/2/3。 volatile 将是这里最便宜的选择。

【讨论】:

  • 您能回答我添加到问题中的位置 4 吗?
  • volatile 的使用在这里不起作用 (Jon Skeet's explanation),而 lock 的使用过多。
【解决方案2】:

虽然您可能使用 volatile 关键字作为布尔标志,但它并不总是保证对该字段的线程安全访问。

在你的情况下,我可能会创建一个单独的类 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();
    }
}

根据需要随意更改代码

附: Volatile 在性能方面具有良好的性能,在您的场景中,它应该可以正常工作,因为看起来您以正确的顺序进行读取和写入。内存屏障可能是通过新读/写来精确实现的——但 MSDN 规范不能保证。是否冒险使用volatile 由您决定。

【讨论】:

  • 你能解释一下为什么volatile 不保证对字段的线程安全访问吗?或分享链接。
  • @StasBZ 请参阅“可以交换它们吗?” Threading in C# 中的表格。 tl;dr- volatile 不会阻止读取尝试在写入较新的值后“读取”缓存值,因此如果 _done 设置为TRUEvolatile 不会阻止它看起来是 FALSE。 (我不赞同这个答案的其余部分,但发帖人对这一点是正确的。)
  • 该链接解释了重新排序inside一个线程,而不是跨线程。获取/释放语义在多个线程之间建立同步原语。链接中的作者是错误的(正如这个答案中的断言):无论您是原子地读取新值还是陈旧值都是 cache-coherency 的问题,而不是内存排序问题 - 同样,大多数处理器(包括 C# 运行的所有平台)默认保证缓存一致性。
  • @Shaggi,在 ARM 上,您没有该保证,而 C# 确实可以在其上运行。 Volatile 不能保证缓存的一致性,因此您最终可能会读取陈旧的值。
  • @creker 阅读此内容后,很明显将“arm”称为架构是没有意义的,但是他们确实在某个时候更改了所有拱门上的缓存一致性保证以始终拥有它。这个有 17 年历史的架构描述了如果你“弄脏”共享内存,你必须如何(在软件中)更新共享内存......不难看出为什么每个人都在朝着缓存一致性方向发展:infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0151c/…A9(至少,并且向前)有缓存一致性:infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0407e/…
【解决方案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 不会阻止读取较旧的值,因此它不符合此处的设计目标。请参阅Jon Skeet's explanationThreading in C# 了解更多信息。

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

    编辑:在讨论了“volatile 在这里不起作用”的说法之后,尚不清楚 C# 规范究竟保证了什么;可以说,volatile 可能 可以保证工作,尽管它有更大的延迟。 MemoryBarrier() 仍然是更好的解决方案,因为它确保了更快的提交。此行为在“C# 4 in a Nutshell”的示例中进行了解释,在“Why do I need a memory barrier?”中进行了讨论。

    不要使用锁

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

    性能影响很小,您可能不会在少量使用时注意到它,但它仍然不是最佳的。此外,它可能会(即使是轻微的)导致线程不足和死锁等更大的问题。

    详细说明为什么 volatile 不起作用

    为了演示这个问题,这里是.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(),但它没有将它们插入到我们需要它们的位置这种情况。

    【讨论】:

    • 也可以查看我的其他评论。 “旧值”(通常称为新值或陈旧值)是缓存一致性的影响,而不是内存排序。如果你的 volatile 读/写是 atomic 类型(提示:volatile 类型要求是相同的), volatile 确实总是保证最新的值。有关详细信息,请参阅规范的 §10.5。
    • @Shaggi,该规范没有说明任何关于防止陈旧值的内容。它只提到内存重新排序。所以你仍然有缓存一致性问题,它会让你在像 ARM 这样的东西上遇到麻烦
    • 我不喜欢这个答案。在这种情况下,锁应该是您使用的第一个也是几乎唯一的东西。只是因为他们工作并且不需要太多的专业知识。如果由于某种原因不想锁定,可以使用互锁。如果你仍然认为你需要别的东西,那就再想一想。如果您考虑手动放置内存屏障,您应该真正知道自己在做什么以及为什么。
    • @creker 锁在这里是一个概念上不正确的工具。他们会做一些奇怪的事情,比如阻止两个线程同时读取.IsDone。另外,您必须通过不必要的性能损失来为不受欢迎的功能付出代价,这既是为了首先获得锁,又是为了在经常遇到读取阻塞时获得更大的锁。最后,锁会导致重负载下的线程饥饿,从而冻结程序。
    【解决方案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();
        });
    });
    

    请注意,要使上述线程安全,我们唯一要做的就是在调用 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 线程池之前使用它:https://stackoverflow.com/a/436552/1612743

    【讨论】:

    • 这是解决问题的正确方法。平台提供同步,无需手动同步。编写多线程代码已经够难了。
    • 谢谢。为了防止潜在的误解:不幸的是,该解决方案还不是官方平台的一部分,UnityMainThreadDispatcherUnityThreadPool 是每个商店都必须自己重新创建的类。尽管我确实为其中一个提供了部分实现并提供了另一个链接,以减轻痛苦。我目前正在考虑将两者的完整但不完美的版本发布为社区 wiki 答案。
    【解决方案6】:

    我认为您不需要在代码中添加那么多。除非 Unity 的 Mono 版本确实与常规的 .Net 框架(我确信它没有)做一些巨大的和代码破坏性的不同,否则在你的场景中你不会在不同的线程中拥有不同的 _done 副本。所以,不需要 Interlocked,不需要 volatile,不需要黑魔法。

    您真正可能遇到的情况是,您的主线程将_done 设置为false,同时您的后台线程将其设置为true,这绝对是一件坏事。这就是为什么您需要用“锁”来包围这些操作(不要忘记将它们锁定在 SAME 同步对象上)。

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

    【讨论】:

    • “除非 Unity 的 Mono 版本确实与常规的 .Net Framework [...] 有很大的不同,否则你不会在你的不同线程中拥有不同的 _done 副本场景。" - 在框架之外有一个世界,即运行代码的 CPU。 CPU 可以将_done 存储在一个寄存器中,并且每个线程维护一个单独的寄存器文件副本,您可以在两个线程中得到两个不同的_done 副本。
    • 决定哪个变量应该存储在寄存器中的不是CPU,而是优化期间的编译器。我非常怀疑编译器是否会将用于多个类方法的类数据成员变量移动到寄存器中。
    • 我并没有暗示 CPU 会做出这个决定(尽管它的措辞也不够简洁)。然而,关键是,任何变量必须加载到 CPU 寄存器中,如果 CPU 需要使用它,至少会创建一个瞬态,其中存在两个不同的副本。
    • 您现在正在重新表述我自己答案的第二段。
    • 你的第一段声称,在不同的内存位置永远不会有两个副本,你的第二段永远不会挑战这一点。我的 cmets 声称,该变量可以在不同的位置(例如内存和 CPU 寄存器)有两个不同的副本。这与您提出的整个答案相矛盾,坦率地说,基于您想让它与您的第一段一致的假设,我不知道如何处理您的第二段。两者不可能同时正确。在高频问答中获得 0 票作为提示。
    猜你喜欢
    • 1970-01-01
    • 2014-10-08
    • 2011-03-08
    • 1970-01-01
    • 1970-01-01
    • 2018-01-01
    • 2022-09-23
    • 1970-01-01
    • 2011-04-06
    相关资源
    最近更新 更多