ASP.NET の非同期でありがちな Deadlock を克服する


環境 : .NET Framework 4.5

こんにちは。

今週は、Build Insider OFFLINE「.NET Web プログラミングにおける非同期 IO のすべて」のフォロー ブログのその 2 です。今回は、時間がなくなり ほとんど紹介できなかった Deadlock のサンプル・コードをいくつか紹介します。

守るべきことは、お伝えした唯一のこと (非同期と同期を混在させない) ですが、実際のコードで違いを見ていただき、その感覚をつかんでみてください。

 

今なお活躍する SynchronizationContext

Deadlock の解説の前に、1 つ予備知識として SynchronizationContext について記載しておきます。

SynchronizationContext は、これまでの非同期プログラミングにおいて重要な概念でした。
例えば、下記は、まったく意味のないサンプル コードですが、このようにカスタムの SynchronizationContext (MyNonsenseSynchronizationContext) を作成し、Post メソッドに Breakpoint を置いて Debug してみてください。10 秒後に、この Post メソッドが呼ばれるのがわかります。

. . .
using System.Threading;
using System.ComponentModel;
. . .

private static BackgroundWorker bw = new BackgroundWorker();

static void Main(string[] args)
{
  var syncCtx = new MyNonsenseSynchronizationContext();
  SynchronizationContext.SetSynchronizationContext(syncCtx);

  Console.WriteLine("Main {0}",
    Thread.CurrentThread.ManagedThreadId);

  bw.DoWork += BwDoWork;
  bw.RunWorkerCompleted += BwRunWorkerCompleted;
  bw.RunWorkerAsync();

  Console.ReadLine();
}

static void BwDoWork(object sender,
  DoWorkEventArgs e)
{
  Console.WriteLine("Worked {0}",
    Thread.CurrentThread.ManagedThreadId);
  Thread.Sleep(10000);
  e.Result = null;
}

static void BwRunWorkerCompleted(object sender,
  RunWorkerCompletedEventArgs e)
{
  Console.WriteLine("Completed {0}",
    Thread.CurrentThread.ManagedThreadId);
}
. . .

sealed class MyNonsenseSynchronizationContext :   
  SynchronizationContext
{
  public override void Post(SendOrPostCallback d, object state)
  {
    // Please set breakpoint, here !
    ThreadPool.QueueUserWorkItem(new WaitCallback(d), state);
  }

  public override void Send(SendOrPostCallback d, object state)
  {
    throw new NotSupportedException("Sending is not supported.");
  }
  . . .
}

フレームワークは、非同期で実行された処理 (BwDoWork) が完了して RunWorkerCompleted イベント (上記の BwRunWorkerCompleted メソッド) を呼ぶ際、この処理 (BwRunWorkerCompleted) を同期的に処理するために SynchronizationContext (今回の場合、MyNonsenseSynchronizationContext) に実行を依頼します。
つまり、この同期要求を実際にどう処理するかは、SynchronizationContext の実装次第ということになります。上記のサンプル・コードは、この処理を、結局、ThreadPool に投げて非同期に処理するという乱暴なサンプルですが、実は、コンソール・アプリケーションなどでは、似た処理をおこなっています。(一方、Windows フォーム、WPF など UI 系のアプリケーションでは異なります。)

なぜ、このような SynchronizationContext が .NET 2.0 から導入されたかという背景は、私のセッションで解説した通りです。
それ以前の Win 32 アプリや Windows アプリでは、Message Pump や UI スレッドなどの、いわゆる同期を制御する「マスター」のようなスレッドが存在していましたが、ASP.NET の非同期処理では、もはやこうした元となるスレッドは存在しません。つまり、複数のスレッドで同期を制御するための抽象的なスケジューラーが必要であり、.NET 2.0 以降、こうした統一的な役割を SynchronizationContext が担うことになりました。
実際、セッションで解説したように、コンソール・アプリケーション、UI アプリケーション、Web アプリケーションなど、それぞれで使用されている SynchronizationContext はまったく実装が異なっており、ASP.NET では、セッションの医者と患者の例で紹介したようなスケジューリングをおこなう AspNetSynchronizationContext (SynchronizationContext から継承) が採用されています。

さて、上記は EAP (Event-based Asynchronous Pattern)のサンプル・コードでしたが、今や Task の時代に入り、この SynchronizationContext を直接見かける機会はほとんどなくなりました。実際、Task を使ったプログラミングでは、ご存じの方も多いかと思いますが、TaskScheduler という新しいスケジューラーを使用します。(このスケジューラーを明示しないと、暗黙裡に既定のスケジューラーが使用されます。)
Task ベースでプログラミングをおこなう場合、もはや、この SynchronizationContext は関係ないように思われるかもしれません。しかし、実際には、このあとのサンプルで見ていくように、特に、async/await を使用したマーシャリングにおいて SynchronizationContext が大きく関与します。

 

さまざまなデッドロック (Deadlock) の例

では、デッドロックの例をいくつか確認し、その理由を考えてみましょう。下記のサンプルは、すべて学習用に簡単なサンプルにしていますが、現実の開発では、これらがさまざまに関係しあって発生することでしょう。(特定するのも、決して容易なことではないでしょう。)

まずは、以下の ASP.NET MVC のコードを実行してみてください。
下記の http://heavyweb10.cloudapp.net/ を呼び出すと 10 秒後に応答が返ってきますが、実は、このプログラムは決して返ってくることはありません。この単一の処理で Deadlock が発生します。

. . .
using System.Threading.Tasks;
using System.Net.Http;
. . .

public class HomeController : Controller
{
  public ActionResult Index()
  {
    ViewBag.Test = GetHeader().Result;
    return View();
  }

  private async Task<string> GetHeader()
  {
    HttpClient cl = new HttpClient();
    HttpResponseMessage res = await cl.GetAsync("http://heavyweb10.cloudapp.net/");
    return res.ToString();
  }
}

理由を簡単に説明しましょう。

await では、内部で TaskAwaiter と呼ばれるオブジェクトが使用されますが、この TaskAwaiter の既定の動作では、使用されている TaskScheduler が何であろうと関係なく、Current の SynchronizationContext を使って同期処理を Post します。(この既定の動作を変更することも可能ですが、ここでは解説を省略します。) つまり、上述した AspNetSynchronizationContext が使用されます。
しかし、既に GetHeader().Result が呼ばれている (1 つの同期ブロックが待機中) という点に注意してください。AspNetSynchronizationContext では、例の医者と患者の解説でおわかりのように、1 つの要求 (Request) で 2 つ以上の同期ブロックが同時に実行されることはありません。つまり、この await で Post された同期処理は、GetHeader().Result の処理が終わるまで待機します。同時に、GetHeader().Result は、この Post された同期処理が実行されないと、戻ってくることはありません。
つまり、Deadlock が発生するわけです。

このため、例えば、下記の通り ContinueWith を使って書き換えると、Deadlock は発生せずに動いてしまいます。(セッションで解説した、「await と ContinueWith を使った書き換えは、リソースの観点では等価ではない」というのは、こうした背景によります。)

public class HomeController : Controller
{
  public ActionResult Index()
  {
    ViewBag.Test = GetHeader().Result;
    return View();
  }

  private Task<string> GetHeader()
  {
    HttpClient cl = new HttpClient();
    return cl.GetAsync("http://heavyweb10.cloudapp.net/")
      .ContinueWith((t) =>
      {
        return t.Result.ToString();
      });
  }
}

また、たとえ 2 箇所で await しても、下記のように All async で書く場合はちゃんと動きます。
理由は、AspNetSynchronizationContext に Post された 2 つの同期ブロックを、例の医者と患者の方式で順番に処理してくれるからです。(理由は省略しますが、.NET 4.5 以降では、両方同じように書けるなら、なるべく、ContinueWith よりも、async / await で記述するほうが良いでしょう。)

public class HomeController : Controller
{
  public async Task<ActionResult> Index()
  {
    ViewBag.Test = await GetHeader();
    return View();
  }

  private async Task<string> GetHeader()
  {
    HttpClient cl = new HttpClient();
    HttpResponseMessage res = await cl.GetAsync("http://heavyweb10.cloudapp.net/");
    return res.ToString();
  }
}

また、上記の Deadlock の場合の処理をコンソール・アプリケーションにコピーして実行すると、なんと動いてしまいます。
理由は、もうおわかりですね。コンソール・アプリケーションでは AspNetSynchronizationContext は使用されず、上記の MyNonsenseSynchronizationContext のサンプルのように、スレッド プールを使った SynchronizationContext が使用されるためです。
このように、非同期の処理では、たとえコードがコンソール・アプリケーションで動作しても、ASP.NET に移すと動かなくなることがあります。

static void Main(string[] args)
{
  string test = GetHeader().Result;
  Console.WriteLine(test);
  Console.ReadLine();
}

static async Task<string> GetHeader()
{
  HttpClient cl = new HttpClient();
  HttpResponseMessage res = await cl.GetAsync("http://heavyweb10.cloudapp.net/");
  return res.ToString();
}

他にも、こんなサンプルも考えられます (下記)。
下記は、同じく、同期メソッドの中で非同期処理をおこなっているサンプルです。

public ActionResult Index()
{
  // wait for 10 seconds
  Task.Delay(10000).ContinueWith((t) =>
  {
    // some kind of procedure
    . . .
  }).Wait();

  return View();
}

このコードは、例えば、最大スレッド (MaxWorkerThreads) を 10 個に制限した場合、9 個までは問題なく動作しますが、10 個同時に処理すると Deadlock を起こします。(要求すべてが Lock されます。)
理由は、ContinueWith でスレッドが必要ですが、10 個のスレッドすべてが Wait() で待機しているため (実行中であるため)、スレッドを割り当てることができず、待機状態になるためです。Wait() は ContinueWith() が終了するのを待ち、ContinueWith() は Wait() で待機している 10 個のスレッドのいずれかが解放されるのを待ちます。つまり、Deadlock となります。

上記のように書くなら、むしろ、以下の通りすべて同期で書いてしまったほうが、まだましです。

public ActionResult Index()
{
  // wait for 10 seconds
  System.Threading.Thread.Sleep(10000);

  // some kind of procedure
  . . .

  return View();
}

また、もっとも理想的な方法としては、下記の通り、すべて非同期で書いてしまう方が良いでしょう。上記と異なり、10 秒待機している間、スレッドの無駄使いをしません。

public async Task<ActionResult> Index()
{
  // wait for 10 seconds
  await Task.Delay(10000);

  // some kind of procedure
  . . .

  return View();
}

なお、同じく解説する時間がありませんでしたが、上記で何度も登場している AspNetSynchronizationContext は、実は、Task の登場にあわせて (Task との併用で問題が生じないように) 内部実装が変更されています。以前の AspNetSynchronizationContext の実装は、LegacyAspNetSynchronizationContext というオブジェクトに変更されているので注意してください。例えば、Visual Studio 2012 で .NET 4 と .NET 4.5 の ASP.NET MVC 4 のプロジェクトを作成すると、Current の SynchronizationContext は、前者では LegacyAspNetSynchronizationContext、後者では AspNetSynchronizationContex が使用されます。
「何を注意すれば良いの ?」 と思うかもしれませんが、この実装の相違が、稀に問題を引き起こす場合があります。例えば、この最新の AspNetSynchronizationContext を使用せずに await を使用すると、その後のブロックで HttpContext.Current が正しくコピーされず、null になります。組み合わせによって、これと同等の現象が発生するケースがいくつかあります。
だんだん deadlock の話とそれてくるので省略しますが、また別の機会にこうしたポイントなども解説したいと思います。

 

さて、いろいろなサンプルを紹介しましたが、ここでは、こうした面倒なメカニズムを細かく理解してもらうために記載しているのではありません。プログラマーの皆さんに理解してほしいことは、セッションでお伝えした「混ぜるな危険」という点です。そして、どんな混ぜ方をすると、どんな問題が発生するか、多くのサンプルを通して感覚をご理解いただきたいという点です。

私のセッション (.NET Web プログラミングにおける非同期 I/O のすべて) では非同期 IO だけに絞って解説しましたが、Build Insider OFFLINE の河合さんのセッション「.NET最先端技術によるハイパフォーマンス ウェブ アプリケーション」では、もっと広い視点でパフォーマンス向上のためのポイントを解説していますので (残念ながら、私は聞けなかったのですが。。。)、スライドなど、是非 参考にしてみてください。

最後に、このセッション全体を通してお伝えしたかったことは、せっかく .NET を使うなら、ハイ・パフォーマンス & ハイ・スケーラブルな Web を ! です。良い道具は、うまく活用していきましょう。

Comments (0)

Skip to main content