この記事は公開から4年以上経過しています。
NET開発でマルチスレッドプログラミングを行う際にReleaseビルド時にだけバグが顕在化するケースについて、簡単なサンプルプログラムで説明します。
本例では問題を意図的に再現していますが、実運用でのスレッド関連不具合は再現度が極端に低い難解なバグの原因にもなるため相当厄介です。
問題
.NETでマルチスレッドのプログラムを作るとReleaseビルドとDebugビルドで挙動が異なる場合がある。
検証ソースコード
プログラムを起動してPress any key
と表示されたら何かキーを押下します。
Debugビルドバイナリではキー押下ですぐにプログラムがすぐに終了しますが、Releaseビルド(最適化が有効)バイナリの場合は、キーを何度押下してもプログラムが終了されません。
(デバッガ経由の実行は開発環境のJIT最適化設定で動作が異なるため、検証される場合はEXEファイルを直接実行して行ってください。)
本例はC#(.NET Core2.1)で解説していますが、.NET FrameworkやVB.NETなどでも同様の問題が発生するため注意が必要です。
using System;
using System.Threading.Tasks;
namespace ThreadTest
{
internal class Program
{
// ループ終了判定フラグ
private static bool exitFlag = false;
private static void Main(string[] args)
{
var i = 0;
// ワーカースレッド
var t = Task.Run(() =>
{
Console.WriteLine("Begin thread");
// ループ終了が指示されるまでループ
while (!exitFlag)
{
// dummy
i = ++i & 1;
}
// ループ終了が分かりやすいようにプロセスを強制終了
Environment.Exit(0);
});
// キー押下待機
while (true)
{
Console.WriteLine("Press any key");
Console.ReadKey();
// ワーカースレッドのループ終了を指示
exitFlag = true;
Console.WriteLine($"Thread stop request {exitFlag}");
}
}
}
}
原因
マルチプロセッサ(コア)のマルチスレッド環境下では、JITコンパイラの最適化により実装者が意図していない機械語コードが生成される。
Debugビルドバイナリ実行時に生成されたアセンブラコード
-
キー押下待機ロジックのコード。キーを押下するとマーカー部分のメモリアドレスの領域に1(true)をセット。
-
終了判定ロジックのコード。マーカー部分のメモリアドレスからバイト値を読み込み、値が0の場合はループ。
この例では同じ揮発メモリアドレス上の値を判定していることから、キー押下によりプログラムが終了する。
Releaseビルドバイナリ実行時に生成されたアセンブラコード
-
キー押下待機ロジックのコード。キーを押下するとマーカー部分のメモリアドレスの領域に1(true)をセット。
-
終了判定ロジックのコード。マーカー部分のメモリアドレスから拡張汎用レジスタにバイト値を読み込み、値が0の場合は初回ループ、2回目以降の判定では上記拡張汎用レジスタの値が更新されず0のため永久ループ。
この例ではレジスタにキャッシュされた値を判定していることから、キーを押下しても終了できない。
対応
スレッド間で変数を共有するときはアトミック変数であっても必要に応じてvolatileとメモリバリア(インターロック)を適用する。
Frameworkのおかげで簡単に利用できるマルチスレッドですが、正しい実装にはFrameworkバージョンの違いなども踏まえた、かなり深い理解が必須となります。
以下の参考ウェブサイトに非常にわかり易く解説されていますので、是非確認してみてください。
参考ウェブサイトなど
-
Microsoft Docs
C# メモリ モデルの理論と実践 -
Microsoft Docs
C# メモリ モデルの理論と実践 (第 2 部) -
Joseph Albahari
Threading in C# -
JPCERT
C/C++ セキュアコーディングスタンダード DCL22-C. キャッシュできないデータには volatile を使う -
JPCERT
C/C++ セキュアコーディングスタンダード POS03-C. volatile を同期用プリミティブとして使用しない
以上です。