色んな事を書く

シンプルさを極めたエンジニアになりたい

Span<T> とか Memory<T> とか

Span 構造体

Span<T> はメモリ上の連続したデータを扱うための構造体です。メモリと言っているのは、その実態がスタックであろうがマネージドヒープ上のものであろうがネイティブコードから提供されたものであろうが問題ありません

連続したデータという事で、配列で見てみるのが分かりやすいと思います。

var arr = new int[3] { 1, 2, 3 };
var sp = arr.AsSpan();

sp[1] = 100;
arr.Dump(); // 1, 100, 3

Span の何が嬉しいの

  1. 単純に速い
  2. そしてそれを安全に扱える
    • 別に Span がなくても unsafe とか使えば同じことが出来るが、アプリ開発においてそんな危険なことしたくない

だと思います。例えば string 型の一部分を抜き出したい時に Substring メソッドを使うと思います。このメソッドは内部的に新しく stringインスタンスを生成しているのでヒープアロケーションが発生してしまいます。Span を使えば切り出したい string の先頭の参照と末尾までの長さしか持たないので、余計なヒープアロケーションが発生しません。

var s = "abcdefg";

// ここで新しくstringインスタンスを生成している
var sub = s.Substring(2, 3);
sub.Dump();  // cde

// あくまで元のsの参照を内部に持っている
var span = s.AsSpan(2, 3);
span.Dump(); // cde

C#は内部的にコピー作ったり、受ける用の領域を取ったりと見えないところでnewしてます。Substring の例がまさにそれですね。

Span の制約

Span<T> は内部的には ref 構造体です。ref 構造体stack only 型とも言われ、その名が示すようにスタック領域にしか配置できません。そのため以下の制約があります。

  • ref struct 型の field を持てるのは ref struct 型のみ
  • Box 化できない
  • 非同期処理で使えない
var arr = new int[1] { 1 };
var sp = arr.AsSpan();

Test(arr);
// ここでコンパイルエラー
Test(sp);

void Test(int[] hoge)
{   
}

// ここでコンパイルエラー
async Task Test(Span<int> hoge)
{
}

class User
{
    // ここでコンパイルエラー
    public Span<int> Hoge { get; set; }
}

そもそもスタック専用であるのは何故か

  • GC のオーバーヘッドをなるべく下げるため
    • Span はオブジェクトへの参照を持つため、GC の追跡対象となる。すると GC のコストが無視できなくなってしまうらしい
    • スタックに配置しておくと寿命がスタックフレームより短いことが保証される
  • ヒープに配置して複数スレッドからアクセスすると分裂の恐れがある
    • 読み書きがアトミックな操作ではないため、以下のような順序で処理が起きると範囲外の領域にアクセスする恐れがある
      1. length が 50 の Span がある
      2. スレッド A が Span の参照と length を 20 に書き換えようとする
      3. スレッド A が Span の参照先の書き換えを完了する
      4. スレッド B が Span の読み取りを行う
      5. スレッド A により書き換え済みの参照から length 50 分読み取ってしまう
    • ここの 89 と 90 の間で読み取られるイメージ

なのでヒープに配置される参照型は ref 構造体を持てませんし、普通の構造体も参照型の field になりうるので持てません。また、Box 化も出来ません。

非同期メソッドの場合は、たいていの場合スタックフレームよりも長く存在する必要があるため全てのフィールドをヒープに存在できるようにしているらしいです (この辺りはまだよくわからない)。なので使えないようです。どのスレッドで実行されるかわからない、というのもあると思います。

Span と ArraySegment

配列の一部分だけを切り取るなら Span を使う必要もなく、ArraySegment という手もありました。例えば int 型配列の 2~4 番目の要素を抜き取りたい時、以下のように書けます。

var arr = new int[6] {1, 2, 3, 4, 5, 6};
var seg = new ArraySegment<int>(arr, 2, 2);

arr.Dump();  // 1, 2, 3, 4, 5, 6
seg.Dump(); // 3, 4

seg[0] = 1;

// ArraySegment は array インスタンスへの内部に持っているので、arr に影響する
arr.Dump();  // 1, 2, 1, 4, 5, 6
seg.Dump(); // 1, 4

となります。これだけ見ると Span と同じじゃんと思えちゃうので、違いを比較してみます。

Span ArraySegment
値への参照を持つ マネージド配列の参照を持つ
必要な範囲の先頭要素の参照を持つ 必要な範囲の先頭要素の Index (offset) とそこからの要素数 (count) を持つ
実装 実装

なので、SpanArraySegment と比較して

  • Span は先頭の参照を直接持つため、Offset 分の計算が不要
  • Offset を持たない分フィールドが少ない
  • 値への参照を持つため、配列以外も扱える
    • メモリ上の連続した値であれば何でも扱える

というメリットがあると言えます。

Span を使って配列を安全にスタック上に配置する

Span<T> がなくても配列をスタックに置くことは可能です。しかし、それをするためには以下のようなコードを書く必要があります。例えば int 型の配列で考えています。

unsafe
{
    // int型のポインタになる
    int* buffer = stackalloc int[10];
    for(var i = 0; i < 10; i++)
    {
        buffer[i] = i;
    }
}

書けはするのですが、一番の問題は unsafe を使う必要があるという点だと思っています。Web アプリなんかを開発する際に unsafe を使わねばならん自体は少ないと思いますし、そうすると。出来るだけ開発者が意識すべきことを減らし安全にプロダクト開発していきたいよねってのがあります。

Span<T> を使えばものすごく単純に書くことが出来ます。

Span<int> arr = stackalloc int[3] {1, 2, 3};

foreach(var i in arr)
{
    i.Dump();   
}

Span<T> を使えば unsafe を使わずに済みますし配列 Like (配列ではないため) に扱えるので C#er にとってなじみやすいと思います (LINQ は使えないけどね)。

stackallock では unsafe が必要だったのになぜ Span<T> にするとそれが不要になるかというと

  1. Span<T> は ref struct 型なので、そのインスタンスを含むスタックフレームよりも長く存在出来ない
    • そのため Span<T> よりも先に Span<T> の参照先が破棄されることがない (出処の保証)
  2. Span<T> はメモリ上の境界値チェックを行うので、範囲外アクセスによる危険がない

という事なのかなと思います。

注意点として、stackalloc 出来るのはアンマネージな型のみです。なので intlong は扱えますが、string やユーザ定義の class などは扱えません。

Span<int> i = stackalloc int[1] { 1 };
Span<long> l = stackalloc long[1] { 1L };
Span<char> c = stackalloc char[1] { 'a' };

// コンパイルエラー
Span<string> s = stackalloc string[1] { "s" };
Span<User> u = stackalloc User[1] { new User { Name = "hoge" }};

class User
{
    public string Name { get; set; }
}

Span<T>スタック上に配置する配列として宣言できるってのはまた話が別な気がしておるのだよな。

// NG
Span<User> ul = new List<User> { new User { Name = "" } };
// OK
Span<User> ua = new User[] { new User { Name = ""} };

class User
{
    public string Name { get; set; }
}

なんで List はダメなんだっけ。

Memory

Memory も Span と同じように範囲アクセスが出来ます。一番の違いは普通の構造体でありヒープに配置できるという点です。ただし、内部的には object の参照 を持つため、stackalloc は使えません。

ヒープに配置出来るため、非同期メソッドでも使えます。Memory -> Span の変換も出来るため (コストは高いらしい)、同期で読み書きが必要になったタイミングで Span にするのが良いのではないでしょうか。

var arr = new int[1] { 1 };
var m = arr.AsMemory();

await Test(m);


async Task Test(Memory<int> hoge)
{
}

使い分けのガイドラインもあるようです。

learn.microsoft.com

参考

C# - All About Span: Exploring a New .NET Mainstay | Microsoft Learn

California Consumer Privacy Act (CCPA) Opt-Out Icon