前回は、Thread クラスを使ったマルチスレッド処理の基本を見ました。
今回は、複数のスレッドが同じデータを扱うときに必要になる 同期 と、.NET が管理する ThreadPool について整理します。
共有データと競合状態
複数スレッドから同じ変数を更新すると、期待通りの結果にならないことがあります。
using System;
using System.Threading;
int counter = 0;
void CountUp()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
Thread t1 = new Thread(CountUp);
Thread t2 = new Thread(CountUp);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(counter);
counter++ は「値を読む」「1 を足す」「値を書き戻す」という複数の処理に分解できます。
その途中に別スレッドが割り込むと、更新が失われることがあります。
このような状態を 競合状態 と呼びます。
lock キーワードで同期する
もっともよく使う同期方法の 1 つが lock です。
using System;
using System.Threading;
object gate = new object();
int counter = 0;
void CountUp()
{
for (int i = 0; i < 100_000; i++)
{
lock (gate)
{
counter++;
}
}
}
Thread t1 = new Thread(CountUp);
Thread t2 = new Thread(CountUp);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(counter);
lock ブロックには、同時に 1 つのスレッドだけが入れます。
これにより、counter++ の途中で別スレッドが割り込むことを防げます。
lock に使うオブジェクトは、通常 private readonly object として用意します。
文字列や this をロック対象にすると、外部コードと意図せず同じロックを共有する危険があるため避けます。
Monitor 型で同期する
lock 文は、内部的には Monitor.Enter と Monitor.Exit を使っています。
明示的に書くと、次のような形になります。
using System;
using System.Threading;
object gate = new object();
int counter = 0;
void CountUp()
{
for (int i = 0; i < 100_000; i++)
{
bool lockTaken = false;
try
{
Monitor.Enter(gate, ref lockTaken);
counter++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(gate);
}
}
}
}
Thread t1 = new Thread(CountUp);
Thread t2 = new Thread(CountUp);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(counter);
通常は lock を使えば十分です。
Monitor を直接使うのは、タイムアウト付きでロックを試したい場合など、より細かい制御が必要なときです。
if (Monitor.TryEnter(gate, TimeSpan.FromSeconds(1)))
{
try
{
Console.WriteLine("ロックを取得できました。");
}
finally
{
Monitor.Exit(gate);
}
}
else
{
Console.WriteLine("ロックを取得できませんでした。");
}
Interlocked 型でアトミック操作を行う
単純なカウントアップであれば、lock より Interlocked の方が簡潔です。
using System;
using System.Threading;
int counter = 0;
void CountUp()
{
for (int i = 0; i < 100_000; i++)
{
Interlocked.Increment(ref counter);
}
}
Thread t1 = new Thread(CountUp);
Thread t2 = new Thread(CountUp);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine(counter);
Interlocked.Increment は、値の読み取り、加算、書き戻しを 1 つの不可分な操作として扱います。
代表的なメソッドは次の通りです。
Increment
整数値を 1 増やします。
Decrement
整数値を 1 減らします。
Add
指定した値を加算します。
Exchange
値を新しい値に置き換えます。
CompareExchange
現在値が期待値と一致した場合だけ、新しい値に置き換えます。
Timer コールバックを使う
System.Threading.Timer を使うと、一定時間ごとに処理を呼び出せます。
using System;
using System.Threading;
using Timer timer = new Timer(
callback: _ => Console.WriteLine($"Tick: {DateTime.Now:HH:mm:ss}"),
state: null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(1));
Console.WriteLine("Enter キーで終了します。");
Console.ReadLine();
dueTime は最初に実行されるまでの待ち時間です。
period は 2 回目以降の実行間隔です。
Timer のコールバックは ThreadPool 上で実行されます。
そのため、コールバック内で共有データを扱う場合は、通常のマルチスレッド処理と同じように同期を考える必要があります。
スタンドアロン discard を使う
Timer などを「変数としては使わないが、破棄されないように保持したい」場合、discard の扱いに注意します。
using System;
using System.Threading;
Timer timer = new Timer(
_ => Console.WriteLine("Tick"),
null,
TimeSpan.Zero,
TimeSpan.FromSeconds(1));
Console.ReadLine();
timer.Dispose();
_ = new Timer(...) のように書くと、参照を保持していないため、意図しないタイミングで GC 対象になる可能性があります。
長く動かしたい Timer は、変数やフィールドに保持しておく方が安全です。
ThreadPool を理解する
ThreadPool は、.NET が管理する再利用可能なスレッドの集合です。
スレッドを毎回新規作成するとコストがかかります。
ThreadPool はあらかじめ用意したスレッドを使い回すことで、短い処理を効率よく実行します。
using System;
using System.Threading;
ThreadPool.QueueUserWorkItem(_ =>
{
Console.WriteLine($"ThreadPool thread: {Thread.CurrentThread.ManagedThreadId}");
});
Console.WriteLine($"Main thread: {Thread.CurrentThread.ManagedThreadId}");
Console.ReadLine();
ThreadPool のスレッドは、基本的にバックグラウンドスレッドです。
また、処理がいつ実行されるかは ThreadPool のスケジューリングに任されます。
ThreadPool を直接使う場面
現在の C# では、ThreadPool を直接使うより Task.Run を使うことが多いです。
using System;
using System.Threading;
using System.Threading.Tasks;
await Task.Run(() =>
{
Console.WriteLine($"Task thread: {Thread.CurrentThread.ManagedThreadId}");
});
Task.Run も、通常は ThreadPool を使って処理を実行します。
そのため、ThreadPool は Task や Parallel の土台として理解しておくとよいです。
まとめ
複数スレッドで共有データを扱うときは、同期が必要になります。
lock はもっとも基本的な同期手段です。
Monitor は lock の裏側にある仕組みで、タイムアウトなどの細かい制御に向いています。
Interlocked は、単純な数値更新を安全かつ軽量に行うために便利です。
また、ThreadPool は、現代の .NET の並列処理を支える重要な仕組みです。
次回は、この ThreadPool の上に構築される Task、Parallel、PLINQ を見ていきます。