catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

UWPで非同期taskを書くときに気を付けること

まだまだtaskの操作に慣れていません。かたりぃなです。

今日はUWPのコードをC++/cxで書くにあたって、詰まったポイントと解決策を書いてみます。

UWPでの非同期taskとは

ムーアの法則の限界が叫ばれてからCPUはマルチコア時代に入っています。モバイル端末でさえも。

CPU資源の有効活用を考えたとき、マルチスレッド・マルチプロセスなコードにすればCPU資源を有効活用できるよねと概念レベルで言うだけならタダですが、実際にやってみると難しいことがあります。

難しいポイントとしては、例えば従来型の手続き型プログラミングの延長でマルチスレッド・マルチプロセスをやろうとすると、次のような問題に直面します。

  • スレッドやプロセス間のデータ受け渡し
  • スレッドの同期
  • スレッド立ち上げのコストと、並列実行のトレードオフ

特にC/C++のような手続き型・オブジェクト指向な言語でこれらを乗り越えようとすると相当キツイです。

設計上はうまく作ったつもりでも、マルチスレッド・マルチプロセスではテスト難しくなってきて「動くこともあるけど、動かないこともある」なんてことが簡単に起きます。

こういった類の問題はテストでは再現が難しい問題になりがちなので、相当タチが悪いバグになってプログラマを悩ませます。

Windowsアプリではこの問題に対する多くのアプローチがあります。

今回はその中でもtaskをどうやってうまく扱うかという問題に焦点をあてて分析してみます。

いわゆるMicrosoft-PPLです。

公式ドキュメントはこのあたりです。

https://msdn.microsoft.com/ja-jp/library/dd492418.aspx

https://msdn.microsoft.com/ja-jp/library/dd492418.aspx

taskを返す関数=高階関数としてとらえてコードを書く

これは私なりの結論です。

taskとは何かということを考えてコードを書くとき、それは関数であり、taskを生成する関数とは関数を返す関数、いわゆる高階関数だという解釈です。

※あくまで概念レベルで「ああ、私が欲しかったの、こういうやつだ」と感じただけの話なので、その世界で本気でやってる人からは異論あるとは思います。

高階関数の概要はwikipediaで一行で簡単に述べられています。 https://ja.wikipedia.org/wiki/%E9%AB%98%E9%9A%8E%E9%96%A2%E6%95%B0

要は

  • 関数を引数にとる関数
  • 関数を戻り値にできる関数

です。

というわけで 以下にコードと概念を整理します。

PPLでは、従来の関数と呼ばれてきたものを「タスク」として定義できる

タスクとはppl::concurrency::taskテンプレートクラスによってラップされた関数です。

ここではラムダをtaskで包むことに焦点をあてます。実際そういう使い方がほとんどですし。

PPLでタスクを作るには2つの方法がありました。

taskクラスのコンストラクタを使う方法

たとえば整数のリストを受け取って合計を出す関数を考えたとき

auto sum_lambda = [](std::vector<int> nums) -> int {
    int s = 0;
    for(auto val : nums){s += val;}
    return s;
};
auto sum_task = task(sum_lambda(arg_list));

こんな感じになります。 ここでは分けて書きましたが、以下のようにtaskクラスのコンストラクタに直接ラムダを渡してしまうほうが便利かつ安全です。

auto sum_task = task([](std::vector<int> nums) -> int {
    int s = 0;
    for(auto val : nums){s += val;}
    return s;
});

この例を手続き型orオブジェクト指向の考え方のまま読むと「sum_taskはtaskクラスのインスタンス」になります。

しかし「合計を求める"関数"をインスタンス化した」と考えたほうが後々スッキリします。

create_task関数を使う

関数をcreate_task()に渡すことでコンストラクタと同じようにtaskクラスを作ることができます。

UWPのAPI呼び出しなんかは、この関数を使って書かれているサンプルが多かった印象です。 APIの戻り値型はAPIごとに異なっていますが、taskクラステンプレートに戻り値型が適用されるため、あんまり気にせずautoで受ければいいかなと思っています。

ただし、普通は後続の処理(後述のthen)で型を明示的に指定するので、taskクラスインスタンスを直接どうこうするということは意識せずとも良さそうです。

たとえばファイルを開くタスクを作るコードはこうなります。

auto file_get_task = create_task(StorageFile::GetFileFromApplicationUriAsync(uri));

GetFileFromApplicationUriAsyncが返してきたタスクが生成されます。

タスクを実行する

上記の方法で、タスクを作ることはできました。 次は作ったタスクを実行する必要があります。

taskがラップしている関数であっても、C/C++のふつうの関数と同じです。 関数定義だけ書いてもどこかから実行してもらわなければ意味がありません。

wait, when_all, when_anyなどでタスクの実行完了を待つことができます。 結果を拾いたいときはget()で。

上記の方法で生成したタスクに対してそれぞれ呼び出すだけです。

waitとgetは単一のタスクの終了を待つものです。

たとえばこんな風に。

auto sum_task = task([](std::vector<int> nums) -> int {/* 略*/}
sum_task.wait();

when_allは複数のタスクが完了するのを待つ関数です。when_anyは複数のタスクの完了を待つという点では同様ですが、「いずれかが完了するのを待つ」関数です。 以下のコードではテクスチャの読み込み/デコードを並列実行可能なタスクにしたものです。

すべてが並列に実行される保証はありませんが、一例として。

 std::vector< task<void> >  texture_read_tasks;
    for (int i = 0; i < texture_num; i++) {
        auto readtask = task([](int num){/*ごにょごにょ*/});
        texture_read_tasks.push_back(readtask);
    }
    when_all(texture_read_tasks.begin(), texture_read_tasks.end()).then([]() {
        OutputDebugString(L"texture load/decode success\n");
        return;
    });

タスクとタスクをくっつけるthen

UWPのC++/cxでも従来の手続き型のように記述していきたいです。

従来の手続き型のように記述したいというのは、入出力の依存関係があって並列化できないケースなどがわかりやすいです。 たとえばファイル操作のopen/read-write/closeなんかが該当します。

こういうときに役立つのがtaskクラスのメソッドthenです。 タスクの後続タスクを定義するものです。

taskのthenの説明の前にコードを書くときの論理レベルで考えると

  • ファイルを開くタスク
  • ファイルハンドルを使って読みだすタスク
  • ファイルハンドルを閉じるタスク

とタスクを定義できます。これらのタスクの実行は、順序が大切です。

thenはあるタスクの後続タスクを定義するものなので、こういった場面で必須になります。

thenによって数珠つなぎにされたタスクをタスクチェーンと呼ぶらしいです。

タスクチェーンを実行する

thenが返してくるのもタスクです。 どのようなタスクでも実行してあげる必要があります。作ったまま放置ではいけません。

まだ完全に把握できていませんが、私が書いたコードについていえば、task関連の実行時エラーの原因の大半は作ったまま放置でした。

というわけでタスクチェーンの実行です。 これは末尾タスクの終了を待つだけで良いです。

タスクチェーンで「末尾のタスク完了を待つ」ということは、タスクチェーン全体が実行されるのを待つことに相当します。

ここで勘違いしていてすごく詰まったのですが、thenは「後続タスクを定義する」だけであって、「実行する」わけではないです。

なので、作ったタスクチェーンは誰かが実行してあげなければいけません。 (もしくはフレームワークのどこかで一括して実行する機構があるなど)

ラムダを使う理由

タスクを作るのにどうしてラムダを多用するのだろうと自分なりに考えてみました。

私なりの結論としては「task間のデータ受け渡しが安全である」ためと考えました。

C++のラムダは、定義した位置にクロージャオブジェクトが生成されます。 コンストラクタも生成されるので、それを使ってデータ受け渡しが行われます。

すなわち、ラムダの引数がtaskへ受け渡すデータになります。イメージとしてはプロセスへメッセージを送るというほうがしっくりきます。

ここで「安全」といっているのは「taskに渡すデータそのものが競合していない」という前提があったうえでの話です。

その前提を守ったうえでの安全です。

なお「ラムダ使ううえで、これは避けましょう」というのはMS公式からも提示されていました。

https://msdn.microsoft.com/ja-jp/library/dd492427.aspx

要は「taskに渡したラムダの実行完了前に寿命が尽きるオブジェクト(スタック上の変数とか)をキャプチャしないでね」ということですね。

これらの情報をもとに色々コード書いて試行錯誤した結果、スマートポインタ系をラムダの引数に渡すのが一番よさそうだと考えています。

ただし、スマートポインタとはいえ、もしstd::shared_ptrをtaskに受け渡す必要に迫られた場合、「それの寿命が尽きないこと」は言語側で保証できますが「競合をしていないこと」はプログラマが保証しなければいけないので注意が必要です。

理想的な設計

ここまででtaskの基本的な扱いができるようになりました。 次に一歩進んでキレイな設計とは何だろうということについて考えます。

宗教観とか時代の流れとかあるので、ここでの答えはあくまで現時点での私なりの答えです。

関数(をラップしたタスク)を返す関数

急に関数型言語っぽくなりましたね。関数型言語の世界でいう高階関数っぽいものです。

従来の手続き型プログラミングのように値やオブジェクトを返すのではなく関数を返そうみたいなアプローチです。

どうしてこれが良さそうと考えたかというと、

  • 色々試行錯誤した結果から
  • MSのサンプルコードでもこの方式が多い
  • タスクチェーンは呼び出し元が組み立てたいから

最後の以外あまり説得力ないので、最後のだけちょっと書きます。

タスクチェーンを呼び出し元で組み立てたい理由は、従来の手続き型プログラミングでの「ある関数の実行結果を使って、後続の関数を実行する」ように記述していたプログラミング方法の"関数"を"タスク"に置き換えたいからです。

混ぜるな危険

もし「順次処理の関数」と「PPLタスク」を混ぜると次のような面倒な事になってしまいます。

  • 手続き実行結果をもとに後続タスクを実行する
  • タスクの実行結果をもとに手続きを実行する

こういうコードを作ってしまうと「どこがタスクとして実行されて、どこが手続きとしてメインスレッドがら実行されるのか」が見えにくい・わかりにくいものができました。

順次処理とtaskを混ぜたコードは、一応完成しましたが、ちょっと機能拡張しようとかやりはじめたときに手も足もでなくなりました。

taskを返さずに、手続き関数の中でwaitしてはどうかと試みましたが、そうしてしまうと今度は「どの関数がブロッキングで、どの関数がノンブロッキングなのか」がわからなくなりました。

つまりデバッグできないのです。こういうコードは廃棄処分です。

というわけで、UWPの枠組みでやるならできるだけtaskにしたほうがスッキリします。UWPのC++API自体もtask返してくるのが多いですし。

感想

今まで何となくサンプルコードを真似して書いていたtaskですが、高階関数の概念のおかげでスッキリしました。

taskを使って競合を避けるコードを書こうとすると、どことなく関数型言語っぽくなってきた気がするので、また別途記事を書く予定です。

長くなりましたが今回はこれくらいで。