続・スレッドとオブジェクトインスタンス

さて、実は次のエントリは別の内容を書こうと思っていたのですが(現在書きかけ中)、質問がコメントで出ていたので、こちらを明確にしてから次の内容に進むことにします。(わからないことをそのままにしないのはめっちゃ重要ですからね~^^)

kazenami さんと HashedBeef さんのお二人のご質問に共通するポイントは、おそらく

  • スレッドはどのようにして開始されるのか?
  • オブジェクトインスタンスは、どのようにして生成され、どのようにして各スレッドのスタックメモリ内の変数に割り当てられるのか?

というところだと思います。前回のエントリでは、この部分をあやふやにして書いたために、きちんと考えようとすると、とたんに行き詰まりますよね^^。というわけで、もうちょっと補足しようと思います。

[3 種類のスレッド]

.NET アプリケーションの内部では、基本的に 3 種類のスレッドが動作しています。

  • メインスレッド
    そのプロセスに最初に作られ、Main() メソッドを動作するために使われるスレッド。
  • マニュアルスレッド
    バックグラウンドタスクを行うために、自力で作り起こすスレッド。System.Threading.Thread クラスのインスタンスを作ることで作成できる。
  • プールスレッド
    バックグラウンドタスクを行う際、CLR の機能であるスレッドプール機能を用いる際に利用されるスレッド。

これ以外にも、ファイナライザスレッドやアンマネージスレッドなどが動作しているのですが、とりあえずは上記 3 つを覚えれば十分。メインスレッドについては前回の説明でカバーされているので、ここではマニュアルスレッドとプールスレッドについて解説します。

[マニュアルスレッドについて]

まず、マニュアルスレッドから解説しましょう。マニュアルスレッドとは、Thread クラスのインスタンスを作ることで新規にスレッドを起こすというもので、下のコードのようにして作成することができます。細かいコードは覚える必要はありませんが、コード上、以下の点に注目してください。

  • マニュアルスレッドを新規作成する場合には、そのスレッドで何の処理をするのかを指定する必要がある。下記のコードの場合、Task() というメソッドを新規 Thread インスタンスの引数として渡すことにより、この Task() メソッドをマニュアルスレッド上で開始することができる。
  • スレッドには、フォアグラウンドスレッドとバックグラウンドスレッドの二種類があり、スレッドの IsBackground プロパティにより変更することができる。アプリケーションプロセス内のすべてのフォアグラウンドスレッドが終了すると、プロセスが終了する形となる。(=下のコードにおいて、IsBackground プロパティを false にして実行すると、Main() 関数が終了しても、プロセスは生き残り続けます。)
    1: namespace ConsoleApplication1
    2: {
    3:     class Program
    4:     {
    5:         static void Main(string[] args)
    6:         {
    7:             int a = 0;
    8:             int b = 0;
    9:             Thread t = new Thread(new ThreadStart(Task));
   10:             t.IsBackground = true;  // バックグラウンド化してから実行すると...
   11:             t.Start();
   12:             Console.ReadLine();
   13:         } // メインメソッド終了時(=すべてのフォアグラウンドスレッドが終了)に即座に終了する
   14:  
   15:         static void Task()
   16:         {
   17:             int i = 0;
   18:             while (true)
   19:             {
   20:                 i++;
   21:                 Thread.Sleep(500);
   22:                 Console.WriteLine("タスク実行中..." + i.ToString());
   23:             }
   24:         }
   25:     }
   26: }

image

このアプリケーションのメモリ配置は、下図のようになります。

※ 話を単純化するために、変数 t (当該スレッドの情報をラッピングしたオブジェクト)については記述を省略しています。今は、変数 a, b, i がどこに作られるのかに着目してください。

image

[スレッドとオブジェクトインスタンスのメモリ配置の関係]

では、このプログラムを少し改造して、クラスとオブジェクトインスタンスを扱うようなコードに変更してみます。(ちょっとコードがややこしくなっているので、よーく見てください)

    1: namespace ConsoleApplication1
    2: {
    3:     class Program
    4:     {
    5:         static X objX1 = new X();
    6:  
    7:         static void Main(string[] args)
    8:         {
    9:             int a = 0;
   10:             int b = 0;
   11:             X objX2 = new X();
   12:             Thread t = new Thread(new ThreadStart(Task));
   13:             t.IsBackground = true;
   14:             t.Start();
   15:  
   16:             objX1.Increment();
   17:             objX2.Increment();
   18:  
   19:             Console.ReadLine();
   20:         }
   21:         
   22:         static void Task()
   23:         {
   24:             int i = 0;
   25:             X objX3 = new X();
   26:             while (true)
   27:             {
   28:                 objX1.Increment();
   29:                 objX3.Increment();
   30:                 i++;
   31:                 Thread.Sleep(500);
   32:                 Console.WriteLine("タスク実行中..." + i.ToString());
   33:             }
   34:         }
   35:     }
   36:  
   37:     public class X
   38:     {
   39:         private int p = 0;
   40:         private string q = "Nobuyuki";
   41:  
   42:         public int Increment() { return p++; }
   43:     }
   44: }

この場合のメモリ配置は、以下のようになります。

image

さて、このコードの場合にはヤバいコードが存在します。それは、16 行目と 28 行目です。この 16 行目と 28 行目では、どちらも Program クラスの static データである objX1 を介して、&Hxxxx にある内部変数 p を操作する(インクリメントする)ことになります。二つのスレッドが同一変数を同時に操作する危険性があるため、このアプリケーションプログラムには、ロストアップデートの危険性がある、ということになるわけです。具体的には、下図のようなシチュエーションに陥ると、ロストアップデートの危険性があります。

image

このような問題を回避するために利用されるテクニックが、lock ブロックです。具体的には、以下のようなコードを記述します。

    1: public class X
    2: {
    3:     private int p = 0;
    4:     private string q = "Nobuyuki";
    5:  
    6:     public int Increment() 
    7:     {
    8:         lock (this)
    9:         {
   10:             return p++;
   11:         }
   12:     }
   13: }

このようにすると、複数スレッドが同時に同一インスタンス上で Increment メソッドを呼び出したとしても、8~11 行目を実行できるのは先に lock() 処理を行った方のみに限定されます。結果として、同時に二つのスレッドが変数 p を操作することはなくなり、ロストアップデートが発生することはなくなります。

image

# とまあ、マルチスレッドアプリケーションでは、うかつなコードを記述するとかなり危険なのです。><

[プールスレッドについて]

さて、引き続き、プールスレッドについて解説しましょう。一般的に、スレッドの作成/終了にはかなりのオーバヘッドがかかります。このため、上記のようなコードでマニュアルスレッドを作成するという処理は、一回だけ行うのならともかく、何度も繰り返して行わなければならないような場合には不利になります。このような処理のために用意されているのが、スレッドプールと呼ばれるものになります。

スレッドプールの概念図を下図に示します。スレッドプールはその名の通り、アプリケーション処理を動作させるためのスレッドがあらかじめプールされており、そこに処理が割り当てられて動作する、という形になります。

image

さきほどの例を、スレッドプールを使う方法に書き変えたコードを以下に示します。(※ このコードはよいコードではありません。理由は後述。)

    2: {
    3:     static void Main(string[] args)
    4:     {
    5:         int a = 0;
    6:         int b = 0;
    7:         ThreadPool.QueueUserWorkItem(new WaitCallback(AsyncTaskForPool), null);
    8:         Console.ReadLine();
    9:     }
   10:     
   11:     static void AsyncTaskForPool(object o)
   12:     {
   13:         int i = 0;
   14:         while (true)
   15:         {
   16:             i++;
   17:             Thread.Sleep(500);
   18:             Console.WriteLine("タスク実行中..." + i.ToString());
   19:         }
   20:     }
   21: }

先のコードと違って、スレッドを開始させるためのコードが書かれていないことに注意してください。スレッドプールを使う際には、動かしたい処理(メソッド)を、WaitCallback オブジェクトにラッピングして、ワークアイテムキューに放り込みます。このようにすると、スレッドプールの仕組みが自動的に機能し、ワークアイテムキューに放り込まれた処理を自動的に動かしてくれるようになっています。

[スレッドプールと ASP.NET アプリケーション]

さて、このスレッドプールは、以下のような特性を持つ処理をバックグラウンドで動作させる目的には適していません。

  • その処理が長時間を要する場合。

これは、以下のような理由によります。

  • スレッドプールは、.NET Framework の様々な場所で利用されている。

    具体的には、データベースアクセス、XML Web サービス呼び出しなどで利用されています。

  • スレッドプールは、プロセスの中にひとつしかなく、既定では 25 x CPU 数しかない。

    スレッドプール内のスレッドが枯渇すると、キューイングされているワークアイテムの処理が停止する

  • 長時間要するような処理をプールスレッド上で実行すると、その処理がプールスレッドを占有してしまうため、プールスレッドが枯渇し、キューイングされたワークアイテムが処理されなくなる恐れがある。(このため、数秒間で終わらないような長時間を要する処理は、プールスレッドではなく、マニュアルスレッドで動作させた方がよい。)

このため、スレッドプールは、以下のような特性を持つ処理をバックグラウンドで動作させる目的に適しています。

  • 細かい処理(短時間で終わる処理)を、大量に繰り返して実行しなければならない場合。

この典型例が、ASP.NET アプリケーションです。ASP.NET において各ページをマルチスレッド処理したいような場合、スレッドの起動/終了が大量に発生すると、オーバヘッドが非常に大きくなります。しかし、各ページをプールスレッドで処理すると、非常に効率的に処理することができます。

image

[ASP.NET アプリケーションにおけるスレッド構造]

ASP.NET アプリケーションの場合には、メインスレッドでは ASP.NET ランタイムの起動処理などが行われます。そして、入ってくるリクエストは、プールスレッドで処理されることになります。

では、これを具体的な例を使って説明してみましょう。たとえば、今、以下のような Web サイトがあったとします。

image

ここで、PageA, PageB では以下のようなロジックが書かれていたとします。(本来は PageA のようにローカル変数としてビジネスロジッククラスのインスタンスを生成する方がよいのですが、ここではサンプルとして PageB のようなコードも書いてみます。)

    1: public partial class PageA : System.Web.UI.Page
    2: {
    3:     protected void Page_Load(object sender, EventArgs e)
    4:     {
    5:         BizLogicA objBizA = new BizLogicA();
    6:         objBizA.MethodX();
    7:     }
    8: }
    1: public partial class PageB : System.Web.UI.Page
    2: {
    3:     BizLogicB objBizB = new BizLogicB();
    4:  
    5:     protected void Page_Load(object sender, EventArgs e)
    6:     {
    7:         objBizB.MethodY();
    8:     }
    9: }

今、この Web サイトに対して、ある瞬間に、二人のユーザが PageA.aspx を、別の一人のユーザが PageB.aspx を呼び出したとします。この場合には、以下のようになります。

  • 3 つのプールスレッドが動作し、それぞれ PageA, PageA, PageB を処理する。
  • 各スレッドごとにページクラスのインスタンスが作成され、それが動作する。

image

メモリ構造としては、以下のような形になります。(メインスレッドと、インストラクションコードは省略して示します。線を引いてみたけどかえって見づらくなってしまったかも....)

image

[以上を元に考えてみると。]

というわけで、マニュアルスレッドやプールスレッドが利用される場合のメモリ構造について解説をしてきたわけですが、だいたいここまでの知識を元にすれば、前回のエントリのクイズに関しては答えがわかると思います。ちょっとだけヒントを書いておくと、

  • MethodA, MethodB についてはほぼ自明。
  • MethodC はひっかけ問題。ある条件ではマルチスレッドアプリで使っても OK だが、ある条件だと NG。(よってスレッドセーフではない)

になります。

[まとめ]

本エントリでの解説のキーポイントをまとめると、次のようになります。

  • .NET アプリケーション内部で使われる主なスレッドには、以下の 3 種類がある。

    ① メインスレッド

    ② マニュアルスレッド

    ③ プールスレッド

  • 長時間処理をバックグラウンドで行いたいような場合には、マニュアルスレッドを使う。

  • 短時間処理をたくさん捌かなければならないような場合には、プールスレッドを使う。

  • ASP.NET ランタイムは、プールスレッドを使って、複数ユーザからのリクエストをマルチスレッド処理している。

というわけで一気に書き上げてみましたが、結構大変でした;。この説明でわかってもらえるとよいのですが、どうでしょう?^^