unity的C#学习——不安全代码(声明不安全代码块:实现C/C++指针的创建与相关操作)

Posted 时生丶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了unity的C#学习——不安全代码(声明不安全代码块:实现C/C++指针的创建与相关操作)相关的知识,希望对你有一定的参考价值。

文章目录


C# 不安全代码

不安全代码(Unsafe code)是指使用了指针变量的代码块,或者执行需要访问非托管代码(unmanaged code)的操作指针是一个变量,其值为另一个变量的地址,即内存位置的直接地址。在C#中,不安全代码必须使用 unsafe 修饰符标记,并需要在编译时启用 AllowUnsafeCode 选项。

为了编译不安全代码,您必须切换到命令行编译器指定 /unsafe 命令行。
例如,为了编译包含不安全代码的名为 prog1.cs 的程序,需在命令行中输入命令:

csc /unsafe prog1.cs

要在 Visual Studio 中启用 AllowUnsafeCode,请按照以下步骤操作:

  1. 打开你的 C# 项目。
  2. 右键单击项目并选择“属性”。
  3. 选择“建构”选项卡。
  4. 在“高级”下拉菜单中,将“允许不安全代码”选项设置为“是”。
  5. 单击“确定”保存更改。

不安全代码通常用于以下几个方面:

  • 调用非托管代码(例如使用DllImport调用C++ DLL)
  • 实现某些底层功能,例如指针操作和内存管理
  • 对于性能关键的代码,例如高性能算法和图形渲染。

1、不安全代码的基本语法

不安全代码的基本语法主要涉及 unsafe 关键字和指针变量的声明和使用:

1.1 声明不安全代码块

使用 unsafe 关键字声明不安全代码块,例如:

unsafe

    // 不安全的代码

1.2 声明指针变量

使用 * 运算符声明指针变量,例如:

int* ptr;

这里的 ptr 是指向 int 类型变量的指针。下标展示了常见指针类型声明的实例:

实例描述
int* pp 是指向整数的指针。
double* pp 是指向双精度数的指针。
float* pp 是指向浮点数的指针。
int** pp 是指向整数的指针的指针。
int*[] pp 是指向整数的指针的一维数组。
char* pp 是指向字符的指针。
void* pp 是指向未知类型的指针。

在一条语句中声明多个指针时,* 仅与基础类型一起写入,而不是用作每个指针名称的前缀 。 例如:

int* p1, p2, p3;     // 正确  
int *p1, *p2, *p3;   // 错误 

1.3 操作指针变量

变量使用 & 运算符,可以获取变量的地址,例如:

int x = 10;
int* ptr = &x;

这里的 ptr 指向 x 的地址。

指针使用 * 运算符,可以访问指针所指向的变量的值(通常称之为“解引用指针”),例如:

int y = *ptr;

这里的 y 等于 x 的值。

两个运算符也可以结合使用,从而创建一个指向相同变量的指针,下面是一个示例代码:

unsafe 

    int value = 10;
    int* ptr = &value;  // 定义指针变量
    int* ptr2 = &(*ptr);  // 获取指针ptr的地址,并定义新指针ptr2


2、不安全代码的类型转换

在 C# 中,指针和引用类型之间的类型转换需要使用强制类型转换。可以使用 (type)expression 的语法进行强制类型转换,其中 type 是要转换为的类型,而 expression 是要转换的表达式。

例如,将 int* 转换为 long*,可以使用以下语法:

int* ptr = ...;
long* lptr = (long*)ptr;

此外还可以使用类型转换方法,详见上面链接的C#数据类型转换部分。

2.1 错误用法示例

需要注意的是,进行指针类型转换时需要非常小心,因为指针类型的转换很容易导致不安全的内存访问。下面是一个 int 类型的指针转换为 float 类型的指针,然后通过指针修改 float 类型的值的示例代码:

unsafe static void Main(string[] args)

    int intValue = 10;

    // 创建一个指向 int 变量的指针
    int* intPtr = &intValue;

    // 将 int 类型的指针转换为 float 类型的指针
    float* floatPtr = (float*)intPtr;

    // 通过 float 类型的指针修改值
    *floatPtr = 20.5f;

    Console.WriteLine("intValue = 0", intValue);  // 输出:intValue = 1092616192
    Console.WriteLine("*floatPtr = 0", *floatPtr);  // 输出:*floatPtr = 20.5

上述代码中通过 float 类型的指针修改了 intValue 变量的内存,这是一种类型不匹配的类型转换,导致内存错误,intValue 的值变为一串混乱的数字,我们应该避免这样的操作。

2.2 正确用法示例

下面的实例演示了正确的类型转换用法,使用到 ToString 类型转换方式,和强制转换符 (int)

using System;
namespace UnsafeCodeApplication

   class Program
   
      public static void Main()
      
         unsafe
         
            int var = 20;
            int* p = &var;
            Console.WriteLine("Data is: 0 " , var);
            Console.WriteLine("Data is: 0 " , p->ToString());
            Console.WriteLine("Address is: 0 " , (int)p);
         
         Console.ReadKey();
      
   

上面使用了不安全代码来创建一个指向 int 类型变量的指针,并使用 -> 运算符访问该指针所指向的变量,并使用 ToString() 方法将其转换为字符串输出。同时,它还将变量地址强转为 int 类型后打印。

  • -> 运算符用于访问通过指针间接引用的结构体或类的成员。它是一个简便的语法,等同于用 * 运算符访问指针,然后再用 . 运算符访问成员。
  • 例如,对于一个指向结构体的指针 ptr,可以使用 ptr->member 来访问结构体中的成员 member,这等同于使用 (*ptr).member
  • 所以上述代码相当于访问p指针指向 int 类型的实例,再访问该实例的 ToString() 方法,等同语使用 (*p).ToString()

当上面的代码被编译和执行时,它会产生下列结果:

Data is: 20
Data is: 20
Address is: 77128984


3、固定托管对象的地址

使用 fixed 关键字创建指向托管对象(如数组)的指针,并在关键字的作用域内固定托管对象的(首)地址为指针所指的内存位置,以确保 GC 不会移动该托管对象。例如:

unsafe

    int[] arr = new int[10];
    fixed (int* p = arr)
    
        // 操作指向数组的指针 p
    

这里的 p 指向数组 arr 的首地址,且该数组地址在 fixed 代码块的作用域内固定,不会被 GC 移动。

GC是垃圾回收(Garbage Collection)的缩写,是指计算机程序运行时,自动检测和回收不再使用的内存资源的机制。

在固定了数组的内存地址后,我们就可以通过指针操作数组的元素,下面的实例演示了这点:

using System;

namespace UnsafeCodeApplication

   class TestPointer
   
      public unsafe static void Main()
      
         int[] list = 10, 100, 200;

         // 使用fixed关键字创建指向list数组的指针
         fixed (int* ptr = list)
         
            /* 显示指针中数组地址 */
            for (int i = 0; i < 3; i++)
            
               // 打印第i个元素的地址
               Console.WriteLine("Address of list[0]=1", i, (int)(ptr + i));

               // 打印第i个元素的值
               Console.WriteLine("Value of list[0]=1", i, *(ptr + i));
            
         

         Console.ReadKey();
      
   

在上述代码中,数组名称 int[] list指向数组的指针 int *p 是不同的变量类型

  • 我们可以增加指针变量 p,因为它在内存中不是固定的;
  • 但是数组首地址在内存被固定,所以我们不能增加变量 list

当上面的代码被编译和执行时,它会产生下列结果:

Address of list[0] = 31627168
Value of list[0] = 10
Address of list[1] = 31627172
Value of list[1] = 100
Address of list[2] = 31627176
Value of list[2] = 200


4、指针作为方法的参数

指针可以作为方法的参数,使得方法可以直接修改指向内存位置的数据(效果等同于引用传参)。但要注意的是,如果方法的参数中包含指针类型,那么在方法声明中需要添加 unsafe 关键字,表示该方法包含不安全代码。

  • 在声明了 unsafe 的方法内部,就不需要再声明 unsafe 代码块了,因为方法的作用域已经被标记为不安全,可以直接使用指针等不安全的操作

下面是一个实例代码:

using System;

namespace UnsafeCodeApplication

    class TestPointer
    
        // swap 方法:交换两个整型指针所指向的变量的值
        public unsafe void swap(int* p, int *q)
        
            int temp = *p; // 用临时变量保存 p 指针所指向的值
            *p = *q;       // 用 q 指针所指向的值更新 p 指针所指向的值
            *q = temp;     // 用临时变量中的值更新 q 指针所指向的值
        

        public unsafe static void Main()
        
            TestPointer p = new TestPointer();
            int var1 = 10;   // 定义整型变量 var1,并赋值为 10
            int var2 = 20;   // 定义整型变量 var2,并赋值为 20
            int* x = &var1;  // 定义指向 var1 变量的指针 x
            int* y = &var2;  // 定义指向 var2 变量的指针 y

            // 输出变量交换前的值
            Console.WriteLine("Before Swap: var1:0, var2: 1", var1, var2);

            // 调用 swap 方法,将 x 和 y 作为参数传入
            p.swap(x, y);

            // 输出变量交换后的值
            Console.WriteLine("After Swap: var1:0, var2: 1", var1, var2);

            Console.ReadKey();
        
    

当上面的代码被编译和执行时,它会产生下列结果:

Before Swap: var1: 10, var2: 20
After Swap: var1: 20, var2: 10


5、不安全代码的安全性问题

不安全代码具有以下安全性问题:

  • 内存访问越界:不安全代码使用指针访问内存,如果指针指向的内存区域超出了程序分配的内存区域范围,就会出现内存访问越界的问题。内存访问越界可能会导致程序崩溃、数据损坏等问题。

  • 空指针引用:在不安全代码中,指针可能为 null,这就意味着指针指向的内存地址是无效的。如果程序尝试访问空指针所指向的内存区域,就会出现空指针引用的问题。

  • 内存泄漏:在不安全代码中,程序需要手动分配和释放内存,如果程序忘记释放内存,就会出现内存泄漏的问题。内存泄漏可能会导致程序占用过多的内存,最终导致系统崩溃或变慢。

  • 缓冲区溢出:在不安全代码中,程序使用指针访问数组或缓冲区时,如果程序没有对数组或缓冲区的长度进行检查,就可能会出现缓冲区溢出的问题。缓冲区溢出可能会导致程序崩溃、数据损坏或安全漏洞。

因此,在编写和使用不安全代码时,应该非常小心,确保代码的正确性和安全性。可以使用代码静态分析工具或手动代码审查等方式来减少不安全代码的风险。此外,不应该滥用不安全代码,应该尽可能使用安全的代码编写方式来避免潜在的安全问题。


6、使用不安全代码实现高性能算法

使用不安全代码可以实现高性能算法,因为它可以直接访问和修改内存中的数据,而不需要经过语言的类型检查和其他安全性检查。这使得不安全代码可以更快地执行,因为它可以避免一些额外的开销。下面是一些使用不安全代码实现高性能算法的例子:

  • 图像处理算法:在图像处理中,需要处理大量的像素数据。使用不安全代码可以直接访问像素数据,从而实现更快的图像处理算法。

  • 数组操作算法:在某些算法中,需要频繁地访问数组元素。使用不安全代码可以直接访问数组元素,避免了数组边界检查等开销,从而实现更快的数组操作算法。

  • 高精度计算算法:在某些高精度计算算法中,需要频繁地进行位操作和指针操作。使用不安全代码可以更方便地进行这些操作,从而实现更快的高精度计算算法。

需要注意的是,使用不安全代码需要谨慎,因为它可能会导致内存泄漏和其他安全问题。在编写不安全代码时,需要确保代码的正确性和安全性,以避免出现意外的问题。

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

【中文标题】如何为 Unity3D 编写线程安全的 C# 代码?【英文标题】:How to write thread-safe C# code for Unity3D? 【发布时间】: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 在另一个线程中发生变化?

如果可以,如何解决?

    是否可以通过只应用volatile关键字来解决。

    或者是否可以通过InterlockedExchangeRead等方法读写值?

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

编辑 1

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

【问题讨论】:

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

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

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

    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 explanation 或Threading 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。另外,您必须通过不必要的性能损失来为不受欢迎的功能付出代价,这既是为了首先获得锁,又是为了在经常遇到读取阻塞时获得更大的锁。最后,锁会导致重负载下的线程饥饿,从而冻结程序。 让我们continue this discussion in chat。【参考方案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://***.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 票作为提示。

以上是关于unity的C#学习——不安全代码(声明不安全代码块:实现C/C++指针的创建与相关操作)的主要内容,如果未能解决你的问题,请参考以下文章

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

C# 中的不安全代码

C# 不安全/固定代码

C# 通过不安全代码看内存加载

蓝鸥Unity开发基础二——课时21 泛型

C# 错误CS0227 不安全代码只会在使用 /unsafe 编译的情况下出现