Unity DOTS(Jobs)
Unity DOTS(Jobs)

详细介绍Unity DOTS-Jobs的入门和技巧
安装
- Unity版本:2020.3
- Jobs版本:0.50.1
- 地址:Unity Jobs Package
打开
Package Manager
,使用Add package from git URL
添加com.unity.jobs
。
这样就能把想相关依赖包也加入。
NativeContainer
简单来说,Unity针对Jobs和Entities定义了一种可以在C#上安全访问原生内存的数据类型。它包含了一个指向非托管分配内存的指针,在Jobs环境下,Job可以通过NativeContainer安全的访问主线程共享(不是拷贝数据)的数据,提高了访问效率。
注意:NativeContainer虽然包含了指向非托管分配内存的指针,但它自己是托管对象。
NativeContainer实际上是符合NativeContainer安全的数据结构统称,包括以下几种:
名称 | 介绍 |
---|---|
NativeArray | Array的NativeContainer形式 |
NativeList | List的NativeContainer形式 |
NativeHashMap | HashMap的NativeContainer形式 |
NativeMultiHashMap | 一对多HashMap的NativeContainer形式 |
NativeQueue | Queue的NativeContainer形式 |
分配器(Allocator)
当创建一个NativeContainer时,你必须指定你需要的内存分配类型。分配的类型由jobs运行的时间来决定。这种情况下你可以在每一种情况下使分配器达到可能的最好性能。
这里对于NativeContainer的内存分配有三个分配器类型。当你初始化你的NativeContainer时你需要指定一个合适的分配器。
- Allocator.Temp:是
最快的分配类型
。它适用于分配一个生命周期只有一帧或更短时间的操作。你不应当把一个分配器为Temp类型分配的NativeContainer传递给jobs使用。你同时需要在函数返回之前调用Dispose方法(例如MonoBehaviour.Update,或者其他从原生到托管代码的调用) - Allocator.TempJob:是
速度介乎与Temp与Persistent之间的分配类型
。这是一个生命周期为四帧的内存分配而且它是线程安全的。如果你在四帧之内没有调用Dispose,控制台会打印一个由原生代码生成的警告信息。绝大部分小jobs使用这种类型的NativeContainer分配器。 - Allocator.Persistent:是最慢的分配类型,但它可以持续存在到你需要的时间,如果必要的话可以贯穿应用程序的整个生命周期。它是直接调用malloc的一个封装。长时间的jobs可以使用这种分配类型。当性能比较紧张的时候你不应当使用Persistent。
安全性系统
安全性系统内置于所有NativeContainer类型,它会追踪所有关于任何NaiveContainer的读写。
注意:所有安全性检查只在Unity编辑器下可用。
安全性系统是由DisposeSentinel和AtomicSafetyHandle组成:
DisposeSentinel:内存泄漏检测,不过只会在内存泄漏的发生很久之后触发错误。
AtomicSafetyHandle:原子安全检测,对写入冲突进行检查,如果两个及以上的Jobs同时对同一个NaiveContainer写入数据,就会抛出异常。需要等上一个job写入完成后,下一个才能安全写入。读取则不受限制,可以并行读取。
默认情况下,job声明了NaiveContainer之后就拥有读写权限,这样会降低读写访问性能。如果job不用写入操作可以添加
[ReadOnly]
特性。
[ReadOnly]
public NativeArray<int> input;
注意:这边没有针对从一个job中访问静态数据的保护。访问静态数据可以绕过所有的安全性系统并可能导致Unity奔溃。
自定义NativeContainer
根据官方案例做了一些调整(修改/屏蔽了2020.3缺少的API接口):
UnsafeUtility.MallocTracked
改为UnsafeUtility.Malloc
,去掉最后一个参数。UnsafeUtility.FreeTracked
改为UnsafeUtility.Free
,去掉最后一个参数。- 屏蔽
UnsafeUtility.IsNativeContainerType<T>
调用 - 屏蔽
AtomicSafetyHandle.SetNestedContainer
调用
首先是定义NativeContainer结构,有以下条件:
- struct类型
- 有unsafe标签
- 需要标记
[NativeContainer]
特性,告诉JobSystem这是拥有AtomicSafetyHandle的容器 - 由于需要手动管理、释放内存,因此需要实现
IDisposable
接口
[NativeContainer]
public unsafe struct NativeAppendOnlyList<T> : IDisposable where T : unmanaged
{
//...
}
定义基础属性:
- m_Buffer:容器的内存块指针,使用
[NativeDisableUnsafePtrRestriction]
特性解除了原生指针的限制 - m_AllocatorLabel:分配器
- m_Safety:原子安全句柄
- s_staticSafetyId:原子安全句柄的ID
[NativeDisableUnsafePtrRestriction]
internal void* m_Buffer;
internal int m_Length;
internal Allocator m_AllocatorLabel;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
internal AtomicSafetyHandle m_Safety;
internal static readonly int s_staticSafetyId = AtomicSafetyHandle.NewStaticSafetyId<NativeAppendOnlyList<T>>();
#endif
申请内存/释放内存:
NativeContainer由于是采用非托管内存来管理数据,所以需要使用UnsafeUtility.Malloc
和UnsafeUtility.Free
来分配和释放。
为了确保内存块大小正确,还需要使用UnsafeUtility.SizeOf<T>()
来计算类型的内存大小。
//申请内存
int totalSize = UnsafeUtility.SizeOf<T>() * m_Length;
m_Buffer = UnsafeUtility.Malloc(totalSize, UnsafeUtility.AlignOf<T>(), m_AllocatorLabel);
//释放内存
UnsafeUtility.Free(m_Buffer, m_AllocatorLabel);
//写入元素到指针的偏移位置
UnsafeUtility.WriteArrayElement(m_Buffer, m_Length++, value);
原子安全句柄操作:
注意:涉及到AtomicSafetyHandle的需要包含在ENABLE_UNITY_COLLECTIONS_CHECKS
里。
#if ENABLE_UNITY_COLLECTIONS_CHECKS
//创建原子安全句柄
m_Safety = AtomicSafetyHandle.Create();
//设置ID
AtomicSafetyHandle.SetStaticSafetyId(ref m_Safety, s_staticSafetyId);
//每次写入时,自动升级版本号
AtomicSafetyHandle.SetBumpSecondaryVersionOnScheduleWrite(m_Safety, true);
//检查读取权限
AtomicSafetyHandle.CheckReadAndThrow(m_Safety)
//检查写入权限
AtomicSafetyHandle.CheckWriteAndThrow(m_Safety);
//检查解除释放权限
AtomicSafetyHandle.CheckDeallocateAndThrow(m_Safety);
//释放句柄
AtomicSafetyHandle.Release(m_Safety);
#endif
Jobs
以上图片描述了整个Jobs的调度流程。
Job
常用的接口如下:
接口名称 | 作业数 | 线程 | 跨批次写入权限 | 说明 |
---|---|---|---|---|
IJob | 单个 | 单个Worker线程 | 所有数据 | 与其他Job和主线程并行的单个作业 |
IJobParallelFor | 多个 | 多个线程(Main/Worker)并行 | 当前index | 将任务拆分成多个Job,在多个线程并行执行 |
IJobFor | 多个 | Run(Main线程)、 Schedule(单个Worker线程) 多个工作线程同时 | 当前index | 可以理解为执行模式更灵活的IJobParallelFor |
IJobParallelForBatch | 多个 | 多个线程(Main/Worker)并行 | [index, index + count) | 可以理解为数据访问更灵活的IJobParallelFor。 每个Job可以处理多个索引的数据,而不只是当前索引的数据。 |
IJobParallelForDefer | 多个 | 多个线程(Main/Worker)并行 | 当前index | 可以理解为遍历次数能够动态调整的IJobParallelFor。 可以传入NativeList,直到真正调度时才决定长度。 |
IJobParallelForFilter | 多个 | 单个线程(Main/Worker) | 当前index | 用于过滤数据后对List元素进行删除或者添加。(未来改名为IJobFilter) |
IJobParallelForTransform | 多个 | 多个线程(Main/Worker)并行 | 当前index | 可以在Job访问Transform。 |
创建Job有以下几点要注意的:
- Job一定是
struct
- Job里使用的成员变量只能为
Blittable
类型或NativeContainer
类型 - 使用的成员变量不能是引用对象(托管堆内存)
- 不能调用静态对象
- 只能在主线程调用的Unity接口无法使用
Jobs为了解决在多线程出现的数据竞争条件,会为每个Job发送一份数它需要操作的数据副本,而不是主线程中数据的引用,这有效隔离了数据。
由于复制数据是在原生环境操作,所以Job只能访问Blittable
类型或NativeContainer
类型。前者在托管环境和非托管环境传递不需要转换,而后者则直接使用UnsafeUtility.Malloc申请非托管内存。
IJob
根据示例,可以实现IJob接口,然后实现
Execute
。
public struct AddJob : IJob
{
public float number;
public NativeArray<float> result;
public void Execute()
{
for (var i = 0; i < position.Length; i++)
{
result[i] = number + i;
}
}
}
Schedule
调度一个Job,此时会把Job放入(Job)队列,然后JobSystem会在这个Job的依赖(前置)Job完成之后(如果有依赖项)开始调用这个Job。
注意:只能在主线程调用Schedule
。
NativeArray<float> result = new NativeArray<float>(10, Allocator.TempJob);
AddJob job = new AddJob();
job.number = 100;
job.result = result;
// 对Job进行调度,然后拿到调度的句柄
JobHandle handle = jobData.Schedule();
// 等待Job完成
handle.Complete();
Debug.Log(result[0]); //Log: 100
// 需要在最后手动释放NativeContainer
result.Dispose();
JobHandle
当你调用Schedule方法时会返回一个JobHandle。它可以用来阻塞主线程等待完成,或者用来作为其他Job的前置依赖。
下面代码就展示了JobHandle的几种用法:
// 调度Job a
JobHandle aJobHandle = aJob.Schedule();
// 调度Job b,但需要依赖Job a(等待Job a完成)
JobHandle bJobHandle = bJob.Schedule(a);
JobHandle cJobHandle = cJob.Schedule();
// 可以将多个JobHandle合并成单个,然后作为其他Job的前置依赖(需要等待多个Job完成)
JobHandle bcJobHandle = JobHandle.CombineDependencies(bJobHandle, cJobHandle);
JobHandle dJobHandle = dJob.Schedule(bcJobHandle);
// 阻塞主线程,等待完成
dJobHandle.Complete();
P.S. Job在调度时不会立刻开始执行,如果需要在主线程等待作业,并需要访问作业所使用的NativeContainer数据,可以调用Complete方法刷新内存缓存中的Job并开始执行,才可以确保后续逻辑运行时Job已经执行完成,才能在主线程安全的访问Job中的NativeContainer。1
IJobParallelFor
IJobParallelFor是并行Job的其中一个实现接口(后面版本还有IJobParallelForTransform等),它主要是把相同任务拆分成多个Job来同时执行。
在讲解IJobParallelFor前,首先要根据下图理解一些概念:
- Execute(n):单个可执行的任务
- Batch:多个任务(Execute(n))集合
- Batches:多个Batch的集合
- Native Job:放置在Job Queue的原生作业,里面包含了一个Batches
根据官方图示,IJobParallelFor的执行流程如下:
- Main Thread:
- 在主线程创建的并行Job
- 根据ParallelFor Job的数据源长度,创建可执行的任务(Execute(n)),放入Data Source。
- C# Job System
- 将任务划分成多个Batch块,每个Batch有batchSize(innerloopBatchCount)个任务。
- Job Queue
- 创建Native Job,放入Batch块
- 将Native Job放入到Job Queue
- Native Job System
- 从Job Queue弹出Native Job,放入工作线程并执行其Batches
- 存储结果到Native内存(可以理解为Native Container)
在调度IJobParallelFor时,需要设定数据源长度,这可以告诉Job System需要创建多少个任务(Execute方法),因为Job System无法知道哪个NativeContainer是数据源,也不知道数据源长度和任务数的关系。
举个例子就是:NativeArray每三个数据表示为一个三位坐标x、y、z,需要一起处理,那么创建的任务数就是NativeArray.Length/3,并不是NativeArray.Length。
这里有一个限制,只要是[WriteOnly]的NativeArray/NativeList,写入index以外位置都会抛出异常。而[ReadOnly]没有这个限制。如果需要跨批次写入可以使用IJobParallelForBatch。
在调度时还需要设置batchSize(innerloopBatchCount),这是告诉Job System单个batch的任务颗粒度是多少(也就是有多少个Execute方法)。由于一个Native Job在执行完之后会窃取其他的Native Job的剩余批次(一次只能窃取剩余批次的一半,以确保缓存局部性2)。
batchSize可以控制Job的数量,以及线程之间重新分配工作的细化程度。值设置的较小(例如1)可以使线程之间的工作分布更均匀,但也会带来一些开销,所以需要根据实际情况来设置这个值,如果单个任务较为简单,可以将值设置较大,如果单个任务较为复杂耗时,可以将值设置更小。
//调度一个IJobParallelFor,将array作为数据源,将array.Length作为数据源长度。batchSize设置为64
parallelForJob.Schedule(array.Length, 64);
IJobFor
IJobFor可以理解为执行模式更灵活的IJobParallelFor。他提供了三种调用方法:
- Run:直接在主线程顺序执行。还是会根据数据源长度来创建多个任务(Execute)
- Schedule:可以在单个工作线程(或主线程)顺序执行。如果调用JobHandle.Complete方法会直接在主线程执行。
- ScheduleParallel:根据官网描述跟IJobParallelFor.Schedule一样。
看起来IJobFor的用途并不广泛,所以篇章较少。
IJobParallelForBatch
IJobParallelForBatch是数据访问更灵活的IJobParallelFor。IJobParallelFor是无法在[WriteOnly]的NativeArray/NativeList中写入Index以外的数据。而有些情况下,需要写入到其他位置。
IJobParallelForBatch可以把数据以n为一组分配到一个Job,然后这个Job就可以处理[index, index + n]之间的数据。
相当于如下差异:
//IJobParallelFor
void Schedule(int arrayLength, int innerloopBatchCount)
{
for (int i = 0; i < arrayLength; i++)
{
Job.Execute(i);
}
}
//IJobParallelForBatch
void ScheduleBatch(int arrayLength, int innerloopBatchCount)
{
for (int i = 0; i < arrayLength; i+=innerloopBatchCount)
{
Job.Execute(i, innerloopBatchCount);
}
}
IJobParallelForBatch在后续版本迁移到com.unity.collections里。
IJobParallelForDefer
IJobParallelForDefer跟IJobParallelFor的不同是,可以传入NativeList或长度的指针作为调度的参数,可以在真正调度时才确定迭代总次数。
这样的好处是,如果job是在中间(多线程下Schedule后不一定立刻调度)开始调度,他的长度就可以依赖前置的Job来决定。
而IJobParallelFor需要在一开始Schedule就确定了长度,是没法这样动态处理的。
// 数据动态变化(可能是上一个Jobs决定的)
NativeList<Instance> instances;
// instances.Length在真正调度才确定
job.Schedule(instances, 64);
// 另外一种方式传入长度指针
int* countRef = &count
// 调度时传入长度指针
job.Schedule(countRef, 64);
// 之后更改长度
count = 128;
传入NativeList应该是比较常用的用法了,NativeList可能是基于前置IJobParallelForFilter输出的列表。
而传入指针的方式,笔者想象到实际的使用场景是把指针传入到前置job里面来修改,但应该不太常见。
IJobParallelForDefer在后续版本迁移到com.unity.collections里。
IJobParallelForFilter
IJobParallelForFilter用于过滤数据后对NativeList元素进行删除或者添加。他提供了两个常用方法,分别是:
- ScheduleFilter:传入NativeList<T>,对NativeList进行迭代,判断NativeList<T>[index]中的数据是否符合要求,不符合则进行删除。
- ScheduleAppend:传入NativeList<T>和总的迭代次数,判断对应索引的数据,然后对符合的索引进行添加。
void Filter()
{
NativeArray<float> source = new NativeArray<float>(count, Allocator.TempJob);
NativeList<int> filters = new NativeList<int>(count, Allocator.TempJob);
NativeList<int> appends = new NativeList<int>(0, Allocator.TempJob);
for (int i = 0; i < count; i++)
{
source[i] = i + i / 10.0f;
filters.Add(i * 2);
}
if (filterOrAppend)
{
var filterJob = new FilterJob() { };
var filterHandle = filterJob.ScheduleFilter(filters, 64);
filterHandle.Complete();
Debug.Log(filters.Length);
for (int i = 0; i < filters.Length; i++)
{
Debug.Log(filters[i]);
}
}
else
{
var appendJob = new AppendJob() { source = source };
var appendHandle = appendJob.ScheduleAppend(appends, count, 64);
appendHandle.Complete();
Debug.Log(appends.Length);
for (int i = 0; i < appends.Length; i++)
{
Debug.Log(appends[i]);
}
}
source.Dispose();
filters.Dispose();
appends.Dispose();
}
[BurstCompile]
public struct FilterJob : IJobParallelForFilter
{
//在ScheduleFilter中,传入的index实际上NativeList<T>的值。这里是数据还是索引就取决于怎么使用。
public bool Execute(int index)
{
if(index > 10)
{
return true;
}
return false;
}
}
[BurstCompile]
public struct AppendJob : IJobParallelForFilter
{
[ReadOnly]
public NativeArray<float> source;
public bool Execute(int index)
{
if(source[index] > 5)
{
return true;
}
return false;
}
}
ScheduleFilter迭代的是NativeList中的元素,并不是根据NativeList长度来迭代。而迭代的元素是数据还是索引就取决于怎么使用,在例子里面就是数据。
ScheduleAppend的NativeList则不一样,添加的只能是迭代索引值,因为无法确认添加的内容是什么类型。
ScheduleAppend时的NativeList长度可以为0,这样可以减少内存占用,但随之带来的是在Job中扩容,性能会有一定影响。
另外要注意的:IJobParallelForFilter虽然有
ParallelFor
字样,实际上它调用的是JobsUtility.Schedule
,其实是单线程执行,因此最新版本将IJobParallelForFilter改名为IJobFilter
Jobs进阶
展示Jobs的一些进阶用法。
NativeList<T>.AsParallelWriter()
上述讲过的IJobParallelForFilter这个接口,它可以过滤数据,然后对符合的数据进行添加或者删除。实际上也可以使用NativeList<T>.ParallelWriter()来满足添加元素的需求。
ParallelWriter提供了更自由的并行写入的方法,相对IJobParallelForFilter添加时只能添加索引,ParallelWriter能够添加任意内容。
AsParallelWriter()返回NativeList<T>.ParallelWriter类型实例,然后我们将这个类传入到Job,然后在Job中使用ParallelWriter.AddNoResize添加元素。
NativeList<int> filterList = new NativeList<int>(expectedMax, Allocator.TempJob);
var filterJob = new FilterJob{ src = data, outIndex = filterList.AsParallelWriter() };
filterJob.Schedule(data.Length, 64).Complete();
[BurstCompile]
struct FilterJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> src;
public NativeList<int>.ParallelWriter outIndex; // 并发写索引列表
public void Execute(int i)
{
if (src[i] > 0.5f) // 任意过滤条件
{
outIndex.AddNoResize(i);
}
}
}
AddNoResize()可以原子地把元素写到当前Length位置,然后把Length++。
注意:AddNoResize()不会进行扩容,因此需要在使用前预分配足够大的容量。
性能相对比Add要低,因为涉及到原子锁。
NativeList<T>.AsDeferredJobArray()
上述讲过的IJobParallelForDefer这个接口,它可以在正式调度时才确定长度。而Unity官方也提供了另外一种方式实现这个功能,那就是IJob + NativeList<T>.AsDeferredJobArray()。(当然,这方案其实无法并行)
AsDeferredJobArray()主要作用是分配一个NativeArray<T>实例,将数据指针指向原NativeList<T>的数据。这样在IJob.Schedule()时,可以传入这个NativeArray,在实际调度时,才会确定具体长度。
NativeList<int> filter = new NativeList<int>(maxCount, Allocator.TempJob);
//前面的Job对NativeList<T>进行数据过滤,例如IJobParallelForFilter的案例。
//...
//使用
var job = new SumJob{ indices = filter.AsDeferredJobArray(), data = data, sum = output };
job.Schedule();
public struct Sum : IJob
{
[ReadOnly] public NativeArray<int> indices;
public NativeArray<int> data;
public void Execute()
{
sum[0] = 0;
for (int i = 0; i < indices.Length; i++)
{
sum[0] += data[indices[i]];
}
}
}
由于返回的NativeArray<T>并不是NativeList<T>的数据副本,因此不会出现多份内存,也不会增加拷贝的消耗。
特别注意的是:返回的NativeArray<T>是别名,并不拥有数据内存,无需调用Dispose;等原NativeList<T>销毁即可。
NativeList<T>.AsParallelReader()
AsParallelReader()可以快速的拿到NativeList<T>的一个只读、无安全检查的只读NativeArray<T>,跟AsDeferredJobArray()都不会产生拷贝或额外的内存分配。
它的长度在获取的那一刻就已经固定了,这点跟AsDeferredJobArray()有较大的差别,然后AsDeferredJobArray()可以写入,但AsParallelReader()是只读。
虽然这个只读NativeArray<T>不能写入,但原NativeList<T>修改数据(原长度部分的数据)时,由于是只读视图,所以仍然会影响到NativeArray<T>的数据。
特别注意的是:返回的NativeArray<T>是只读视图,并不拥有数据内存,无需调用Dispose;等原NativeList<T>销毁即可。
AsParallelReader()
在后续版本已更名为AsReadOnly()
。
内存管理
StructLayout
StructLayout是一个控制数据字段物理内存布局的特性,他可以添加到Class或者Struct中。
- LayoutKind:指定如何排列类或结构。使用
LayoutKind
的枚举值。 - Pack:控制类或结构的数据字段在内存中的对齐方式。
- Size:指示类或结构的绝对大小。
- CharSet:指示在默认情况下是否应将类中的字符串数据字段作为LPWSTR或LPSTR进行封送处理。
先来说一下LayoutKind:
- Auto:由运行库自动决定对象在内存中的布局方式,因为这是运行库内部规则根据不同运行环境决定的字段顺序和对齐方式,无法确保在非托管代码中的内存布局一致,因此对象无法传给托管代码以外使用。
- Sequential:Class或者Struct中的字段会按其(声明)顺序在内存排列,编译器插入填充字节满足对齐要求。
- Sequential对于
blittable类型
,托管代码和非托管代码的内存布局一致,可以互相传递。 - Sequential对于
非blittable类型
,托管代码不受影响,运行时会自动插入填充字节或调整字段顺序。而非托管代码则会按照声明顺序排序。与托管代码的内存布局可能不同,因此不能直接传递。
- Sequential对于
- Explicit:显式指定字段偏移量。
- Explicit对于
blittable类型
,托管代码和非托管代码的内存布局一致,都是根据字段偏移量来确定内存布局,可以互相传递。 - Explicit对于
非blittable类型
,托管代码不受影响,运行时会自动插入填充字节或调整字段顺序。而非托管代码则会按照字段偏移量来确定内存布局。与托管代码的内存布局可能不同,因此不能直接传递。
- Explicit对于
默认值:
- Struct默认为Sequential,但拥有引用类型字段时,默认会变成LayoutKind.Auto。
- Class默认为LayoutKind.Auto。
Blittable
上文提及到Job的成员变量需要使用Blittable类型或者NativeContainer类型,这里说的Blittable是指那些在托管代码和非托管代码中为相同内存布局的类型总称,特点就是托管和非托管互相传递时不需要进行特殊转换。
- 常见的Blittable:
类型名 | 字段大小 |
---|---|
System.Byte | 1 |
System.SByte | 1 |
System.Int16 | 2 |
System.UInt16 | 2 |
System.Int32 | 4 |
System.UInt32 | 4 |
System.Single | 4 |
System.Int64 | 8 |
System.UInt64 | 8 |
System.Double | 8 |
System.IntPtr | 4(32位) 或 8(64位) |
System.UIntPtr | 4(32位) 或 8(64位) |
常见的Blittable复杂类型:
- Blittable基元类型的一维数组,如整数数组。但是,包含基元类型一维数组字段的类型并不是Blittable。
- 所有字段为
Blittable
的类型,并且使用LayoutKind.Sequential或LayoutKind.Explicit布局的Struct也是Blittable
不是Blittable的情况:
- bool这个最容易理解错,true在不同平台可能会有不同值,对应的字节数可能是1、2或者4。
- char涉及不同的编码。
- 对象引用不是Blittable类型,这包括本身是Blittable对象的引用数组。
有疑问的情况:
- int[][]使用UnsafeUtility.IsBlittable判定为Blittable,这个跟官网描述不一致。
- 非Blittable的struct对象,其数组也判定为Blittable,这个也能笔者疑惑。
注意:有疑问的情况列举的是基于Unity的UnsafeUtility.IsBlittable接口返回结果,实际上.Net的官网描述跟Unity判定笔者是觉得有出入。
原生内存分配
上文分别提到NativeContainer,分别使用了UnsafeUtility.SizeOf和UnsafeUtility.AlignOf两个接口,这两个接口都是分配原生内存时需要使用的接口。
- UnsafeUtility.SizeOf是返回指定结构类型的总字节数,其中包括因为字段对齐而填充的字节。
- UnsafeUtility.AlignOf是指定结构类型在内存中所需的最小对齐大小。在分配内存时需要把结果告诉内存分配,从而确保数据结构正确对齐,提高访问速度。
SizeOf和AlignOf都跟StructLayout相关,AlignOf实际上是根据结构体最大字段字节和StructLayout.Pack取最小值得出来的,表示这个结构体的最小对齐大小。而SizeOf则是根据结构体的对齐内存和填充内字节后的实际字节数得到的。3 4
JobSystemJobDependencies, https://docs.unity3d.com/cn/2020.3/Manual/JobSystemJobDependencies.html ↩︎
Why does cache locality matter for array performance?, https://stackoverflow.com/questions/12065774/why-does-cache-locality-matter-for-array-performance ↩︎
理解.NET结构体字段的内存布局, https://www.cnblogs.com/eventhorizon/p/18913041 ↩︎
aligned_malloc实现内存对齐, https://blog.csdn.net/jin739738709/article/details/122992753 ↩︎