ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) LINQPad で JIT のネイティブコンパイル結果を見る

LINQPad について

LINQPad は C# 用の簡易実行環境ツールです。簡易といいつつも Nuget Package も参照できるうえ、IDE と遜色ないぐらいのデバッグ機能があります。 むしろ、REPL 的に1行ずつ結果を見ながら追記したり、書き捨てのコードを試し書きするなどの用途では圧倒的に使いやすいです。(わざわざ書き捨てのコードのために Visual Studio からプロジェクト作ったりするの面倒ですよね)

書き捨てのコードの試し書きなら無料版でも十分使えます。(Nuget Package との連携機能がない・ブレークポイントが貼れないなどのデメリットあります)

C# をよく使う人で無料版で気に入ったなら有料版 (Premium) が圧倒的におすすめです。追加課金も使用期限も一切ない買い切りで、個人1ユーザーだと最上位版の Premium が1万円ぐらい。現時点の最新の LINQPad 7 は .NET6 も対応してます。旧バージョンの有料ライセンスを持っていたら、最新版のライセンスが半額以下で買えた気がします。

LINQPad で何ができるかは調べたら出てくるので省きます。

LINQPad - The .NET Programmer's Playground

私は Premium 版を買っているので、以降 Premium 版前提で話を進めます。無料版でどこまでの機能が使えるか把握していません。

コンパイル結果 (IL+JIT) を見る

コードを書いて F5 で実行後、結果のパネルの「Results」を 「IL+Native」に切り替えると、コンパイル結果の IL を表示できます。さらに「Show Native Disassembly」を押すと JIT コンパイル結果のネイティブのアセンブリも表示できます。

f:id:ikorin2:20220301054837p:plain

この x64 の JIT コンパイル結果は最適化されてないデバッグビルドの結果です。ネイティブコンパイル結果を好んで見るような変態が見たいのは、おそらく最適化が入ったリリースビルドだと思います。

ここで先ほどの画像の C# コードの先頭に、#LINQPad optimize+ というプリプロセッサディレクティブが意味ありげにコメントアウトしてあります。これをコメントを外すと最適化が入ったコンパイル結果に変わります。

f:id:ikorin2:20220301055709p:plain

コンパイル結果がガラッと変わってすっきりしました。最適化なしではインライン展開されていなかった Foo(1, 2) の呼び出しも、最適化後では定数の3に変わっていますね。(x64 の Main() 関数内 L0004 mov ecx, 3 の部分)

この #LINQPad optimize+ は LINQPad の専用のプリプロセッサディレクティブであるため、もちろん Visual Studio で書いても何の効果もありません。

また、メニューの「Edit」→「Preferences」を開いて「Query」→「Compiler Optimization Default」の設定から、デフォルトのビルドを最適化ありにすることもできます。しかし、普段 LINQPad でコードの試し書きなどする人はリリースビルドにするとブレークポイントが効かなくなるなどのデバッガビリティに影響が出ます。LINQPad の使い方を考えると、デフォルトをリリースビルドにするのはあまりお勧めしません。

f:id:ikorin2:20220301060341p:plain

ちなみに、ここで見られるネイティブコンパイル結果は JIT コンパイル結果であり、Unity の IL2CPP を通したネイティブコンパイルとは別物なのでお間違いのないように。

(C#) ModuleInitializer からのみ呼べるメソッドを作る

小ネタです。C# 9 から ModuleInitializer 属性をつけると、アセンブリのロード時に一度のみ呼び出される処理を書くことができます。

プログラマが処理を差し込めるあらゆるタイミングの中で最も早く、Main メソッドより先に呼び出すことすら可能です。楽しいですね。型情報などのメタ情報のキャッシュなどの用途で利用できます。(用途についての詳細はここでは省略)

静的メソッドに [ModuleInitializer] とつけることで機能するのですが、制限として public または internal である必要があります。モジュール初期化としての自動呼出しでのみ使用したいので、プログラマが明示的にこのメソッドを呼べないようにしたいです。単にこのメソッドを呼ばないように気をつければいいだけなのですが、私はプログラマを信用していません。信用できるのはコンパイラだけ。(人間不信)

呼んではいけないものはコンパイルエラーにしたいです。

internal static class Foo
{
    [ModuleInitializer]
    [Obsolete("Don't call this method explicitly.", true)]
    internal static void MyInitialization()
    {
        // Do something here.
    }
}

[Obsolete] は非推奨 API を表すための属性で、第2引数を true にするとこのメソッドの呼び出しを書くとコンパイルエラーになります。

コンパイラが自動生成する、本来のモジュール初期化としての呼び出しはコンパイルエラーにはなりません。

若干 Obsolete の濫用な気がしますが、文法として正しいコードをコンパイルエラーにする方法が Obsolete 属性しかないので仕方がないです。 前にも 構造体の既定のコンストラクタを禁止する 記事で同じように Obsoleteコンパイルエラーにしていますが、他に方法がないんですよねぇ……

(C#) メモリ確保ベンチマーク on .NET6

この記事は .NET6 Advent Calendar 2021 の14日目の記事です。

.NET6 におけるバッファメモリ確保

以前 .NET Framework4.8 でメモリ確保の方法についてベンチマークを取り、記事にしたことがあります。 (参考 (C#) メモリ確保ベンチマーク 6種盛り)

.NET6 でいろいろとバージョンアップしているので、.NET6 でベンチマークを取ってみました。 前回は6通りの方法を試しましたが、今回は新たに追加されたメソッドを含めて7通りを試します。

ベンチマークライブラリはいつも通り BenchmarkDotNet を使用します。

// (1)
[Benchmark(Baseline = true, Description = "new byte[]")]
public byte[] NewAlloc()
{
    return new byte[Size];
}

// (2)
[Benchmark(Description = "ArrayPool")]
public void SharedPool()
{
    var array = ArrayPool<byte>.Shared.Rent(Size);
    ArrayPool<byte>.Shared.Return(array);
}

// (3)
[Benchmark(Description = "Marshal.Alloc")]
public void MarshalAlloc()
{
    var array = Marshal.AllocHGlobal(Size);
    Marshal.FreeHGlobal(array);
}

// (4)
[Benchmark(Description = "Marshal.Alloc + GCPressure")]
public void MarshalAlloc_WithGCPressure()
{
    var array = Marshal.AllocHGlobal(Size);
    GC.AddMemoryPressure(Size);
    Marshal.FreeHGlobal(array);
    GC.RemoveMemoryPressure(Size);
}

// (5)
[Benchmark(Description = "NativeMemory.Alloc")]
public unsafe void NativeAlloc()
{
    var array = NativeMemory.Alloc((nuint)Size);
    NativeMemory.Free(array);
}

// (6)
[Benchmark(Description = "NativeMemory.Alloc + GCPressure")]
public unsafe void NativeAlloc_WithGCPressure()
{
    var array = NativeMemory.Alloc((nuint)Size);
    GC.AddMemoryPressure(Size);
    NativeMemory.Free(array);
    GC.RemoveMemoryPressure(Size);
}

// (7)
[Benchmark(Description = "GC.AllocateUninitializedArray")]
public byte[] UninitializedArray()
{
    return GC.AllocateUninitializedArray<byte>(Size);
}

メモリ確保の方法として、それぞれ

  • (1) new byte[N]
  • (2) ArrayPool<byte>.Shared.Rent(N)
  • (3) Marshal.AllocHGlobal(N)
  • (4) (3) + GC.AddMemoryPressure
  • (5) NativeMemory.Alloc(N)
  • (6) (5) + GC.AddMemoryPressure
  • (7) GC.AllocateUninitializedArray<byte>(N)

です。(1), (2), (7) はマネージドメモリ、(3), (4), (5), (6) はアンマネージドメモリです。

ベンチマーク結果

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19042.1415 (20H2/October2020Update)
Intel Core i7-10700 CPU 2.90GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100
  [Host]    : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
  RyuJitX64 : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT

Job=RyuJitX64  Jit=RyuJit  Platform=X64  

f:id:ikorin2:20211220031013p:plain

もっとも単純な (1) の new byte[N] は、サイズに対してほぼ比例です。

また、(2) の ArrayPool<byte>.Shared.Rent(N) はサイズによらず定数です。これは、ArrayPool<byte> の内部プールが定常状態で配列がプールから取り出される場合、N によらず常に同じフローです。 ArrayPool.Shared は .NET6 で内部実装に変更がありました。.NET5 以前ではスレッドごとに独立に、長さの異なる17個の配列がキープされていました。.NET5 以前の実装の解説についてはこちらを参照。 これが .NET6 でキープされる配列の最大長が撤廃され、長さが220以上の配列を借りてもプールされます。また、T が unmanaged 型の場合、内部で新たに配列を作るときに new T[] ではなく GC.AllocateUninitializedArray<byte>(N) を使用するようになりました。

(3) と (5) はともにアンマネージドメモリを確保しています。 (5) について、以前の .NET Framework4.8 でのベンチマークでは自分でC++std::mallocstd::free をする dll を用意して呼び出していましたが、.NET6 から同様のメソッドが標準ライブラリ入りしたためそちらを使用しています。また、.NET6 で NativeMemory.Alloc(N) が実装されたことによって (3) の Marshal.AllocHGlobal(N) も内部で同じものを呼ぶように変更されました。したがって、(3) と (5) は全く同一でグラフも完全に一致しています。

(4) と (6) は (3) と (5) に GC Pressure の操作を追加したもので、その操作にかかる時間だけ時間が多くかかっています。

(7) は .NET6 から新たに追加されたメソッドです。new byte[N] と同じくマネージドメモリを確保しますが、配列の要素はゼロ初期化されていません。その分だけ (1) よりも速くなっています。ここで注目すべきは、2048 以下の時には (1) とほぼ一致していることです。これは、GC.AllocateUninitializedArray<T>(N) はサイズが2048バイト未満の場合は内部で new T[N] を呼び出しており、実装上の都合でその方が速いためとのことです。このメソッドの T は unmanaged 型、つまりプリミティブ型と、再帰的に全てのフィールドがプリミティブ型で構成される構造体でのみ使用可能です。

補足など

GC Pressure について

GC.AddMemoryPressure については私自身、使いどころがうまく理解できていません。確かにアンマネージドメモリを確保するとメモリ使用量は増えたにもかかわらずランタイムには認知されません。この時、メモリが逼迫するとランタイムはメモリを空けようと GC を走らせますが、実際にはメモリは空かず、逼迫した状態が続くので何度も意味のない GC を連発するようなことが起こるのかもしれません。しかし、もともとネイティブな外部 dll を呼び出して相互運用するようなアプリケーションの場合、ネイティブ側でどれだけメモリが確保されているかなど C# 側からは知る由もなく、GC.AddMemoryPressure を使うことはできません。これは私の勝手な判断ですが、合計でギガバイト以上の大きなメモリを確保する場合などのメモリが逼迫しうる量を確保しない限り、GC.AddMemoryPressure を使う必要はないと思っています。(もし GC.AddMemoryPressure を使うべき明確な理由や場面を知っている方がいれば教えてください)

アンマネージドメモリの使用について

アンマネージドメモリはその名の通りランタイムに管理されていません。利点として GC の対象にならないため停止時間に影響を与えません。一方欠点として解放を忘れるとメモリリークします。C++ や Rust のようにメモリリークを言語レベルで防ぐためのサポートもなく、生のポインタです。一応 IDisposable に包むと using 構文で自動解放できますが、using を強制する言語サポートはないため危険度としては同じです。ファイナライザを持つクラスに包むと最後のセーフティーとしてメモリリークはしませんが、GC の停止時間中に解放を行うため停止時間を削減するという目的そのものが無意味になります。ライブラリの中で完結する一時バッファとして実装者が責任を持てる範囲以外では使用すべきでないと思います。

また、GC を持つ C# が Rust や C++ より優れている部分として、メモリの断片化が発生しずらいという利点があります。アンマネージドメモリを多用するとこの利点をつぶします。小規模なメモリを何度もアンマネージドメモリに確保し、さらにそれらが長期間生存するような場面には向きません。

バッファとしての使用は、特別な理由がない限り ArrayPool<byte>.Shared.Rent(N) を使用すべきでしょう。

[追記]

.NET Framework 4.8 のときの記事とは計測したマシンスペックが異なるため、以前の記事と単純な実行時間での比較はしないでください。あくまで各手法間の比率で見てください。

ベンチマーク結果の詳細

Method Size Mean Error StdDev Median Ratio RatioSD
'new byte' 16 2.420 ns 0.0573 ns 0.0536 ns 2.418 ns 1.00
ArrayPool 16 14.197 ns 0.0241 ns 0.0214 ns 14.191 ns 5.87 0.14
Marshal.Alloc 16 49.691 ns 0.0782 ns 0.0694 ns 49.684 ns 20.55 0.46
'Marshal.Alloc + GCPressure' 16 87.830 ns 0.1201 ns 0.1124 ns 87.784 ns 36.30
NativeMemory.Alloc 16 49.087 ns 0.0693 ns 0.0579 ns 49.084 ns 20.34 0.47
'NativeMemory.Alloc + GCPressure' 16 86.639 ns 0.1773 ns 0.1572 ns 86.568 ns 35.84
GC.AllocateUninitializedArray 16 1.896 ns 0.0481 ns 0.0450 ns 1.882 ns 0.78 0.03
'new byte' 32 2.486 ns 0.0307 ns 0.0287 ns 2.487 ns 1.00
ArrayPool 32 15.458 ns 0.0095 ns 0.0074 ns 15.456 ns 6.21 0.07
Marshal.Alloc 32 49.709 ns 0.1392 ns 0.1302 ns 49.690 ns 19.99 0.24
'Marshal.Alloc + GCPressure' 32 87.716 ns 0.0756 ns 0.0632 ns 87.705 ns 35.28
NativeMemory.Alloc 32 47.426 ns 0.0657 ns 0.0582 ns 47.415 ns 19.08 0.23
'NativeMemory.Alloc + GCPressure' 32 85.087 ns 0.2800 ns 0.2619 ns 84.965 ns 34.22
GC.AllocateUninitializedArray 32 2.411 ns 0.0358 ns 0.0334 ns 2.405 ns 0.97 0.02
'new byte' 64 3.417 ns 0.0667 ns 0.0624 ns 3.436 ns 1.00
ArrayPool 64 14.707 ns 0.0244 ns 0.0217 ns 14.699 ns 4.30 0.08
Marshal.Alloc 64 53.475 ns 0.0520 ns 0.0434 ns 53.472 ns 15.63 0.29
'Marshal.Alloc + GCPressure' 64 89.940 ns 0.0665 ns 0.0589 ns 89.923 ns 26.28
NativeMemory.Alloc 64 46.917 ns 0.1785 ns 0.1670 ns 46.901 ns 13.73 0.23
'NativeMemory.Alloc + GCPressure' 64 85.983 ns 0.0746 ns 0.0582 ns 85.990 ns 25.15
GC.AllocateUninitializedArray 64 3.381 ns 0.0779 ns 0.0728 ns 3.389 ns 0.99 0.03
'new byte' 128 5.155 ns 0.0990 ns 0.0926 ns 5.165 ns 1.00
ArrayPool 128 15.444 ns 0.0326 ns 0.0305 ns 15.426 ns 3.00 0.05
Marshal.Alloc 128 50.599 ns 0.0346 ns 0.0270 ns 50.603 ns 9.80 0.19
'Marshal.Alloc + GCPressure' 128 89.218 ns 0.0967 ns 0.0905 ns 89.204 ns 17.31
NativeMemory.Alloc 128 47.705 ns 0.1220 ns 0.1081 ns 47.667 ns 9.25 0.17
'NativeMemory.Alloc + GCPressure' 128 89.048 ns 0.0671 ns 0.0628 ns 89.063 ns 17.28
GC.AllocateUninitializedArray 128 5.120 ns 0.0627 ns 0.0489 ns 5.142 ns 0.99 0.02
'new byte' 256 8.876 ns 0.1177 ns 0.1101 ns 8.898 ns 1.00
ArrayPool 256 14.331 ns 0.2157 ns 0.1912 ns 14.230 ns 1.61 0.03
Marshal.Alloc 256 50.962 ns 0.3189 ns 0.2983 ns 50.958 ns 5.74 0.07
'Marshal.Alloc + GCPressure' 256 91.727 ns 0.7887 ns 0.7378 ns 91.540 ns 10.34
NativeMemory.Alloc 256 47.855 ns 0.1256 ns 0.1175 ns 47.818 ns 5.39 0.07
'NativeMemory.Alloc + GCPressure' 256 88.592 ns 0.0957 ns 0.0848 ns 88.622 ns 9.98
GC.AllocateUninitializedArray 256 8.880 ns 0.1124 ns 0.1051 ns 8.934 ns 1.00 0.02
'new byte' 512 16.057 ns 0.3431 ns 0.3210 ns 15.960 ns 1.00
ArrayPool 512 14.185 ns 0.0184 ns 0.0172 ns 14.177 ns 0.88 0.02
Marshal.Alloc 512 50.141 ns 0.0323 ns 0.0270 ns 50.139 ns 3.13 0.07
'Marshal.Alloc + GCPressure' 512 100.263 ns 0.0938 ns 0.0831 ns 100.267 ns 6.25
NativeMemory.Alloc 512 48.371 ns 0.0636 ns 0.0595 ns 48.379 ns 3.01 0.06
'NativeMemory.Alloc + GCPressure' 512 96.198 ns 0.0923 ns 0.0863 ns 96.201 ns 5.99
GC.AllocateUninitializedArray 512 16.169 ns 0.2531 ns 0.2367 ns 16.236 ns 1.01 0.02
'new byte' 1024 30.097 ns 0.4235 ns 0.3961 ns 30.006 ns 1.00
ArrayPool 1024 14.679 ns 0.0093 ns 0.0073 ns 14.679 ns 0.49 0.01
Marshal.Alloc 1024 50.266 ns 0.0349 ns 0.0309 ns 50.269 ns 1.67 0.02
'Marshal.Alloc + GCPressure' 1024 108.525 ns 0.0833 ns 0.0739 ns 108.522 ns 3.61
NativeMemory.Alloc 1024 47.416 ns 0.0289 ns 0.0270 ns 47.405 ns 1.58 0.02
'NativeMemory.Alloc + GCPressure' 1024 110.094 ns 0.0874 ns 0.0818 ns 110.103 ns 3.66
GC.AllocateUninitializedArray 1024 29.920 ns 0.6143 ns 0.5746 ns 29.897 ns 0.99 0.03
'new byte' 2048 57.893 ns 0.9656 ns 0.8560 ns 57.662 ns 1.00
ArrayPool 2048 14.776 ns 0.0128 ns 0.0113 ns 14.773 ns 0.26 0.00
Marshal.Alloc 2048 51.905 ns 0.0445 ns 0.0395 ns 51.899 ns 0.90 0.01
'Marshal.Alloc + GCPressure' 2048 114.753 ns 0.1050 ns 0.0982 ns 114.727 ns 1.98
NativeMemory.Alloc 2048 48.999 ns 0.0228 ns 0.0202 ns 49.002 ns 0.85 0.01
'NativeMemory.Alloc + GCPressure' 2048 111.208 ns 0.1044 ns 0.0872 ns 111.214 ns 1.92
GC.AllocateUninitializedArray 2048 58.826 ns 0.1744 ns 0.1456 ns 58.761 ns 1.02 0.02
'new byte' 4096 118.348 ns 2.3840 ns 2.5508 ns 117.820 ns 1.00
ArrayPool 4096 14.677 ns 0.0147 ns 0.0138 ns 14.673 ns 0.12 0.00
Marshal.Alloc 4096 50.449 ns 0.0249 ns 0.0233 ns 50.445 ns 0.43 0.01
'Marshal.Alloc + GCPressure' 4096 120.027 ns 0.0595 ns 0.0465 ns 120.024 ns 1.02
NativeMemory.Alloc 4096 47.249 ns 0.0265 ns 0.0222 ns 47.247 ns 0.40 0.01
'NativeMemory.Alloc + GCPressure' 4096 115.660 ns 0.1309 ns 0.1160 ns 115.620 ns 0.98
GC.AllocateUninitializedArray 4096 68.978 ns 0.1841 ns 0.1537 ns 68.998 ns 0.59 0.01
'new byte' 8192 241.119 ns 4.5964 ns 4.2995 ns 242.919 ns 1.00
ArrayPool 8192 18.240 ns 0.0137 ns 0.0114 ns 18.235 ns 0.08 0.00
Marshal.Alloc 8192 50.385 ns 0.0422 ns 0.0374 ns 50.369 ns 0.21 0.00
'Marshal.Alloc + GCPressure' 8192 120.433 ns 0.1308 ns 0.1223 ns 120.399 ns 0.50
NativeMemory.Alloc 8192 47.494 ns 0.0318 ns 0.0266 ns 47.487 ns 0.20 0.00
'NativeMemory.Alloc + GCPressure' 8192 120.283 ns 0.0880 ns 0.0735 ns 120.262 ns 0.50
GC.AllocateUninitializedArray 8192 74.431 ns 0.1530 ns 0.1277 ns 74.431 ns 0.31 0.01
'new byte' 16384 424.195 ns 8.2143 ns 8.7893 ns 421.584 ns 1.00
ArrayPool 16384 17.953 ns 0.0227 ns 0.0201 ns 17.943 ns 0.04 0.00
Marshal.Alloc 16384 53.502 ns 0.2266 ns 0.2119 ns 53.448 ns 0.13 0.00
'Marshal.Alloc + GCPressure' 16384 120.846 ns 0.1743 ns 0.1456 ns 120.775 ns 0.28
NativeMemory.Alloc 16384 47.405 ns 0.0418 ns 0.0371 ns 47.391 ns 0.11 0.00
'NativeMemory.Alloc + GCPressure' 16384 117.896 ns 0.0476 ns 0.0371 ns 117.901 ns 0.28
GC.AllocateUninitializedArray 16384 94.471 ns 0.4015 ns 0.3353 ns 94.554 ns 0.22 0.00
'new byte' 32768 790.026 ns 15.5811 ns 15.3027 ns 796.038 ns 1.00
ArrayPool 32768 14.225 ns 0.0072 ns 0.0060 ns 14.222 ns 0.02 0.00
Marshal.Alloc 32768 137.537 ns 0.3049 ns 0.2703 ns 137.594 ns 0.17 0.00
'Marshal.Alloc + GCPressure' 32768 222.805 ns 0.1789 ns 0.1673 ns 222.784 ns 0.28
NativeMemory.Alloc 32768 139.468 ns 2.7590 ns 3.8677 ns 140.092 ns 0.17 0.01
'NativeMemory.Alloc + GCPressure' 32768 216.506 ns 1.1494 ns 1.0751 ns 215.987 ns 0.27
GC.AllocateUninitializedArray 32768 137.147 ns 0.2657 ns 0.2356 ns 137.163 ns 0.17 0.00
'new byte' 65536 1,523.391 ns 27.7315 ns 23.1571 ns 1,532.096 ns 1.00
ArrayPool 65536 18.432 ns 0.0124 ns 0.0104 ns 18.428 ns 0.01 0.00
Marshal.Alloc 65536 109.773 ns 2.2175 ns 3.9986 ns 111.761 ns 0.07 0.00
'Marshal.Alloc + GCPressure' 65536 189.097 ns 3.6164 ns 3.2059 ns 190.010 ns 0.12
NativeMemory.Alloc 65536 105.710 ns 1.4795 ns 2.7423 ns 106.750 ns 0.07 0.00
'NativeMemory.Alloc + GCPressure' 65536 185.469 ns 0.3173 ns 0.2812 ns 185.429 ns 0.12
GC.AllocateUninitializedArray 65536 237.496 ns 1.2092 ns 1.0719 ns 237.694 ns 0.16 0.00
'new byte' 131072 4,932.622 ns 18.8168 ns 15.7129 ns 4,933.810 ns 1.000
ArrayPool 131072 16.016 ns 0.0116 ns 0.0103 ns 16.010 ns 0.003 0.00
Marshal.Alloc 131072 119.555 ns 2.4092 ns 3.6059 ns 118.869 ns 0.025 0.00
'Marshal.Alloc + GCPressure' 131072 241.775 ns 4.8540 ns 12.0883 ns 246.642 ns 0.049
NativeMemory.Alloc 131072 112.695 ns 2.2500 ns 4.1142 ns 112.978 ns 0.023 0.00
'NativeMemory.Alloc + GCPressure' 131072 203.887 ns 3.4076 ns 3.1875 ns 204.198 ns 0.041
GC.AllocateUninitializedArray 131072 2,482.482 ns 8.6712 ns 7.6868 ns 2,482.735 ns 0.503 0.00
'new byte' 262144 9,745.615 ns 14.6181 ns 13.6738 ns 9,744.838 ns 1.000
ArrayPool 262144 14.571 ns 0.0162 ns 0.0151 ns 14.564 ns 0.001 0.00
Marshal.Alloc 262144 384.751 ns 26.3435 ns 76.8453 ns 409.966 ns 0.027 0.01
'Marshal.Alloc + GCPressure' 262144 472.301 ns 13.4946 ns 37.6177 ns 479.022 ns 0.045
NativeMemory.Alloc 262144 711.295 ns 76.9815 ns 217.1276 ns 796.491 ns 0.028 0.02
'NativeMemory.Alloc + GCPressure' 262144 470.812 ns 4.2143 ns 3.9421 ns 470.417 ns 0.048
GC.AllocateUninitializedArray 262144 5,097.969 ns 55.8836 ns 52.2736 ns 5,096.607 ns 0.523 0.01
'new byte' 524288 19,023.374 ns 144.1437 ns 134.8321 ns 19,030.304 ns 1.000
ArrayPool 524288 16.372 ns 0.0065 ns 0.0051 ns 16.373 ns 0.001 0.00
Marshal.Alloc 524288 864.512 ns 10.9232 ns 9.6832 ns 866.815 ns 0.045 0.00
'Marshal.Alloc + GCPressure' 524288 662.507 ns 12.7071 ns 15.1269 ns 662.596 ns 0.035
NativeMemory.Alloc 524288 660.036 ns 5.1624 ns 8.3363 ns 657.784 ns 0.035 0.00
'NativeMemory.Alloc + GCPressure' 524288 645.454 ns 12.7841 ns 14.2094 ns 650.756 ns 0.034
GC.AllocateUninitializedArray 524288 9,936.610 ns 42.6100 ns 39.8574 ns 9,959.137 ns 0.522 0.00
'new byte' 1048576 210,343.268 ns 4,036.2318 ns 3,775.4937 ns 209,505.695 ns 1.000
ArrayPool 1048576 14.407 ns 0.0054 ns 0.0045 ns 14.407 ns 0.000 0.00
Marshal.Alloc 1048576 5,664.862 ns 20.7977 ns 19.4542 ns 5,661.308 ns 0.027 0.00
'Marshal.Alloc + GCPressure' 1048576 7,513.813 ns 19.9332 ns 16.6451 ns 7,516.030 ns 0.036
NativeMemory.Alloc 1048576 4,355.013 ns 8.0762 ns 7.1594 ns 4,357.634 ns 0.021 0.00
'NativeMemory.Alloc + GCPressure' 1048576 5,020.571 ns 30.2852 ns 28.3288 ns 5,023.151 ns 0.024
GC.AllocateUninitializedArray 1048576 7,733.933 ns 129.8119 ns 121.4262 ns 7,717.241 ns 0.037 0.00
'new byte' 2097152 487,347.627 ns 61,547.5253 ns 181,474.2670 ns 576,913.638 ns 1.000
ArrayPool 2097152 15.909 ns 0.0759 ns 0.0673 ns 15.882 ns 0.000 0.00
Marshal.Alloc 2097152 4,588.926 ns 35.5412 ns 33.2452 ns 4,573.381 ns 0.010 0.01
'Marshal.Alloc + GCPressure' 2097152 8,018.549 ns 47.9033 ns 42.4650 ns 8,010.034 ns 0.018
NativeMemory.Alloc 2097152 4,589.413 ns 24.3240 ns 22.7527 ns 4,588.242 ns 0.010 0.01
'NativeMemory.Alloc + GCPressure' 2097152 7,800.359 ns 24.4334 ns 21.6596 ns 7,801.991 ns 0.018
GC.AllocateUninitializedArray 2097152 16,230.549 ns 1,001.2648 ns 2,840.4215 ns 14,941.833 ns 0.063 0.10
'new byte' 4194304 1,153,983.587 ns 22,865.1377 ns 34,917.4802 ns 1,170,402.966 ns 1.000
ArrayPool 4194304 15.194 ns 0.0714 ns 0.0668 ns 15.204 ns 0.000 0.00
Marshal.Alloc 4194304 4,695.135 ns 49.0898 ns 45.9186 ns 4,692.053 ns 0.004 0.00
'Marshal.Alloc + GCPressure' 4194304 5,120.850 ns 14.4461 ns 12.8061 ns 5,120.153 ns 0.004
NativeMemory.Alloc 4194304 7,144.996 ns 53.5635 ns 50.1033 ns 7,139.875 ns 0.006 0.00
'NativeMemory.Alloc + GCPressure' 4194304 8,005.085 ns 74.1182 ns 65.7039 ns 8,014.642 ns 0.007
GC.AllocateUninitializedArray 4194304 56,849.782 ns 9,976.5391 ns 28,943.7489 ns 48,485.974 ns 0.049 0.02
'new byte' 8388608 1,148,999.833 ns 22,853.2166 ns 40,025.5654 ns 1,149,472.656 ns 1.000
ArrayPool 8388608 14.672 ns 0.1140 ns 0.1067 ns 14.654 ns 0.000 0.00
Marshal.Alloc 8388608 4,657.386 ns 34.4025 ns 32.1801 ns 4,665.833 ns 0.004 0.00
'Marshal.Alloc + GCPressure' 8388608 5,151.209 ns 38.3535 ns 35.8759 ns 5,146.686 ns 0.005
NativeMemory.Alloc 8388608 7,180.269 ns 25.9670 ns 24.2896 ns 7,178.865 ns 0.006 0.00
'NativeMemory.Alloc + GCPressure' 8388608 5,175.859 ns 34.8045 ns 32.5561 ns 5,166.141 ns 0.005
GC.AllocateUninitializedArray 8388608 582,251.110 ns 31,505.5869 ns 92,894.9338 ns 589,878.369 ns 0.496 0.08
'new byte' 16777216 1,835,476.538 ns 36,402.7929 ns 102,674.7232 ns 1,837,862.939 ns 1.000
ArrayPool 16777216 14.438 ns 0.0617 ns 0.0577 ns 14.430 ns 0.000 0.00
Marshal.Alloc 16777216 4,766.127 ns 28.3081 ns 26.4794 ns 4,761.963 ns 0.002 0.00
'Marshal.Alloc + GCPressure' 16777216 5,266.717 ns 37.5639 ns 35.1373 ns 5,258.134 ns 0.003
NativeMemory.Alloc 16777216 7,130.423 ns 44.2171 ns 41.3607 ns 7,136.340 ns 0.004 0.00
'NativeMemory.Alloc + GCPressure' 16777216 5,322.742 ns 17.3828 ns 13.5714 ns 5,327.187 ns 0.003
GC.AllocateUninitializedArray 16777216 1,608,216.958 ns 47,482.1191 ns 140,002.0994 ns 1,607,935.217 ns 0.881 0.09
'new byte' 33554432 2,586,667.734 ns 322,461.3751 ns 950,784.6401 ns 2,520,573.242 ns 1.000
ArrayPool 33554432 17.521 ns 0.0986 ns 0.0874 ns 17.499 ns 0.000 0.00
Marshal.Alloc 33554432 7,403.940 ns 43.5760 ns 40.7610 ns 7,395.505 ns 0.003 0.00
'Marshal.Alloc + GCPressure' 33554432 5,395.639 ns 47.5055 ns 44.4367 ns 5,401.366 ns 0.003
NativeMemory.Alloc 33554432 4,674.743 ns 30.5416 ns 28.5687 ns 4,670.855 ns 0.002 0.00
'NativeMemory.Alloc + GCPressure' 33554432 5,395.462 ns 43.5393 ns 40.7267 ns 5,394.284 ns 0.003
GC.AllocateUninitializedArray 33554432 2,179,107.702 ns 43,172.6520 ns 100,059.1858 ns 2,189,359.583 ns 1.003 0.41
'new byte' 67108864 2,239,400.302 ns 139,427.1431 ns 411,104.0771 ns 2,200,031.348 ns 1.000
ArrayPool 67108864 16.233 ns 0.0554 ns 0.0491 ns 16.209 ns 0.000 0.00
Marshal.Alloc 67108864 5,134.274 ns 22.7777 ns 21.3063 ns 5,134.368 ns 0.002 0.00
'Marshal.Alloc + GCPressure' 67108864 5,696.211 ns 51.0449 ns 47.7474 ns 5,696.205 ns 0.003
NativeMemory.Alloc 67108864 7,734.205 ns 50.1103 ns 44.4215 ns 7,739.951 ns 0.004 0.00
'NativeMemory.Alloc + GCPressure' 67108864 7,179.701 ns 59.8916 ns 53.0924 ns 7,187.198 ns 0.003
GC.AllocateUninitializedArray 67108864 2,620,094.243 ns 122,614.2257 ns 361,530.8110 ns 2,662,540.283 ns 1.208 0.28
'new byte' 134217728 2,807,654.795 ns 59,852.9969 ns 174,594.0012 ns 2,787,194.434 ns 1.000
ArrayPool 134217728 17.360 ns 0.0999 ns 0.0934 ns 17.349 ns 0.000 0.00
Marshal.Alloc 134217728 8,191.272 ns 35.0218 ns 31.0459 ns 8,196.585 ns 0.003 0.00
'Marshal.Alloc + GCPressure' 134217728 6,022.638 ns 96.8344 ns 90.5789 ns 6,016.302 ns 0.002
NativeMemory.Alloc 134217728 5,672.151 ns 41.0983 ns 38.4434 ns 5,676.926 ns 0.002 0.00
'NativeMemory.Alloc + GCPressure' 134217728 6,060.486 ns 35.3757 ns 31.3597 ns 6,058.861 ns 0.002
GC.AllocateUninitializedArray 134217728 2,908,906.631 ns 57,655.1371 ns 164,493.3504 ns 2,924,241.309 ns 1.040 0.09

(C#) async/await を理解する

この記事は Qiita C# Advent Calendar 2021 の5日目の記事です。

はじめに

C# で async/await が登場してからずいぶんと時間がたち、モダンな C# においてはほぼ必須となりました。Unity でも UniTask などのライブラリもあり、簡単に非同期処理が書けます。この記事では C# での非同期処理の歴史にも触れつつ async/await の動作原理について書きます。

Unity C# の話を書いた方が需要が高そうなので Unity および UniTask を前提にした説明とコードが多く出てきますが、async/await は C# の言語機能であるため、動作原理自体は .NET でも同じです。非 Unity の文脈では適宜読み替えてください。

また、詳細を完璧に説明するよりもわかりやすさを重視したため、一部正確さを欠いた説明をしています。ご了承ください。

async/await の登場以前

GUI アプリケーションで重い計算処理をそのまま行うと、UI がフリーズしてしまうためバックグラウンドで処理を行う必要があります。今では async/await を使った非同期処理で記述することができますが、async/await が登場する以前はどのように行っていたのでしょうか?方法はいくつかありますが、原理的には以下のようにスレッドを立ててその完了をコールバック処理を登録する形で実行していました。

f:id:ikorin2:20211205070514j:plain

実際にはスレッドを自分で作るのはかなり非効率なのでスレッドプールを用いたり、Windows Forms では主に BackgroundWorker というクラスを使っていたりといろいろありますが、それらは全てレガシーな C# なのでここでは解説しません。重要なのは async/await 登場以前はデリゲートによるコールバックによって非同期処理 (バックグラウンドスレッドでの処理) を行っていたということです。

このコールバックによる非同期処理にはいくつか問題がありました。多段に非同期処理を書くと、完了コールバックの中で別の非同期処理を書き、さらにその中で完了コールバックを書くことになり、いわゆるコールバック地獄になり可読性が悪くなります。また、例外処理にも問題があり、非同期処理の呼び出し先 (別スレッド) で発生した例外を呼び出し元のスレッドまで伝播するのは困難でした。

これが async/await 構文の登場によって図の右側のように書けるようになり、上記の問題が非常に簡単になりました。

非同期処理とバックグラウンド処理

async/await が導入された当時、その主なモチベーションは重い処理をバックグラウンドスレッドで実行するのを簡単に記述したいということでした。Task クラスによる async/await が導入されてから、今のように Unity で UniTask が登場するまでにはやや時間があります。というのも、Unity で最も使用頻度の高い async/await の使い道はメインスレッド内での非同期処理だからです。

よく C#Task クラスの解説で言及されていることですが、「非同期処理」と「別スレッドで処理を行う」ことは異なります。

f:id:ikorin2:20211205043229j:plain

上記の説明で、何を言っているのかさっぱりわからない方は読み飛ばして大丈夫です。このあとの解説を読んで、再度この説明に戻ってくれば理解できると思います。

Unity の PlayerLoop 上での処理の流れ

ここからはしばらく Unity を前提とした話になります。以下の図は Unity の PlayerLoop (フレーム毎のループ) を簡易的に表した図です。

f:id:ikorin2:20211205044906j:plain

簡単のため、PlayerLoop には EarlyUpdate, Update, LateUpdate の3つしかタイミングがないとし、オブジェクトは3つだけしかないとしています。

青の矢印は、プログラムが実行される順番を表しており、1フレーム内では常に縦方向に処理が進んでいきます。一方、プログラマが実装する処理は、特定のオブジェクトが時間軸に沿って何を行うのかを記述したい場合がほとんどで、これは図の横方向の流れになります。プログラマが実装したい処理は横方向であるのに、プログラムは縦方向に書かざるを得ないために時間軸に沿った処理を実装するのが難しいのです。

しかし、async/await を使うとこれを横方向にプログラムを記述することができるようになります。

f:id:ikorin2:20211205044927j:plain

たとえば、await UniTask.Yield() はプログラムの処理を次のフレームにジャンプさせることができます。これを使うと、フレーム毎に処理を実行しつつ横軸方向にプログラムを記述することができます。また、await UniTask.DelayFrame(a) は指定したフレーム数だけ処理をジャンプすることができます。

ここで、await と async はそれぞれ以下のように考えることができます。

  • await: await した位置でプログラムの処理の流れを特定の時間までジャンプする
  • async: このメソッドの中で時間をジャンプしていることを示すマーク

このように考えると、少しは async/await が何を意味しているのか分かってきたような気がします。

コルーチンについて ここでは詳説しませんが、Unity での時間軸方向の処理は Unity のコルーチンというシステムによって async/await を使わずに書くことができます。上記の図のような処理はコルーチンでも同じことが実装できます。しかし、このあと解説する別スレッドとの連携や並行処理など、コルーチンでは記述できないものも async/await では記述できるため、上位互換として考えることができます。

await によるスレッド間移動

async/await は非同期処理はスレッドを移動することもできます。UniTask の場合は await UniTask.SwitchToThreadPool() によって別のスレッドにジャンプすることができます。

f:id:ikorin2:20211205052038j:plain

await の意味について「await した位置でプログラムの処理の流れを特定の時間までジャンプする」と前述しましたが、特定の時間というのは別スレッドの時間にすることも可能ということです。

同期コンテキスト

Unity を含め GUI のアプリケーションではメインスレッドは特別扱いされます。GUI に関するオブジェクトやデータは、パフォーマンス上の理由やフレームワークの実装上の都合でメインスレッドからしか扱えないように設計されているのが一般的です。つまり、バックグラウンド処理を行った後 UI を更新するためには、メインスレッドに戻ってくる必要があります。これを簡単に行うための仕組みとして、同期コンテキスト (System.Threading.SynchronizationContext) というものがあります。

ここで、UniTask と Task では同期コンテキストの扱いが異なります。

f:id:ikorin2:20211205061306p:plain

同期コンテキストは、スレッドに1つ存在するか存在しないかのどちらかです。Task は同期コンテキストをキャプチャし、UniTask はキャプチャしません。

[Task]

まず、上の図で、 Task を使ってメインスレッドから別スレッドで処理を開始する await Task.Run(action) について見ていきます。指定された action は別スレッド上で実行されますが、その後 await した呼び出し元に戻るときに必ずメインスレッドに戻ります。これが、同期コンテキストをキャプチャするということです。(図の左上)

ただし、await Task.Run(action) を開始したスレッドがメインスレッドでない場合、通常は同期コンテキストはメインスレッドにしか存在していないためキャプチャが起こりません。(図の左下)

また、メインスレッドから開始した場合でも同期コンテキストをキャプチャしない (=メインスレッドに戻らない) ように明示的に指定することもできます。(図の右上)

[UniTask]

一方で、UniTask は同期コンテキストをキャプチャしません。つまり、await しても勝手にメインスレッドに戻ったりすることはなく、メインスレッドに戻りたいときは自分で await UniTask.SwitchToMainThread() などを呼び出す必要があります。

async メソッドの戻り値

await をしているメソッドは、メソッドの定義に async とつける必要があります。この時、async なメソッドの戻り値の型として TaskUniTask と書きますが、この時実際に戻り値として関数から返されているオブジェクトは何なのでしょうか?

f:id:ikorin2:20211205061042j:plain

async はメソッド内で await によって処理がジャンプしていることを示すマークなのですが、それと同時にコンパイラがメソッドの内部を書き換えるということも示しています。

たとえば、上記の図の左側で、戻り値が async UniTask なメソッドについて考えます。これは引数で与えられた bool 値によって処理がジャンプする場合とジャンプしない場合があります。

  • true の場合: ジャンプせず、何も処理を行わずにメソッドを終了する。
  • falseの場合: 別スレッドにジャンプし、DoSomething()を実行する。

この時、コンパイラは async なメソッドを async を使わない形に変換するため、この二つの条件を満たすような UniTask を自動生成し、この UniTask を返すようなメソッドに書き換えて、async を消します。

一方、上記の図の右側のように、戻り値を async Task にしたメソッドの場合は以下のようになります。

  • true の場合: ジャンプせず、何も処理を行わずにメソッドを終了する。
  • falseの場合: 別スレッドにジャンプし、DoSomething()を実行する。その後、呼び出し元がメインスレッドならメインスレッドに戻る。

条件を満たす Taskコンパイラが自動生成し、async を使わない形に書き換えられることは同じですが、その Task の条件が異なります。Task は同期コンテキストをキャプチャするため、「呼び出し元がメインスレッドならメインスレッドに戻る」という処理が発生します。

await によるジャンプの実現方法

await によって時間軸をジャンプできることはわかりました。これは UniTask や Task の内部でどのように実現されているのでしょうか。

f:id:ikorin2:20211205063850j:plain

await による時間のジャンプはデリゲートをキューに入れ、ジャンプ先でデリゲートを取り出して実行することによって実現されています。ここで、一番最初に説明した async/await 登場以前の非同期処理を思い出してください。スレッドを越える時も、スレッドから戻ってくる時もデリゲートを渡し、デリゲートを実行することで非同期処理を行っていました。

つまり、Task や UniTask によって高度にラップされているものの、本質的には async/await と async/await を使わないコールバックによる従来の非同期処理は同じなのです。

async void はなぜ非推奨か

最後に、async void がなぜ非推奨なのかについて触れておきます。

f:id:ikorin2:20211205064913j:plain

async void Hoge() { ... } は「Hoge() メソッドの中で await はできるが、Hoge() メソッド自体は待機 (await) 出来ない」ことを表します。メソッドを待機できないとは、

  • その中で発生した例外をその外側に伝播できない
  • そのメソッドがいつ終わったか、正常に終わったかを検知する手段がない

ことを意味します。通常、これらの状態は避けなければなりません。逆に、これらを許容できる場合には async void と書いてもよいということになります。

(C#) C#10 で構造体の既定のコンストラクタを禁止する

構造体の既定のコンストラク

構造体 (struct) には既定のコンストラクタが暗黙的に定義されます。

public struct MyStruct
{
    public string? Value { get; }

    public MyStruct(string? value)
    {
        Value = value;
    }
}

// 既定のコンストラクタ
var myStruct = new MyStruct();
// myStruct.Value == null
Console.WriteLine(myStruct.Value);

既定のコンストラクタはすべてのフィールドが0や null で初期化された状態であり、この引数のない既定のコンストラクタを自分で書くことはできませんでした。つまり、C#9.0 以前のコードでは default(T) と同義でした。

ところが、C#10.0 からは構造体に引数無しのコンストラクタを定義できるようになりました。ただし、互換性のため、引数無しのコンストラクタが定義されていない場合は引き続き default(T) と同じ挙動をする既定のコンストラクタが定義されます。

構造体の既定のコンストラクタを禁止する

ハイパフォーマンスな実装においてはクラスを避けて構造体で記述したいが、既定のコンストラクタでは正しい状態を保てない場合があります。そのため仕方なくクラスで記述したり、安全のためのチェックを書かざるを得ない場合があります。例えば、前述の MyStruct は既定のコンストラクタを使用した場合を考慮して Value プロパティを null 許容型の string? にせざるをえないなどの状況です。

C#9.0 以前ではこれを防ぐ方法はなく、Roslyn アナライザなど使わない限り使用者に注意喚起を表示することもできませんでした。 C#10.0 ではこれを明示的に禁止できます。

public struct MyStruct
{
    public string Value { get; }

    [Obsolete("Don't use default constructor", true)]
    [EditorBrowsable(EditorBrowsableState.Never)]
    public MyStruct()
    {
        throw new NotSupportedException("Don't use default constructor");
    }

    public MyStruct(string value)
    {
        ArgumentNullException.ThrowIfNull(value);
        Value = value;
    }
}

Obsolete 属性をつけた引数無しのコンストラクタを記述します。Obsolete 属性の2つ目の引数を true にすると、使用者がこのコンストラクタを記述した場合にコンパイルエラーにすることができます。

また、[EditorBrowsable(EditorBrowsableState.Never)] をつけることで IDE などのインテリセンスに表示されなくなるため、使用者側からは存在していないように見えます (Visual Studio 2022 で確認済み)。ただし、[EditorBrowsable(EditorBrowsableState.Never)] の効果はパッケージや dll を参照した時にしか効果がないため、同一アセンブリ内やプロジェクトを参照している場合などには見えてしまいます。

default とジェネリクスは禁止できない

Obsolete 属性のコンパイルエラーの抜け道

Obsolete 属性によるコンパイルエラーの抜け道として、ジェネリクスwhere T : new() 制約によるインスタンス生成があります。

public static T GetInstance<T>() where T : new()
{
    return new T();
}

// コンパイルエラーにはならない
var myStruct = GetInstance<MyStruct>();

上記の場合、MyStruct() の中で NotSupportedException を投げているためインスタンス自体が生成されることはありませんが、コンパイルエラーとして静的に禁止することはできません。

default(T) の扱い

引数無しコンストラクタは禁止できますが、default および配列などの規定値としてゼロ初期化された構造体が使われてしまうことはあります。

var array = new MyStruct[10];
Console.WriteLine(array[0].Value);  // null

MyStruct myStruct = default;
Console.WriteLine(myStruct.Value);  // null

Roslyn アナライザを使わない限り、構造体の default を禁止することはできません (アナライザを使っても完璧に禁止するのは難しいです)。 構造体で default を書くことは、参照型において null と書くのと同義ととらえると、「変数を初期化していない (null) なのだから、その値を使うと正しく動かないのは当然」と考えることもできなくはないですが、C# の歴史的に見てやや無理があるのも否めません。

 

ゼロ初期化状態の構造体を不正なものとして既定のコンストラクタを禁止するのが効果的に機能する場面は、ハイパフォーマンスなチューニングを必要とする箇所であり、それを実装する人も使う人もある程度 C# に知識のある人です。default(T) の扱いは十分理解していると考えると問題ないのかもしれませんが、ライブラリとして広く一般に使う場面を想定するとやや無理があるのも理解できます。場面に応じて適切に使うべきでしょう。

なんでも構造体実装にするのは速い反面、このような問題もありますが、既定のコンストラクタを静的にある程度禁止できるのはかなり有効です。

(C#) 構造体で列挙型ライクな定義を作る

小ネタです。毎回 C# のあまり一般的ではない珍妙な実装の記事ばかり書いていますが、今回もまた珍妙な実装です。実用性は一応ありますが、一般的ではない珍妙な方法だと理解した上でご利用ください。

概要

構造体をつかって列挙型のような型を作っていきます。

public enum Fruit : int
{
    Apple = 0,
    Orange = 1,
    Grape = 2,
}

上のenum Fruitはよくある感じの列挙型です。これを構造体で似たようなものを作ってみます。

public readonly struct Fruit
{
    private readonly int _v;

    public static Fruit Apple => new Fruit(0);
    public static Fruit Orange => new Fruit(1);
    public static Fruit Grape => new Fruit(2);

    private Fruit(int value) => _v = value;
}

説明用なので、IEquatable<T>実装など構造体に必要な実装は省略していますが、書かれていると思ってください。以下、この方法で実装した構造体を擬似列挙体と呼ぶことにします。

使い方は普通の列挙体と同じです。

var fruit = Fruit.Apple;
if (fruit == Fruit.Apple)
{
    Console.WriteLine("This is an apple.");
}

逆に通常の列挙体と違う点はEnumクラスを使えないことと、switchができないなどの点があります。

// 普通の enum では switch できるが、今回の疑似列挙体では使えない
var fruit = Fruit.Apple;
switch(fruit)
{
    case Fruit.Apple:
        return "This is an apple.";
    default:
        return "Something other";
}
// 普通の enum では Enum 型を使えるが、今回の疑似列挙体では使えない
var allFruits = Enum.GetValues<Fruit>();

メリット・デメリット

構造体で列挙体に似せて作った疑似列挙体のメリットはいくつかあります

  • 定義が存在しない値を生成できない
  • インスタンスメソッド、演算子を定義できる
  • 破壊的変更をせずに内部値を変更できる

1つめは定義が存在しない値を生成できないことです。通常の列挙体の場合、数値型とのキャストが相互に可能なため、列挙体として定義が存在しない値を作れてしまいます。

// Fruit に値が100の要素定義は存在しないが、合法的に作れる。
var fruit = (Fruit)100;

存在しない値を合法的に作れてしまうので、利用者側のエラーチェックが意外と面倒です。疑似列挙体の場合、値を設定できるコンストラクタをprivateにしてしまえば、定義が存在しない値はアンセーフを使わない限り生成できなくなります。

2つめは、疑似列挙体ではメソッドや演算子を定義できることで、これがもともと列挙体にはできないのが意外と不便です。メソッドは拡張メソッドで代用できますが、演算子は普通の列挙体ではできません。例えば他の型と==で比較を定義したい時などがありますが、疑似列挙体ならこれができます。

3つめは破壊的変更をせずに内部値を変更できることです。これはある意味最大のメリットです。列挙体の場合、その内部にある数値も含めて公開情報であるため、数値を変更すると後方互換性が崩れます。疑似列挙体の場合は、その数値をprivateに保持しており、数値型とのキャストや比較などを実装しない限り破壊的変更になりません。 ただし、0の値をもつ定義だけはdefaultが変わってしまうため変更できません。何を0にするかは通常の列挙体と同様、よく考えて定義しましょう。

 

逆にデメリットとしては

  • 定数 (const) になれない
  • メソッドのデフォルト引数値に書けない
  • switch 文で分岐できない
  • Enum クラスの機能が使えない (自分で書く必要がある)

などです。普通ではない実装なので、このデメリットをよく考えた上でメリットのほうが大きいなら使ってもいいかなという感じです。

もっと詳しく

最初に省略した部分も含めて、最低限必要な機能を実装したFruit型の疑似列挙体の全文はだいたい以下のようになると思います。

using System;
using System.Diagnostics;

[DebuggerDisplay("{ToString(),nq}")]
public readonly struct Fruit : IEquatable<Fruit>
{
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    private readonly int _v;

    public static Fruit Apple => new Fruit(0);
    public static Fruit Orange => new Fruit(1);
    public static Fruit Grape => new Fruit(2);

    private Fruit(int value) => _v = value;

    public override bool Equals(object? obj) => obj is Fruit f && Equals(f);
    public bool Equals(Fruit other) => _v == other._v;
    public override int GetHashCode() => _v.GetHashCode();
    public static bool operator ==(Fruit left, Fruit right) => left.Equals(right);
    public static bool operator !=(Fruit left, Fruit right) => !(left == right);
    public override string ToString()
    {
        if (this == Apple) { return "Apple"; }
        else if (this == Orange) { return "Orange"; }
        else if (this == Grape) { return "Grape"; }
        else { return _v.ToString(); }
    }
}

DebuggerDisplay属性が書いてあるのは、デバッガ上で表示したときに通常の列挙体と同じような見た目になってほしいからで、DebuggerBrowsableがフィールドについているのは、デバッガでその内部値を見せたくない (将来的に内部値を変更したいという目的のために、デバッガ上でさえ値を公開したくない) という理由です。

見てわかるかと思いますが、記述量が多すぎてすごく面倒くさいです。しかし、その大半は自動的に実装できそうな内容です。これを簡単に生成してくれるソースジェネレータライブラリがほしくなりますね。今作っていますが公開するかは不明です。正直、ソースジェネレータは作るのが面倒なので、自分で使うだけなら T4 Template でいいような気がします。

ものすごく細かいことを言うと、面倒くさいのでやりませんが上記のToStringのなかでifで条件分岐している部分は、本当は内部の値で二分探索すると速くなります。

また、.NET5 (or .NET6 ?) の JIT コンパイラは最適化によって、プリミティブ型をラップした構造体をプリミティブ型と同じ最適化をかけるように改善されたりしているはずなので、ちゃんと書けば速度上のデメリットはないはずです。

(C#) lock フリーで高速なスレッドセーフ操作

C#排他制御をしたい場合いくつか方法があります。何も考えずに雑にlock構文を書くだけで同期は取れるのですが、より高度にパフォーマンスチューニングしたい場合のため、効率的な方法を紹介します。

例えば以下のようなものがあるとします。

// スレッドセーフではないもの
public static class Foo
{
    private static int[] _array = new int[10];
    private static int _updateCount;

    public static void SetValue(int index, int value)
    {
        _array[index] = value;
        _updateCount++;
    }

    public static (int value, int updateCount) GetValue(int index)
    {
        return (_array[index], _updateCount);
    }
}

上記のコードのSetValueメソッドは、内部の配列の指定のインデックスに値をセットし、更新回数を記録しています。GetValueメソッドは指定のインデックスの値と更新回数を返します。これらのメソッドはスレッドセーフではないため、マルチスレッドで正しく動作する保証はありません。今回はこれらをスレッドセーフにする方法をいくつか見ていきます。なお、説明用の例として簡単のために、範囲外のインデックスは指定されないという前提にします。

lockによる同期

// lock でスレッドセーフ化したもの
public static class Foo
{
    private static readonly object _lockObj = new object();
    private static int[] _array = new int[10];
    private static int _updateCount;

    public static void SetValue(int index, int value)
    {
        lock(_lockObj) {
            _array[index] = value;
            _updateCount++;
        }
    }

    public static (int value, int updateCount) GetValue(int index)
    {
        lock(_lockObj) {
            return (_array[index], _updateCount);
        }
    }
}

最初の例にlockを追加しただけですが、これだけでスレッドセーフになります。簡単ですね。lock(_lockObj)している間、他のスレッドはlock(_lockObj)内に入ることができないため、その内部ではマルチスレッドのことを考える必要がありません。難しいことを考えたくない、かつ特にパフォーマンスチューニングも求めていない場合、これで十分です。

ThreadStatic 属性によるスレッドセーフ

static フィールドにThreadStaticAttributeをつけると、簡単にスレッドセーフにすることができます。これは同期的に操作をするというより、そもそも同期をとる必要がない構造にするというものです。

// ThreadStatic による実装
public static class Foo
{
    private const int ArrayLength = 10;
    [ThreadStatic]
    private static int[]? _array;
    [ThreadStatic]
    private static int _updateCount;

    public static void SetValue(int index, int value)
    {
        _array ??= new int[ArrayLength];
        _array[index] = value;
        _updateCount++;
    }

    public static (int value, int updateCount) GetValue(int index)
    {
        return (_array == null) ? (0, 0) : (_array[index], _updateCount);
    }
}

最初のコードと違うのは、[ThreadStatic]が付いていることです。この属性が付いているフィールドはスレッドごとに別のフィールドが存在している状態になります。スレッドローカルなメモリにしかアクセスしていないため、マルチスレッドによる問題は発生しません。スレッド間での同期が不要なため高速です。

ただし、スレッド A でSetValueで値をセットしたものは、スレッドBでGetValueを呼んでも取得できません。そのため、この方法が使えるのはスレッドごとに独立していても問題がないものだけです。

注意点としては、[ThreadStatic]をつけるフィールドは static である必要があります。また、このフィールドは初期値をセットしたり、static コンストラクタで初期化してはいけません。初期化をしても、最初に呼ばれたスレッドでしか初期化が走らないためおかしくなります。そのため、各スレッドで_arrayにアクセスする時に配列を作成する必要があります。

ThreadStatic属性を使った方法は、現実的な場面ではSystem.Buffers.ArrayPool<T>.Sharedなどで使われています。(詳細は過去記事を参照)

SpinWait による軽量な同期

まず次のようなFastSpinLockという構造体を作ります。(中身は後で説明します)

public struct FastSpinLock
{
    private const int SYNC_ENTER = 1;
    private const int SYNC_EXIT = 0;
    private int _syncFlag;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Enter()
    {
        if(Interlocked.CompareExchange(ref _syncFlag, SYNC_ENTER, SYNC_EXIT) == SYNC_ENTER) {
            Spin();
        }
        return;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Exit() => Volatile.Write(ref _syncFlag, SYNC_EXIT);

    [MethodImpl(MethodImplOptions.NoInlining)]
    private void Spin()
    {
        var spinner = new SpinWait();
        spinner.SpinOnce();
        while(Interlocked.CompareExchange(ref _syncFlag, SYNC_ENTER, SYNC_EXIT) == SYNC_ENTER) {
            spinner.SpinOnce();
        }
    }
}

これを使って、最初のコードをスレッドセーフにします。

// SpinWait による軽量な同期
public static class Foo
{
    private static FastSpinLock _spinLock;
    private static int[] _array = new int[10];
    private static int _updateCount;

    public static void SetValue(int index, int value)
    {
        try {
            _spinLock.Enter();
            _array[index] = value;
            _updateCount++;
        }
        finally {
            _spinLock.Exit();
        }
    }

    public static (int value, int updateCount) GetValue(int index)
    {
        try {
            _spinLock.Enter();
            return (_array[index], _updateCount);
        }
        finally {
            _spinLock.Exit();
        }
    }
}

まずは使い方ですが、try-finally で EnterメソッドとExitメソッドを呼びます。EnterからExitまでの間はlockと同じく単一のスレッドしか入れないため同期が取れます。注意点としては、このFastSpinLock構造体は mutable であるため、readonlyにしてはいけません。

 

どうしてこれで同期が取れるかというFastSpinLock構造体の原理を説明します。

同期を取るためのフラグが_syncFlagで、誰も制御を取っていない場合はこのフラグが0です。Enterメソッドを呼ぶと_syncFlagが0の場合、1になります。この時、このフラグの操作はInterlocked.CompareExchangeによってアトミックに行われます。0から1に変わった時のみ、そのままEnterメソッドを抜けます。(=スレッド競合が発生していない状態)

Enterメソッドで_syncFlagが1だった場合、スピンウェイトをします。スピンウェイトとは無限ループによってフラグが0になるのを待つことで同期を取る方法です。この時、ただ無限ループするだけではCPUに負荷がかかるだけで無駄な待機をしてしまうため、フラグが1だった場合は現在のスレッドの残りのタイムスライスを放棄して、次のコンテキストスイッチを待ちます。また、複数回ループを待機してもフラグを取得できなかった場合、待機時間を長くしてみるべきです。これらの操作を自分で正しく書くのは面倒なため、System.Threading.SpinWaitという構造体が用意されており、自動で行ってくれます。これでフラグを取得できるまでスピンウェイトします。

Exitメソッドではフラグを0に戻しています。_syncFlagint型であるため、書き込みはアトミックであることが保証されています。ただし、このフラグはマルチスレッドから読み書きされるため、確実にメモリに書き込まれている必要があります。通常の代入ではコンパイラの最適化によって、シングルスレッドでは問題が起きない範囲で順番が前後したり、CPUキャッシュ上だけで操作が行われたりするため、この最適化をしないようにVolatile.Writeによって確実に書き込みます。

上記の実装は、ほとんどの場合競合が発生せず待機が行われない、かつ同期を取得している時間も非常に短いという想定において最速になるよう実装しています。Enterメソッドではホットパスのコード量を最小にしてインライン展開し、スピンウェイトの待機処理はインライン展開しないようにしています。スレッドの競合が発生しない場合、スレッドセーフではないコードに比べてオーバーヘッドが非常に少ないです。

 

ちなみに、このFastSpinLockとだいたい同じ実装をしているSystem.Threading.SpinLockという構造体があるのですが、System.Threading.SpinLockは上記の実装よりもう少しリッチで複雑な処理をしています。それを使ってもいいのですが、前述の条件で最小かつ最速は上記の実装です。

ベンチマーク

ベンチマークです。各手法それぞれ以下のようなコードの速度を計測しています。スレッド間の競合が発生しない場合での速度を計測しています。 (ベンチマークコードの全体はページ最後に gist を貼っています)

[Benchmark]
public int NotThreadSafe()
{
    for (int i = 0; i < 10; i++)
    {
        Foo.SetValue(i, i);
    }
    int result = 0;
    for (int i = 0; i < 10; i++)
    {
        result = Foo.GetValue(i).value;
    }
    return result;
}
BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.1110 (20H2/October2020Update)
Intel Core i7-10700 CPU 2.90GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100-preview.4.21255.9
  [Host]     : .NET 5.0.6 (5.0.621.22011), X64 RyuJIT
  Job-DFQJUV : .NET 5.0.6 (5.0.621.22011), X64 RyuJIT

Jit=RyuJit  Platform=X64  IterationCount=100  

結果

Method Mean Error StdDev Median Ratio RatioSD Rank Gen 0 Gen 1 Gen 2 Allocated
not thread safe 51.11 ns 0.044 ns 0.129 ns 51.09 ns 1.00 0.00 1 - - - -
lock 303.88 ns 0.399 ns 1.158 ns 303.73 ns 5.95 0.03 4 - - - -
ThreadStatic 172.30 ns 0.128 ns 0.371 ns 172.18 ns 3.37 0.01 3 - - - -
FastSpinLock 149.94 ns 0.158 ns 0.461 ns 149.82 ns 2.93 0.01 2 - - - -

スレッドセーフでないコードが最も早いのは当然として、ThreadStatic属性による方法よりスピンロックの方が速いのが驚きです。おそらくThreadStatic属性の制約上、フィールドを初期化できないため毎回初期化されているかをチェックしている分だけ遅くなっています。逆に言うと条件分岐一回分で差が出るぐらいFastSpinLockの実装がガチガチです。

別にlockが遅いわけではないんですが、他が速すぎて相対的に遅いですね。

結論としては lock より速い方法はありますが、同期が必要な操作にかかる時間やスレッド競合の頻度などでかなりシビアな判断が必要になります。ここまで書いておいてなんですが、何を使えばいいかわからなければlockしてください。一番簡単です。

ベンチマークのコード

gist.github.com