++C++; // 未確認飛行 C

Google
Web ufcpp.net

マルチスレッド

目次

キーワード

概要

.NET Framework では、マルチスレッドプログラムを作成するためのクラスライブラリを提供しています。 C# でマルチスレッドプログラムを作成する場合、これらライブラリ中のクラスを用いて行うことになります。 また、C# ではスレッド間の同期を取るために lock 文という構文を用意しています。

マルチスレッドとは

まず、スレッドに関して簡単に説明しておきます。 簡単に言うと、スレッド(thread: 糸、筋道)とは一連の処理の流れのことを言います。 図1 に示すように、 処理の流れが一本道な物をシングルスレッド、 複数の処理を平行して行う物をマルチスレッドと呼びます

図1: スレッドの概念図

シングルスレッドに関しては特に何も説明する必要はないと思います。 問題のマルチスレッドの方ですが、 例えば、何か非常に計算に時間のかかる処理があったとします。 処理を行っている間、計算に専念してもいいのならマルチスレッドは必要ないのですが、 処理を行っている最中にもユーザーからの入力は受け付けなければならない場合があります。 典型例を挙げるとアクションゲーム等がそうです。 キャラクターの動き計算している間、 ユーザーからの入力を受け付けないようではアクションゲームとして成り立ちません。 このような場合、 ユーザーからの処理を受け付けるスレッドと、 計算に時間のかかる処理を行うスレッドを平行して動かすのが普通です。 要するに、重たい処理を行っている最中でもプログラム全体がフリーズしてしまわないようにするために、 複数の処理を平行して行うのがマルチスレッドプログラムです。

C# におけるマルチスレッドプログラミング

C# でマルチスレッドプログラムを作成する場合、 .NET Framework のクラスライブラリが提供している System.Threading.Thread クラス を使用します。 スレッド作成の手順は以下の通りです。

  1. スレッドで実行したい処理をメソッドとして記述する。
  2. 作成したメソッドを ThreadStart デリゲートとして Thread クラスのコンストラクタに渡す。
  3. Thread クラスの Start メソッドを呼び出し、スレッドを開始する。
  4. Thread クラスの Join メソッドを呼び出し、スレッドの終了を待つ。

以下にスレッド作成の例を示します。

using System;
using System.Collections;
using System.Threading;

class Counter
{
  int id;

  public Counter(int id){this.id = id;}

  // 1.
  // スレッドの処理内容を記述する。
  // (この例では、ランダムな時間間隔で文字列を出力する。)
  public void Run()
  {
    Random rnd = new Random();

    for(int i=0; i<4; ++i)
    {
      Thread.Sleep(rnd.Next(50, 100)); // ランダムな間隔で処理を一時中断

      Console.Write("{0} (ID: {1})\n", i, id);
    }
  }
}

class TestThread
{
  static void Main()
  {
    const int N = 3;
    Thread[] threads = new Thread[N];

    // スレッド開始
    for(int i=0; i<N; ++i)
    {
      Counter counter = new Counter(i);

      // 2.
      // Thread クラスの構築する
      threads[i] = new Thread(new ThreadStart(counter.Run));

      // 3.
      // Start を使ってスレッドの開始する
      threads[i].Start();
    }

    // スレッド終了待ち
    for(int i=0; i<N; ++i)
    {
      // 4.
      // Join を使ってスレッドの終了を待つ
      threads[i].Join();
    }
  }
}

実行結果は以下のようになります。 (毎回異なる結果になります。) 3つのスレッドが平行して動いていることが分かると思います。

0 (ID: 1)
0 (ID: 2)
0 (ID: 0)
1 (ID: 1)
1 (ID: 2)
1 (ID: 0)
2 (ID: 1)
2 (ID: 2)
2 (ID: 0)
3 (ID: 2)
3 (ID: 0)
3 (ID: 1)

サンプル

入力された数値の素因数分解を行うプログラムです。 (値が大きくなると素因数分解は非常に時間がかかります。) ユーザからの入力を受け付けるスレッドと計算を行うスレッドの2つのスレッドで処理を行います。

using System;
using System.Collections;
using System.Threading;

enum State
{
  ready,   // 計算開始前
  running, // 計算真っ最中
  wait,    // 計算一時停止中
}

class TestThread
{
  static long sNum;
  static State sThreadState;

  static void Main()
  {
    Thread thread = null;

    Console.Write(
      "素因数分解を行います。\n" +
      "何か数値を入力してください。\n" +
      "(計算途中で何かキー入力を行うと処理を中断します。)\n" +
      "(q と入力するとプログラムを終了します。)\n");

    sThreadState = State.ready;

    while(true)
    {
      Console.Write("> ");
      string line = Console.ReadLine();

      if(sThreadState == State.running) // 計算中
      {
        sThreadState = State.wait; // 計算中断

        // 計算を中止するかどうか確認する。
        Console.Write(
          "計算を中断しました。\n" +
          "  c     : 計算中止\n" +
          "  q     : プログラム終了\n" +
          "  その他: 計算続行\n" +
          "# ");
        line = Console.ReadLine();
        line.ToLower();
        if(line.Length != 0)
        {
          if(line[0] == 'c')
          {
            sThreadState = State.ready;
            thread.Join();
            Console.Write("計算を中止しました。\n");
            continue;
          }
          else if(line[0] == 'q')
          {
            return;
          }
        }

        sThreadState = State.running; // 計算再開
      }
      else
      {
        if(line.Length == 0) continue;

        // q が入力されたらプログラム終了。
        line.ToLower();
        if(line[0] == 'q') return;

        // 因数分解を開始する。
        try{sNum = Int64.Parse(line);}
        catch(FormatException  )
        {
          Console.Write("不正な文字列が入力されました。\n"); continue;
        }
        catch(OverflowException)
        {
          Console.Write("値が大きすぎます。\n");             continue;
        }

        sThreadState = State.running;
        thread = new Thread(new ThreadStart(ThreadFunction));
        thread.Start();
      }
    }
  }

  static void ThreadFunction()
  {
    Console.Write("素因数分解開始\n");
    IList factors = Factorization(sNum);
    if(factors != null)
    {
      Console.Write("\n素因数分解終了\n");

      foreach(long i in factors)
      {
        if(sThreadState == State.ready) break;
        if(sThreadState == State.wait) continue;
        Console.Write("{0} ", i);
      }
      Console.Write("\n");
    }
    sThreadState = State.ready;
  }

  /// <summary>
  /// 素因数分解を行う。
  /// (馬鹿でかい数字を素因数分解しようとすると非常に重たい。)
  /// </summary>
  /// <param name="n">素因数分解したい数値</param>
  /// <returns>因数のリスト</returns>
  static IList Factorization(long n)
  {
    ArrayList factors = new ArrayList();

    long sqrtn = (long)Math.Ceiling(Math.Sqrt(n) + 1);
    long i=2;

    while(i < sqrtn)
    {
      if(sThreadState == State.ready) break;
      if(sThreadState == State.wait) continue;

      if(n % i == 0)
      {
        factors.Add(i);
        n /= i;
        Console.Write("{0}", i);
      }
      else
      {
        ++i;
      }

      Console.Write('.'); // 途中経過を表示
    }

    if(n != 1)
      factors.Add(n);

    return factors;
  }//Factorization
}
素因数分解を行います。
何か数値を入力してください。
(計算途中で何かキー入力を行うと処理を中断します。)
(q と入力するとプログラムを終了します。)
>1998
> 素因数分解開始
2..3.3.3...................................37.........
素因数分解終了
2 3 3 3 37
743
> 素因数分解開始
...........................
素因数分解終了
743
256
> 素因数分解開始
2.2.2.2.2.2.2.2................
素因数分解終了
2 2 2 2 2 2 2 2
q

排他制御

マルチスレッドプログラムでは、複数のスレッドが1つのデータに対して操作することがあります。 この際に、何も考えず、ただ素直にプログラミングを行うと、 意図しない結果になる場合があります。 例えば、以下の例について考えて見ましょう。

using System;
using System.Collections;
using System.Threading;

class TestThread
{
  static int num; // 複数のスレッドから同時にアクセスされる。

  const int THREAD_NUM = 20;
  const int ROOP_NUM   = 20;

  /// <summary>
  /// THREAD_NUM 個のスレッドを立てる。
  /// それぞれのスレッドの中で num を ROOP_NUM 回インクリメントする。
  /// </summary>
  static void Main()
  {
    Thread[] threads = new Thread[THREAD_NUM];

    for(int i=0; i<THREAD_NUM; ++i)
    {
      threads[i] = new Thread(new ThreadStart(CountUp));
      threads[i].Start();
    }

    for(int i=0; i<THREAD_NUM; ++i)
    {
      threads[i].Join();
    }

    Console.Write("{0} ({1})\n", num, THREAD_NUM * ROOP_NUM);
    // num と THREAD_NUM * ROOP_NUM は一致するはずなんだけど・・・
  }

  static void CountUp()
  {
    for(int i=0; i<ROOP_NUM; ++i)
    {
      // num をインクリメント。
      // 実行結果が顕著に出るように、途中で Sleep をはさむ。
      int tmp = num;
      Thread.Sleep(1);
      num = tmp + 1;
    }
  }
}
21 (400)

この例では、20個のスレッドが同時に1つの変数 num を書き換えています。 各スレッド内で num を20回インクリメントしていますので、 実行結果は20×20で400と表示することが期待されますが、 実際の実行結果では21と表示されています。 しかも、必ずこの結果になるわけではなく、 実行結果は毎回変わります。 (僕の実行環境では、20~23くらいの値になります。)

どうしてこのような現象が起きるのかと言うと、 複数のスレッドがどういう順番でどれだけ実行されるかが決まっていないからです。 例えば、上記の num をインクリメントするスレッドが2つある場合、 以下の図2に示すような実行順序や図3に示すような実行順序が考えられます。 このとき、図2に示す実行順序になった場合には num が1しか増えませんが、 図3に示す実行順序になった場合には num がちゃんと2増えます。

図2: マルチスレッドの動作1

図3: マルチスレッドの動作2

例に挙げたプログラムでは、実行結果が顕著になるように、 num の読み出し、加算、書き込みを分け、 間に Thread.Sleep (処理の一時休止)を挟んでいます。 しかし、num のインクリメントを ++num; と言うように1つの処理にまとめてもこの問題は解決されません。 ++num; という処理は、見た目上は1つの処理になっていますが、 これをコンパイルすると読み出し、加算、書き込みという3つの命令になり、 この3つの命令の間でスレッドの切り替わりが起きる可能性もあります。

しかも、Thread.Sleepを挟まない場合、 「滅多に起きないけども、ごくごく稀に値が狂う」という、 デバッグする上では最も困難な現象が起きます。 100万回に1回とか、1千万回に1回とか、それくらいの低頻度で起こる不具合なんて、 デバッグしたくてもなかなかできるものではありません。

このような問題を解決するためには排他制御(exclusive operation)というものが必要になります。 例えば、上述の例の場合、あるスレッドが num の読み出し、加算、書き込みという3つの処理を行っている間、他のスレッドが同じ処理を行えないようにする必要があります。 このように、複数のスレッドが同時に行ってはいけない一連の処理が記述された部分のことをクリティカルセクション(critical section)と呼びます。 そして、排他制御とは、複数のスレッドが同時に1つのデータの読み書きを行わないように制御することを言います。

C# における排他制御

C# では排他制御のための専用の構文“lock 文”を持っています。 ここでは lock 文について説明する前に、 lock 文の動作の基となる System.Threading.Monitor クラス を用いた排他制御について説明します。

スレッドの排他制御を行うためには、同期オブジェクトと排他ロックという概念を用います。 考え方としては、排他制御が必要となる部分、すなわち、クリティカルセクションに入る前に、 あるオブジェクトに鍵をかけます。 鍵がかかっている間、他のスレッドは同じオブジェクトに鍵をかけることは出来ず、 鍵がはずされるまで待たされます。 そして、鍵をかけたスレッドはクリティカルセクションを終えた後にオブジェクトにかかっている鍵をはずします。 このとき、鍵をかける対象となるオブジェクトのことを同期オブジェクト、 鍵をかける操作のことを排他ロックと言います。 また、鍵をかける操作をロックの取得(またはただ単にロック(lock))と呼び、 鍵をはずす操作をロックの解放(またはアンロック(unlock))と呼びます。

C# では参照型の任意の変数を同期オブジェクトとして使用できます。 同期オブジェクトを何にするか迷う場合には、 通常、インスタンス変数に対する排他制御を行う場合、this を同期オブジェクトに使い、 静的変数に対しては typeof(クラス) を使います。

そして、排他ロックをかけるためのクラスが System.Threading.Monitor クラスです。 Monitor クラスにはオブジェクトにロック取得のための Enter メソッド(クリティカルセクションに入ると言う意味)と、ロック解放のための Exit メソッド(クリティカルセクションから出る)という2つの静的メソッドがあり、 これらを用いることで排他ロック制御を行います。

例として、先ほどのプログラムに対して排他制御を施してみましょう。 必要な部分のみ抜き出すと以下のようになります。

  static void CountUp()
  {
    for(int i=0; i<ROOP_NUM; ++i)
    {
      object syncObject = typeof(TestThread); // 同期オブジェクト

      Monitor.Enter(syncObject); // ロック取得
      try
      {
        //↓クリティカルセクション
        int tmp = num;
        Thread.Sleep(1);
        num = tmp + 1;
        //↑クリティカルセクション
      }
      finally
      {
        Monitor.Exit(syncObject); // ロック解放
      }
    }
  }

実行結果は以下のように変わります。

400 (400)

この例の場合、try ブロック内がクリティカルセクションになります。 処理の途中で例外が発生しても正しくロックを解放できるように、 Exit メソッドは finally ブロック内に記述します。

lock 文

排他制御の手順をまとめると以下のようになります。

object syncObject = typeof(クラス名);
// インスタンス変数に対する排他制御の場合、
// object syncObject = this;

Monitor.Enter(syncObject);
try
{
  クリティカルセクション
}
finally
{
  Monitor.Exit(syncObject);
}

リソースの破棄」 で説明した using 文や、 「foreach」 で説明した foreach 文と同様に、 C# には lock 文と言う排他制御のための専用の構文があります。 lock 文は以下のようにして用います。

lock(同期オブジェクト)
{
  クリティカルセクション
}

lock 文を用いると、コンパイラが自動的に Monitor クラスを用いた排他制御用のコードを生成してくれます。 例として、先ほどの Monitor クラスを用いて書き直したプログラムを、 さらに lock 文を使って書き換えると以下のようになります。

  static void CountUp()
  {
    for(int i=0; i<ROOP_NUM; ++i)
    {
      lock(typeof(TestThread))
      {
        int tmp = num;
        Thread.Sleep(1);
        num = tmp + 1;
      }
    }
  }

実行結果は先ほどの例と同様に以下のようになります。

400 (400)

volatile

コンパイラは、コードの最適化の過程で、不要な部分を丸々削除してしまう場合があります。 通常は、不要な部分は削除してもらった方がありがたいのですが、 マルチスレッドプログラミングにおいては、 一見不要に見えても実は必要な部分が生じる可能性があります。

例えば、1つのスレッド内では値を読むだけで、 書き込みをせず、他のスレッドから値を書き込むという場合を考えてみてください。 コンパイラは他のスレッドのことまでは知ることができないので、 コンパイラからすると、値を書き換えもしないのに何度も読み出してる無駄なコードに見えます。

こういう状況を想定して、 一見無駄に見えても、他のスレッドで値が更新されている可能性のある変数には volatile(ヴォラタイル: 揮発性、変わりやすい)という修飾子をつけます。 volatile 修飾子の付いた変数への値の読み書きは、 コンパイラの最適化によって削除されることはありません。

Transtation into English

[お問い合わせ](q)