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

26

我希望了解如何编写线程安全的代码。

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

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. 或者是否可以通过Interlocked的方法(如ExchangeRead)来读取和写入值?

  3. 如果我将_done的读取和写入操作放在lock (_someObject)中,是否需要使用Interlocked或其他方法来防止缓存?

编辑1

  1. 如果我将_done定义为volatile,并从多个线程调用Update方法。是否可能会出现两个线程在我将_done赋值为false之前进入if语句的情况?

2
你为什么不尝试使用“任务”而不是使用“线程”呢? - M. Adeel Khalid
3
请注意,线程的开销比较大,每次启动新线程可能并不理想。 - Marc Gravell
5
我知道你在做什么。你只是想从另一个线程中执行一些东西来操作主“线程”。你现在有一个基本的方法可以实现这个目的,但不能在生产代码中使用。你需要一个队列系统。请查看帖子了解正确的做法。我为此制作了一个“UnityThread”类。 - Programmer
2
如果你想使用多线程,我建议使用一个库。例如,UniRX为Unity提供了一个整洁的线程池和任务替代方案,并且还附带了所有的Rx工具集(顾名思义),这在正确实现异步操作方面非常有帮助。 - Kroltan
1
请参阅C#中的线程中的“它们可以交换吗?”表格,了解为什么volatile无法完全解决此问题的解释。 - Nat
显示剩余5条评论
6个回答

15
  1. 是的,但从技术上讲,这不是volatile关键字的作用;尽管它会产生这样的结果,但大多数使用volatile的情况都是为了这种副作用;实际上,volatile的MSDN文档现在只列出了这种副作用场景(链接) - 我猜原来的措辞(关于重新排序指令)太令人困惑了?所以现在也许这才是官方的用法?

  2. Interlocked没有bool方法;您需要使用像0/1这样的int值,但这基本上就是bool - 请注意,Thread.VolatileRead也可以使用

  3. lock有一个完整的屏障;在那里你不需要任何其他构造,lock本身就足以让JIT理解你需要什么

个人而言,我会使用volatile。您已经方便地按递增的开销顺序列出了1/2/3;volatile将是此处最便宜的选项。


请问您能回答我在问题中添加的第4个位置吗?谢谢。 - Stas BZ
1
使用volatile在这里不起作用(Jon Skeet的解释),而lock则过于繁琐。 - Nat

11

尽管您可能会使用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 不能保证对字段的线程安全访问吗?或者分享链接。 - Stas BZ
3
请参见C#中的线程中的“它们是否可以交换?”表。__简而言之__,volatile不能防止在新值被写入后,读取尝试从缓存的值中“读取”,因此如果将 _done 设置为 TRUEvolatile无法防止其似乎为FALSE。(我不是赞同回答的其余部分,但对于这个特定的观点,该帖子是正确的。) - Nat
该链接解释了一个线程内的重新排序,而不是跨线程。获取/释放语义在多个线程之间建立同步原语。链接中的作者是错误的(这个答案中的断言也是如此):无论您以原子方式读取新鲜值还是旧值都是缓存一致性的问题,而不是内存排序 - 此外,大多数处理器(包括C#运行的所有平台)默认情况下都保证缓存一致性。 - Shaggi
2
@Shaggi,在ARM上你没有那个保证,但C#可以在其上运行。Volatile不能保证缓存一致性,因此你可能会读取到过期的值。 - creker
阅读这篇文章后,很明显将“arm”称为一种架构是没有意义的,然而他们在某个时候改变了所有架构的缓存一致性保证。这个17年历史的架构描述了如果你“脏”了共享内存,你必须(在软件中)更新它...不难看出为什么每个人都朝着缓存一致性的方向发展:http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0151c/I27743.htmlA9(至少是向前的)具有缓存一致性:http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0407e/CDDEHDDG.html - Shaggi

6
也许你甚至不需要_done变量,因为如果你使用线程的IsAlive()方法,就可以实现相同的行为。(假设你只有1个后台线程)
像这样:
if(_thread == null || !_thread.IsAlive())
{
    _thread = new Thread(Work);
    _thread.Start();
}

顺便说一句,我没有测试过...这只是一个建议 :)


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的解释C#中的线程

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

编辑:在讨论“volatile在这里无法工作”的说法之后,C#规范确切保证了什么尚不清楚;可以争辩说,volatile可能被保证工作,尽管延迟更大。 MemoryBarrier()仍然是更好的解决方案,因为它确保了更快的提交。这种行为在“C# 4 in a Nutshell”的示例中有所解释,讨论在“为什么需要内存屏障?”中。

不要使用锁

锁是一种用于强制进程控制的重型机制。在这样的应用程序中,它们是不必要的笨重工具。

性能影响很小,您可能在轻度使用时不会注意到它,但它仍然是次优的。而且,它可能会对更大的问题如线程饥饿和死锁做出贡献(即使只有轻微的贡献)。

volatile无法工作的详细信息

为了演示此问题,这里有来自Microsoft(通过ReferenceSource)的.NET源代码

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读/写是原子类型(提示:volatile类型要求相同),则volatile确实始终保证最新的值。有关更多信息,请参见规范§10.5。 - Shaggi
1
@Shaggi,规范并没有提到防止陈旧值的问题。它只涉及内存重排序。因此,在类似ARM这样的情况下,您仍然会遇到缓存一致性的问题,并且会给您带来麻烦。 - creker
1
我不喜欢这个答案。在这种情况下,锁应该是你使用的第一件事情,而且基本上是唯一的东西。因为它们有效并且不需要太多专业知识。如果出于某种原因您不想使用锁,则可以使用Interlocked。如果您仍然认为需要其他东西,请再次考虑。如果您正在考虑手动放置内存屏障,那么您应该真正了解自己在做什么以及为什么要这样做。 - creker
@creker 在这里使用锁是一个概念上不正确的工具。它们会做一些奇怪的事情,比如防止两个线程同时读取 .IsDone。此外,您必须通过获取锁来支付不需要的功能的代价,这将导致性能受到不必要的影响,如果经常遇到读取相互阻塞,则会产生更大的影响。最后,锁可能会在重负载下导致线程饥饿,从而冻结程序。 - Nat
让我们在聊天中继续这个讨论 - Shaggi

4
新手可以通过以下方式在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,即可使上述线程安全。无需使用volatile或显式锁定。

这是来自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://dev59.com/snRC5IYBdhLWcg3wAcbU#436552


这是解决问题的正确方式。当平台提供同步时,无需手动进行同步。编写多线程代码已经够难了。 - Benjamin Gruenbaum
谢谢。为了避免潜在的误解:不幸的是,这个解决方案还没有成为官方平台的一部分,UnityMainThreadDispatcherUnityThreadPool是每个商店都必须自己重新创建的类。虽然我提供了一个部分实现和另一个链接,以减轻痛苦。我正在考虑发布完整但不完美的两个版本作为社区维基答案。 - Peter

-2

我认为你不需要在代码中添加那么多东西。除非Unity的Mono版本真的与常规的.Net Framework有很大的区别(我确信它没有),否则在你的情况下,你不会在不同的线程中拥有不同副本的_done。因此,不需要Interlocked,不需要volatile,也不需要黑魔法。

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

只要你像在代码示例中描述的那样使用_done,每次写入变量时都可以“lock”它,一切都会很好。


4
“除非Unity的Mono版本与普通的.Net Framework有非常大的并且破坏性的差异[...],否则在您的情况中,您不会在不同的线程中拥有不同副本的_done变量。”- 框架之外存在一个世界,即运行代码的CPU。 CPU可以将_done存储在寄存器中,每个线程维护一个单独的寄存器文件副本,这样就可能在两个线程中得到两个不同的_done副本。 - IInspectable
不是CPU决定哪些变量应该存储在寄存器中,而是编译器在优化期间进行决策。我非常怀疑编译器会将一个被多个类方法使用的类数据成员变量移动到寄存器中。 - rs232
我并没有暗示CPU要做出那个决定(尽管措辞也不够简洁)。然而,重点是,如果CPU需要处理任何变量,则必须将其加载到CPU寄存器中,这将至少创建一个瞬态状态,其中存在两个不同的副本。 - IInspectable
你的第一段声称,在不同的内存位置中永远不会有两个副本,而你的第二段从未对此提出质疑。我的评论声称,变量可以在不同的位置(例如内存和CPU寄存器)具有两个不同的副本。这与你提出的整个答案相矛盾,坦率地说,如果你认为第二段与第一段意思相符,我就不知道该怎么理解了。两者不能同时正确。在高频问答中得到0个赞应该是一个提示。 - IInspectable
唯一重要的事情是:a)两个线程将访问相同的变量,没有“缓存版本”b)它们不应同时访问它,这就是为什么需要关键部分。我看到你很沮丧,无法分离不同的抽象层;如果您仍然无法理解差异以及其重要性,请随时提出更多问题。 - rs232
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接