スレッドとは?
ベルトコンベアをイメージしてください。
スレッドとは、処理が流れてくる1つのベルトコンベアで、流れてきた順番通りに処理を行う機構です。
アプリケーションを実行すると、通常1つのスレッドが用意されます。
プログラムを実行させたら、それはその1つのスレッド上で全て実行されます。
1つのスレッド上では、確実に処理の順番が決まります。
シングルスレッドでの動作
private void Function()
{
Console.WriteLine("1");
Console.WriteLine("2");
Console.WriteLine("3");
}
上記処理の実行結果が下記になることは理解できます。
1
2
3
プログラミングが少しでも分かる人にとっては当然の結果です。
むしろ上記結果にならない方が不思議なはずです。
何回連続でFunction()をコールしても、123123123123の順番は絶対に崩れません。
では、次に下記Sum()という関数を例に取ります。
0に初期化したcountという変数を10回加算する処理です。
// あえてprivate変数を使います
private int count;
// サンプル関数
private int Sum()
{
// 変数を初期化
count = 0;
for ( int i = 0; i < 10; i++ )
{
count++;
}
return count;
}
このSum()関数も確実に 10 を返します。
ただし、これがマルチスレッドになると少し話が変わります。
マルチスレッドとは?
冒頭に書いたベルトコンベアが複数ある状態で、かつ同時にベルトコンベアが動いている状態です。
メリットは、複数の処理を同時に行える事にあります。
ラインが増えるんだから、どんどんスレッドを増やせば処理も増やせるし実行速度が上がるよね!便利じゃん!
まあ、だいたいその通りですが、少し気を使う必要が出てきます。
動作確認してみる
まずはシングルスレッドで検証
- 今回はC#のコンソールアプリケーションで作成しています。
- SampleProc()は、後のマルチスレッドでも使うため、あえてこの形にしています。
- このコードをベースに、改変がある部分だけ後半は抜粋してコードを書いています。
using System.Threading;
// エントリーポイントクラス
class Program
{
static void Main(string[] args)
{
var tc = new thread_class();
// 10回連続でコールする
for(int i = 0; i < 10; i++ )
{
SampleProc(tc);
}
}
// Sum()をコールして結果を出力する関数
public static void SampleProc(object obj)
{
var tc = (thread_class)obj;
Console.WriteLine(tc.Sum());
}
}
// 検証用クラス
public class thread_class
{
// あえてprivate変数を使う
private int count;
// サンプル関数
public int Sum()
{
// 変数を初期化
count = 0;
for ( int i = 0; i < 10; i++ )
{
// 処理が高速すぎると分かりづらいため、10ミリ秒待ちながらカウントアップ
Thread.Sleep(10);
count++;
}
return count;
}
}
このプログラムの実行結果は以下になります。
10
10
10
10
10
10
10
10
10
10
0に初期化されたcount変数に1を10回足しているので、10回とも「10」が結果として出力されました。
Sum()関数のループ内で10ミリ秒待っている効果で、流れるように結果が表示されるはずです。
ここは100ミリ秒でも1000ミリ秒でも大丈夫です。
マルチスレッドで検証
ではマルチスレッドでの出力結果はどうなるでしょうか。
ここでは、SampleProc()を10個のスレッドに分けて、同時に実行するイメージです。
マルチスレッドで実行させるためのコードは以下になります。
Mainのループの中を変更するだけです。
static void Main(string[] args)
{
var tc = new thread_class();
// 10回連続でコールする
for (int i = 0; i < 10; i++)
{
// SampleProc(tc); // <---コメントアウト
// スレッドの作成(SampleProcを作成されたスレッドで実行させる)
var t = new Thread(new ParameterizedThreadStart(SampleProc));
// スレッドを実行
t.Start(tc);
}
}
実行結果
79
81
80
85
78
82
86
84
ぶっ壊れているわけじゃありません。
そして実行するたびに、毎回違う結果になりますし、皆さんの実行結果も違う結果になるでしょう。
ですが、これは予定通りです。
また、シングルスレッドの時と同じく、10ミリ秒待ってる処理を変えずに10回ループを回しているのに、一瞬で結果が表示されたはずです。
なんとなくマルチスレッドの威力と意味不明な体験ができたと思います。
マルチスレッドの実行結果の原因と対策
原因
なぜSampleProc()が同時に実行されると、このような結果になるのでしょうか。
ポイントは、
private int count;
です。
複数のスレッドから同時にこの変数を参照しています。
シングルスレッドでは同時に実行されるコードがないため、0が入っているcountに1を足したら必ず1になります。
マルチスレッドではSum()が同時に実行されるため、Sum()からcountに同時にアクセスしている状態になります。
こうなると、countが今なんの値になっているのか、全く分からない状況になります。
結果、10個のスレッドから一気にcountを加算しているので、10という値は返しません。
また、スレッドは別の実行単位になるので、毎回同じタイミングで同じように動作しないため、実行するたびに結果が変わります。
スレッドセーフとは
マルチスレッドでも正常に動作することを、スレッドセーフといいます。
今回の例ではマルチスレッドでの動作に対応していないため、スレッドセーフではないという事になります。
対策(スレッドセーフにする)
1.スタック変数を使用する
これが最も安全で簡単な対策です。
スタック変数とは、関数内で宣言された変数です。
スタック変数はスレッドごとに用意されるため、複数のスレッドから同時に参照される事がありません。
public int Sum()
{
var count = 0; // <--- ここで宣言したスタック変数
for (int i = 0; i < 10; i++)
{
// 処理が高速すぎると分かりづらいため、10ミリ秒だけ待つ
Thread.Sleep(10);
count++;
}
return count;
}
2-1.同時実行できないようにする(同期オブジェクトを使用)
マルチスレッドで、それぞれのスレッドが独立して自由に動作している状態を非同期という言い方をします。
この独立しているスレッドを協調させて対策を取ることを、同期させるといいます。
データ構造的に、スタック変数を使えない場合も多くあります。
今回はSum()が同時に実行されるからダメだということで、同期をさせて同時に実行させないようにします。
// 同期オブジェクトを作成
private static object obj = new object();
// サンプル関数
public int Sum()
{
// lockブロック実行中の時はここで待機
lock(obj)
{
// 変数を初期化
count = 0;
for (int i = 0; i < 10; i++)
{
// 処理が高速すぎると分かりづらいため、10ミリ秒だけ待つ
Thread.Sleep(10);
count++;
}
return count;
}
}
正常に全て10が返るようになりましたが、lockブロックは常に1つのスレッドしか処理できないため、マルチスレッドですがシングルスレッドと同じだけ時間がかかります。
同期・非同期をうまく組み合わせることが重要です。
2-2.同時実行できないようにする(Taskとasync/awaitを使う)
方針や動作は2-1と同じです。
lockを使わずに、async/awaitを使って同期させることができます。
こちらの書き方の方が一般的だと思います。
// async Taskとする
static async Task Main(string[] args)
{
var tc = new thread_class();
// 10回連続でコールする
for (int i = 0; i < 10; i++)
{
// awaitを付けて呼ぶ
await Task.Run(() => SampleProc(tc));
}
Thread.Sleep(1000); // awaitを付けずに呼んだ時に、コンソール出力前にプログラムが終了するので待つ
}
// lockは使用しない
public class thread_class
{
// あえてprivate変数を使う
private int count;
// サンプル関数
public int Sum()
{
// 変数を初期化
count = 0;
for (int i = 0; i < 10; i++)
{
// 処理が高速すぎると分かりづらいため、10ミリ秒だけ待つ
Thread.Sleep(10);
count++;
}
return count;
}
}
Main関数の中のawaitが、処理を待つ役目をしてくれます。
一見、ブレークポイントを貼ってもどんどんループが回るので、待ってないのではないかという感じに見えますが、ちゃんと待ってます。
awaitを付けずにTask.Runを呼べば、10という結果にならないという現象が再現するので、試してみるのも良いでしょう。
ただ、ループが一気に回ってコンソール出力前にプログラムが終わってしまうので、Mainの一番最後にThread.Sleep(1000)等で処理待ちをする必要があります。
さいごに
簡単にという予定だったのが、思いの外長い記事になってしましましたが、本来の目的のマルチスレッドの説明は達成できたのではないかと思います。
マルチスレッドでの同期処理は、デッドロックという恐ろしい現象を引き起こす恐れもありますが、それはまた今度の機会にします。
最後までお付き合い頂き、ありがとうございました。