[C#] 39. lockキーワードとdeadlock(デッドロック)


Study / C#    作成日付 : 2019/07/24 00:57:35   修正日付 : 2021/09/28 19:17:28

こんにちは。明月です。


この投稿はC#のlockキーワードとdeadlock(デッドロック)に関する説明です。


以前の投稿でスレッドに関して説明しました。

link - [C#] 37. スレッド(Thread)を使い方、Thread.Sleep関数を使い方


スレッドとはプロセスの中で同時にいろんな処理を実行するための並列処理ということに説明しました。それならこの並列処理を処理する時に、一つのインスタンスや変数に値を処理するとどのようになるかな?

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

namespace Example
{
  class Program
  {
    // 実行関数
    static void Main(string[] args)
    {
      // 総合に関する変数
      int sum = 0;
      // ThreadPoolでJoinのためのリスト
      var list = new List<EventWaitHandle>();
      // ラムダ関数
      var action = new Action(() =>
      {
        // EventWaitHandleインスタンス生成
        var wait = new EventWaitHandle(false, EventResetMode.ManualReset);
        // リストにインスタンスを追加
        list.Add(wait);
        // スレッドプールにスレッド追加
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 繰り返しの0から10まで
          for (int i = 0; i <= 10; i++)
          {
            // sumの値を足してsum変数に格納
            sum += i;
            // スレッド待機時間を1ミリ秒
            Thread.Sleep(1);
          }
          // WaitHandle解除
          wait.Set();
        });
      });
      // 繰り返しの0から9まで
      for (int i = 0; i < 10; i++)
      {
        // ラムダ関数実行(スレッド実行)
        action();
      }
      // listにあるEventWaitHandle関数がすべてSetが呼び出したらJoin解除
      WaitHandle.WaitAll(list.ToArray());
      // スレッドで足した値を出力
      Console.WriteLine(sum);

      // 任意のキーを押してください
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}


上の結果をみると0から10まで足すスレッドを10回に実行しました。

なので、0から10まで足すと55だし、10回に実行したので予想する結果の値は550が出力することが正常です。


でも結果は550ではなく481ですね。望んだ結果が出ませんでした。

理由はsumに値を足す時、sumの値が0の場合、始めのスレッドで1を足して1になるし、二つ目のスレッドで足す時に、1を足して2がなると予想します。

でも、Threadというのは並列処理なので、始めのスレッドで0から1を足す時、二つ目のスレッドで1から2になることが確かではありません。つまり、始めから0から1を足す時、二つ目のスレッドで1を足す時、まだsumの値が始めのスレッドで1になる前なので、sumの値が0の可能性があります。

改めて説明すると、始めスレッド、二つ目のスレッドで0から1を足すことになります。そのように重なる処理が多くなるとsumの値が550まで届かないことになります。


スレッドは少し早い処理のため、並列処理をしますが、予想する値が出ないなら意味がありません。

しかし、スレッド、このすべての並列処理でsumに足す処理だけスレッドをすべて同期化したらこの問題が解決になります。

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

namespace Example
{
  // 例クラス
  class Node
  {
    // Sum変数プロパティ
    public int Sum { get; set; }
  }
  class Program
  {
    // 実行関数
    static void Main(string[] args)
    {
      // 総合に関するインスタンス
      Node node = new Node();
      // nodeインスタンスのSumの値を初期化
      node.Sum = 0;
      // ThreadPoolでJoinのためのリスト
      var list = new List<EventWaitHandle>();
      // ラムダ関数
      var action = new Action(() =>
      {
        // EventWaitHandleインスタンス生成
        var wait = new EventWaitHandle(false, EventResetMode.ManualReset);
        // リストにインスタンスを追加
        list.Add(wait);
        // スレッドプールにスレッド追加
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 繰り返しの0から10まで
          for (int i = 0; i <= 10; i++)
          {
            // nodeインスタンスにlock
            lock (node)
            {
              // sumの値を足してsum変数に格納
              node.Sum += i;
            }
            // スレッド待機時間を1ミリ秒
            Thread.Sleep(1);
          }
          // WaitHandle解除
          wait.Set();
        });
      });
      // 繰り返しの0から9まで
      for (int i = 0; i < 10; i++)
      {
        // ラムダ関数実行(スレッド実行)
        action();
      }
      // listにあるEventWaitHandle関数がすべてSetが呼び出したらJoin解除
      WaitHandle.WaitAll(list.ToArray());
      // スレッドで足した値を出力
      Console.WriteLine(node.Sum);

      // 任意のキーを押してください
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}


クラスインスタンスにlockを掛けたらlock掛けた領域を使ったら他のlockを掛けるところでlockを通過することを待つことになります。

つまり、並列処理の始めのスレッドでlock(node)を通過することになると二つ目のスレッドでの始めのスレッドのlock(node)のスタック領域が終了するまで待機することになります。

そうなら始めのスレッドのlock(node)でスタックが終了になると二つ目のスレッドのlock(node)が通過して三つ目のスレッドのlock(node)が待機することになります。その順番とおりにlock(node)領域を通過することになるので結論は同時に値が足すことは発生しません。

すべてのスレッドでNodeインスタンスに関して同期化になりました。


lockのキーワードは原始データタイプ、つまりint char byteみたいのデータタイプにはできずに無条件にクラスタイプのインスタンスだけにlockを掛けることができます。

using System;
using System.Threading;

namespace Example
{
  // 例クラス
  class Node
  {
  }
  class Program
  {
    // 実行関数
    static void Main(string[] args)
    {
      // 総合に関するインスタンス
      Node node1 = new Node();
      Node node2 = new Node();
      // ラムダ関数
      var action = new Action<Node, Node, string>((a, b, str) =>
      {
        // スレッドプールにスレッド追加
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 繰り返しの0から10まで
          for (int i = 0; i <= 10; i++)
          {
            // nodeインスタンスにlock
            lock (a)
            {
              // nodeインスタンスにlock
              lock (b)
              {
                // コンソール出力
                Console.WriteLine("lock and lock " + str + " = " + i);
                // スレッド待機時間を1ミリ秒
                Thread.Sleep(1);
              }
            }
          }
        });
      });
      // ラムダ関数実行(スレッド実行)
      action(node1, node2, "Action 1");
      action(node2, node1, "Action 2");

      // 任意のキーを押してください
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}


上の例の結果をみればスレッドがlockにより止まりました。

ここでlockが重ねていることが問題ではありません。状況によりlockを重ねて実装することができますが、上みたいに実装するとデッドロック(deadlock)になります。

理由はactionの始めの呼び出しのパラメータでnode1とnode2を入れて、二つの目の呼び出しのパラメータでnode2とnode1に順番を変わって入れました。


そうなるとaction関数の始めのスレッドでnode1インスタンスにlockを掛けて、その同時に二つ目のスレッドでnode2インスタンスにlockを掛けます。

また、始めのスレッドでnode2のlockに入ろうと思えば、二つ目のスレッドでnode2がロックに掛けているのでロックが解けるまで待ちます。

二つ目のスレッドでnode1のlockに入ろうと思えば、始めのスレッドでnode1がロックに掛けている状況でロックが解けるまで待ちます。つまり、両方のlockがお互いのlockが解けるまで待つことのデッドロック(deadlock)になります。


デッドロックとはお互いのlockの領域でlockが掛けている状況でお互いのlockが解けるまで永遠に待つことの意味です。デッドロックになると別にエラーが発生することではなく、そのままプログラムが止まる状況ですが、問題はプログラム上でデッドロックになる部分を探すことは簡単ではありません。

それでプログラムを作成する時、デッドロックにならないように設計を気を付けなければならないですが、一番の重要なlockを重ねないように作成することが重要です。

当たり前にデッドロックが発生しやすいように上みたいに作成しません。

using System;
using System.Threading;

namespace Example
{
  // 例クラス
  class Node
  {
  }
  class Program
  {
    // Lock関数
    static void SetTestLock(Node node1, Node node2)
    {
      lock (node1)
      {
        // Lock関数の呼び出し
        SetTestLock(node2);
      }
    }
    // Lock関数
    static void SetTestLock(Node node)
    {
      lock (node)
      {
        // コンソール出力
        Console.WriteLine("lock");
        // スレッド待機時間を1ミリ秒
        Thread.Sleep(1);
      }
    }
    // 実行関数
    static void Main(string[] args)
    {
      // 総合に関するインスタンス
      Node node1 = new Node();
      Node node2 = new Node();
      // ラムダ関数
      var action = new Action<Node, Node>((a, b) =>
      {
        // スレッドプールにスレッド追加
        ThreadPool.QueueUserWorkItem((cb) =>
        {
          // 繰り返しの0から10まで
          for (int i = 0; i <= 10; i++)
          {
            // Lock関数呼び出す
            SetTestLock(a, b);
          }
        });
      });
      // ラムダ関数実行(スレッド実行)
      action(node1, node2);
      action(node2, node1);

      // 任意のキーを押してください
      Console.WriteLine("Press Any key...");
      Console.ReadLine();
    }
  }
}


lockが重ねみたいに見えないようにlockを関数に実装しました。でも、結局に処理流れはlockが重ねているのでデッドロック(deadlock)になります。

つまり、実務でもこのみたいに作成してデッドロック(deadlock)になる場合はすごく多いです。関係ない関数で各のインスタンスにlockを掛けますが、デッドロックになる場合です。


デッドロックにならないため、何個かのルールがあります。

まず、lockを掛けるインスタンスはできれば一つに統一にすることが良いです。実は設計上でデータの変換や使うインスタンスにlockを掛けることが良いですが、lock専用のObjectタイプのインスタンスを生成してlockを管理することが良いです。

そしてlockの領域をできれば小さいスタックで作成する方が良いです。lockだけでは性能には影響がありませんが、lockの中で処理が遅くなると他の同期化になるlockでlockが終了するまで待機をすることになるので結論的に性能が遅くなります。


ここまでC#のlockキーワードとdeadlock(デッドロック)に関する説明でした。


ご不明なところや間違いところがあればコメントしてください。

#C#
最新投稿