構造化された同時実行

TL;DR:「構造化された同時実行」とは、関数が呼び出し元の前に完了することが保証されるのと同じように、子操作が親の前に完了することが保証されるように、非同期計算を構造化する方法を指します。 これは単純で退屈に思えますが、C++ ではまったく違います。構造化された同時実行性 (特に C++20 コルーチン) は、非同期アーキテクチャの正確さと単純さに大きな影響を与えます。これは、非同期の有効期間を通常の C++ レキシカル スコープに対応させることで、非同期プログラムに最新の C++ スタイルをもたらし、オブジェクトの有効期間を管理するための参照カウントを不要にします。

構造化プログラミングと C++

1950 年代にさかのぼると、初期のコンピューティング業界は構造化プログラミングを発見しました。レキシカル スコープ、制御構造、およびサブルーチンを備えた高レベル プログラミング言語により、テストを使用したアセンブリ レベルでのプログラミングよりもはるかに読みやすく、書きやすく、維持しやすいプログラムが得られました。 -and-jump 命令と goto .この進歩は非常に大きな飛躍であったため、構造化プログラミングについてはもはや誰も語っていません。それは単なる「プログラミング」です。

C++ は、他のどの言語よりも構造化プログラミングを徹底的に活用しています。オブジェクトの有効期間のセマンティクスは、スコープの厳密なネストを反映しており、これに結び付けられています。つまり、構造 あなたのコードの。関数アクティベーションのネスト、スコープのネスト、およびオブジェクトの有効期間のネスト。オブジェクトの存続期間はスコープの右中括弧で終わり、厳密なネストを維持するために、オブジェクトは構築の逆の順序で破棄されます。

最新の C++ プログラミング スタイルは、この構造化された基盤の上に構築されています。オブジェクトには値のセマンティクスがあります — これらは int のように動作し、リソースは決定論的にデストラクタでクリーンアップされます。これにより、リソースの有効期間が終了した後にリソースが使用されないことが構造的に保証されます。これはとても 重要です。

スコープとライフタイムのこの厳密なネストを放棄すると、たとえば、ヒープ上のオブジェクトを参照カウントするとき、またはシングルトン パターンを使用するとき、言語の強みに取り組むのではなく、それと戦っていることになります。

スレッドの問題

並行性が存在する中で正しいプログラムを作成することは、シングルスレッド コードの場合よりもはるかに困難です。これには多くの理由があります。理由の 1 つは、シングルトンや動的に割り当てられたオブジェクトなどのスレッドが、ネストされた小さなスコープを嘲笑することです。 以内で最新の C++ スタイルを使用できますが、 スレッドでは、ロジックとライフタイムが複数のスレッドに分散すると、プログラムの階層構造が失われます。シングル スレッド コードの複雑さ (特に、ネストされたスコープに関連付けられたネストされた有効期間) を管理するために使用するツールは、非同期コードに変換されません。

言いたいことを理解するために、単純な同期関数を非同期にするとどうなるか見てみましょう。

void computeResult(State & s);

int doThing() {
  State s;
  computeResult(s);
  return s.result;
}

doThing() 簡単です。いくつかのローカル状態を宣言し、ヘルパーを呼び出してから、何らかの結果を返します。おそらく時間がかかりすぎるため、両方の関数を非同期にしたいとします。問題ありません。継続チェーンをサポートする Boost 先物を使用しましょう:

boost::future<void> computeResult(State & s);

boost::future<int> doThing() {
  State s;
  auto fut = computeResult(s);
  return fut.then(
    [&](auto&&) { return s.result; }); // OOPS
}

以前に先物を使ってプログラミングしたことがあるなら、おそらく悲鳴をあげているでしょう「いやー!」 .then() 最後の行で、computeResult() の後に実行する作業をキューに入れます 完了します。 doThing() 結果の未来を返します。問題は、doThing() の場合です。 State の寿命を返します オブジェクトが終了し、継続がまだそれを参照している .これはダングリング リファレンスであり、クラッシュの原因となる可能性があります。

何がうまくいかなかったのですか?先物はまだ利用できない結果を計算することを可能にし、ブースト フレーバーは継続をチェーンすることを可能にします。ただし、継続は別のスコープを持つ別の関数です。多くの場合、これらの個別のスコープ間でデータを共有する必要があります。きちんとネストされたスコープも、ネストされたライフタイムもありません。次のように、状態の有効期間を手動で管理する必要があります。

boost::future<void>
computeResult(shared_ptr<State> s); // addref
                                    // the state

boost::future<int> doThing() {
  auto s = std::make_shared<State>();
  auto fut = computeResult(s);
  return fut.then(
    [s](auto&&) { return s.result; }); // addref
                                       // the state
}

どちらの非同期操作も状態を参照するため、状態を維持する責任を共有する必要があります。

これについて考える別の方法は次のとおりです。この非同期計算の寿命は? doThing() のときに開始します が呼び出されますが、継続するまで終了しません — ラムダが future.then() に渡されます - 戻り値。 その寿命に対応するレキシカル スコープはありません。 そして、それが私たちの苦悩の源です。

非構造化同時実行

エグゼキュータについて考えると、話はさらに複雑になります。 Executor は、実行コンテキストへのハンドルであり、スレッドやスレッド プールなどに作業をスケジュールできます。多くのコードベースにはエグゼキューターの概念があり、遅延やその他のポリシーを使用してスケジュールを設定できるコードベースもあります。これにより、計算を IO スレッド プールから CPU スレッド プールに移動したり、非同期操作を遅延して再試行したりするなど、クールなことを実行できます。便利だが goto のように これは非常に低レベルの制御構造であり、明確にするのではなく難読化する傾向があります。

たとえば、私は最近、リソースの非同期割り当てを再試行するエグゼキューターとコールバック (ここではリスナーと呼びます) を使用するアルゴリズムに出会いました。以下は大幅に簡略化したバージョンです。休憩後に記載しています。

// This is a continuation that gets invoked when
// the async operation completes:
struct Manager::Listener : ListenerInterface {
  shared_ptr<Manager> manager_;
  executor executor_;
  size_t retriesCount_;

  void onSucceeded() override {
    /* ...yay, allocation succeeded... */
  }
  void onFailed() override {
    // When the allocation fails, post a retry
    // to the executor with a delay
    auto alloc = [manager = manager_]() {
      manager->allocate();
    };
    // Run "alloc" at some point in the future:
    executor_.execute_after(
      alloc, 10ms * (1 << retriesCount_));
  }
};

// Try asynchronously allocating some resource
// with the above class as a continuation
void Manager::allocate() {
  // Have we already tried too many times?
  if (retriesCount_ > kMaxRetries) {
    /* ...notify any observers that we failed */
    return;
  }

  // Try once more:
  ++retriesCount_;
  allocator_.doAllocate(
    make_shared<Listener>(
      shared_from_this(),
      executor_,
      retriesCount_));
}

allocate() メンバー関数は、最初に、操作がすでに何度も再試行されているかどうかを確認します。そうでない場合は、ヘルパー doAllocate() を呼び出します 関数、成功または失敗のいずれかで通知されるコールバックを渡します。失敗すると、ハンドラーは延期された作業をエグゼキューターにポストし、エグゼキューターは allocate() を呼び出します。 戻るため、遅延して割り当てを再試行します。

これは非常にステートフルで、やや遠回りの非同期アルゴリズムです。ロジックは多くの関数といくつかのオブジェクトにまたがっており、制御とデータ フローは明らかではありません。オブジェクトを存続させるために必要な複雑な参照カウントのダンスに注意してください。作業をエグゼキューターに投稿すると、さらに難しくなります。このコードのエグゼキュータには継続の概念がないため、タスクの実行中に発生したエラーは行き場がありません。 allocate() 関数は、プログラムのどの部分でもエラーから回復できるようにしたい場合、例外をスローしてエラーを知らせることはできません。エラー処理は、帯域外で手動で行う必要があります。キャンセルをサポートしたい場合も同様です。

これは非構造化同時実行です :アドホックで非同期操作をキューに入れます ファッション;依存する作業を連鎖させ、継続または「ストランド」エグゼキューターを使用して、順次一貫性を強制します。また、強い参照カウントと弱い参照カウントを使用して、データが不要になるまでデータを存続させます。タスク A がタスク B の子であるという正式な概念はなく、子タスクが親の前に完了することを強制する方法もありません。また、「これがアルゴリズムです」と指して言うことができるコード内の場所もありません。

その非局所的な不連続性により、正確性と効率性について推論することが難しくなります。構造化されていない並行性を、多数の同時リアルタイム イベントを処理するプログラム全体にまで拡張し、アウトオブバンドの非同期制御とデータ フローを手動で処理し、共有状態への同時アクセスを制御し、オブジェクトの有効期間を管理するという付随的な複雑さが圧倒的になります。

構造化された同時実行

コンピューティングの黎明期に、非構造化プログラミング スタイルが急速に構造化スタイルに取って代わられたことを思い出してください。 C++ へのコルーチンの追加により、現在、非同期コードで同様のフェーズ シフトが発生しています。上記の再試行アルゴリズムを (Lewis Baker の人気のある cppcoro ライブラリを使用して) コルーチンの観点から書き直すと、次のようになります。

// Try asynchronously allocating some resource
// with retry:
cppcoro::task<> Manager::allocate() {
  // Retry the allocation up to kMaxRetries
  // times:
  for (int retriesCount = 1;
       retriesCount <= kMaxRetries;
       ++retriesCount) {
    try {
      co_await allocator_.doAllocate();
      co_return; // success!
    } catch (...) {}

    // Oops, it failed. Yield the thread for a
    // bit and then retry:
    co_await scheduler_.schedule_after(
      10ms * (1 << retriesCount));
  }

  // Error, too many retries
  throw std::runtime_error(
    "Resource allocation retry count exceeded.");
}

これがどのように改善されたかを挙げてみましょう:

<オール>
  • すべてが 1 つの機能に組み込まれています。良い地域。
  • 状態 (retriesCount など) ) は、参照カウントが必要なオブジェクトのメンバーとしてではなく、ローカル変数で維持できます。
  • 通常の C++ エラー処理技術を使用できます。
  • allocator_.doAllocate() への非同期呼び出しが構造的に保証されています この関数が実行を継続する前に完了します。
  • ポイント(4)には深い意味があります。記事の冒頭にある些細な例を考えてみましょう。コルーチンに関する次の再実装は完全に安全です:

    cppcoro::task<> computeResult(State & s);
    
    cppcoro::task<int> doThing() {
      State s;
      co_await computeResult(s);
      co_return s.result;
    }
    

    computeResult であることがわかっているため、上記のコードは安全です。 doThing より前に完了 再開され、したがって s の前に

    キャンセル

    並行操作の有効期間が、使用するリソースの有効期間内に厳密にネストされ、プログラム スコープに関連付けられる、同時実行に対する構造化されたアプローチを採用すると、shared_ptr のようなガベージ コレクション手法を使用する必要がなくなります。 寿命を管理する。これにより、ヒープ割り当てとアトミック参照カウント操作が少なくて済む、より効率的なコード、および推論が容易でバグが発生しにくいコードが得られます。ただし、このアプローチの 1 つの意味は、親操作が完了する前に、常に子操作に参加して待機する必要があることを意味します。これらの子操作から切り離して、参照カウントがゼロになったときにリソースを自動的にクリーンアップすることはできなくなりました。結果が不要になった子操作を不必要に長時間待機する必要がないようにするには、それらの子操作をキャンセルして迅速に完了するメカニズムが必要です。したがって、構造化された同時実行モデルでは、不必要な待ち時間の発生を避けるために、キャンセルの深いサポートが必要です。

    参照によってローカル変数を子コルーチンに渡すたびに、構造化された有効期間と構造化された同時実行性に依存していることに注意してください。親コルーチンがそのローカル変数のスコープを出て破棄する前に、子コルーチンが完了し、そのオブジェクトを使用していないことを確認する必要があります。

    構造化された同時実行> コルーチン

    私が「構造化された並行性」について話すとき、コルーチンについて話しているだけではありません。私が言いたいことを理解するために、コルーチンとはについて簡単に説明しましょう そうでないもの .特に、C++ コルーチンには本質的に並行性はまったくありません。これらは実際には、関数をコールバックに分割するようコンパイラに指示する方法にすぎません。

    上記の単純なコルーチンを考えてみましょう:

    cppcoro::task<> computeResult(State & s);
    
    cppcoro::task<int> doThing() {
      State s;
      co_await computeResult(s);
      co_return s.result;
    }
    

    co_await とは とはどういう意味ですか?陳腐な答えは、cppcoro::task<> の作成者を意味します。 それが意味することを望んでいます(特定の範囲内で)。より完全な答えは co_await です 現在のコルーチンを一時停止し、残りのコルーチンをまとめます (ここではステートメント co_return s.result; )継続として、それを待機可能なオブジェクトに渡します(ここでは、 task<> computeResult(s) によって返されます )。その awaitable は通常、子タスクが完了したときに後で呼び出すことができるように、どこかに保存します。それが cppcoro::task<> です たとえば、

    つまり、task<> タイプとコルーチン言語機能が共謀して、退屈な古いコールバックの上に「構造化された同時実行」を重ねます。それでおしまい。それが魔法です。それはすべて単なるコールバックですが、非常に特定のパターンのコールバックであり、これを「構造化」するのはそのパターンです。このパターンにより、子の操作が親の前に完了し、そのプロパティが利益をもたらします。

    構造化された同時実行が実際には特定のパターンでのコールバックにすぎないことを認識すると、コルーチンなしで構造化された同時実行を実現できることがわかります。 .もちろん、コールバックを使用したプログラミングは新しいものではなく、パターンをライブラリにコード化して再利用可能にすることができます。それが libunifex の機能です。 C++ 標準化に従う場合、それは Executors 提案からの送信者/受信者の抽象化が行うことでもあります。

    構造化された同時実行の基礎として libunifex を使用すると、上記の例を次のように記述できます。

    unifex::any_sender_of<> computeResult(State & s);
    
    auto doThing() {
      return unifex::let_with(
        // Declare a "local variable" of type State:
        [] { return State{}; },
        // Use the local to construct an async task:
        [](State & s) {
          return unifex::transform(
            computeResult(s),
            [&] { return s.result; });
        });
    }
    

    コルーチンがあるのに、なぜ誰かがそれを書くのでしょうか?確かに正当な理由が必要ですが、いくつか思いつきます。コルーチンを使用すると、コルーチンが最初に呼び出されたときに割り当てが行われ、再開されるたびに間接的な関数呼び出しが行われます。コンパイラはそのオーバーヘッドを排除できる場合もありますが、そうでない場合もあります。コールバックを直接使用することで (構造化された同時実行パターンで)、トレードオフなしでコルーチンの多くの利点を得ることができます。

    ただし、このスタイルのプログラミングには別のトレードオフがあります。同等のコルーチンよりも読み書きがはるかに困難です。私は、将来的にはすべての非同期コードの 90% 以上を単に保守性のためにコルーチンにするべきだと考えています。ホットコードの場合、コルーチンを低レベルの同等のものに選択的に置き換え、ベンチマークを参考にしてください。

    同時実行

    前述のとおり、コルーチンは本質的に同時実行ではありません。それらはコールバックを記述する方法にすぎません。コルーチンは本質的にシーケンシャルであり、task<> の遅延性 タイプ — コルーチンが一時停止を開始し、待機するまで実行を開始しない — は、プログラムに並行性を導入するために使用できないことを意味します。既存の future ベースのコードは、多くの場合、操作がすでに熱心に開始されていると想定し、アドホック を導入します。 プルーニングを慎重に行う必要がある同時実行性。そのため、その場しのぎで同時実行パターンを何度も再実装する必要があります。 ファッション。

    構造化された同時実行では、同時実行パターンを再利用可能なアルゴリズムに成文化して、構造化された方法で同時実行を導入します。たとえば、task がたくさんあるとします。 すべてが完了するまで待って、結果を tuple で返したいと考えています。 、それらすべてを cppcoro::when_all に渡します と co_await 結果。 (Libunifex には when_all もあります アルゴリズム)

    現在、cppcoro も libunifex も when_any を持っていません。 そのため、一連の同時操作を起動して、最初のときに戻ることはできません 1つが完了します。ただし、これは非常に重要で興味深い基本アルゴリズムです。構造化された並行性の保証を維持するために、最初の子タスクが完了すると when_any 他のすべてのタスクのキャンセルを要求する必要がありますそして、それらがすべて終了するのを待ちます .このアルゴリズムの有用性は、プログラム内のすべての非同期操作がキャンセル要求に迅速に応答することに依存しています。これは、現代の非同期プログラムにおけるキャンセルの深いサポートがいかに重要であるかを示しています。

    移行

    これまで、構造化された並行性とは何か、そしてそれが重要な理由について説明してきました。そこにたどり着く方法については話し合っていません。すでにコルーチンを使用して非同期 C++ を記述している場合は、おめでとうございます。 理由をより深く理解し、感謝することで、構造化された同時実行の利点を享受し続けることができます。 コルーチンは非常に変革的です。

    構造化された並行性、キャンセルの深いサポート、または非同期の抽象化さえも欠いているコードベースの場合、その仕事は困難です。 紹介から始めることもできます 構造化された同時実行パターンが必要とする保証を周囲のコードが提供する島を切り開くために、複雑さを軽減します。これには、たとえば、インプレッションの作成が含まれます 基礎となる実行コンテキストがそれを直接提供しない場合でも、スケジュールされた作業の迅速なキャンセルの。その追加された複雑さはレイヤーに分離でき、構造化された並行性のアイランドをその上に構築できます。次に、簡素化作業を開始し、フューチャー スタイルまたはコールバック スタイルのコードを取得してコルーチンに変換し、親子関係、所有権、および有効期間を引き出します。

    まとめ

    co_await を追加 計算の構造を乱すことなく、同期関数を非同期にします。待機中の非同期操作は、通常の関数呼び出しと同様に、呼び出し元の関数が完了する前に必ず完了します。革命:何も変わらない .スコープとライフタイムは、スコープが時間的に不連続であることを除いて、以前と同じようにネストされます。生のコールバックと先物では、その構造は失われます。

    コルーチン、およびより一般的な構造化された同時実行は、最新の C++ スタイルの利点 (値のセマンティクス、アルゴリズム駆動の設計、決定論的なファイナライズによる明確な所有権のセマンティクス) を非同期プログラミングにもたらします。これは、async ライフタイムを通常の C++ レキシカル スコープに結び付けるためです。コルーチンは、非同期関数を一時停止ポイントでコールバックに分割します。コールバックは、スコープ、有効期間、および関数のアクティブ化の厳密なネストを維持するために非常に特殊なパターンで呼び出されます。

    co_awaitをふりかける エラー処理の例外、ローカル変数の状態、リソースを解放するためのデストラクタ、値または参照によって渡される引数、および適切で安全で慣用的なモダンの他のすべての特徴です。 C++.

    読んでくれてありがとう。

    C++ の構造化された同時実行について詳しく知りたい場合は、Lewis Baker の 2019 年の CppCon 講演を必ずチェックしてください。

    "\e"