January 2018

Volume 33 Number 1

C# - Span のすべて: .NET の新しい頼みの綱を探索する

Stephen Toub | January 2018

メモリ内のデータをその場で操作する特殊な並べ替えルーチンを公開するとします。公開するメソッドは、配列を受け取り、その T[] を操作する実装を用意することになるでしょう。メソッドの呼び出し元に配列があり、その配列全体を並べ替える必要がある場合はそれほど問題はありません。しかし、呼び出し元が必要としているのがその配列の一部のみを並べ替えることだとしたらどうでしょう。その場合、おそらく、オフセットとカウントを受け取るオーバーロードも公開することになります。しかし、次の場合はどうでしょう。メモリ内のデータをサポートするとしても、そのデータが配列内に存在するものではなく、たとえば、ネイティブ コードから取得していているか、スタック上に存在する場合で、なおかつポインターと長さしか手元にない場合です。そのようなメモリの任意の領域を操作しながら、配列全体や配列のサブセットを同じく適切に操作し、なおかつマネージ配列とアンマネージ ポインターも同様に適切に操作する、そんな並べ替えメソッドは記述するとしたらどうすればよいでしょう。

別の例も挙げてみましょう。特別な解析メソッドなど、System.String に対する操作を実装しているとします。公開するメソッドでは、文字列を受け取り、文字列を操作する実装を用意することになります。しかし、その文字列のサブセットに対する操作をサポートする必要がある場合はどうすればよいでしょう。String.Substring を使用すれば、関心のある部分だけを切り出すことはできます。しかし、これはやや負荷の高い操作で、文字列の割り当てとメモリ コピーが必要になります。配列の例で述べたとおり、オフセットとカウントを受け取ることはできますが、その場合、呼び出し元に文字列がなく、代わりに char[] があるとしたらどうでしょう。また、呼び出し元に char* があるとしたらどうでしょう。char* には、stackalloc を使用してスタック上の一部の領域に作成されるものや、ネイティブ コードの呼び出しの結果として作成されるものなどがあります。呼び出し元に割り当てとコピーを求めず、それでも文字列型、char[] 型、および char* 型の入力を同じく適切に操作する解析メソッドは、どうすれば記述できるでしょう。

どちらの状況でも、アンセーフ コードとポインターを使用できる可能性があります。それにより、ポインターと長さを受け取る実装を公開します。しかし、それでは .NET の中核を成している安全性に対する保証がなくなり、大半の .NET 開発者にとって過去のものになっているバッファー オーバーランやアクセス違反などの問題が発生します。また、操作を実行している間は、取得するポインターの有効性が維持されるようにマネージ オブジェクトを固定する必要があるなど、パフォーマンスのさらなる低下も引き起こします。関係するデータの型によっては、そもそもポインターを取得することが現実的でない場合もあります。

この難しい問題にも解決策はあります。それが Span<T> です。

Span<T> について

System.Span<T> は、.NET の中核となる新しい値型です。この型は任意のメモリの隣接領域を表現できます。そのメモリがマネージ オブジェクトと関連づけられていたり、相互運用機能を介してネイティブ コードから提供されていたり、またはスタック上に存在していたとしても問題ありません。また、同時に、配列のようなパフォーマンス特性を備えた安全なアクセスも提供します。

たとえば、次のように配列から Span<T> を作成できます。

var arr = new byte[10];
Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>

そこから、Span の Slice メソッドのオーバーロードを利用して、この配列のサブセットのみを表す (指す) Span を簡単かつ効率的に作成できます。その後、結果の Span にインデックスを付けて、元の配列内の関連部分のデータを書き込んだり読み取ったりすることができます。

Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2);
slicedBytes[0] = 42;
slicedBytes[1] = 43;
Assert.Equal(42, slicedBytes[0]);
Assert.Equal(43, slicedBytes[1]);
Assert.Equal(arr[5], slicedBytes[0]);
Assert.Equal(arr[6], slicedBytes[1]);
slicedBytes[2] = 44; // Throws IndexOutOfRangeException
bytes[2] = 45; // OK
Assert.Equal(arr[2], bytes[2]);
Assert.Equal(45, arr[2]);

前述のとおり、Span は配列にアクセスしてそのサブセットを使用するだけの方法ではありません。これはスタック上のデータを参照する場合にも使用できます。以下に例を示します。

Span<byte> bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans
bytes[0] = 42;
bytes[1] = 43;
Assert.Equal(42, bytes[0]);
Assert.Equal(43, bytes[1]);
bytes[2] = 44; // throws IndexOutOfRangeException

もっと大まかに言えば、ネイティブ ヒープから割り当てられたメモリを参照するなど、任意のポインターと長さを参照するために使用できます。たとえば次のようになります。

IntPtr ptr = Marshal.AllocHGlobal(1);
try
{
  Span<byte> bytes;
  unsafe { bytes = new Span<byte>((byte*)ptr, 1); }
  bytes[0] = 42;
  Assert.Equal(42, bytes[0]);
  Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);
  bytes[1] = 43; // Throws IndexOutOfRangeException
}
finally { Marshal.FreeHGlobal(ptr); }

Span<T> インデクサーは、ref 戻り値 (参照戻り値) と呼ばれる C# 7.0 で導入された C# 言語の機能を活用します。このインデクサーは、"ref T" という戻り値の型を使用して宣言します。これは配列へのインデックス付けのようなセマンティックスを提供し、実際の保存場所への参照を返します。その場所に存在する対象のコピーを返すわけではありません。

public ref T this[int index] { get { ... } }

この参照戻りインデクサーの影響は、このインデクサーと、参照戻りを行わない List<T> インデクサーを比較するなどの例で、非常に顕著に表れます。以下に例を示します。

struct MutableStruct { public int Value; }
...
Span<MutableStruct> spanOfStructs = new MutableStruct[1];
spanOfStructs[0].Value = 42;
Assert.Equal(42, spanOfStructs[0].Value);
var listOfStructs = new List<MutableStruct> { new MutableStruct() };
listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable

System.ReadOnlySpan<T> という、Span<T> の 2 つ目のバリアントは、読み取り専用アクセスを可能にします。この型は Span<T> とほぼ同じようですが、そのインデクサーでは C# 7.2 の新機能が活用されており、"ref T" の代わりに "ref readonly T" が返される点が異なります。これにより、System.String のような不変のデータ型を操作できるようになります。ReadOnlySpan<T> を使用すると、割り当てもコピーも行うことなく、非常に効率よく文字列をスライスできます。これを以下に示します。

string str = "hello, world";
string worldString = str.Substring(startIndex: 7, length: 5); // Allocates ReadOnlySpan<char> worldSpan =
  str.AsReadOnlySpan().Slice(start: 7, length: 5); // No allocation
Assert.Equal('w', worldSpan[0]);
worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned to

Span には前述以外にも多数のメリットがあります。たとえば、Span は reinterpret_casts の概念をサポートします。つまり、Span<byte> が Span<int> になるようにキャストできます (このとき、Span<int> の 0 番目のインデックスは Span<byte> の先頭 4 バイトにマッピングされます)。このようにしてバイトのバッファーを読み取れば、グループ化されたバイトを int として操作するメソッドに対し、その読み取ったバッファーを安全かつ効率的に渡すことができます。

Span<T> の実装方法

開発者は一般的に、使用するライブラリの実装方法を理解する必要がありません。しかし、Span<T> の場合は、その背後にある細部について、少なくとも基本的なところは理解しておく価値があります。これらの細部には、そのパフォーマンスと使用上の制約の両方に関して、何かしら示唆するものがあるためです。

まず、Span<T> は ref と長さを含む値型で、次のように定義されます。

public readonly ref struct Span<T>
{
  private readonly ref T _pointer;
  private readonly int _length;
  ...
}

ref T フィールドの概念は最初は奇妙に感じるかもしれません。実のところ、C# または MSIL でも ref T フィールドを実際に宣言することはできません。ただし、実は、Span<T> はランタイムでは特殊な内部型を使用するように記述されます。この型は Just-In-Time (JIT) 組み込みとして扱われ、ランタイムでは JIT により ref T フィールドに相当するものが生成されます。次の ref の使用方法を考えてみてください。こちらの方がもっと親しみやすいでしょう。

public static void AddOne(ref int value) => value += 1;
...
var values = new int[] { 42, 84, 126 };
AddOne(ref values[2]);
Assert.Equal(127, values[2]);

このコードでは、(最適化はさておき) ref T がスタック上に配置されているかのように、配列のスロットが参照によって渡されます。Span<T> における ref T も考え方は同じで、構造体内部に単純にカプセル化されています。このような ref を直接的または間接的に含む型を ref-like 型と呼びます。C# 7.2 コンパイラではシグネチャで ref 構造体を使用することにより、このような ref-like 型を宣言できます。

この簡単な説明から、次の 2 つのことが明らかになります。

  1. Span<T> は、動作が配列上と同じくらい効率的になるような方法で定義されます。つまり、Span へのインデックス付けでは、ポインターおよびその開始オフセットから先頭を特定する計算を必要としません。ref フィールド自体に既にその両方がカプセル化されています (これに対して、ArraySegment<T> には個別のオフセット フィールドがあります。そのため、インデックス付けと受け渡しの両方に高い負荷がかかります)。
  2. ref-like 型としての Span<T> には性質上、その ref T フィールドに起因する制約がいくつか伴います。

この 2 つ目の要素には興味深い影響があります。その影響の結果、.NET に 2 つ目の型セットと関連する型セットが含まれることになります。その先導役となるのが Memory<T> です。

Memory<T> とその必要性について

Span<T> は ref フィールドが含まれているため ref-like 型です。また、ref フィールドは配列などのオブジェクトの先頭を参照するだけでなく、オブジェクトの途中も参照できます。

var arr = new byte[100];
Span<byte> interiorRef1 = arr.AsSpan().Slice(start: 20);
Span<byte> interiorRef2 = new Span<byte>(arr, 20, arr.Length – 20);
Span<byte> interiorRef3 =
  Span<byte>.DangerousCreate(arr, ref arr[20], arr.Length – 20);

これらの参照は内部ポインターと呼ばれ、その追跡は .NET ランタイムのガベージ コレクターからすると比較的負荷の高い操作になります。そのため、存在し得る内部ポインター数に暗黙の下限を設定しているかのように、ランタイムではこれらの ref がスタック上にしか存在できなくなります。

さらに、上記の Span<T> はコンピューターの語長よりも長くなっています。つまり、Span の読み取りと書き込みはアトミック操作ではありません。ヒープ上にある Span フィールドを複数のスレッドで同時に読み取りおよび書き込みする場合、"分裂" のリスクがあります。 有効な参照を含む初期化済みの Span と、値が 50 の対応する _length について考えてみます。1 つのスレッドで新しい Span をその上から書き込み、新しい _pointer 値を書き込むところまで達したとします。その場合、対応する _length を 20 に設定する前に、2 つ目のスレッドでその Span が読み取られます。これには新しい _pointer は含まれますが、古い (そしてより長い) _length は含まれません。

その結果、Span<T> インスタンスはスタック上にのみ存在でき、ヒープ上では存在できません。つまり、Span をボックス化することはできません (したがって、たとえば、既存のリフレクション呼び出し API は、ボックス化を必要とするため Span<T> を使用できません)。これは、クラスでも非 ref-like 構造体でも、Span<T> フィールドを使用できないことを意味します。したがって、Span が暗黙のうちにクラス上のフィールドになる可能性がある場所では Span を使用できません。たとえば、Span をラムダ式に取得したり、async メソッドまたは反復子においてローカルとして使用するなどです (これらの "ローカル" は最終的にコンパイラ生成のステート マシン上でフィールドになる可能性があるためです)。 また、Span<T> をジェネリック引数として使用できないことも意味します。このような型引数のインスタンスは最終的にボックス化されたり、その他の方法でヒープに保存されたりする可能性があるためです (また、現時点で利用できる "where T : ref struct" 制約はありません)。

これらの制約は多くのシナリオには無関係です。コンピューター処理中心の機能や同期処理機能については特にそうだといえます。ただし、非同期機能はまた別の話です。今回冒頭で言及した、配列、配列スライス、ネイティブ メモリなどに関するほとんどの問題は、同期操作を扱う場合にも非同期操作を扱う場合にも存在します。しかし、Span<T> をヒープに保存できず、非同期操作全体で維持できないなら、どのような解決策があるでしょう。そこで Memory<T> の出番です。

Memory<T> looks very much like an ArraySegment<T>:
public readonly struct Memory<T>
{
  private readonly object _object;
  private readonly int _index;
  private readonly int _length;
  ...
}

Memory<T> は Span の場合とまったく同じように配列から作成してスライスできます。ただし、Memory<T> は (非 ref-like) 構造体であるため、ヒープ上に存在することができます。その後、同期処理が実際に必要になったときに、そこから Span<T> を取得できます。たとえば次のようになります。

static async Task<int> ChecksumReadAsync(Memory<byte> buffer, Stream stream)
{
  int bytesRead = await stream.ReadAsync(buffer);
  return Checksum(buffer.Span.Slice(0, bytesRead));
  // Or buffer.Slice(0, bytesRead).Span
}
static int Checksum(Span<byte> buffer) { ... }

Span<T> と ReadOnlySpan<T> の関係と同様、Memory<T> にも対応する読み取り専用の ReadOnlyMemory<T> があります。また、ご想像のとおり、その Span プロパティでは ReadOnlySpan<T> が返されます。これらの型どうしの間で変換を行う組み込みメカニズムの簡単な概要については、図 1 を参照してください。

図 1 Span に関連する型の間における割り当て不要/コピー不要の変換

変換元 変換先 メカニズム
ArraySegment<T> Memory<T> 暗黙のキャスト、AsMemory メソッド
ArraySegment<T> ReadOnlyMemory<T> 暗黙のキャスト、AsReadOnlyMemory メソッド
ArraySegment<T> ReadOnlySpan<T> 暗黙のキャスト、AsReadOnlySpan メソッド
ArraySegment<T> Span<T> 暗黙のキャスト、AsSpan メソッド
ArraySegment<T> T[] 配列プロパティ
Memory<T> ArraySegment<T> TryGetArray メソッド
Memory<T> ReadOnlyMemory<T> 暗黙のキャスト、AsReadOnlyMemory メソッド
Memory<T> Span<T> Span プロパティ
ReadOnlyMemory<T> ArraySegment<T> DangerousTryGetArray メソッド
ReadOnlyMemory<T> ReadOnlySpan<T> Span プロパティ
ReadOnlySpan<T> ref readonly T インデクサー取得アクセサー、マーシャリング メソッド
Span<T> ReadOnlySpan<T> 暗黙のキャスト、AsReadOnlySpan メソッド
Span<T> ref T インデクサー取得アクセサー、マーシャリング メソッド
String ReadOnlyMemory<char> AsReadOnlyMemory メソッド
String ReadOnlySpan<char> 暗黙のキャスト、AsReadOnlySpan メソッド
T[] ArraySegment<T> ctor、暗黙のキャスト
T[] Memory<T> ctor、暗黙のキャスト、AsMemory メソッド
T[] ReadOnlyMemory<T> ctor、暗黙のキャスト、AsReadOnlyMemory メソッド
T[] ReadOnlySpan<T> ctor、暗黙のキャスト、AsReadOnlySpan メソッド
T[] Span<T> ctor、暗黙のキャスト、AsSpan メソッド
void* ReadOnlySpan<T> ctor
void* Span<T> ctor

Memory<T> の _object フィールドは T[] ほど厳密に型指定されていないのがわかります。それどころか、オブジェクトとして格納されます。これは、System.Buffers.OwnedMemory<T> のように、Memory<T> では配列以外の対象をラップできることを強調しています。OwnedMemory<T> は抽象クラスで、プールから取得されたメモリなど、有効期間を厳しく管理する必要があるデータをラップするために使用できます。これは、今回扱う範囲を超えた高度なトピックですが、この方法で Memory<T> を使用することで、たとえば、ポインターをネイティブ メモリにラップできます。ReadOnlyMemory<char> も、ReadOnlySpan<char> と同様、文字列で使用できます。

Span<T> と Memory<T> を .NET ライブラリと統合する方法

上記の Memory<T> コード スニペットには、Memory<byte> を渡している Stream.ReadAsync の呼び出しがあるのがわかります。しかし、現在の .NET の Stream.ReadAsync は byte[] を受け取るように定義されています。これはどのようなしくみなのでしょうか。

Span<T> とその関連機能をサポートする中で、数百もの新しいメンバーと型が.NET 全体に追加されています。これらの多くは既存の配列ベースのメソッドと文字列ベースのメソッドのオーバーロードですが、それ以外に処理の特定領域に重点を置いたまったく新しい型もあります。たとえば、Int32 などのすべてのプリミティブ型には、文字列を受け取る既存のオーバーロードに加え、ReadOnlySpan<char> を受け取る Parse オーバーロードも用意されています。コンマで区切られた 2 つの数値 ("123,456" など) を含む文字列が予期される状況を想像してください。これらの 2 つの数値を抽出する必要があるとします。現在なら、次のようなコードを記述するでしょう。

string input = ...;
int commaPos = input.IndexOf(',');
int first = int.Parse(input.Substring(0, commaPos));
int second = int.Parse(input.Substring(commaPos + 1));

しかし、これでは 2 つの文字列割り当てが行われます。パフォーマンスが求められるコードを記述している場合、2 つの文字列割り当ては負荷が高すぎる可能性があります。代わりに、次のようなコードを記述できるようになります。

string input = ...;
ReadOnlySpan<char> inputSpan = input.AsReadOnlySpan();
int commaPos = input.IndexOf(',');
int first = int.Parse(inputSpan.Slice(0, commaPos));
int second = int.Parse(inputSpan.Slice(commaPos + 1));

新しい Span ベースの Parse オーバーロードを使用することで、この処理全体で割り当てが行われなくなります。DateTime、TimeSpan、Guid などの中核となる型から、BigInteger や IPAddress などの高度な型に至るまで、Int32 のようなプリミティブには、同様の解析メソッドと書式設定メソッドが存在します。

実は、このような多数のメソッドがフレームワーク全体に追加されています。System.Random から、System.Text.StringBuilder や System.Net.Sockets まで、{ReadOnly}Span<T> と {ReadOnly}Memory<T> の操作が簡単かつ効率的になるオーバーロードが追加されています。これらの一部には他にもメリットがあります。たとえば、Stream には次のメソッドが用意されています。

public virtual ValueTask<int> ReadAsync(
  Memory<byte> destination,
  CancellationToken cancellationToken = default) { ... }

byte[] を受け取って Task<int> を返す既存の ReadAsync メソッドとは異なり、このオーバーロードは byte[] の代わりに Memory<byte> を受け取るだけでなく、Task<int> の代わりに ValueTask<int> を返すこともわかります。ValueTask<T> は、非同期メソッドで頻繁に同期を取って返すことが見込まれる場合や、すべての共通の戻り値について完了したタスクをキャッシュできる可能性が低い場合に、割り当てを回避できる構造体です。たとえば、ランタイムでは、結果が true で完了した Task<bool> をキャッシュできます。結果が false でも同様です。しかし、Task<int> の考えられるすべての結果の値について、40 億個のタスク オブジェクトをキャッシュすることはできません。

Stream 実装では、ReadAsync 呼び出しが同期を取って完了するような方法でバッファリングすることがよくあるため、この新しい ReadAsync オーバーロードでは ValueTask<int> が返されます。つまり、同期を取って完了する非同期 Stream 読み取り操作では割り当てがまったく行われなくなる可能性があります。ValueTask<T> は、他の新しいオーバーロードでも使用されています。たとえば、Socket.ReceiveAsync、Socket.SendAsync、WebSocket.ReceiveAsync、TextReader.ReadAsync のオーバーロードなどです。

また、Span<T> を使用することで、過去にメモリの安全性に関する懸念が生じたメソッドを、フレームワークに含められるようになる場所があります。なんらかの ID など、ランダム生成された値を含む文字列を作成する必要がある状況を考えてみましょう。現在なら、次のように char 配列の割り当てが必要になるコードを記述することになるでしょう。

int length = ...;
Random rand = ...;
var chars = new char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

代わりに、スタック割り当てを使用して、さらに Span<char> も活用することで、アンセーフ コードを使用する必要性を回避できます。このアプローチでは、次のように、ReadOnlySpan<char> を受け取る新しい文字列コンストラクターも利用します。

int length = ...;
Random rand = ...;
Span<char> chars = stackalloc char[length];
for (int i = 0; i < chars.Length; i++)
{
  chars[i] = (char)(rand.Next(0, 10) + '0');
}
string id = new string(chars);

ヒープ割り当てが回避されるという点でこれは優れていますが、まだ、スタック上に生成されたデータを文字列にコピーすることが強制されます。また、このアプローチが機能するのは、必要な領域の量が、スタックに対してかなり少ない場合に限られます。32 バイトなど、長さが短ければ問題ありません。しかし、それが数千バイトである場合は、スタック オーバーフローの状況が起こりやすくなる恐れがあります。代わりに、文字列のメモリに直接書き込めるとしたらどうでしょう。Span<T> ではそれが可能になります。文字列の新しいコンストラクターに加え、文字列には Create メソッドも用意されています。

public static string Create<TState>(
  int length, TState state, SpanAction<char, TState> action);
...
public delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);

このメソッドを実装すると、構築中に、文字列を割り当てた後、文字列の中身を設定するために書き込める、書き込み可能な Span を渡すことができます。この場合は Span<T> のスタック専用の特徴が役に立ち、文字列のコンストラクターが完了する前に (文字列の内部記憶域を参照する) Span が消滅することが保証されます。そのため、構築の完了後に Span を使用して文字列を変更できなくなります。

int length = ...;
Random rand = ...;
string id = string.Create(length, rand, (Span<char> chars, Random r) =>
{
  for (int i = 0; chars.Length; i++)
  {
    chars[i] = (char)(r.Next(0, 10) + '0');
  }
});

これで、割り当てを回避するだけでなく、ヒープ上の文字列のメモリに直接書き込むことになります。つまり、コピーも回避することになり、スタックのサイズ制限による制約を受けなくなります。

新しいメンバーが追加された中核のフレームワーク型以外にも、特定のシナリオで処理を効率化するために Spans を操作する新しい .NET 型が多数開発されています。たとえば、テキスト処理を頻繁に行うパフォーマンスの高いマイクロサービスと Web サイトを作成する開発者は、UTF-8 での作業時に文字列のエンコードとデコードを行う必要がなくなれば、大幅にパフォーマンスを向上できます。これを実現するため、System.Buffers.Text.Base64、System.Buffers.Text.Utf8Parser、System.Buffers.Text.Utf8Formatter のような新しい型が追加されています。これらは Span<byte> で機能し、Unicode エンコーディングとデコーディングを回避するだけでなく、かなり下位の各種ネットワーク スタックで一般的なネイティブ バッファーも処理できるようになります。

ReadOnlySpan<byte> utf8Text = ...;
if (!Utf8Parser.TryParse(utf8Text, out Guid value,
  out int bytesConsumed, standardFormat = 'P'))
  throw new InvalidDataException();

この機能はすべてパブリック専用ではありません。それどころか、フレームワーク自体でこれらの新しい Span<T> ベースのメソッドと Memory<T> ベースのメソッドを使用することで、パフォーマンスを高めることができます。.NET Core 全体の呼び出しサイトは、不要な割り当てを避けるために、新しい ReadAsync オーバーロードの使用に移行しています。部分文字列を割り当てることにより行っていた解析では、割り当てが行われない解析を活用するようになっています。Rfc2898DeriveBytes のようなニッチな型でさえ、この動きに加わっており、System.Security.Cryptography.Hash­Algorithm で新しい Span<byte> ベースの TryComputeHash メソッドを利用し、割り当ての非常に大幅な削減を実現すると同時にスループットも改善しています (アルゴリズムの反復処理 1 回につき 1 バイト配列の節約します。アルゴリズムでは反復処理が数千回行われることもあります)。

これは、中核の .NET ライブラリのレベルで止まることなく、スタックに至るまで続いています。ASP.NET Core は Span に大きく依存するようになっています。たとえば、Kestrel サーバーの HTTP パーサーは Span をベースに作成されています。将来は、ASP.NET Core のミドルウェア パイプラインなど ASP.NET Core の下位レベルでは、パブリック API から Span が公開される可能性もあります。

.NET ランタイムについて

.NET ランタイムで安全性を提供する 1 つの方法は、配列へのインデックス付けで配列の長さを超えることができないようにすることです。この手法は境界チェックとして知られています。たとえば、次のメソッドを考えてみます。

[MethodImpl(MethodImplOptions.NoInlining)]
static int Return4th(int[] data) => data[3];

本稿を執筆している x64 コンピューターでは、このメソッドに対して生成されるアセンブリは次のようになります。

sub      rsp, 40
       cmp      dword ptr [rcx+8], 3
       jbe      SHORT G_M22714_IG04
       mov      eax, dword ptr [rcx+28]
       add      rsp, 40
       ret
G_M22714_IG04:
       call     CORINFO_HELP_RNGCHKFAIL
       int3

cmp 命令はデータ配列の長さをインデックス 3 と比較します。次の jbe 命令は 3 が範囲外である場合に範囲チェック失敗ルーチンにジャンプします (例外はスローされます)。JIT ではこのようなアクセスが配列の範囲から出ないようにするコードを生成する必要があります。ただし、これはすべての個々の配列で境界チェックにアクセスしなければならないという意味ではありません。次の Sum メソッドを考えてみましょう。

static int Sum(int[] data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

JIT では、data[i] へのアクセスが配列の範囲から出ないようにするコードをここで生成する必要があります。ただし、JIT ではそのループ構造から i が常に範囲内にとどまることがわかるため (ループは各要素を先頭から最後まで反復処理します)、配列の境界チェックを行わないように最適化できます。したがって、このループに対して生成されるアセンブリ コードは次のようになります。

G_M33811_IG03:
       movsxd   r9, edx
       add      eax, dword ptr [rcx+4*r9+16]
       inc      edx
       cmp      r8d, edx
       jg       SHORT G_M33811_IG03

cmp 命令はまだループ内にあります。ただし、edx レジスタに格納されている i の値を、r8d レジスタに格納されている配列の長さと単純に比較するためだけのものです。追加の境界チェックはありません。

このランタイムは、同様の最適化を Span (Span<T> と ReadOnlySpan<T> の両方) に適用します。前のコードと次のコードを比べてください。次のコードでは、パラメーターの型にのみ変更が加えられています。

static int Sum(Span<int> data)
{
  int sum = 0;
  for (int i = 0; i < data.Length; i++) sum += data[i];
  return sum;
}

このコードに対して生成されるアセンブリは以下とほぼ同じになります。

G_M33812_IG03:
       movsxd   r9, r8d
       add      ecx, dword ptr [rax+4*r9]
       inc      r8d
       cmp      r8d, edx
       jl       SHORT G_M33812_IG03

このアセンブリ コードは非常に似ています。その一因は、境界チェックが取り除かれていることです。ただし、JIT により Span インデクサーが組み込みとして認識されていることも関係しています。つまり、実際の IL コードがアセンブリに変換されているのではなく、JIT によってインデクサー用に特別なコードが生成されています。

これはすべて、ランタイムで配列に適用する最適化と同じ種類の最適化を、Span に適用できることを説明するのが目的です。このように、Span はデータにアクセスするための効率的なメカニズムになっています。詳細については、bit.ly/2zywvyI (英語) のブログ記事を参照してください。

C#言語とコンパイラについて

これまでに触れた機能が C# 言語とコンパイラに追加された後押しにより、.NET では Span<T> が最重要機能になっています。C# 7.2 のいくつかの機能は Span に関連するものです (また、実際のところ Span<T> を使用するには C# 7.2 コンパイラが必要になります)。そのような 3 つの機能を見ていきましょう。

ref 構造体: 前述のように、Span<T> は ref-like 型です。これはバージョン 7.2 の時点の C# では ref 構造体として公開されます。構造体の手前に ref キーワードを配置することで、Span<T> のような他の ref 構造体型をフィールドとして使用できることを C# コンパイラに知らせます。そうすることで、型に割り当てられている関連制約を登録することにもなります。たとえば、Span<T> 向けに構造体 Enumerator を記述するには、その Enumerator で Span<T> を格納する必要があり、したがって、Enumerator 自体が ref 構造体であることが必要となるでしょう。これは次のようになります。

public ref struct Enumerator
{
  private readonly Span<char> _span;
  private int _index;
  ...
}

Span の stackalloc 初期化: 以前のバージョンの C# では、stackalloc の結果はポインター ローカル変数に格納することしかできませんでした。C# 7.2 の時点では、stackalloc を式の一部として使用できるようになり、Span をターゲットにすることが可能になります。また、これは unsafe キーワードを使用せずに行うことができます。そのため、次のように記述するのではなく、

Span<byte> bytes;
unsafe
{
  byte* tmp = stackalloc byte[length];
  bytes = new Span<byte>(tmp, length);
}

次のようにシンプルに記述することができます。

Span<byte> bytes = stackalloc byte[length];

これは、操作を実行するなんらかのスクラッチ領域が必要な状況でも非常に重宝します。しかし、比較的小さなサイズについてはヒープ メモリの割り当てを回避したいところです。以前は次の 2 つの選択肢がありました。

  • 2 つの完全に異なるコード パスを記述し、スタックベース メモリとヒープベース メモリを割り当て、これらに対して操作を行う。
  • マネージ割り当てに関連付けられているメモリを固定してから、スタックベース メモリにも使用されていて、アンセーフ コードでポインター操作を使用して記述される実装にデリゲートする。

現在は、コードの重複なしにセーフ コードを使用して最小限の形式で同じことを実現できます。

Span<byte> bytes = length <= 128 ? stackalloc byte[length] : new byte[length];
... // Code that operates on the Span<byte>

Span 使用検証: Span は特定のスタック フレームに関連付けられている場合もあるデータを参照できるため、無効になっているメモリを参照できる可能性のある方法で Span を受け渡しするのは危険になる恐れがあります。たとえば、以下を実行しようとするメソッドを考えてみます。

static Span<char> FormatGuid(Guid guid)
{
  Span<char> chars = stackalloc char[100];
  bool formatted = guid.TryFormat(chars, out int charsWritten, "d");
  Debug.Assert(formatted);
  return chars.Slice(0, charsWritten); // Uh oh
}

ここでは、領域がスタックから割り当てられ、次にその領域への参照を返すことが試みられています。しかし、戻るとすぐに、その領域の使用は無効になります。さいわい、C# コンパイラではこのような無効な使用法は ref 構造体により検出され、コンパイルに失敗して次のエラーが表示されます。

エラー CS8352: 参照される変数が宣言のスコープ外に公開される可能性があるため、このコンテキストでローカル 'chars' を使用することはできません。

今後の展望

今回説明した型、メソッド、ランタイム最適化などの要素は .NET Core 2.1 に組み込まれる流れにあります。その後、これらは .NET Framework で利用できるようになると見込んでいます。Utf8Parser のような新しい型だけでなく、Span<T> のような中核的な型も、.NET Standard 1.1 と互換性のある System.Memory.dll パッケージで利用できるようになる方向に向かっています。これにより、こうした機能が .NET Framework と .NET Core の既存のリリースで利用できるようになるでしょう。ただし、これらのプラットフォームに組み込まれる際に一部の最適化は実装されないでしょう。このパッケージのプレビューは今すぐ試すことができます。それには、NuGet から System.Memory.dll パッケージへの参照を追加するだけです。

当然、現在のプレビュー バージョンから安定版リリースが実際に提供されるまでの間に最新の変更が加えられる可能性があるので注意してください。そのような変更では、機能セットを試した開発者から寄せられるフィードバックが大きな助けとなります。そのため、ぜひ実際に試してみてください。進行中の作業については、github.com/dotnet/coreclr (英語) および github.com/dotnet/corefx (英語) のリポジトリを定期的に確認してください。また、aka.ms/ref72 でもドキュメントを参照できます。

最後に、この機能セットの成功は、最新の .NET プログラムで効率性と安全性を兼ね備えたメモリへのアクセスを提供することを目標に、機能セットを試し、フィードバックを送って、これらの型を利用した独自のライブラリをビルドする開発者全員の肩にかかっています。実際に使用した体験についてのご報告をお待ちしております。また、.NET をさらに改善するために GitHub での取り組みにご協力いただければさいわいです。


Stephen Toub はマイクロソフトで .NET に携わっています。GitHub (github.com/stephentoub) で Stephen の活動を見ることができます。

この記事のレビューに協力してくれた技術スタッフの Krzysztof Cwalina、Eric Erhardt、Ahson Khan、Jan Kotas、Jared Parsons、Marek Safar、Vladimir Sadov、Joseph Tremoulet、Bill Wagner、Jan Vorlicek、Karel Zikmund に心より感謝いたします。


この記事について MSDN マガジン フォーラムで議論する