いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

.NET クラスライブラリ探訪-059 (System.Lazy, 遅延初期化, double-checked locking, race-to-initialize)


今回は、System.Lazyクラスについてちょこっとメモメモ。


Lazyクラスは.NET Framework 4.0から追加された型です。
名前の通り、遅延初期化をサポートするためのクラスです。


スレッド処理でよくある共通の問題として、如何にして共有フィールドの遅延初期化を
スレッドセーフに行うかという問題があります。
マルチスレッドの場合、単純なオブジェクトの構築の場合でも気をつける必要があります。


たとえば、HeavyObjectというクラスがあったとして、このクラスの初期化には
すこし時間がかかるとします。また、利用される頻度はそんなに多くないけど必要であるとします。
その場合、以下のように

class Hoge
{
  public readonly HeavyObject Heavy = new HeavyObject();
}

と、宣言していると毎回Hogeの初期化時にHeavyObjectも構築されるので
ちょっと無駄な場合があります。


で、次に遅延初期化を考えます。必要とされる最初の段階まで
インスタンスの構築を遅らせます。

class Hoge
{
  HeavyObject _heavy;
  
  public HeavyObject Heavy
  {
    get
    {
      if (_heavy == null)
      {
        _heavy = new HeavyObject();
      }
      
      return _heavy;
    }
  }
}


シングルスレッドな場合はこれで大丈夫です。
でも、これをマルチスレッドな環境で実行した場合は大丈夫でしょうか?つまりスレッドセーフかどうか?
複数のスレッドが同時にプロパティに入ってきたら?同時にif文の評価が行われたら?
状況によっては、同時にインスタンスが生成され、上書きされます。
結論として、上記の処理はスレッドセーフではありません。


通常の解決方法としては、ロックを行うようにします。
(実際には、double-checked lockingやrace-to-initializeパターンなどいろいろな方法があります。)

class Hoge
{
  HeavyObject     _heavy;
  readonly object _lock = new object();
  
  public HeavyObject Heavy
  {
    get
    {
      lock (_lock)
      {
        if (_heavy == null)
        {
          _heavy = new HeavyObject();
        }
        
        return _heavy;
      }
    }
  }
}


double-checked lockingの場合

class Hoge
{
  volatile HeavyObject _heavy;
  readonly object _lock = new object();
  
  public HeavyObject Heavy
  {
    get
    {
      if (_heavy == null)
      {
        lock (_lock)
        {
          if (_heavy == null)
          {
            _heavy = new HeavyObject();
          }
        }
      }
      
      return _heavy;
    }
  }
}


race-to-initializeパターンの場合

class Hoge
{
  volatile HeavyObject _heavy;
  
  public HeavyObject Heavy
  {
    get
    {
      if (_heavy == null)
      {
        var heavy = new HeavyObject();
        Interlocked.CompareExchange(ref _heavy, heavy, null);
      }
      
      return _heavy;
    }
  }
}


前置きが長くなりましたが、Lazyクラスは上記のような遅延初期化を
行うためのクラスです。利用方法は、以下のようになります。

var lazy1 = new Lazy<HeavyObject>(() => new HeavyObject(), true); 

または

var lazy1 = new Lazy<HeavyObject>(() => new HeavyObject(), LazyThreadSafetyMode.ExecutionAndPublication); 

です。
LazyThreadSafetyModeにてExecutionAndPublicationを指定するとdouble-checked lockingパターン、
PublicationOnlyを指定すると、race-to-initializeパターンに対応した処理が実行されます。


先ほどのサンプルの場合は、以下のように書き換えられます。

class Hoge
{
  Lazy<HeavyObject> _heavy = new Lazy<HeavyObject>(() => new HeavyObject(), LazyThreadSafetyMode.ExecutionAndPublication);
  
  public HeavyObject Heavy
  {
    get
    {
      return _heavy.Value;
    }
  }
}


以下サンプルです。

  #region LazySamples-01
  /// <summary>
  /// Lazy<T>, LazyInitializerクラスのサンプルです。
  /// </summary>
  public class LazySamples01 : IExecutable
  {
    public void Execute()
    {
      //
      // Lazy<T>クラスは、遅延初期化 (Lazy Initialize)機能を付与するクラスである。
      //
      // 利用する際は、LazyクラスのコンストラクタにFunc<T>を指定することにより
      // 初期化処理を指定する。(たとえば、コストのかかるオブジェクトの構築などをFuncデリゲート内にて処理など)
      //
      // また、コンストラクタにはFunc<T>の他にも、第二引数としてスレッドセーフモードを指定出来る。
      // (System.Threading.LazyThreadSafetyMode)
      //
      // スレッドセーフモードは、Lazyクラスが遅延初期化処理を行う際にどのレベルのスレッドセーフ処理を適用するかを指定するもの。
      // スレッドセーフモードの指定は、LazyクラスのコンストラクタにてLazyThreadSafetyModeかboolで指定する。
      //   ・None:                    スレッドセーフ無し。速度が必要な場合、または、呼び元にてスレッドセーフが保証出来る場合に利用
      //   ・PublicationOnly:         複数のスレッドが同時に値の初期化を行う事を許可するが、最初に初期化に成功したスレッドが
      //                             Lazyインスタンスの値を設定するモード。(race-to initialize)
      //   ・ExecutionAndPublication: 完全スレッドセーフモード。一つのスレッドのみが初期化を行えるモード。
      //                             (double-checked locking)
      //
      // Lazyクラスのコンストラクタにて、スレッドセーフモードをbool型で指定する場合、以下のLazyThreadSafetyModeの値が指定された事と同じになる。
      //    ・true : LazyThreadSafetyMode.ExecutionAndPublicationと同じ。
      //    ・false: LazyThreadSafetyMode.Noneと同じ。
      //
      // Lazyクラスは、例外のキャッシュ機能を持っている。これは、Lazy.Valueを呼び出した際にコンストラクタで指定した
      // 初期化処理内で例外が発生した事を検知する際に利用する。Lazyクラスのコンストラクタにて、既定コンストラクタを使用するタイプの
      // 設定を行っている場合、例外のキャッシュは有効にならない。
      //
      // また、LazyThreadSafetyMode.PublicationOnlyを指定した場合も、例外のキャッシュは有効とならない。
      //
      // 排他モードで初期化処理を実行
      var lazy1 = new Lazy<HeavyObject>(() => new HeavyObject(TimeSpan.FromMilliseconds(100)), LazyThreadSafetyMode.ExecutionAndPublication);
      // 尚、上は以下のように第二引数をtrueで指定した場合と同じ事。
      // var lazy1 = new Lazy(() => new HeavyObject(TimeSpan.FromSeconds(1)), true);
      
      // 値が初期化済みであるかどうかは、IsValueCreatedで確認出来る。
      Console.WriteLine("値構築済み? == {0}", lazy1.IsValueCreated);
      
      //
      // 複数のスレッドから同時に初期化を試みてみる。 (ExecutionAndPublication)
      //
      Parallel.Invoke
      (
        () => 
        {
          Console.WriteLine("[lambda1] 初期化処理実行 start.");
          
          if (lazy1.IsValueCreated)
          {
            Console.WriteLine("[lambda1] 既に値が作成されている。(IsValueCreated=true)");
          }
          else
          {
            Console.WriteLine("[lambda1] ThreadId={0}", Thread.CurrentThread.ManagedThreadId);
            var obj = lazy1.Value;
          }
          
          Console.WriteLine("[lambda1] 初期化処理実行 end.");
        },
        () => 
        {
          Console.WriteLine("[lambda2] 初期化処理実行 start.");
          
          if (lazy1.IsValueCreated)
          {
            Console.WriteLine("[lambda2] 既に値が作成されている。(IsValueCreated=true)");
          }
          else
          {
            Console.WriteLine("[lambda2] ThreadId={0}", Thread.CurrentThread.ManagedThreadId);
            var obj = lazy1.Value;
          }
          
          Console.WriteLine("[lambda2] 初期化処理実行 end.");
        }
      );
      
      Console.WriteLine("==========================================");
      
      //
      // 複数のスレッドにて同時に初期化処理の実行を許可するが、最初に初期化した値が設定されるモード。
      // (PublicationOnly)
      //
      var lazy2 = new Lazy<HeavyObject>(() => new HeavyObject(TimeSpan.FromMilliseconds(100)), LazyThreadSafetyMode.PublicationOnly);

      Parallel.Invoke
      (
        () => 
        {
          Console.WriteLine("[lambda1] 初期化処理実行 start.");
          
          if (lazy2.IsValueCreated)
          {
            Console.WriteLine("[lambda1] 既に値が作成されている。(IsValueCreated=true)");
          }
          else
          {
            Console.WriteLine("[lambda1] ThreadId={0}", Thread.CurrentThread.ManagedThreadId);
            var obj = lazy2.Value;
          }
          
          Console.WriteLine("[lambda1] 初期化処理実行 end.");
        },
        () => 
        {
          Console.WriteLine("[lambda2] 初期化処理実行 start.");
          
          if (lazy2.IsValueCreated)
          {
            Console.WriteLine("[lambda2] 既に値が作成されている。(IsValueCreated=true)");
          }
          else
          {
            Console.WriteLine("[lambda2] ThreadId={0}", Thread.CurrentThread.ManagedThreadId);
            var obj = lazy2.Value;
          }
          
          Console.WriteLine("[lambda2] 初期化処理実行 end.");
        }
      );
      
      Console.WriteLine("値構築済み? == {0}", lazy1.IsValueCreated);
      Console.WriteLine("値構築済み? == {0}", lazy2.IsValueCreated);
      
      Console.WriteLine("lazy1のスレッドID: {0}", lazy1.Value.CreatedThreadId);
      Console.WriteLine("lazy2のスレッドID: {0}", lazy2.Value.CreatedThreadId);
    }
    
    class HeavyObject
    {
      int _threadId;
      
      public HeavyObject(TimeSpan waitSpan)
      {
        Console.WriteLine(">>>>>> HeavyObjectのコンストラクタ start. [{0}]", Thread.CurrentThread.ManagedThreadId);
        Initialize(waitSpan);
        Console.WriteLine(">>>>>> HeavyObjectのコンストラクタ end.   [{0}]", Thread.CurrentThread.ManagedThreadId);
      }
      
      void Initialize(TimeSpan waitSpan)
      {
        Thread.Sleep(waitSpan);
        _threadId = Thread.CurrentThread.ManagedThreadId;
      }
      
      public int CreatedThreadId
      {
        get
        {
          return _threadId;
        }
      }
    }
  }
  #endregion


以下、実行結果です。

  値構築済み? == False
  [lambda2] 初期化処理実行 start.
  [lambda2] ThreadId=4
  >>>>>> HeavyObjectのコンストラクタ start. [4]
  [lambda1] 初期化処理実行 start.
  [lambda1] ThreadId=3
  >>>>>> HeavyObjectのコンストラクタ end.   [4]
  [lambda2] 初期化処理実行 end.
  [lambda1] 初期化処理実行 end.
  ==========================================
  [lambda1] 初期化処理実行 start.
  [lambda1] ThreadId=3
  >>>>>> HeavyObjectのコンストラクタ start. [3]
  [lambda2] 初期化処理実行 start.
  [lambda2] ThreadId=4
  >>>>>> HeavyObjectのコンストラクタ start. [4]
  >>>>>> HeavyObjectのコンストラクタ end.   [3]
  [lambda1] 初期化処理実行 end.
  >>>>>> HeavyObjectのコンストラクタ end.   [4]
  [lambda2] 初期化処理実行 end.
  値構築済み? == True
  値構築済み? == True
  lazy1のスレッドID: 4
  lazy2のスレッドID: 3

一つ目の実行では、LazyThreadSafetyMode.ExecutionAndPublicationを指定しているので
排他モードで初期化が行われており、二つ目の実行ではPublicationOnlyを指定しているので
いったんどちらのスレッドも初期化に入ってきているのですが、最初に初期化に成功した
スレッドの値が適用されている事がわかります。


以下、参考資料です。

================================
過去の記事については、以下のページからご参照下さい。

サンプルコードは、以下の場所で公開しています。