WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

タスク並列ライブラリ (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

ConfigureAwait によるデッドロックの回避

by WebSurfer 21. September 2020 13:22

先の記事「await と Task.Result によるデッドロック」の続きです。この記事には、先の記事で述べたデッドロックを回避する方法を書きます。

(5) ConfigureAwait

Task.ConfigureAwait(Boolean) メソッドを使って、先の記事で述べた Task.Result プロパティを使ったコードにおけるデッドロックを回避することができます。(UI がフリーズするのは避けられませんが)

ConfigureAwait メソッドの使用

Microsoft のドキュメント「非同期プログラミングのベストプラクティス」に以下の説明があります。

"既定では、未完了の Task を待機するときは、現在のコンテキストがキャプチャされ、Task が完了するときのメソッドの再開に使用されます。 ・・・中略・・・ GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext があります。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。"

初めは Task.Result で待機しているスレッドを await 終了後に使おうとするからデッドロックが起こるのだろうと思っていましたが、それでは ASP.NET のケースが説明できません。ASP.NET で非同期にする目的はスレッドプールのスレッドの有効利用で、await 前までに使っていたスレッドはスレッドプールに戻し、await が完了後の処理はスレッドプールから新たにスレッドを取得して行いますので(ASP.NET の場合 await 前後でスレッドが異なるということ)。

少なくとも ASP.NET の場合、使われるスレッドが await 前後で同じかどうかはデッドロックに関係なく、上に書いた「一度に実行するコードを 1 つのチャンクに限定する」が関係しているようです。

ということは、GUI アプリ / ASP.NET アプリどちらにせよ、await が完了した後の残りの処理を、await で待機する際にキャプチャした「現在のコンテキスト」ではなく、別のコンテキストで行えばデッドロックにはならないはずです。

Microsoft のドキュメント「非同期プログラミングのベストプラクティス」に以下のように書いてあります。

"ConfigureAwait(continueOnCapturedContext: false) を追加すると、デッドロックが回避されます。この場合、await が完了するとき、スレッドプールのコンテキスト内で async メソッドの残り処理の実行が試みられます。このメソッドは完了でき、返されたタスクを完了するため、デッドロックは発生しません。"

というわけで、ConfigureAwait メソッドを使って以下のコードで試してみました。上の画像を表示したものです。

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsAsyncTest
{
    public partial class Form3 : Form
    {
        public Form3()
        {
            InitializeComponent();
        }

        // Task.Result 使用
        private void button1_Click(object sender, EventArgs e)
        {
            label1.Text = "UI スレッド、ID=" + 
                Thread.CurrentThread.ManagedThreadId;

            label2.Text = TimeCosumingMethod().Result;

            label1.Text += " / ID=" +
                Thread.CurrentThread.ManagedThreadId;
        }

        // await 使用
        private async void button2_Click(object sender, EventArgs e)
        {
            label1.Text = "UI スレッド、ID=" +
                Thread.CurrentThread.ManagedThreadId;

            label2.Text = await TimeCosumingMethod();

            label1.Text += " / ID=" +
                Thread.CurrentThread.ManagedThreadId;
        }

        private async Task<string> TimeCosumingMethod()
        {
            await Task.Delay(3000).ConfigureAwait(
                continueOnCapturedContext: false);

            return "TimeCosumingMethod の戻り値、ID=" +
                Thread.CurrentThread.ManagedThreadId;
        }
    }
}

上のコードのように ConfigureAwait(continueOnCapturedContext: false) を追加すると、TimeCosumingMethod メソッドで await Task.Delay(3000) が完了した後の残りの処理は、await で待機する際にキャプチャした「現在のコンテキスト」ではなく、スレッドプールのコンテキスト内で実行されるためデッドロックは回避できるということのようです。

上の画像を見てください。UI スレッドの ManagedThreadId(上の画像では ID=1)と TimeCosumingMethod メソッドの残りを実行したスレッド(上の画像では ID=4)が異なっています。

ただし、Task.Result を使う方は UI がフリーズするのは避けられません。一方、await を使う方は UI はフリーズしません(メッセージループは常に動いていて、マウスのクリックなどのユーザーイベントを処理して UI に反映してくれます)。

理由は、Task.Result は UI スレッドをブロックして TimeCosumingMethod メソッドが完了するのを同期的に待機するのに対して、await は呼び出し元に処理を戻して非同期的に待機するからのようです。

@IT の記事「第2回 非同期メソッドの構文」に以下の説明があります。

"await という名前から、「渡した非同期タスクが完了するまで、呼び出し元スレッドをブロックして待機する」というように感じるかもしれないが、実際はそうではない。await 演算子の意味は、「待っているタスクがまだ完了していない場合、メソッドの残りをそのタスクの『継続』として登録して呼び出し元に処理を戻し、タスクが完了したら登録しておいた継続処理を実行すること」なので注意が必要だ。"

Windows Forms アプリでは「Application.Run メソッド」によって UI スレッドで標準のメッセージループの実行が開始されるそうです。

なので、Task.Result を使う方は、UI スレッドがブロックされている間(上のコード例では約 3 秒間)はメッセージループは動いておらず、マウスのクリックなどのユーザーイベントには無反応(UI がフリーズ状態)になるということのようです。

Tags: , , , ,

.NET Framework

await と Task.Result によるデッドロック

by WebSurfer 20. September 2020 12:50

先の記事「非同期プログラミング」の続きで、非同期プログラミングに関して調べたことを備忘録的に書いたものです。

(4) デッドロック

先の記事の「(3) 訳が分からなかった話」に書いた Task.Result プロパティを使ったコードを Windows Forms のような GUI アプリに使うとデッドロックになります。(注: 先の記事のコンソールアプリではデッドロックになることはありません。理由後述)

Windows Forms アプリのコードでそのあたりを分かりやすく書くと以下のような感じです。コメントに「// デッドロック」と書いた方のメソッドを実行するとデッドロックが発生してアプリは固まってしまいます。

using System;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsAsyncTest
{
    public partial class Form3 : Form
    {
        public Form3()
        {
            InitializeComponent();
        }

        // デッドロック        
        private void button1_Click(object sender, EventArgs e)
        {
            label1.Text = "";
            string str = TimeCosumingMethod().Result;
            label1.Text = str;
        }

        // 正常動作
        private async void button2_Click(object sender, EventArgs e)
        {
            label1.Text = "";
            string str = await TimeCosumingMethod();
            label1.Text = str;
        }

        private async Task<string> TimeCosumingMethod()
        { 
            await Task.Delay(3000);
            return "TimeCosumingMethod の戻り値";
        }
    }
}

ちなみに ASP.NET Web アプリでも、Task.Result を使った同様なコードで、デッドロックが発生してアプリは固まってしまいます。

その理由は Microsoft のドキュメント「非同期プログラミングのベストプラクティス」の「すべて非同期にする」のセクションに書いてあります。

・・・が、自分はその説明を読んでもよく分かりませんでした。(汗) 自分の独断と偏見による個人的解釈まじりですが、以下のようなことになっているのであろうと想像しています。

Microsoft のドキュメント「Task<TResult>.Result プロパティ」と「Task.Wait メソッド」に以下の説明があります。

"Task.Result プロパティの get アクセサーにアクセスすると、非同期操作が完了するまで呼び出し元のスレッドがブロックされます。これは、Wait メソッドを呼び出すことと同じです。"

"Wait は、現在のタスクが完了するまで呼び出し元のスレッドを待機させる同期メソッドです。"

また、Microsoft のドキュメント「非同期プログラミングのベストプラクティス」に以下の説明があります。

"既定では、未完了の Task を待機するときは、現在のコンテキストがキャプチャされ、Task が完了するときのメソッドの再開に使用されます。 ・・・中略・・・ GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext が使われます。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。しかし、このコンテキストは既にその内部にスレッドを持っており、これは asyncメソッドが完了するのを (同期して) 待機します。それらは、それぞれもう一方を待機し、デッドロックを引き起こします。"

ということで、以下のようになっているのだろうと思いました。

  1. まず、上のコードの「デッドロック」とコメントしてある button1_Click メソッドに注目。その中の Task.Result プロパティを使っているところで、呼び出し元のスレッド(UI スレッド)は TimeCosumingMethod メソッドが完了するのを同期的に待機する。

    注: 上に書いた公式ドキュメントには Task.Result で「非同期操作が完了するまで呼び出し元のスレッドがブロックされます」とありますが、実際にコードを書いて検証してみると、呼び出し先の TimeCosumingMethod メソッドの await の前まで呼び出し元のスレッド(上の例では UI スレッド)で実行され、await の行に制御が移るとそこでデッドロックになります。
  2. 呼び出された TimeCosumingMethod メソッドに制御が移り、その中の await Task.Delay(3000); の await で待機するとき現在のコンテキストがキャプチャされ、await が完了した後はキャプチャしたコンテキストで残りを実行しようとする。  
  3. Windows Forms アプリでは、先の記事の「(2) Windows Forms アプリの ManagedThreadId」の例のとおり、スレッドの ManagedThreadId の値は変わらない。即ち、await で待機するときキャプチャする「現在のコンテキスト」および await 完了後に使われるキャプチャしたコンテキストでは同じ UI スレッドが使われ続けている。
  4. ということは、TimeCosumingMethod メソッドで await 完了後に使われる「キャプチャした現在のコンテキスト」のスレッドは、ステップ 1 で待機しているスレッドと同じということになる。
  5. ステップ 1 で待機しているスレッドは TimeCosumingMethod メソッドが完了しないと解放されないが、TimeCosumingMethod メソッド内の await が完了した時点では解放されていないので、その場所で開放されるのを永遠に待つことになってデッドロックに陥る。    

Windows Forms のような GUI アプリの場合は、実際にコードを書いての検証結果から、上に書いた通り UI スレッドが使われるのを確認しており、上記 1 ~ 5 で説明になっていると思います。

が、ASP.NET のケースが上記 1 ~ 5 では説明できません。ASP.NET で非同期にする目的はスレッドプールのスレッドの有効利用で、await 前までに使っていたスレッドはスレッドプールに戻し、await 完了後の処理はスレッドプールから新たにスレッドを取得して行いますので。(ASP.NET の場合 await 前後でスレッドが異なるということです)

Windows Forms アプリと異なり、ASP.NET アプリでは使うスレッドがどうなっているかより「一度に実行するコードを 1 つのチャンクに限定する」の方が関係しているのかも。つまり、以下のようなことではないかと思っています。

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

Windows Forms アプリでデッドロックする理由も上記のようなことなのかもしれません。が、今はこれ以上調べる気力がないです。もし何か分かったら追記します。


一方、コンソールアプリでは SynchronizationContext.Current は null になります。ということは await で待機する際にキャプチャできる「現在のコンテキスト」は存在せず、await 完了後の残りの処理は ASP.NET や Windows Forms アプリとは異なるようです。「非同期プログラミングのベストプラクティス」に以下のように書いてあります。

"コンソールアプリケーションでは、一度に 1 つのチャンクに制限する SynchronizationContext ではなく、スレッドプールを備えた SynchronizationContext を使用するため、await が完了するとき、スレッド プールのスレッドで async メソッドの残り処理のスケジュールが設定されます。"

上のステップ 2 のところで TimeCosumingMethod メソッドの残りを実行するのは「スレッドプールを備えた SynchronizationContext」になるようです。await で待機するときにキャプチャした「現在のコンテキスト」ではないのでデッドロックにならないということのようです。


最後に、上のサンプルコードで「// 正常動作」とコメントしたように Task.Result を使わないで 2 ヶ所で await した場合はデッドロックにはならないのは何故でしょう?

参考にさせていただ記事「ASP.NET の非同期でありがちな Deadlock を克服する」に以下のように書いてありました。

"AspNetSynchronizationContext に Post された 2 つの同期ブロックを、例の医者と患者の方式で順番に処理してくれるから"

・・・だそうですが、正直言ってその説明では分かりませんでした。(汗) はっきりした理由が分かったら追記します。


ConfigureAwait によるデッドロックの回避に続く。

Tags: , , , , ,

.NET Framework

About this blog

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

Calendar

<<  May 2021  >>
MoTuWeThFrSaSu
262728293012
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar