今回はUnityの「NativeArray」構造体について。
UnityでC# Job SystemやECSなどを扱う際、Unity内部のC++側(Unmanagedな領域)にメモリを確保することが多くなります。このアンマネージドなメモリ領域を扱うために、Unityは「NativeArray」という特殊な構造体を提供しています。
また、Unityの一部のAPIではNativeArrayに対応したオーバーロードが用意されており、これを用いることでより高速な動作を実現することが可能になります。
今回の記事ではこの「NativeArray」について、概要や仕組み、実際の使い方などを解説していきます。NativeArrayはUnityのDOTSにおいて非常に重要な役割を果たすものであるため、是非とも使えるようになっていきましょう。
また今回の記事の内容は、C#における構造体やメモリ領域に関する知識を前提としています。これらは以下の記事にまとめてあるため、構造体周りから知りたい方はこちらの記事を参考にしてみてください。
Unityにおけるメモリ領域
NativeArray自体の話を始める前に、まずはUnityにおけるメモリ管理の話から始めます。
通常C#でclassをnew()でインスタンス化した場合、そのメモリはヒープに確保されます。ヒープ領域はC#側で管理され、参照されなくなったらGCによって回収されるのが基本です。
しかし、Unityのメモリ領域については純粋なC#に比べて若干事情が異なります。
そもそもUnityのエンジン本体はC#ではなくC/C++で実装されており、エンジンは実体であるネイティブコードとユーザー定義のC#スクリプトの2つが合わさって動いています。このうちUnity内部のネイティブコードは非公開ですが、C#側に関してはGithub上で「UnityCsReference」として公開されており、誰でも閲覧することが可能になっています。
このコードを読んでいくとわかりますが、私たちが普段使っているTransformやRigidbodyなどのコンポーネントの実体はC#側には存在せず、実際のデータは全て内部のC++側で管理されています。これらのコンポーネントはC++オブジェクトのラッパーに過ぎません。
[NativeHeader("Configuration/UnityConfigure.h")]
[NativeHeader("Runtime/Transform/Transform.h")]
[NativeHeader("Runtime/Transform/ScriptBindings/TransformScriptBindings.h")]
[RequiredByNativeCode]
public partial class Transform : Component, IEnumerable
{
protected Transform() { }
// The position of the transform in world space.
public extern Vector3 position { get; set; }
// Position of the transform relative to the parent transform.
public extern Vector3 localPosition { get; set; }
// Get local euler angles with rotation order specified
internal extern Vector3 GetLocalEulerAngles(RotationOrder order);
// Set local euler angles with rotation order specified
internal extern void SetLocalEulerAngles(Vector3 euler, RotationOrder order);
...
}
上のコードはTransformコンポーネントの実装を一部抜粋したものです。コードを見ての通り、プロパティの実際の値はC#側には定義されていません。メソッド等もexternでネイティブコードの呼び出しを行なっています。
Unityのメモリ領域はこのような仕組みになっているため、Job System等のDOTSの機能を利用する際や、容量の大きなデータを扱う際など、C#側ではなくエンジン内部にメモリを直接確保したい状況が出てきます。メモリ確保には通常unsafeコードを利用するため、メモリの二重解放によるクラッシュやメモリリークなどの危険が伴います。これを安全におこなうための構造体が「NativeArray」である、ということです。
NativeArrayを使ってみる
「百聞は一見にしかず」ということで、簡単なサンプルを通してNativeArrayを実際に使ってみましょう。
using Unity.Collections;
using UnityEngine;
public class NativeArrayExample : MonoBehaviour
{
void Start()
{
// NativeArrayを生成、アンマネージド領域にメモリを確保する
NativeArray<int> array1 = new NativeArray<int>(10, Allocator.Temp);
// 通常の配列のように使用できる
for (int i = 0; i < array1.Length; i++)
{
// 適当に値を代入するだけ
array1[i] = 10;
}
// 構造体なのでコピーされているように見えるが...?
NativeArray<int> array2 = array1;
// array2を書き換える
for (int i = 0; i < array2.Length; i++)
{
array2[i] = 0;
}
// array1を読み取る
for (int i = 0; i < array1.Length; i++)
{
// 書き換えたのはarray2のはずが、0が表示される!
// これはNativeArrayが単なるメモリの参照(ポインタ)に過ぎないため
Debug.Log(array1[i]);
}
// 使い終わったら "必ず" Dispose()を呼び出して破棄する
// これを忘れるとメモリリークするので注意
array1.Dispose();
// array2はarray1と同じメモリを指しているため、こちらをDisposeする必要はない
}
}
詳しいことは後ほど説明しますが、ざっくりまとめると
- new()する際にAllocatorを指定してメモリを確保
- 通常の配列のように使用する
- 使用後はDispose()を呼び出して破棄する
といった流れです。使い勝手はほとんど配列と変わりませんが、使用後にDisposeの呼び出しが必須である点には注意してください。これを忘れると確保したメモリがいつまで経っても解放されず、メモリリークを引き起こします。
一つややこしい点としてNativeArrayは構造体ですが、コピーされても問題ないように設計されています。NativeArrayの中身は単なるメモリの参照(ポインタ)であり、データそのものが入っているわけではありません。そのため上のコードのようにコピーされたとしても、両者は同一のメモリ領域を読み書きしていることになります。
また、NativeArrayには安全性チェックが搭載されており、メモリの二重解放や並列処理時の競合状態を検知してエラーを出してくれます。また、Disposeによるメモリの解放を忘れた場合には、エディタのコンソールに警告が表示されるようになっています。これらの安全性チェックはエディタ上でのみ有効で、ビルド時には無効化されます。
配列 / NativeArray<T> / Span<T>の違い
上のコードを見ただけでは、NativeArrayはほとんど配列と同じように見えます。また、C#でメモリ周りを扱ったことがある方であれば、NativeArrayはSpan構造体に似ていると感じるかもしれません。ここでは、これら3つの違いについてを詳しく見ていきます。
配列
C#の配列は参照型(class)であり、ヒープ上にメモリを確保します。そのため、無闇にnew()しまくるとGCの対象になりパフォーマンスの低下につながります。C#では配列にする型に制限はなく、多次元配列の作成も可能です。
構造体の記事でも扱いましたが、値型の配列のメモリ配置は上のように直列に要素が並びます。この場合には連続したメモリ領域にアクセスできるため、列挙は非常に高速になります。
NativeArray<T>
これに対し、NativeArray<T>は値型(struct)であり、エンジン内部のネイティブメモリ側にメモリを確保します。ネイティブメモリはGC対象外であるため、確保したら必ず手動で解放する(Disposeを呼ぶ)必要があります。
また、型引数にはintやfloat, bool等の値型や、フィールドに一切参照型を含まないunmanagedなstructのみが使用できます。NativeArrayをネストすることはできないため、通常の方法では多次元配列を表現できません。多次元配列を作成する方法については後述します。
またメモリ配置については上の値型の配列と同様、要素が直列に並びます。そのためこちらも列挙は非常に高速です。さらにNativeArrayは連続したメモリ領域のポインタを持っているだけなので、配列と異なりヘッダーは存在しません。
Span<T>
Spanは連続したメモリ領域を扱うためにC#が用意している構造体です。配列から文字列、ネイティブメモリまであらゆるメモリ領域を指すことができ、アロケーションを避けた高速な読み書きを可能にします。
Spanもメモリの読み書きを安全に扱うための構造体であり、NativeArrayとは非常によく似ています。違いとしては、NativeArrayはUnityのネイティブメモリを扱うための構造体で専用の安全性チェックがあり、Spanはより汎用的な構造体であらゆるメモリ領域を扱える、といったところでしょうか。
また、NativeArrayはSpan/ReadOnlySpanに簡単に変換することが可能です。
var array = new NativeArray<byte>(100, Allocator.Temp);
// NativeArrayをSpanに変換
Span<byte> span = array.AsSpan();
// NativeArrayをReadOnlySpanに変換
ReadOnlySpan<byte> readonlySpan = array.AsReadOnlySpan();
Allocatorを使い分ける
NativeArrayをnew()する際、メモリの確保に用いるAllocatorを指定する必要があります。Allocatorは利用する用途に応じて使い分ける必要があり、Collectionsパッケージのマニュアルに詳しい説明が載っています。
いくつか種類がありますが、実際に用いるのは以下の3つのみです。Allocator.NoneやAllocator.Invalidを指定すると例外がスローされます。
Allocator.Temp
有効期間が1フレーム以内の一時的なメモリの確保に用いられます。メモリ確保/解放の速度はAllocatorの中でも最速です。ただし、Allocator.Tempで確保したNativeArrayをJob SystemのJobに渡すことはできません。Allocator.Tempはnew()したスコープ内でのみ利用する一時的なバッファとして使用することになります。
また、Tempで確保したメモリはフレーム終了後に自動で破棄されるため、手動で解放を行う必要はありません。
Allocator.TempJob
有効期間が4フレーム以内のメモリ確保に用いられ、4フレームを超えると警告が表示されます。Allocator.Tempよりも若干有効期間に猶予があるため、その名の通りJob内で一時的に利用する用途で使われます。メモリ確保/解放はTempよりも低速ですが、下のPersistentよりは高速です。
TempJobで確保したメモリを破棄するには、手動でDisposeを呼び出すか、Job内のフィールドに[DeallocateOnJobCompletion]属性を付加するとJobの完了時に自動で破棄してくれます。
Allocator.Persistent
無期限のメモリ確保に用いられるAllocatorです。永続的に使用できますが、メモリの確保と解放の速度は最も遅くなります。有効期間が存在しないため、Allocator.Persistentで確保したら必ずDisposeを呼んでメモリを解放する必要があります。
Allocatorを作成する
これらに加えて、独自のAllocatorを作成することも可能です。が、Allocatorを自作するケースは見たことがない(というか私自身やったことがない)上にかなり文量が多くなってしまうのでここでは解説しません。これについて知りたい方は公式マニュアルの「Custom allocator overview」を参照してください。
また、この機能を使用して作成された「Rewindable Allocator」というAllocatorが存在します。こちらを使用することで必要なメモリブロックを事前に割り当てておき、任意のタイミングでRewind()を呼び出すことで確保したメモリをまとめて解放することが可能になります。
using System;
using Unity.Collections;
public struct ExampleStruct
{
// AllocatorHelperを介してカスタムAllocatorを使用する
AllocatorHelper<RewindableAllocator> rwdAllocatorHelper;
// AllocatorHelper内のAllocatorにrefでアクセスするためのプロパティ
public ref RewindableAllocator RwdAllocator => ref rwdAllocatorHelper.Allocator;
// RewindableAllocatorを作成するサンプル
void CreateRewindableAllocator(AllocatorManager.AllocatorHandle backgroundAllocator, int initialBlockSize, bool enableBlockFree = false)
{
// backgroundAllocatorから新たなRewindableAllocatorを作成し、AllocatorManagerに登録する
rwdAllocatorHelper = new AllocatorHelper<RewindableAllocator>(backgroundAllocator);
// メモリブロックを割り当て、Allocatorを有効化する
RwdAllocator.Initialize(initialBlockSize, enableBlockFree);
}
// RewindableAllocatorを用いてメモリ確保を行うサンプル
public unsafe void UseRewindableAllocator(out NativeArray<int> nativeArray, out NativeList<int> nativeList, out byte* bytePtr)
{
// RewindableAllocatorを用いてNativeArrayを作成する
// RewindableAllocatorでまとめてメモリの解放を行うため、このNativeArrayをDisposeする必要はない
var nativeArray = CollectionHelper.CreateNativeArray<int, RewindableAllocator>(100, ref RwdAllocator);
// RewindableAllocatorを用いてNativeListを作成する
var nativeList = new NativeList<int>(RwdAllocator.Handle);
for (int i = 0; i < 50; i++)
{
nativeList.Add(i);
}
// RewindableAllocatorを用いてメモリの確保を行う
var bytePtr = (byte*)AllocatorManager.Allocate(ref RwdAllocator, sizeof(byte), sizeof(byte), 10);
}
// RewindableAllocatorで確保したメモリを解放する
public void FreeRewindableAllocator()
{
// 確保したメモリを全て解放する (RwdAllocatorは引き続き使用可能)
RwdAllocator.Rewind();
}
// RewindableAllocatorを破棄する
void DisposeRewindableAllocator()
{
// 確保したメモリを解放し、Allocatorを破棄する
RwdAllocator.Dispose();
// AlloatorManagerから登録を解除し、AllocatorHelperを破棄する
rwdAllocatorHelper.Dispose();
}
}
その他のNativeContainer
CollectionsパッケージにはNativeArray<T>以外にもNativeList<T>、NativeQueue<T>などが用意されており、これらのネイティブメモリを扱う構造体を「NativeContainer」と呼びます。これらもNativeArray同様、Allocatorを指定してメモリを確保し、使用後はDisposeで解放を行う必要があります。また、これらのコンテナをネストすることはできません。
こちらもドキュメントに詳細が記載されていますが、せっかくなのでここでもいくつか紹介していきましょう。
NativeList<T>
要素の数が変更可能なNativeContainerです。List<T>と同様に、AddやRemoveAtなどで要素の追加/削除を行うことができます。
NativeQueue<T>
可変長のQueueを実装可能なNativeContainerです。こちらも通常のQueue<T>と同様にEnqueueやDequeueを使用できます。
NativeHashMap<TKey, TValue>
NativeContainer版のDictionaryです。使用方法もDictionaryと同様になります。並列書き込みには適していないため、その場合にはNativeParallelHashMap<TKey, TValue>を代わりに利用します。
NativeText
可変長の文字列を扱うNativeContainerです。stringとは異なり、NativeTextは構造体です。また、C#のstringはUTF-16ですが、NativeTextの文字列はUTF-8としてエンコードされます。
Lengthがbyte配列に相当する長さを返す、this[]でアクセスできないなど、通常のstringとは使用感が大きく異なります。そもそも可変長の文字列を扱うことが少ないため、基本的にはこちらではなくFixedString系の構造体を使うことになるでしょう。
NativeReference<T>
単一の値を持つNativeContainerです。挙動は要素1のNativeArrayとほとんど変わりませんが、こちらの方が可読性に優れているので、1つの値を扱う際には積極的にこちらを使っていくと良いでしょう。
NativeSlice<T>
こちらは少々特殊なNativeContainerで、NativeArrayの一部の範囲を参照する際に使用されます。元のNativeArrayと指しているメモリ領域は同じであるため、こちらを書き換えると元のNativeArrayも変更されることに注意してください。
Unsafeの世界へ
さて、ここまではunsafeを避けた安全なNativeContainerの使い方について説明してきました。普通にJob System等の用途で使用する分には上の内容だけ押さえておけば十分あり、わざわざunsafeなコードを扱う必要はありません。
しかし、さらに多くの用途でNativeContainerを使いこなしていくには、UnsafeUtility等の低レベルなAPIを扱っていく必要があるでしょう。ここからはunsafeコードをフル活用したより高度な使用方法について解説していきます。
UnsafeContainer
先ほどまでは安全性の高いNativeContainerを扱ってきましたが、それとは別にUnsafeContainerというコンテナも用意されています。
UnsafeList<T>、UnsafePtrList<T>、UnsafeHashMap<TKey, TValue>など、NativeContainer同様に様々なコンテナが用意されており、その名の通り安全性を犠牲にして使用できる範囲を拡大させたものになります。
UnsafeContainerを扱う利点として、UnsafeContainerはネストが可能であるという点があります。そのため、これを利用してNativeContainerよりも自由度の高い実装を行うことができます。
ただし、UnsafeContainerには一切の安全性チェックが搭載されていないため、Disposeを2度呼び出したりするだけで簡単にクラッシュを引き起こします。そのため可能な限りUnsafeContainerの利用を避け、NativeContainerの方を使用するようにしましょう。
メモリを手動で確保/解放する
NativeArray等を用いて手軽にメモリ確保/解放を行うことも可能ですが、この確保と解放を全て手動で管理したい場合もあります。
Unityではメモリの操作を行うためのAPIとして「UnsafeUtility」が提供されています。これを利用することで、メモリに関する様々な操作を行うことが可能になります。
メモリを確保するにはMalloc、解放するにはFreeを用います。
using Unity.Collections.LowLevel.Unsafe;
// 必要なメモリのサイズを取得
int size = UnsafeUtility.SizeOf<int>();
// メモリアライメントを取得
int alignment = UnsafeUtility.AlignOf<int>();
// Mallocでメモリを確保する
void* ptr = UnsafeUtility.Malloc(size, alignment, Allocator.Persistent);
// Freeで確保したメモリの解放を行う
UnsafeUtility.Free(ptr, Allocator.Persistent);
当然ですが、既に解放したメモリのポインタに対してFreeを呼び出すとクラッシュします。その名の通り非常に危険なコードであるため、取扱いには細心の注意を払ってください。
また、MallocTracked / FreeTrackedを使うことでメモリの使用状況を追跡し、メモリリーク時にコンソールに警告を表示させることも可能です。
int size = UnsafeUtility.SizeOf<int>();
int alignment = UnsafeUtility.AlignOf<int>();
// MallocTrackedで、リークの追跡を有効化してメモリを確保する
void* ptr = UnsafeUtility.MallocTracked(size, alignment, Allocator.Persistent, 1);
// FreeTrackedで追跡中のメモリの解放を行う
UnsafeUtility.FreeTracked(ptr, Allocator.Persistent);
その他にもMemCpy、MemSet等や、Unsafe.Asに相当するAsなども用意されています。
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
var nativeArray = new NativeArray<int>(10, Allocator.Temp);
NativeArrayUnsafeUtility
また、NativeArray等のNativeContainerに対してunsafeな操作を行うための拡張として、「NativeArrayUnsafeUtility」クラスが定義されています。この中にはNativeArrayへの拡張メソッドや、ポインタとNativeArrayの変換メソッド等が含まれています。
比較的よく使うのはGetUnsafePtr()という拡張メソッドで、NativeArrayが指すメモリのポインタを取得できます。これはデータコピーを避けて高速な列挙を行いたい場合などに便利です。
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
var nativeArray = new NativeArray(10, Allocator.Temp);
// 内部のポインタを取得できる
var ptr = nativeArray.GetUnsafePtr();
また、ConvertExistingDataToNativeArray()を用いると、他の場所で確保したメモリのポインタをNativeArrayに変換できます。(AtomicSafetyHandleについては後ほど解説します。)
// 既存のポインタをNativeArrayに変換 (AllocatorにはNoneを指定する)
var array = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray<byte>(
ptr,
array.Length,
Allocator.None
);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 安全性チェック用のAtomicSafetyHandleを設定する
NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref array, AtomicSafetyHandle.GetTempMemoryHandle());
#endif
NativeContainerを自作する
Collectionsパッケージには様々なNativeContainerが用意されていますが、時には用途に応じて独自のNativeContainerを実装したい場面も存在します。というわけで、NativeContainerを自作する方法についても解説していきます。
こちらも実際にコードを見せた方が早いので、まずは公式のサンプルコードを参考にしつつサクッと実装してみましょう。やたら長くて読む気が失せてこなくもないですが、後に一つずつ解説して行くので適当に読み流してください。
using System;
using System.Diagnostics;
using Unity.Burst;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Collections;
// NativeContainerであることを示す属性を付加
[NativeContainer]
// これは主にJobSystem用に設定される属性で、IJobParallelFor内でアクセスできるindex範囲を制限できるようになる (細かい説明は後ほど)
[NativeContainerSupportsMinMaxWriteRestriction]
// デバッガ用のクラスを指定。Visual Studio等のツールで中身を確認できるようになるため、これもセットで実装する
[DebuggerDisplay("Length = {Length}")]
[DebuggerTypeProxy(typeof(NativeCustomArrayDebugView<>))]
public unsafe struct NativeCustomArray<T> : IDisposable where T : unmanaged
{
// ポインタと長さを保持するフィールド
[NativeDisableUnsafePtrRestriction] internal void* m_Buffer;
internal int m_Length;
// 安全性チェック用のフィールド、実行時には含まれない
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// [NativeContainerSupportsMinMaxWriteRestriction]属性用のフィールド
// フィールド名は必ず「m_MinIndex」「m_MaxIndex」である必要がある
internal int m_MinIndex;
internal int m_MaxIndex;
// 安全機構として用いるAtomicSafetyHandle。こちらも名前は「m_Safety」から変更してはいけない
internal AtomicSafetyHandle m_Safety;
// デバッガが型の判別に用いるID、BurstがアクセスできるようにSharedStatic<int>で定義する
internal static readonly SharedStatic<int> s_staticSafetyId = SharedStatic<int>.GetOrCreate<NativeCustomArray<T>>();
#endif
// メモリ確保に使用したAllocatorを保持する、これも名前は「m_AllocatorLabel」で
internal Allocator m_AllocatorLabel;
public NativeCustomArray(int length, Allocator allocator)
{
// メモリサイズを取得
long totalSize = UnsafeUtility.SizeOf<T>() * length;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 不正なAllocatorが指定されたら例外をスロー
if (allocator <= Allocator.None) throw new ArgumentException("Allocator must be Temp, TempJob or Persistent", "allocator");
// lengthのチェック
if (length < 0) throw new ArgumentOutOfRangeException("length", "Length must be >= 0");
#endif
// MallocTrackedで新たにメモリを確保する
m_Buffer = UnsafeUtility.MallocTracked(totalSize, UnsafeUtility.AlignOf<T>(), allocator, 0);
UnsafeUtility.MemClear(m_Buffer, totalSize);
m_Length = length;
m_AllocatorLabel = allocator;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
m_MinIndex = 0;
m_MaxIndex = length - 1;
// 新たなAtomicSafetyHandleを作成し、StaticSafetyIdを設定
m_Safety = CollectionHelper.CreateSafetyHandle(allocator);
CollectionHelper.SetStaticSafetyId<NativeCustomArray<T>>(ref m_Safety, ref s_staticSafetyId.Data);
#endif
}
public int Length { get { return m_Length; } }
public unsafe T this[int index]
{
get
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 読み取りが可能かをチェック
// すでにメモリが解放されている、またはJobによるアクセスがある場合は例外をスロー
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
// indexの境界チェック
if (index < m_MinIndex || index > m_MaxIndex) FailOutOfRangeError(index);
#endif
// ネイティブメモリから配列の値を読み取る
return UnsafeUtility.ReadArrayElement<T>(m_Buffer, index);
}
set
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 書き込みが可能かをチェック
// すでにメモリが解放されている、またはJobによるアクセスがある場合は例外をスロー
AtomicSafetyHandle.CheckWriteAndThrow(m_Safety);
// indexの境界チェック
if (index < m_MinIndex || index > m_MaxIndex) FailOutOfRangeError(index);
#endif
// ネイティブメモリに配列の値を書き込む
UnsafeUtility.WriteArrayElement(m_Buffer, index, value);
}
}
public T[] ToArray()
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
#endif
var array = new T[Length];
for (var i = 0; i < Length; i++)
array[i] = UnsafeUtility.ReadArrayElement<T>(m_Buffer, i);
return array;
}
public bool IsCreated
{
get { return m_Buffer != null; }
}
public void Dispose()
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 不要になったAtomicSafetyHandleを破棄
// すでに破棄されている場合は例外をスロー
CollectionHelper.DisposeSafetyHandle(ref m_Safety);
#endif
// 確保したメモリを解放する
UnsafeUtility.FreeTracked(m_Buffer, m_AllocatorLabel);
m_Buffer = null;
m_Length = 0;
}
#if ENABLE_UNITY_COLLECTIONS_CHECKS
private void FailOutOfRangeError(int index)
{
// IJobParallelForが不正な範囲にアクセスしたら例外をスローする
if (index < Length && (m_MinIndex != 0 || m_MaxIndex != Length - 1))
throw new IndexOutOfRangeException(string.Format(
"Index {0} is out of restricted IJobParallelFor range [{1}...{2}] in ReadWriteBuffer.\n" +
"ReadWriteBuffers are restricted to only read & write the element at the job index. " +
"You can use double buffering strategies to avoid race conditions due to " +
"reading & writing in parallel to the same elements from a job.",
index, m_MinIndex, m_MaxIndex));
throw new IndexOutOfRangeException(string.Format("Index {0} is out of range of '{1}' Length.", index, Length));
}
#endif
}
// デバッガ用のクラス
internal sealed class NativeCustomArrayDebugView<T> where T : unmanaged
{
private NativeCustomArray<T> m_Array;
public NativeCustomArrayDebugView(NativeCustomArray<T> array)
{
m_Array = array;
}
public T[] Items
{
get { return m_Array.ToArray(); }
}
}
めっちゃ長い。長い上に当たり前のようにunsafe。
まあ、中でやっていることは確保したメモリに対して配列っぽくアクセスできるようにしているだけです。ただし、メモリの操作は非常に危険であるためAtomicSafetyHandleを利用して様々な安全性チェックを追加しています。とりあえず順番にみていきましょう。
属性の定義
// NativeContainerであることを示す属性を付加
[NativeContainer]
// これは主にJobSystem用に設定される属性で、IJobParallelFor内でアクセスできるindex範囲を制限できるようになる (細かい説明は後ほど)
[NativeContainerSupportsMinMaxWriteRestriction]
// デバッガ用のクラスを指定。Visual Studio等のツールで中身を確認できるようになるため、これもセットで実装する
[DebuggerDisplay("Length = {Length}")]
[DebuggerTypeProxy(typeof(NativeCustomArrayDebugView<>))]
public unsafe struct NativeCustomArray<T> : IDisposable where T : unmanaged
{
...
}
定義を見ると、まず初めに色々属性が付加されているのがわかるかと思います。
[NativeContainer] 属性は構造体がNativeContainerであることを示すための属性です。これをつけることでJobSystemにおける安全性チェックが有効化されます。
[NativeContainerSupportsMinMaxWriteRestriction] 属性は、アクセスできるindexの範囲を限定する制限を利用可能にするための属性です。これはJobSystemで用いられますが、説明が長くなるので後回し。
[DebuggerDisplay]と[DebuggerTypeProxy]はC# Debugger用の属性で、Visual Stadio等のツール上で配列の中身を視覚化することが可能になります。これもセットで実装しておきましょう。
ポインタの定義
[NativeDisableUnsafePtrRestriction] internal void* m_Buffer;
まずは、メモリを参照するためのポインタを定義します。C#なのにポインタ使うのか…という感じですが、DOTSではポインタ使いまくることになるので慣れましょう。ポインタは怖くない。
また、フィールドについている[NativeDisableUnsafePtrRestriction] 属性はJobでポインタを用いるためのものです。Job Systemでは競合状態を招く恐れがあるためJob内にポインタを含めると例外をスローしますが、NativeContainerは安全性を確保した上で行うので問題ありません。
メモリ確保
// MallocTrackedで新たにメモリを確保する
m_Buffer = UnsafeUtility.MallocTracked(totalSize, UnsafeUtility.AlignOf<T>(), allocator, 0);
UnsafeUtility.MemClear(m_Buffer, totalSize);
先ほど紹介したMallocTrackedによって直接ネイティブメモリ上にメモリを確保します。これによってメモリの使用状況を追跡できます。
AtomicSafetyHandle
// 安全機構として用いるAtomicSafetyHandle。名前は「m_Safety」から変更してはいけない
internal AtomicSafetyHandle m_Safety;
// デバッガが型の判別に用いるID、BurstがアクセスできるようにSharedStatic<int>で定義する
internal static readonly SharedStatic<int> s_staticSafetyId = SharedStatic<int>.GetOrCreate<NativeCustomArray<T>>();
NativeContainerでは不正なメモリ操作や競合状態を防ぐための安全機構として「AtomicSafetyHandle」という構造体を使用します。
以前はこれに加えて「DisposeSentinel」というクラスを用いてメモリリークを検知していましたが、Collections 2.1.0-exp.4以降は使用されなくなりました。現在はAtomicSafetyHandle一つでOKです。(古い記事ではDisposeSentinelを使用しているので注意してください。DisposeSentinelは参照型であるため、これを利用するとBurstなどの機能が制限されます。)
注意点としてフィールド名は「m_Safety」である必要があり、異なると正しく動作しません。NativeContainerの実装周りではフィールド名が指定されているものが多いので注意しましょう。
また、フィールドの定義や処理をENABLE_UNITY_COLLECTIONS_CHECKSで囲うことで、処理をエディタ上のみに限定します。実機でこれらのチェックは必要ないので、公式の実装に従って定義を分けておきましょう。
AtomicSafetyHandleの作成
// AtomicSafetyHandle.Create()で新たなAtomicSafetyHandleを作成する
m_Safety = AtomicSafetyHandle.Create();
// Allocator.Tempでメモリ確保した場合にはこちらを代わりに用いる
m_Safety = AtomicSafetyHandle.GetTempMemoryHandle();
// CollectionHelperから作成すると上の細かい使い分けをまとめてやってくれる
m_Safety = CollectionHelper.CreateSafetyHandle(allocator);
まずは新たなAtomicSafetyHandleを作成します。ここは基本的にCollectionHelper.CreateSafetyHandle()を使っておけば大丈夫です。Allocatorに応じて最適なハンドルを取得してくれます。
CollectionHelper.SetStaticSafetyId<NativeCustomArray<T>>(
ref m_Safety, // 作成したAtomicSafetyHandle
ref s_staticSafetyId.Data // SharedStaticとしてIDを保持する
);
また、作成したハンドルに対してデバッガが型を追跡するためのIDを設定しておく必要があります。これも忘れずに行っておきましょう。
値の読み取り/書き込み
// 読み取り時のチェック
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
// 書き込み時のチェック
AtomicSafetyHandle.CheckWriteAndThrow(m_Safety);
ポインタを通して値の操作を行う際には、必ずその前にCheckReadAndThrow / CheckWriteAndThrowを呼び出してチェックを行います。
すでにコンテナが破棄されてメモリが解放されている場合や、Jobが別のスレッドで読み取り/書き込みを行っている間に操作を行おうとすると例外がスローされます。このチェックを挟むことによって、メモリの不正アクセスや競合状態を回避して安全にデータにアクセスできます。
また、ポインタから値を操作する場合にはUnsafeUtility.ReadArrayElement<T>()やUnsafeUtility.WriteArrayElement<T>()が便利です。
Dispose時の処理
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 不要になったAtomicSafetyHandleを破棄
CollectionHelper.DisposeSafetyHandle(ref m_Safety);
#endif
// 確保したメモリを解放する
UnsafeUtility.FreeTracked(m_Buffer, m_AllocatorLabel);
m_Buffer = null;
m_Length = 0;
Dispose関数内には、まず初めにCollectionHelper.DisposeSafetyHandle()によるチェックを行います。このチェックを挟むことでメモリの二重解放を回避します。その後にFreeTrackedで追跡中のメモリを解放すればOKです。
[NativeContainerSupportsMinMaxWriteRestriction]について
先ほど後回しにしたこの属性についても触れておきましょう。
こちらはIJobParallelForというJobに渡した際にアクセスするindexを制限するために使用されます。こちらについてはこの記事では詳しく説明しませんが、IJobParallelForに渡したNativeContainerは並列処理のために分割される可能性があるため、アクセス範囲を制限することで安全性を高めています。
internal int m_MinIndex;
internal int m_MaxIndex;
この属性を用いるにはフィールドに「m_MinIndex」及び「m_MaxIndex」を定義しておく必要があります。こちらも名前を変更することはできません。
実際の境界チェックはFailOutOfRangeError関数内で行なっています。
private void FailOutOfRangeError(int index)
{
// IJobParallelForが不正な範囲にアクセスしたら例外をスローする
if (index < Length && (m_MinIndex != 0 || m_MaxIndex != Length - 1))
throw new IndexOutOfRangeException(string.Format(
"Index {0} is out of restricted IJobParallelFor range [{1}...{2}] in ReadWriteBuffer.\n" +
"ReadWriteBuffers are restricted to only read & write the element at the job index. " +
"You can use double buffering strategies to avoid race conditions due to " +
"reading & writing in parallel to the same elements from a job.",
index, m_MinIndex, m_MaxIndex));
throw new IndexOutOfRangeException(string.Format("Index {0} is out of range of '{1}' Length.", index, Length));
}
まとめ
今回はNativeArrayなどのNativeContainerについてまとめてみました。後半の内容に至ってはunsafeだらけでC#らしくないコードが多かったように思いますが、ネイティブメモリを扱う以上unsafeコードは避けて通れない道でしょう。ポインタを恐れないことは大事。
また、現在NativeContainerに関する情報はかなり少なく、RewindableAllocatorやAtomicSafetyHandleに関しては英語の情報ですらほぼ見当たらない状況です。ですので、こうして記事にまとめておけばいずれ役に立つんじゃないかなあと思ってます。
是非ともNativeArrayを使いこなし、Unityの機能を最大限に引き出して開発を進めていきましょう。