WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core MVC の非同期プログラミング

by WebSurfer 2. January 2021 17:40

先の記事「ASP.NET MVC の非同期プログラミング」は .NET Framework 版の MVC5 アプリの話ですが、ASP.NET Core 3.1 MVC アプリでも同様なことを検証しましたのでその結果を書きます。

ASP.NET Core 3.1 MVC の非同期プログラミング

以下に (1) async / await を利用した非同期プログラミングで使用されるスレッドがどのようになるか、(2) Task.Result などを使った同期コードと非同期コードを混在させるとデッドロックは起きるのか、(3) Task.ConfigureAwait(Boolean) メソッドを付与するとどう変わるかついて書きます。

意外だったのは、.NET Framework 版と違って、Task.Result を使ってもデッドロックならないということででした。

なお、Kestrel の場合はどうなるかですが、Visual Studio を使って IIS Express (インプロセス ホスティング) と Kestrel を切り替えて両方の動作を確認しました。どちらも同じ結果となりました。

(1) 使用されるスレッド

ASP.NET で非同期プログラミングを行う目的は、スレッドプールにある限られた数のスレッドを有効利用し、スループットを向上するためです。なので非同期メソッドのチェーンの一番深いところにある await 前後でスレッドが切り替わるはずです。

それを確認するために以下のコードで試してみた結果が上の画像です。期待通り、TimeCosumingMethod メソッドの await 前後で ManagedThreadId が 27 から 25 に変わっています。

public async Task<ActionResult> AsyncTest()
{
    ViewBag.Id1 = "開始時, ID=" +
                  Thread.CurrentThread.ManagedThreadId;

    ViewBag.Id2 = await TimeCosumingMethod();

    // .NET Framework 版の MVC5 アプリでは上に代えて以下のようにすると
    // デッドロックになったが Core 3.1 版ではデッドロックにはならない
    //ViewBag.Id2 = TimeCosumingMethod().Result;

    ViewBag.Id3 = "終了時, ID=" +
                  Thread.CurrentThread.ManagedThreadId;

    return View();
}

private async Task<string> TimeCosumingMethod()
{
    int id = Thread.CurrentThread.ManagedThreadId;

    await Task.Delay(3000);

    // ConfigureAwait(false) の付与は結果に関係なし。
    // デッドロックになる時は ConfigureAwait(false) が勝手に付与され、
    // デッドロックにならない時は付与しても無視されるような感じ
    //await Task.Delay(3000).
    //    ConfigureAwait(continueOnCapturedContext: false);

    return "TimeCosumingMethod の戻り値, ID(IN)=" + id +
           " / ID(OUT)=" + Thread.CurrentThread.ManagedThreadId;
}

(2) Task.Result の使用

先の記事「await と Task.Result によるデッドロック」で書いたように Task.Result を使った同期コードと非同期コードを混在させるとデッドロックは起きるのかということの確認です。

結果は下の画像の通り処理は無事完了し、デッドロックは起きなかったです。.NET Framework 版の MVC5 アプリで TimeCosumingMethod の await Task.Delay(3000); に .ConfigureAwait(false) を付与した場合と同じ結果です。すなわち ID(OUT) のみ ManagedThreadId が異なり他は同じになっています。

Task.Result の使用

Task.Result をどのように使ったかは上のコードの AsyncTest アクションメソッドのコメントを見てください。.NET Framework 版での検証と全く同じやり方ですです。

.NET Framework 版でデッドロックなる理由は: まず、TimeCosumingMethod().Result で 1 つの同期ブロックが待機中となる。呼び出された TimeCosumingMethod メソッドの await で待機する際にキャプチャされた「現在のコンテキスト」で await 完了後の同期処理を実行しようとする。しかし「現在のコンテキスト」では一度に実行するコードは 1 つのチャンクに限定されている。なので、Result プロパティでの待機が終わるまで await 完了後の同期処理は実行できない。結果デッドロックになる・・・ということで、Core 3.1 でも同じになると信じていたんですが、一体どうなっているのでしょう?

(3) ConfigureAwait の付与

AsyncTest アクションメソッドで Result を使うか否かで結果は上の画像のように変わりますが、上のコードの TimeCosumingMethod のコメントに書きましたように、ConfigureAwait(false) の付与はその結果に関係なかったです。

Core では AsyncTest アクションメソッドで Result を使ってデッドロックになる時は ConfigureAwait(false) が勝手に付与され、正しく await してデッドロックにならない時は ConfigureAwait(false) を付与しても無視されているような感じです。

.NET Framework 版とは話が大きく変ってきてしまうのですが、一体どうなっているのでしょう。どうも今までの知識は Core には役に立たないようで、また勉強しなければならないようです。でも、今はその気力がないです。(笑)

Tags: , , , , ,

CORE

タスク並列ライブラリ (TPL)

by WebSurfer 27. December 2020 14:53

タスク並列ライブラリ (TPL) は Windows Forms のような GUI アプリでは使い難い点があるということを今更ながらですが学びましたので、備忘録として書いておきます。

タスク並列ライブラリの検証

最近の PC の CPU はマルチコアが当たり前になっているようですが、PC のマルチコアを有効に使うプログラミングをサポートするため、.NET Framework 4.0 で導入されたタスク並列ライブラリ (TPL・・・Parallel.For とか Parallel.Invoke とか) を利用するという話を巷でよく耳にします。

マルチスレッドの基本的な話ですが、@IT の記事「第1回 マルチスレッドはこんなときに使う (1/2)」の「マルチスレッドの動作原理」セクションを見てください。リンク切れになると困るので画像だけ借用して以下に表示しておきます。

マルチスレッドの比較

② のようになると OS がスレッドを切り替えて処理を行うのでそのオーバーヘッドの分逆に遅くなるということになります。なので、マルチスレッドアプリで処置時間の短縮を図るなら ③ のようにマルチコアを利用できる環境が必要で、さらにマルチコアを有効に利用するプログラミングを行うという話になると思います。

その際に自分がよく聞くのがタスク並列ライブラリ (TPL) や Parallel LINQ (PLINQ) を使うという話です。Microsoft のドキュメント「.NET での並列プログラミング」や、ググるとヒットする記事例えば @IT の記事「ループをParallelクラスで並列処理にするには?」を読むと TPL, PLINQ は並列処理には万能のような気がしてました。

Microsoft のドキュメント「タスク並列ライブラリ (TPL)」には、

"TPL は、使用可能なすべてのプロセッサを最も効率的に使用するように、コンカレンシーの程度を動的に拡大します。The TPL scales the degree of concurrency dynamically to most efficiently use all the processors that are available."

・・・と言う記述がありますし、PC のマルチコアを有効に使うという局面に限れば最強のように思えます。

しかし、Windows Forms のような GUI アプリではそうでもなさそうな感じです。自分が気が付いた限りですが、以下の 2 点が問題だと思いました。(自分が回避策を知らないだけという可能性は否定できませんが)

  1. 並列に実行される複数のメソッドがすべて完了するまで UI がブロックされる。
  2. 並列実行するメソッドには非同期版は使えない。

以下のサンプルコードの ParallelInvoke_Click メソッドは Parallel.Invoke を使って 5 つの同期版メソッド Work を並列に実行するものですが、5 つのメソッドがすべて完了するまで UI がブロックされるので、アプリはフリーズしたようになります。その他の TPL, PLINK を使った xxxxx_Click メソッドも同様で、完了するまで UI がブロックされます。それを回避する手段は、自分が探した限りですが、なさそうです。

さらに、非同期版メソッド WorkAsync は使えません。詳しくはサンプルコードのコメントに書きましたので見てください。なので、ライブラリなどで非同期版メソッドしか提供されてない場合は何ともならないと思われます。

というわけで、下のサンプルコードの WhenAll1_Click, WhenAll2_Click メソッドのように、Parallel.Invoke など使わないで、await Task.WhenAll(...) で待機するようにし、並列化については OS に任せるのが良さそうと思いました。(実際にサンプルを動かしてみると並列化してくれているような感じはしました。② のようになっている可能性は否定しきれませんが)

非同期版メソッド WorkAsync も使えます。下のサンプルコードの WhenAll2_Click メソッドを見てください。上の画像がその実行結果です。(ThreadID が 1 で UI スレッドと同じなのは、WorkAsync メソッドの中で ManagedThreadId を取得するのが UI スレッドだからです)

using System;
using System.Windows.Forms;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;

namespace WinFormsApp1
{
    public partial class TaskParallelLibrary : Form
    {
        public TaskParallelLibrary()
        {
            InitializeComponent();
        }

        // 同期版メソッド
        private string Work(int n)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            string retunVlaue =
                $"n = {n}, ThreadID = {id}, start:{DateTime.Now:ss.fff}, ";

            Thread.Sleep(3000);

            retunVlaue += $"end:{DateTime.Now:ss.fff}";

            return retunVlaue;
        }

        // 非同期版メソッド
        private async Task<string> WorkAsync(int n)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            string retunVlaue =
                $"n = {n}, ThreadID = {id}, start: {DateTime.Now:ss.fff}, ";

            await Task.Delay(3000);

            retunVlaue += $"end: {DateTime.Now:ss.fff}";

            return retunVlaue;
        }

        // 非同期版メソッド 5 つを for ループで逐次実行
        private async void InOrder_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            for (int n = 0; n < 5; n++)
            {
                this.label1.Text += await WorkAsync(n) + "\r\n";
            }

            this.label1.Text += "完了";
        }

        // 非同期版メソッド 5 つを Parallel.Invoke で実行
        // 終了まで UI はブロックされる
        private void ParallelInvoke_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            string[] results = new string[5];

            Parallel.Invoke(
                () => results[0] = Work(0),
                () => results[1] = Work(1),
                () => results[2] = Work(2),
                () => results[3] = Work(3),
                () => results[4] = Work(4));

            foreach (string result in results)
            {
                this.label1.Text += result + "\r\n";
            }

            this.label1.Text += "完了";

            // 非同期版メソッド WorkAsync は使えない

            // 以下のようにすると Task.Result でデッドロックになる
            //Task<string> result1 = null, result2 = null;
            //Parallel.Invoke(
            //    () => result1 = WorkAsync(0),
            //    () => result2 = WorkAsync(1));
            //this.label1.Text += result1.Result + "\r\n";
            //this.label1.Text += result2.Result + "\r\n";

            // 以下のようにすると await で待つことなく終わってしまう
            //string result1 = "", result2 = "";
            //Parallel.Invoke(
            //    async () => result1 = await WorkAsync(0),
            //    async () => result2 = await WorkAsync(1));
            //this.label1.Text += result1 + "\r\n";
            //this.label1.Text += result2 + "\r\n";

        }

        // 非同期版メソッド 5 つを Parallel.For で実行
        // 終了まで UI はブロックされる
        private void ParallelFor_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            string[] results = new string[5];

            Parallel.For(0, 5, n => results[n] = Work(n));

            foreach (string result in results)
            {
                this.label1.Text += result + "\r\n";
            }

            this.label1.Text += "完了";
        }

        // 非同期版メソッド 5 つを Parallel.ForEach で実行
        // 終了まで UI はブロックされる
        private void ParallelForEach_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            string[] results = new string[5];

            Parallel.ForEach(Enumerable.Range(0, 5), 
                             n => results[n] = Work(n));

            foreach (string result in results)
            {
                this.label1.Text += result + "\r\n";
            }

            this.label1.Text += "完了";
        }

        // 非同期版メソッド 5 つを PLINK で実行
        // 終了まで UI はブロックされる
        private void PLINK_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            var results = Enumerable.Range(0, 5).AsParallel().
                          Select(n => Work(n));

            foreach (string result in results)
            {
                this.label1.Text += result + "\r\n";
            }

            this.label1.Text += "完了";
        }

        // ***** 以下 TPL, PLINK に代えて Task.WhenAll を使用 *****

        // 同期版メソッド 5 つを Task.Run で実行、
        // await Task.WhenAll で待機        
        private async void WhenAll1_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            string[] results = new string[5];
            var taskList = new List<Task>();
            for (int n = 0; n < 5; n++)
            {
                int i = n;
                taskList.Add(Task.Run(() => results[i] = Work(i)));
            }

            // WaitAll は await で待機できないので注意
            await Task.WhenAll(taskList.ToArray());

            foreach (string result in results)
            {
                this.label1.Text += result + "\r\n";
            }

            this.label1.Text += "完了";
        }

        // 非同期版メソッド 5 つを実行、
        // await Task.WhenAll で待機
        private async void WhenAll2_Click(object sender, EventArgs e)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            this.label1.Text = $"UI Thread ID = {id}\r\n";

            Task<string>[] results = new Task<string>[5];
            var taskList = new List<Task>();
            for (int n = 0; n < 5; n++)
            {
                int i = n;
                taskList.Add(results[i] = WorkAsync(i));
            }

            await Task.WhenAll(taskList.ToArray());

            foreach (Task<string> result in results)
            {
                this.label1.Text += result.Result + "\r\n";
            }

            this.label1.Text += "完了";
        }

    }
}

Tags: , , , , ,

.NET Framework

ASP.NET MVC の非同期プログラミング

by WebSurfer 4. October 2020 15:50

ASP.NET MVC アプリで async / await を利用した非同期プログラミングで (1) 使用されるスレッドがどのようになるか、(2) Task.Result などを使った同期コードと非同期コードを混在させるとデッドロックは起きるのか、(3) ConfigureAwait メソッドでデッドロックは回避できるのかについて書きます。(.NET Framework アプリの話です。ASP.NET Core 3.1 MVC アプリの検証結果は別の記事に書きました)

ASP.NET MVC の非同期プログラミング

ちなみに ASP.NET Web Forms アプリ用の HTTP ハンドラで async / await を使って非同期呼び出しをする話は先の記事「非同期 HTTP ハンドラ (2)」に書きましたので興味があればそちらを見てください。

(1) 使用されるスレッド

ASP.NET で非同期プログラミングを行う目的はスレッドプールにある限られた数のスレッドを有効利用しスループットを向上するためです。そこが UI の応答性の向上を目的としている Windows Forms のような GUI アプリと違うところです。

ASP.NET アプリでは Web サーバーがクライアントから要求を受けるとスレッドプールからスレッドを確保して要求を処置します。非同期操作をしなければ、要求を受けてから応答を返すまで最初に確保したスレッドを保持し続けます。

Web アプリでは、外部のデータベースや Web API などにアクセスしてデータを取得するということが多いと思いますが、それに時間がかかる場合は一旦使っていたスレッドはスレッドプールに戻し、データ取得後の処理はスレッドプールから新たにスレッドを取得して行うようにすればスレッドプールのスレッドの有効利用が可能です。

そのあたりの詳細は Microsoft のドキュメント「ASP.NET の非同期/待機の概要」に図解入りで説明されているので見てください。

非同期プログラミングを行うと await 前後で実際にスレッドは違うのかを ASP.NET MVC アプリで試した結果が上の画像です。そのコードは以下の通りです。

public async Task<ActionResult> AsyncTest()
{
    ViewBag.Id1 = "開始時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    ViewBag.Id2 = await TimeCosumingMethod();

    ViewBag.Id3 = "終了時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    return View();
}

private async Task<string> TimeCosumingMethod()
{
    int id = Thread.CurrentThread.ManagedThreadId;

    await Task.Delay(3000);

    return "TimeCosumingMethod の戻り値, ID(IN)=" + id + 
           " / ID(OUT)=" + Thread.CurrentThread.ManagedThreadId;
}

上の画像の ID の数字 (ManagedThreadId) を見てください。TimeCosumingMethod メソッドの await 前後で ManagedThreadId が 6 から 7 に変わっています。ちなみに、Windows Forms のような GUI アプリでは await 前後いずれも UI スレッドになり ManagedThreadId は変わりません。

ASP.NET でも await で待機するときに現在のコンテキストがキャプチャされ、await 完了後はキャプチャしたコンテキストで続きの処理が行われるのは GUI アプリと同様だそうですが、await 前後で同じになるようにしているのはスレッドではなく HttpContext だそうです。それは仕組み上当たり前&そうせざるを得ないと思います。

(2) Task.Result でデッドロック

先の記事「await と Task.Result によるデッドロック」で書いたような Task.Result を使った同期コードと非同期コードを混在させるとデッドロックは起きるでしょうか?

その記事にも少し書きましたが、上のコードの await TimeCosumingMethod() を TimeCosumingMethod().Result に代えるとデッドロックは起きます。そのメカニズムは以下のようなことであろうと思います。

まず、TimeCosumingMethod().Result で 1 つの同期ブロックが待機中となる。呼び出された TimeCosumingMethod メソッドの await で待機する際にキャプチャされた「現在のコンテキスト」で await 完了後の同期処理を実行しようとする。しかし「現在のコンテキスト」では一度に実行するコードは 1 つのチャンクに限定されている。なので、Result プロパティでの待機が終わるまで await 完了後の同期処理は実行できない。結果デッドロックになる。

(3) ConfigureAwait でデッドロック回避

先の記事「ConfigureAwait によるデッドロックの回避」で書いたように、await 完了後の同期処理を実行するのに、await で待機する際にキャプチャした「現在のコンテキスト」ではなく、別のコンテキストで行えばデッドロックにはなりません。

ConfigureAwait メソッドの使用

以下のコードのように ConfigureAwait(false) を追加することにより、await 完了後の残り処理は、キャプチャしたコンテキストではなく、スレッドプールのコンテキストで処理されるのでデッドロックは回避でき、上の画像のとおり実行が完了します。

public async Task<ActionResult> AsyncTest()
{
    ViewBag.Id1 = "開始時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    ViewBag.Id2 = TimeCosumingMethod().Result;

    ViewBag.Id3 = "終了時, ID=" + 
                  Thread.CurrentThread.ManagedThreadId;

    return View();
}

private async Task<string> TimeCosumingMethod()
{
    int id = Thread.CurrentThread.ManagedThreadId;

    // ConfigureAwait(false) を追加するとデッドロックは回避できる
    await Task.Delay(3000).
        ConfigureAwait(continueOnCapturedContext: false);

    return "TimeCosumingMethod の戻り値, ID(IN)=" + id + 
           " / ID(OUT)=" + Thread.CurrentThread.ManagedThreadId;
}

ただし、スレッドは await 前後で同じになります。ということは、要求を受けた時に確保したスレッドを応答を返すまでずっと使い続けていたということで、スレッドの有効利用という ASP.NET の非同期の目的は果たせてないようです。

await 前後でスレッドが異なる場合は、await 前にキャプチャしたコンテキストを await 後でも使わないと HttpContext が渡せないが、continueOnCapturedContext: false ではそれができないので同じスレッドを使い続けざるを得ないということではないかと思います。

Tags: , , ,

MVC

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  February 2021  >>
MoTuWeThFrSaSu
25262728293031
1234567
891011121314
15161718192021
22232425262728
1234567

View posts in large calendar