kkamegawa's weblog

Visual Studio,TFS,ALM,VSTS,DevOps関係のことについていろいろと書いていきます。Google Analyticsで解析を行っています

.NET 4.5でのTPL改良について

この記事はC# Advent Calendarの12/18分です。
C#というよりも、.NET Framework 4.5におけるTPL(Task Parallel Library)のチューニングについて簡単に。英語ですが、TPLそのものに関しては.NET 4がリリースされたタイミングでMSからドキュメントが出ています。
Download Articles on Parallel Programming with the .NET Framework 4 from Official Microsoft Download Center
.NET Framework 4.5ではこれをどういう風に改良したかというドキュメントが出ています。誤解があれば突っ込みお願いします。
Download Arcticles on Parallel Programming with the .NET Framework 4.5 from Official Microsoft Download Center

Taskクラスの実装変更

TaskクラスにはInternalメンバ変数にこんな感じで持っていたそうです。

public class Task : … 
{ 
  // ... 
  // The execution context to run the task within, if any. 
  internal ExecutionContext m_capturedContext; 
  // Lazily created if waiting is required. 
  internal ManualResetEventSlim m_completionEvent; 
  // Extra data instantiated only on-demand 
  internal ContingentProperties m_contingentProperties; 
  // ... 
  internal class ContingentProperties 
  { 
    internal List<TaskContinuation> m_continuationList; 
    // ... 
  } 
}

.NET 4.5ではこんな感じに変わっているそうです。.NET 4ではInternalクラスにListがあったのですが、消えています。かわりに遅延作成されるメンバ変数に置き換わっています。

public class Task : … 
{
   // Continuations storage 
   internal object m_continuationObject; 
   // Extra data instantiated only on-demand 
   internal ContingentProperties m_contingentProperties; 
   // ... 
   internal class ContingentProperties 
   {
     // The execution context to run the task within, if any. 
     internal ExecutionContext m_capturedContext; 
     // Lazily created if waiting is required. 
     internal volatile ManualResetEventSlim m_completionEvent; 
     // ... 
   } 
}

理由としては、.NET 4のTPLでは当初fork-joinスタイルの呼び出しが多いだろうと考えられていたからだそうです。fork-joinってのはこんなの。

Task A = Task.Factory.StartNew(() => { ParallelLogicA(); });
Task B = Task.Factory.StartNew(() => { ParallelLogicB(); });
Task.WaitAll(A, B);
AdditionalLogic();

要は、複数のTaskを一気に起動して、全部終わるまでじーっと待つってパターンですね。これなら.NET4の実装でもよかったんですが、実際やってみるとこれではちょっとよろしくないということがわかって、continuation-based(継続実行とでも訳せばいいのかな?)が優位になるようにTaskクラスの実装を変更したということのようです。こんな感じの。

Task A = Task.Factory.StartNew(() => { ParallelLogicA(); });
Task B = Task.Factory.StartNew(() => { ParallelLogicB(); });
Task.Factory.ContinueWhenAll(new Task[] { A, B }, _ =>
{
  AdditionalLogic();
});

この変更に伴い、Taskクラスが一つ作られるごとに4byte(x86の場合。x64だと8byte)減ったということだそうです。
m_capturedContextフィールドもTaskクラスのinternalクラスに移動しています。これめったに使わないから移動した…ってことなんでしょうか?
m_completionEventがTask.ContingentPropertiesに移動した理由はTaskが完了していないタイミングで、Task.Wait()を呼び出してしまうと、メモリリークではないけど、ContingentPropertiesが膨れ上がってしまうことを防ぐためだそうです。ただ、こんなケースはまだ.NET 4流の実装が役に立つケースだそうですが、こう書くのは実際にはほとんどないだろうとのこと。

Task t1 = Task.Factory.StartNew( () => {});
((IAsyncResult)t1).AsyncWaitHandle.WaitOne();

これで多くのシナリオでTaskクラスが使いやすくなったとのことだそうです。

Continuation streamlining

.NET Framework4.5では継続的な実行(fork-joinモデルではないコード)のためにTaskクラスをがんばってチューニングしたそうです。.NET 4では以下の問題があったそうです。

  • TaskクラスのContingentPropertiesがどんどん増えてしまう
  • TaskContinuationがListで格納されていたため、追加するごとに増えてしまっていた

前述のとおり.NET 4.5ではm_continuationListをTask.ContingentPropertiesから消して、Task.m_continuationObjectにしたと。こうすることにより、Interlocked.CompareExchange()も.NET4のときより早くなったよということだそうです。ほとんどの場合、継続する処理は一つだから二つ以上のことを最初から考えない実装にしたってことでしょうか?このようにしても.NET 4.5のほうが.NET 4よりも早くなっているとのことらしいです。

Task最適化

Taskクラスから二つprivateメンバ変数を消して、一つを基底クラスのフラグに代替させたそうです。これでちょびっとメモリが減ったということだそうで。

ベンチマーク

これは元資料を読んでいただきたいですが、こういう資料が出てくるのはあまり記憶にありません(とくにDeveloper Preview段階で)。
Task生成で10%から20%、Taskの生成で50%位の差が出ています。Object型のメンバ変数二つ生成しなくなったのは大きいんですかね。とはいえ、生成速度は個人的にはあまり重要視していません。どうせTaskの中身で起動される処理のほうがよっぽど時間がかかるので。
それよりもきになるのはメモリ使用量のほうです。Task.WaitAll()とTask.WaitAny()を実行したとき、.NET 4では生成したTaskクラスに比例して使用メモリが増加していますが、.NET 4.5ではいくら作ろうとも一定サイズです。といっても、10個程度ではたぶん気にする必要はないでしょう。ともあれ、.NET 4.5ではメモリにやさしくなっています。

.NET 4.5時代はどう書くべきか1-メモリ使用量削減

メモリを少なくするように書きましょう。まず、こんな書き方があったとします。

ConcurrentQueue<int> cq = new ConcurrentQueue<int>();
...
Task t1 = Task.Factory.StartNew( () => { for (int i = 0; i < 10; i++) cq.Enqueue(i); });

cqはt1のdelegateの中で使っているため、クロージャーオブジェクトがこの中で生成されてしまうそうです。必要であればILを見てください。

private class Locals { 
  public ConcurrentQueue<int> _cq; 
  public void Anonymous() { 
    for (int i = 0; i < 10; i++) _cq.Enqueue(i); 
  } 
} ... 
Locals _locals = new Locals();
_locals._cq = new ConcurrentQueue<int>();
Task t1 = Task.Factory.StartNew(new Action(_locals.Anonymous));

じゃあこの余計なメモリ確保を回避するためにはどうすればいいか?Task.Factory.StartNewにstateオブジェクトがあるので、これをオーバーロードすれば回避できるそうです。

ConcurrentQueue<int> cq = new ConcurrentQueue<int>();
...
Task t1 = Task.Factory.StartNew( state => 
{ 
  var mycq = (ConcurrentQueue<int>)state;
  for (int i = 0; i < 10; i++) mycq.Enqueue(i); 
}, cq);

このstateオブジェクトを使用するというテクニックはTask.ContinueWithでも使用できるそうです。コーディング例はpdfに載っています。
Taskクラスがメモリを使いだすのはContingentPropertiesフィールドを使ってしまうような条件を満たしたときだそうです。つまりContingentPropertiesを使わないようにすれば、.NET 4.5のTaskクラスはあまりメモリを使わくなります。ContingentPropertiesを使う条件は以下の通り。

  • TaskをCancellationTokenつきで作成する
  • TaskクラスのExecutionContextをデフォルト以外で作成する
  • Taskクラスを親タスクに参加させる
  • TaskクラスをFault状態で終了させる
  • Taskクラスを((IAsyncResult)Task).AsyncWaitHandle.Wait()を使用して待機させる

ちょっと特殊っぽい条件が多いですね。おおむね問題ないと思います。

.NET 4.5時代はどう書くべきか2-コンストラクタに書かない

コンストラクターから呼び出されるところで並列処理を書かない。とくにメンバ変数を操作したりすると、簡単にスレッドセーフが崩れます。

.NET 4.5時代はどう書くべきか3-高頻度呼び出し箇所を展開する

メソッド入口のnullチェックなどは高頻度で実行されることが分かっているため、可能であれば分割してしまいましょう。うまくいけばJITが切り出したチェック関数の部分をインライン展開してくれます。
ただ、pdfの著者の環境ではなかなかインライン展開されなかったとも書かれてます…。

.NET 4.5時代はどう書くべきか4-awaitの安易な使用はちょっと待った

awaitのかわりにContinueWith/Unwrapが使えないかどうか、今一度確認しましょう。awaitはかなり良くできていますが、ContinueWith/Unwrapで書いたほうが使用メモリにやさしくなっています。