次の方法で共有


この記事は機械翻訳されたものです。

Windows と C++

効率的で構成可能な非同期システムを追求する (機械翻訳)

Kenny Kerr

 

Kenny Kerrコンピューターのハードウェアの実装、C プログラミング言語の設計コンピュータ プログラミングに不可欠なアプローチに従ってくださいに大きく影響を受けてください。 このアプローチは、プログラムの状態を具現化するステートメントのシーケンスとしてプログラムについて説明します。 これは意図的な選択で C だったデザイナー デニスリッチー。 それは彼に代替してアセンブリ言語を生成するために許可。 リッチーはまたより洗練された強力なシステム ソフトウェアの創造につながる品質とプログラムの保守性の向上を有効にするのに証明されている、構造化された手続き型のデザインを採用しました。

特定のコンピューター アセンブリ言語は通常、プロセッサによってサポートされている命令のセットで構成されます。 プログラマはレジスタを参照できます-文字通り少量のメモリ、プロセッサ自体に — 同様としてメイン メモリ内のアドレスします。 アセンブリ言語は、また再利用可能なルーチンを作成する単純な方法を提供する、プログラムの別の場所にジャンプのいくつかの指示が含まれます。 C 言語で関数を実装するためには、「スタック」と呼ばれるメモリの少量が予約されています。 ほとんどの部分については、このスタックまたはコール スタック、プログラムは自動的に状態を格納できるようにと呼ばれる各関数に関する情報を格納 — ローカルおよびその呼び出し元と共有 — と、関数が完了すると実行を再開する場所を知っています。 これは、ほとんどのプログラマが 2 番目の考えを与えていないまだ効率的とわかりやすいプログラムを書くことが可能ものの非常に重要な部分を今日コンピューティングの基礎的な部分です。 次のコードを考えます。

int sum(int a, int b) { return a + b; }
int main()
{
  int x = sum(3, 4);
  return sum(x, 5);
}

シーケンシャル実行の仮定を考えると、それは明らかです — 明示的場合 — 何は任意の時点で、プログラムの状態になります。 これらの関数は最初の仮定いくつか自動ストレージ関数の引数および戻り値のためだけでなく、戻り値は関数を呼び出すときに実行を再開する場所を知っているプログラムのためのいくつかの方法がなければ意味がないです。 C および C++ のプログラマにとっては、それはこれを可能にし、私たちがシンプルで効率的なコードを記述することができますスタックです。 残念ながら、それも非同期に来るとき、C および C++ のプログラマを傷つけるの世界が発生、スタックへの依存はプログラミングします。 C および C++ は、世界で競争力と生産性を維持するために適応しなければならないなど、プログラミング言語の従来のシステムは、ますます非同期操作をいっぱい。 C を疑うがプログラマはしばらくの間、同時実行を達成するために従来の技術に依存し続ける私 C++ をより迅速に進化し、効率的かつ構成可能な非同期システムを書くのより豊かな言語を提供する期待しています。

先月コルーチンのマクロをシミュレートする軽量の協調的マルチタスクを実装する任意の C または C++ コンパイラでを今日使用できる単純な手法を検討しました。 十分なものの C のプログラマは、それを提示いくつかの課題 C++ プログラマーは自然に、当然、抽象化を壊す他の構成体の間でローカル変数に依存しています。 この列で可能な今後の方向性より自然で構成可能な方法で非同期プログラミングを直接サポートする c++ を探索するつもり。

タスクとスタックをリッピング

私は前回のコラムで述べたように (msdn.microsoft.com/magazine/jj553509)、同時実行スレッド プログラミングも意味しません。 これは、2 つの別々 の問題の融合ですが、いくつかの混乱を引き起こすことは十分に普及しています。 C++ 言語はもともとの同時実行の明示的なサポートを提供していないので、プログラマ当然さまざまなテクニックと同じを達成するために使用。 プログラムがより複雑になったように、必要となった — おそらく明白な — プログラム論理のタスクに分割します。 各タスクは、独自のスタックとミニ プログラムの並べ替えになります。 通常、OS は、このスレッドを実装します、各スレッドが独自のスタックを与えられます。 これは、タスクを独立してしばしば先手を打ってスケジュー リング ポリシーと複数の処理コアの状況に応じて実行することができます。 しかし、各タスク、またはミニの C++ プログラムは単純にで書き、そのスタックの分離と、スタックを体現状態のおかげで順番に実行することができます。 このタスクあたり 1 スレッド アプローチはしかしいくつかの明白な制限があります。 スレッドごとのオーバーヘッドは、多くの場合高額です。 たとえそれが多くの複雑さを同期する必要性のためにスレッド間の協力の不足につながるのでへのアクセスを共有状態またはスレッド間の通信。

別のアプローチは、多くの人気を博しているイベント ドリブン プログラミングです。 もっと明らかには、同時実行イベント ドリブン プログラミングでは、UI の開発と協調タスク管理の形式を実装するには、コールバック関数に依存するライブラリの多くの例を考慮するとき、スレッド プログラミングを意味しません。 しかし、このアプローチの限界は少なくとも 1 スレッドあたりタスク アプローチの問題です。 きれいで、逐次プログラムの web になりますすぐに — または、楽観的、スパゲッティ スタック — 凝集の一連のステートメントと関数の呼び出しの代わりにコールバック関数の。 以前いた単一の関数呼び出しルーチンは今 2 つ以上の関数に破れているのでこれはスタックをリッピングと呼ばれます。 これは、順番にも頻繁にプログラム全体での波及効果に します。 リッピング スタックは、すべてについての複雑さを心配している場合は、悲惨です。 1 つの関数の代わりに、今、少なくとも 2 つ必要があります。 それは 1 つのスタックの場所と別の間生き残る必要がありますよう自動ストレージ スタック上のローカル変数の依存ではなく、今明示的にこの状態のストレージ管理する必要があります。 この分離を収容するループを書き直す必要がありますなど、単純な言語を構築します。 最後に、プログラムの状態は、もはやでスタックを具体化され、頻繁に手動で「プログラマの組み立て方」の頭する必要がありますのでスタック リッピング プログラムのデバッグははるかに困難です。 組込みシステムの簡単なフラッシュ ストレージ ドライバーの例明らかに順次実行を提供する同期操作を表現私の最後の列からを考慮します。

void storage_read(void * buffer, uint32 size, uint32 offset);
void storage_write(void * buffer, uint32 size, uint32 offset);
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0);
  storage_write(buffer, sizeof(buffer), 1024);
}

ここで何が起こっているを把握するハードではないです。 スタックによってバックアップされる 1 KB のバッファーは、バッファーにデータが読み取られるまで、プログラムを中断する、storage_read 関数に渡されます。 この同じバッファーは、次の転送が完了するまで、プログラムを中断する、storage_write 関数に渡されます。 この時点で、プログラムを安全に返します自動的に、コピー操作に使用されたスタック容量を再利用します。 明白な欠点は、プログラムが中断された、I/O の完了を待っている間有用な仕事をやっていないことです。

前回のコラムで私は単純な手法の実装を実証­C++ で menting 協調的マルチタスクすることができる方法で戻るプログラミングのシーケンシャル スタイルに。 ただし、ローカル変数を使用できるように、それはややです限られました。 スタック管理関数呼び出し限り自動のまま、行くの返しますが、自動スタック変数の損失はかなり厳しい制限です。 それでも、それは本格的なスタックをリッピングを打ちます。 伝統的なイベント駆動型のアプローチを使用するよう何上記のコードを見て可能性がありますを考慮し、スタック内のアクションをリッピングをはっきりと見ることができます。 まず、ストレージ関数はコールバック関数による一般イベント通知は、いくつかの並べ替えを収容するために再宣言される必要があります。

typedef void (* storage_done)(void * context);
void storage_read(void * b, uint32 s, uint32 o, storage_done, void * context);
void storage_write(void * b, uint32 s, uint32 o, storage_done, void * context);

次に、プログラム自体は、適切なイベント ハンドラーを実装するように書き換える必要があります。

void write_done(void *)
{
  ...
signal completion ...
}
void read_done(void * b)
{
  storage_write(b, 1024, 1024, write_done, nullptr);
}
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0, read_done, buffer);
  ...
wait for completion signal ...
}

これは、以前の同期アプローチよりもはっきりとはるかに複雑ですまだ非常には C および C++ プログラムの間で今日の規範です。 以上 3 つの関数の main 関数にもともと限られていたコピー操作の現在の広がる方法に注意してください。 それだけが、ほぼ逆のプログラムに関する理由 write_done コールバックを read_done する前に宣言する必要があるし、それがメイン関数の前に宣言する必要があります必要があります。 それでも、このプログラムがやや単純で、どのようにこれはイベントの「チェーン」は完全に任意の実際のアプリケーションで実現したともっと面倒な取得と思います感謝してください。

C + + 11、エレガントなソリューションに向かっていくつかの注目すべきステップをしたが、我々 はまだそこにしていません。 C + + 11 は今言っても過言で標準のライブラリでは、同時実行についてそれはまだ言語で主にサイレントを持っています。 ライブラリ自体も簡単にコンポーザブルと非同期のより複雑なプログラムを記述するプログラマを許可するのに十分な移動しないでください。 それにもかかわらず、素晴らしい仕事が行われていると C + + 11 さらに改良のための良い基盤を提供します。 最初に、どのような C を表示するつもり + + 11 提供し、何が欠けていると、最後に、可能な解決策。

クロージャとラムダ式

一般的な用語では、クロージャは、関数の実行に必要な任意の非局在情報を識別するいくつかの状態との結合関数です。 昨年私のスレッド プールのシリーズで覆われて TrySubmitThreadpoolCallback 関数を考えます (msdn.microsoft.com/magazine/hh335066)。

void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * state) { ...
}
int main()
{
  void * state = ...
TrySubmitThreadpoolCallback(callback, state, nullptr);
  ...
}

Windows 関数が関数としていくつかの状態の両方を受け付ける方法に注意してください。 これは、実際に変装での閉鎖です。 それは確かにあなたの典型的な閉鎖のようなを見ていませんが、機能は同じです。 間違いなく、関数オブジェクトは同じ端を達成します。 クロージャ機能のプログラミングの世界が C での名声にファーストクラスの概念をバラのよう + + 11 は、ラムダ式のフォームで同様の概念をサポートするために進歩を遂げている:

void submit(function<void()> f) { f(); }
int main()
{
  int state = 123;
  submit([state]() { printf("%d\n", state); });
}

この例ではいくつかの他のコンテキストで実行する関数オブジェクトがふりをすることができます単純な送信関数があります。 関数オブジェクトは、ラムダ式では、main 関数から作成されます。 この単純なラムダ式には簡潔なクロージャとして説得力のある資格を必要な属性が含まれています。 [状態] 一部"キャプチャするには、"どのような状態であることを示します、残りの部分は効果的にこの状態にアクセス権を持つ匿名関数です。 コンパイラがこのオフを引っ張る関数オブジェクトの道徳的な等量を作成することをはっきりと見ることができます。 送信関数は、テンプレートをされていた、コンパイラも離れて構文の向上に加えてパフォーマンスの向上につながる関数オブジェクト自体は、最適化している可能性があります。 より大きい質問は、しかし、これは本当に有効な閉鎖かどうかです。 ラムダ式は、本当に非ローカル変数をバインドすることによって、式を閉じていますか? この例を明らかにする必要があります少なくとも、パズルの一部。

int main()
{
  int state = 123;
  auto f = [state]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

このプログラムは「123」プリントは「0」状態変数は、値ではなく参照によってキャプチャされたため。 私はもちろん、参照によって変数をキャプチャするためにそれを伝えることができます。

int main()
{
  int state = 123;
  auto f = [&]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

ここでは変数の参照およびコンパイラの把握私状態変数に参照させることによってキャプチャする既定キャプチャ モードを指定です。 さすがに、プログラム今忠実「0」「ではなく 123」印刷します。問題は、もちろん、変数のストレージがまだ宣言されていたスタック フレームにバインドされていることです。 送信関数実行遅延、スタック アンワインドする場合は、状態が失われ、プログラムが正しいでしょう。

JavaScript などの動的言語は、C の不可欠の世界依存して機能的なスタイルとマージすることによってこの問題を回避を取得とはるかに少ないがスタック上に各オブジェクトは本質的に、順不同の連想コンテナー。 C + + 11 は、彼らは非常に簡潔ではない場合でも、効率的な選択肢を提供する shared_ptr と make_shared のテンプレートを提供します。 だから、ラムダ式は、スマート ポインターによってクロージャのコンテキストで定義することができ、あまりにも多くの構文オーバーヘッドなしスタックから解放される状態を問題の一部を解決します。 それは理想的ではないが、それはスタートです。

約束と先物

一見して、別の C + + 先物と呼ばれる 11 の機能の答えを提供する表示可能性があります。 明示的に非同期関数呼び出しを有効にするには、先物取引の考えることができます。 もちろん、課題は、何が定義です正確にことを意味してどのように実装を取得します。 先物と例を説明する方が簡単です。 元の同期 storage_read 関数のバージョンを将来の対応は、このようになります:

// void storage_read(void * b, uint32 s, uint32 o);
future<void> storage_read(void * b, uint32 s, uint32 o);

唯一の違いは、戻り値の型、将来テンプレートでラップされますに注意してください。 アイデアは、新しい storage_read 関数を開始または、将来のオブジェクトを返す前に伝送キューです。 この将来は、操作が完了するまで待機する同期オブジェクトとして使用できます。

int main()
{
  uint8 buffer[1024];
  auto f = storage_read(buffer, sizeof(buffer), 0);
  ...
f.wait();
  ...
}

これは、コンシューマー側の非同期の方程式と呼ばれるかもしれない。 Storage_read 関数は、プロバイダー側を抽象し、同様に簡単です。 Storage_read 関数は、約束の作成し要求のパラメーターと共にキューし、関連付けられて将来戻る必要があります。 また、これはコードで理解しやすいです:

future<void> storage_read(void * b, uint32 s, uint32 o)
{
  promise<void> p;
  auto f = p.get_future();
  begin_transfer(move(p), b, s, o);
  return f;
}

操作が完了すると、ストレージ ドライバー未来に信号の準備ができていることができます。

p.set_value();

どのような値はこれですか? まあ、いや我々 はボイドの約束と将来の専門分野を使用しているが、file_read 関数を含めることができますこのストレージ ドライバーの上に建てられたファイル システムの抽象化を想像することができますので、まったく値します。 この関数は、特定のファイルのサイズを知ることなしに呼び出される必要があります。 それは、実際の転送バイト数を返すことができます。

future<int> file_read(void * b, uint32 s, uint32 o);

このシナリオでは、バイト数を通信に使用するチャネルをこのように提供して実際に転送 int 型との約束も使用されます。

promise<int> p;
auto f = p.get_future();
...
p.set_value(123);
...
f.wait();
printf("bytes %d\n", f.get());

将来の結果を取得する get メソッドを提供します。 偉大な我々 の未来に待っている方法が、すべての問題を解決 ! まあ、それほど高速。 これは本当に私たちの問題を解決しましたか。 我々 は同時に複数の操作をオフを蹴ることですか? ということですね。 我々 の集計操作を簡単に作成または未処理の操作のいずれかまたはすべてを待つだけでもできますか。 号 同期元の例では、必ずしも書き込み操作の前に完了した読み取り操作を始めた。 だから先物実際私たち非常に遠く取得しません。 問題は将来の待っているの行為は、まだ同期操作は、イベントのチェーンを作成するための標準的な方法がないです。 また、先物の集計を作成する方法はありません。 先物が任意の数の待機する可能性があります。 すべての先物は準備ができてちょうど最初の 1 つを待つ必要があります。

未来の先物

先物との約束の問題は、彼らがこれまで十分に行っていないし、間違いなく完全に欠陥があります。 方法と、待機、結果準備ができるまでどのブロックの両方を取得、並行処理と非同期プログラミングには正反対します。 我々 は場合の結果を取得しようとする try_get など何か必要があります得るのではなく利用可能ですが、戻るすぐに、関係なく。

int bytes;
if (f.try_get(bytes))
{
  printf("bytes %d\n", bytes);
}

さらに行く、我々 は単に、非同期操作の完了にラムダ式を関連付けることができますので先物継続メカニズムを提供する必要があります。 これは、ときに我々 は先物取引の構成可能性を参照してくださいに開始:

int main()
{
  uint8 buffer[1024];
  auto fr = storage_read(buffer, sizeof(buffer), 0);
  auto fw = fr.then([&]()
  {
    return storage_write(buffer, sizeof(buffer), 1024);
  });
  ...
}

Storage_read 関数は、将来の読み取り (fr) を返します、ラムダ式はこの将来は書き込み未来 (fw) を生じるその後メソッドを使用しての継続を構築する使用されます。 先物が常に返されるためより暗黙を好むかもしれないが相当のスタイル。

auto f = storage_read(buffer, sizeof(buffer), 0).then([&]()
{
  return storage_write(buffer, sizeof(buffer), 1024);
});

この場合のみ単一明示的な未来を表すすべての操作の集大成です。 これはシーケンシャル構成と呼ばれるかもしれないが、パラレル、またはコンポジションも (WaitForMultipleObjects 考える) 最も重要なシステムに不可欠でしょう。 この場合、我々 は wait_any と wait_all の可変個引数関数のペア必要があります。 再び、これら私たちの前に次のメソッドを使用して、集計の継続としてのラムダ式を提供できるように、先物を返します。 また、完成した未来完了した特定の未来は明らかではない場合の継続に渡すときに便利かもしれません。

先物の将来より徹底的な捜すの取り消し、本質的なトピックを含む見てください Artur Laksberg とニクラス ・ グスタフソン紙で、「A 標準プログラム インターフェイスを非同期操作に」で bit.ly/MEgzhn

どこ私は、先物の将来には、深く掘るし、効率的かつ構成可能な非同期システムを記述するアプローチもより多くの流体を表示、次の割賦用にチューニング滞在します。

Kenny Kerr ソフトウェア職人のネイティブ Windows 開発への情熱です。 彼に到達 kennykerr.ca

この記事のレビュー、次技術専門家のおかげで:Artur Laksberg