Span 構造体
Span<T>
はメモリ上の連続したデータを扱うための構造体です。メモリと言っているのは、その実態がスタックであろうがマネージドヒープ上のものであろうがネイティブコードから提供されたものであろうが問題ありません。
連続したデータという事で、配列で見てみるのが分かりやすいと思います。
var arr = new int[3] { 1, 2, 3 }; var sp = arr.AsSpan(); sp[1] = 100; arr.Dump(); // 1, 100, 3
Span の何が嬉しいの
- 単純に速い
- 参照を扱うだけなので、余計なヒープアロケーションがなくなる
- そしてそれを安全に扱える
- 別に 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 のオーバーヘッドをなるべく下げるため
- ヒープに配置して複数スレッドからアクセスすると分裂の恐れがある
- 読み書きがアトミックな操作ではないため、以下のような順序で処理が起きると範囲外の領域にアクセスする恐れがある
- length が 50 の Span がある
- スレッド A が Span の参照と length を 20 に書き換えようとする
- スレッド A が Span の参照先の書き換えを完了する
- スレッド B が Span の読み取りを行う
- スレッド 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) を持つ |
実装 | 実装 |
なので、Span
は ArraySegment
と比較して
- 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>
にするとそれが不要になるかというと
Span<T>
は ref struct 型なので、そのインスタンスを含むスタックフレームよりも長く存在出来ない- そのため
Span<T>
よりも先にSpan<T> の参照先
が破棄されることがない (出処の保証)
- そのため
Span<T>
はメモリ上の境界値チェックを行うので、範囲外アクセスによる危険がない
という事なのかなと思います。
注意点として、stackalloc
出来るのはアンマネージな型のみです。なので int
や long
は扱えますが、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) { }
使い分けのガイドラインもあるようです。
参考
C# - All About Span: Exploring a New .NET Mainstay | Microsoft Learn