マルチスレッドプログラミングにおいて、スレッド間でリソースを共有する際、適切な同期処理を使用しないとデータの不整合や予期せぬバグが発生する可能性があります。この記事では、C#でスレッド間のリソース共有に関する問題発生と対策方法を紹介します。
問題の例
以下のサンプルコードは、スレッド間でのリソース共有に関する問題を示しています。
この例では、タスク1とタスク2を並列実行します。タスク1はカウンタを100万回インクリメント、タスク2はカウンタを100万回デクリメントます。
タスク1と2のインクリメント/デクリメント回数は同じなので、プログラム終了時、共有リソースCounterの値はゼロになるはずですが、実際はどうなるでしょう。
using System;
using System.Threading;
class Program
{
static int Counter = 0; // 共有リソース
static void Main(string[] args)
{
// スレッドの作成と開始: タスク1
Thread thread1 = new Thread(new ThreadStart(Task1));
thread1.Start();
// スレッドの作成と開始: タスク2
Thread thread2 = new Thread(new ThreadStart(Task2));
thread2.Start();
// 両スレッドの終了をメインスレッドが待機
thread1.Join();
thread2.Join();
Console.WriteLine($"Final counter value: {Counter}");
}
// タスク1: カウンタを100万回インクリメント
static void Task1()
{
for (int i = 0; i < 1000000; i++)
{
Counter++; // 共有リソースの編集
}
}
// タスク2: カウンタを100万回デクリメント
static void Task2()
{
for (int i = 0; i < 1000000; i++)
{
Counter--; // 共有リソースの編集
}
}
}
実行結果1回目
Final counter value: -169191
続行するには何かキーを押してください . . .
実行結果2回目
Final counter value: 11531
続行するには何かキーを押してください . . .
実行結果3回目
Final counter value: 680041
続行するには何かキーを押してください . . .
上記実行結果より、プログラムの動作は安定せず、カウンタの値は期待値ゼロになりません。
問題点
このコードでは、Counter++/Counter– の操作がアトミック(不可分)ではありません。つまり、Counterの読み込み、インクリメントまたはデクリメント、書き戻しの各ステップが途中で中断され、他のスレッドが割り込む可能性があります。
2つのスレッドが共有リソースの変更を同時に行うと、このように競合状態が発生し、最終的な 共有リソースの内容が予期しない値となる可能性があります。
対策版
問題を解決するためには、同時に複数のスレッドが共有リソースにアクセスできないようにする必要があります。以下のサンプルコードでは、lock ステートメントを使用して同時に1つのスレッドのみが Counter を更新できるようにします。
using System;
using System.Threading;
class Program
{
static int Counter = 0; // 共有リソース
static readonly object counterLock = new object(); // ロック用のオブジェクト
static void Main(string[] args)
{
// スレッドの作成と開始: タスク1
Thread thread1 = new Thread(new ThreadStart(Task1));
thread1.Start();
// スレッドの作成と開始: タスク2
Thread thread2 = new Thread(new ThreadStart(Task2));
thread2.Start();
// 両スレッドの終了をメインスレッドが待機
thread1.Join();
thread2.Join();
Console.WriteLine($"Final counter value: {Counter}");
}
// タスク1: カウンタを100万回インクリメント
static void Task1()
{
for (int i = 0; i < 1000000; i++)
{
lock (counterLock) // ★Counter編集を1スレッドに限定するガード
{
Counter++; // 共有リソースの編集
}
}
}
// タスク2: カウンタを100万回デクリメント
static void Task2()
{
for (int i = 0; i < 1000000; i++)
{
lock (counterLock) // ★Counter編集を1スレッドに限定するガード
{
Counter--; // 共有リソースの編集
}
}
}
}
実行結果1回目
Final counter value: 0
続行するには何かキーを押してください . . .
実行結果2回目
Final counter value: 0
続行するには何かキーを押してください . . .
実行結果3回目
Final counter value: 0
続行するには何かキーを押してください . . .
上記のように、カウンタは期待値であるゼロを表示するようになりました。
解説
- lock ステートメントは、ブロック内のコードが一度に1つのスレッドによってのみ実行されることを保証します。これにより、共有リソースの操作が安全に行われるようになります。
- lock ステートメントに与えている counterLock は、共有リソースへのアクセスを同期するために使用される専用のオブジェクトです。ひとつの共有リソースについて同じオブジェクトを使うようにしてください。
まとめ
本記事では、スレッド間でリソースを共有する際に発生する問題と、対策の方法を紹介しました。この記事で紹介した lock ステートメントは、共有リソースに対する同時アクセスを制御し、データの整合性を保つための簡単かつ効果的な方法です。
マルチスレッドプログラミングは、その強力さとともに複雑さも持ち合わせています。スレッド間でリソースを共有する場合、データの競合やデッドロックなど、さまざまな問題に直面する可能性があります。これらの問題を避けるためには、同期メカニズムの正しい使用方法を理解し、プログラムの各部分がどのように相互作用するかを慎重に検討する必要があります。