非同期プログラミング

PPL を使用した C++ での非同期プログラミング

Artur Laksberg

ハリウッドの配役責任者は、「問い合わせにはお答えできません。こちらから連絡します。」というそっけない言葉で役者志望者をはねつけると、よく言われます。ところが開発者にとっては、このせりふは多くのソフトウェア フレームワークの動作方法を表すと感じます。つまり、プログラマがアプリケーション全体の制御フローを決めるのではなく、フレームワークが環境を管理し、プログラマが用意したコールバックやイベント ハンドラーがフレームワークから呼び出されるのに似ています。

非同期システムでは、このパラダイムによって非同期操作の開始と完了が分離されます。プログラマは操作を開始し、コールバックを登録します。このコールバックは、結果が使用可能になったときに呼び出されます。完了を待つ必要がないということは、操作が進行している間に効率よく作業を進められるということです。たとえば、メッセージ ループにサービスを提供したり、他の非同期操作を開始したりできます。すべての潜在的なブロック操作についてこのパターンにきちんと従えば、処理に時間がかかり、画面が "すりガラス状" になったり、"回転する輪" が表示されたりする現象は過去のものになります。アプリは、(よく使われる言葉ですが) 速くて滑らかになるでしょう。

Windows 8 では、非同期操作が広く利用され、WinRT は非同期を一貫した方法で処理する新しいプログラミングのモデルを提供します。

図 1 は、非同期操作を使用する基本パターンを示しています。このコードでは、C++ の関数がファイルから文字列を読み取ります。

図 1 ファイルの読み取り

 

template<typename Callback>
void ReadString(String^ fileName, Callback func)
{
  StorageFolder^ item = KnownFolders::PicturesLibrary;

  auto getFileOp = item->GetFileAsync(fileName);
  getFileOp->Completed = ref new AsyncOperationCompletedHandler<StorageFile^>
    ([=](IAsyncOperation<StorageFile^>^ operation, AsyncStatus status)
  {
    auto storageFile = operation->GetResults();
    auto openOp = storageFile->OpenAsync(FileAccessMode::Read);
    openOp->Completed = 
      ref new AsyncOperationCompletedHandler <IRandomAccessStream^>
      ([=](IAsyncOperation<IRandomAccessStream^>^ operation, AsyncStatus status)
    {
      auto istream = operation->GetResults();
      auto reader = ref new DataReader(istream);
      auto loadOp = reader->LoadAsync(istream->Size);
      loadOp->Completed = ref new AsyncOperationCompletedHandler<UINT>
        ([=](IAsyncOperation<UINT>^ operation, AsyncStatus status)
      {
        auto bytesRead = operation->GetResults();
        auto str = reader->ReadString(bytesRead);
        func(str);
      });
    });
  });
}

まず、ReadString の戻り値の型が void だということに気付きます。おわかりのように、この関数は値を返しません。代わりに、ユーザーが提供するコールバックを受け取り、結果が使用可能になったときに呼び出します。非同期プログラミングの世界へようこそ。「問い合わせないでください。こちらから呼び出します」。

WinRT の非同期操作のしくみ

WinRT で非同期処理の中心になるのは、Windows::Foundation 名前空間で定義される IAsyncOperation、IAsyncAction、IAsyncOperationWithProgress、および IAsyncActionWithProgress の 4 つのインターフェイスです。WinRT におけるすべての潜在的なブロック操作や実行時間の長い操作は、非同期として定義されます。慣例では、メソッドの名前は "Async" で終わり、戻り値の型は 4 つのインターフェイスのいずれかになります。図 1 の例の GetFileAsync メソッドはこのようなメソッドで、IAsyncOperation<StorageFile^> を返します。多くの非同期操作は、値を返さない IAsyncAction 型です。進行状況を通知する操作は、IAsyncOperationWithProgress と IAsyncActionWithProgress によって公開されます。

非同期操作の完了コールバックを指定するには、Completed プロパティを設定します。このプロパティは、非同期インターフェイスと完了の状態を受け取るデリゲートです。このデリゲートは関数ポインターを指定してインスタンスが作成されますが、ラムダを使用することが最も多いでしょう (C++11 のこの部分についてはもうよくわかっていると思います)。

操作の値を取得するには、インターフェイスの GetResults メソッドを呼び出します。GetFileAsync の呼び出しから返されるのと同じインターフェイスですが、完了ハンドラーではこのインターフェイスの GetResults しか呼び出せないことに注意します。

完了デリゲートの 2 つ目のパラメーターは AsyncStatus で、操作の状態を返します。実際のアプリケーションでは、GetResults を呼び出す前にこの値を確認することになります。図 1 では、説明を簡潔にするためこの部分は省略しました。

ほとんどの場合、複数の非同期操作を一緒に使用することになるでしょう。今回の例では、まず (GetFileAsync を呼び出して) StorageFile のインスタンスを取得し、OpenAsync を使用して開き、IInputStream を取得します。次に、データを読み込み (LoadAsync)、DataReader を使用して読み取ります。最後に、文字列を取得してユーザー指定のコールバック関数を呼び出します。

構成

呼び出しのブロックを回避するには、操作の開始と完了を分離することが不可欠です。問題は、コールバック ベースの複数の非同期操作を構成するのが困難で、コードをそのように構成した理由を考え、デバッグするのが難しいことです。その結果生じる "コールバック スープ" (コールバックが多すぎる状況) を制御するため、なんらかの対処が必要です。

具体的な例を考えてみましょう。前のサンプルの ReadString 関数を使用して 2 つのファイルを順に読み取り、結果を結合して単一の文字列にすることを考えます。そこで、このサンプルをコールバックを受け取る関数として再度実装します。

template<typename Callback>
void ConcatFiles1(String^ file1, String^ file2, Callback func)
{
  ReadString(file1, [func](String^ str1) {
    ReadString(file2, [func](String^ str2) {
      func(str1+str2);
    });
  });
}

悪くないでしょう。

この解決法で何も問題ないと思われるかもしれませんが、次のことを考えてみてください。file2 の読み取りは、いつ開始すればよいでしょう。本当に 2 つ目のファイルの読み取りを開始する前に、1 つ目のファイルの読み取りを終えている必要があるでしょうか。まったく必要ありません。複数の非同期操作をすぐに開始し、データを読み取り終えたときに処理した方がはるかに良いでしょう。

では、試してみましょう。まず、2 つの操作を同時に開始して操作が完了する前に関数から戻るため、中間結果を格納する特別なヒープ割り当てオブジェクトが必要です。このオブジェクトを ResultHolder と呼びます。

ref struct ResultHolder
{
  String^ str;
};

図 2 に示すように、先に完了する操作が results->str メンバーを設定します。2 番目に完了する操作は、そのメンバーを使用して最終結果を形成します。

図 2 2 つのファイルからの同時読み取り

template<typename Callback>
void ConcatFiles(String^ file1, String^ file2, Callback func)
{
  auto results = ref new ResultHolder();

  ReadString(file1, [=](String^ str) {
    if(results->str != nullptr) { // Beware of the race condition!
      func(str + results->str);
    }
    else{
      results->str = str;
    }
  });

  ReadString(file2, [=](String^ str) {
    if(results->str != nullptr) { // Beware of the race condition!
      func(results->str + str);
    }
    else{
      results->str = str;
    }
  }); 
}

ほとんどの場合はこれでうまくいきますが、完全ではありません。コードでは明らかに競合状態が発生し、エラーが処理されません。やるべきことはまだまだあります。2 つの操作を組み合わせるような単純なことにも、非常に多くのコードが必要で、問題なく行うのは容易ではありません。

並列パターン ライブラリのタスク

Visual Studio の並列パターン ライブラリ (PPL: Parallel Patterns Library) は、C++ で並列プログラムや非同期プログラムを簡単かつ高い生産性で作成できるように設計されています。スレッドやスレッド プールのレベルで操作するのではなく、PPL のユーザーは、タスクなどの高度な抽象化、parallel_for や parallel_sort などの並列アルゴリズム、および concurrent_vector などの同時実行に対応するコンテナーを使用できます。

次期リリースの Visual Studio では、新しく PPL の task クラスが導入され、非同期に実行する個別の作業ユニットを簡潔に表現できます。これにより、独立した (相互に依存する) タスクという観点でプログラム ロジックを表現でき、それらのタスクのスケジュールはランタイムによって最適な方法で管理されます。

タスクを非常に使いやすくしているのは、その構成の豊富さです。最もシンプルな形式は 1 つのタスクをもう 1 つのタスクの "継続" として宣言することで、2 つのタスクをシーケンシャルに構成できます。この一見ささいな構成要素により、複数のタスクを興味深い方法で組み合わせることができます。join や choice など (これらは後で取り上げます)、より高いレベルの PPL の構成要素は、多くがそれ自体この手法で構築されています。タスクの継続は、非同期操作の完了をより正確な方法で表すのにも使用できます。図 1 のサンプルに戻り、今度は PPL のタスクを使用して作り直します (図 3 参照)。

図 3 入れ子になった PPL タスクを使用するファイルの読み取り

task<String^> ReadStringTask(String^ fileName)
{
  StorageFolder^ item = KnownFolders::PicturesLibrary;
  task<StorageFile^> getFileTask(item->GetFileAsync(fileName));
  return getFileTask.then([](StorageFile^ storageFile) {
    task<IRandomAccessStream^> openTask(storageFile->OpenAsync(
      FileAccessMode::Read));
    return openTask.then([](IRandomAccessStream^ istream) {
      auto reader = ref new DataReader(istream);
      task<UINT> loadTask(reader->LoadAsync(istream->Size));
      return loadTask.then([reader](UINT bytesRead) {
        return reader->ReadString(bytesRead);
      });
    });
  });
}

非同期を表すのにコールバックの代わりにタスクを使用するようになるため、ユーザーが提供するコールバックはありません。作り直した関数ではタスクを返します。

この実装では、GetFileAsync が返した非同期操作を基に getFileTask タスクを作成しました。次に、この操作の完了をタスクの継続として設定しました (then メソッド)。

then メソッドについて、もう少し詳しく説明しましょう。then メソッドのパラメーターはラムダ式です。実際には、これは関数ポインター、関数オブジェクト、または std::function のインスタンスにすることも可能ですが、PPL (そしてもちろん新しい C++) でラムダ式が広く利用されているため、これ以降は "ラムダ" という言葉をあらゆる種類の呼び出し可能なオブジェクトを意味するのに用います。

then メソッドの戻り値の型は、なんらかの T 型のタスクです。この T 型は、then メソッドに渡されたラムダの戻り値の型で決まります。その基本形式では、ラムダが T 型の式を返すとき、then メソッドは task<T> を返します。たとえば、次の継続タスクにおけるラムダは int 型を返すので、結果の型は task<int> になります。

task<int> myTask = someOtherTask.then([]() { return 42; });

図 3 で使用している継続タスクの型は少し異なります。タスクを返し、そのタスクの "非同期のラップ解除" を実行するので、次のように、結果の型は task<task<int>> ではなく task<int> になります。

task<int> myTask = someOtherTask.then([]() {
  task<int> innerTask([]() {
    return 42; 
  });
  return innerTask;
});

少し複雑だと思われるかもしれませんが、やる気を失わないでください。もっと興味をひく例をいくつかご覧いただければ、より理解が深まると約束します。

タスク構成

ここまで説明したことを活用して、ファイル読み取りの例を基に構築を続けましょう。

C++ では、関数とラムダに存在するすべてのローカル変数は、返されるときには失われることを思い出してください。状態を維持するには、ヒープやその他の長期間存続するストレージに手動で変数をコピーする必要があります。前に holder クラスを作成したのは、そのためです。非同期に実行するラムダでは、ポインターや参照を使って関数に含まれている状態を一切取得しないように注意する必要があります。そうしないと、関数が終了したときに、ポインターがメモリの無効な場所を指すことになります。

then メソッドは非同期インターフェイスでラップ解除を実行するということを活かして、サンプルをより簡潔に書き直します。ただその場合は、もう 1 つ holder 構造体を導入する必要が生じます (図 4 参照)。

図 4 複数タスクをチェーンする

ref struct Holder
{
  IDataReader^ Reader;
};
task<String^> ReadStringTask(String^ fileName)
{
  StorageFolder^ item = KnownFolders::PicturesLibrary;

  auto holder = ref new Holder();

  task<StorageFile^> getFileTask(item->GetFileAsync(fileName));
  return getFileTask.then([](StorageFile^ storageFile) {
    return storageFile->OpenAsync(FileAccessMode::Read);
  }).then([holder](IRandomAccessStream^ istream) {
    holder->Reader = ref new DataReader(istream);
    return holder->Reader->LoadAsync(istream->Size);
  }).then([holder](UINT bytesRead) {
    return holder->Reader->ReadString(bytesRead);
  });
}

図 3 のサンプルでは操作が入れ子状で、行の先頭部分が "階段" のようになっているのとは対照的に、手順が連続的なのでコードが読みやすくなりました。

then メソッド以外にも、PPL には構成可能な構成要素がいくつかあります。その 1 つに、join 演算があり、when_all メソッドで実装します。when_all メソッドは、タスクのシーケンスを受け取り、結果のタスクを返します。結果のタスクは、すべての構成タスクの出力を std::vector に集めます。引数が 2 つの一般的な場合のために、PPL には && 演算子という便利な短縮形があります。

ここでは、この方法で join 演算子を使用してファイル連結メソッドを再度実装します。

task<String^> ConcatFiles(String^ file1, String^ file2)
{
  auto strings_task = ReadStringTask(file1) && ReadStringTask(file2);
  return strings_task.then([](std::vector<String^> strings) {
    return strings[0] + strings[1];
  });
}

choice 演算も、役に立ちます。一連のタスクでは、最初のタスクが完了したときに choice (when_any メソッドで実装) が完了します。join と同様、choice には || 演算子という引数が 2 つの場合の短縮形があります。

choice は、冗長な実行や投機的な実行などの場合に役立ちます。つまり、複数のタスクを開始し、最初に完了するタスによって必要な結果が決まるような場合です。操作にタイムアウトを追加することもできます。タスクを返す操作から開始し、そのタスクを一定時間スリープ状態になるタスクと組み合わせます。スリープ状態になるタスクが先に完了した場合、操作がタイムアウトして、操作全体を破棄またはキャンセルすることができます。

PPL には、他にも task_completion_event というタスクの構造化に役立つ構成要素があります。これは、タスクと PPL 以外のコードとの相互運用性のために使用できます。task_completion_event は、スレッドまたは IO 完了コールバックに渡され、そこで最終的に設定されます。task_completion_event を基に作成されたタスクは、task_completion_event が設定されると完了します。

PPL による非同期操作の作成

ハードウェアのパフォーマンスを少しの無駄もなく活用する必要があるときは、C++ が最適な言語です。Windows 8 では、その他の言語が役立つ場合もあります。たとえば、GUI を作成するには JavaScript と HTML5 の組み合わせが適しており、C# は開発者の作業を生産性を高めるといった具合です。Metro スタイル アプリを作成するには、自分に適した、使い慣れた言語を使用してください。実際、同じアプリでもさまざまな言語を使用できます。

パフォーマンスを最大限に引き出すため、アプリケーションのフロントエンドを JavaScript や C# などの言語で、バックエンド コンポーネントを C++ で作成することがよくあります。C++ コンポーネントによってエクスポートされる操作がコンピューターまたは I/O の制約を受ける場合は、非同期操作として定義することをお勧めします。

既に紹介した 4 つの WinRT の非同期インターフェイス (IAsyncOperation、IAsyncAction、IAsyncOperationWithProgress、および IAsyncActionWithProgress) を実装するため、PPL は concurrency 名前空間で create_async メソッドと progress_reporter クラスを定義します。

最も簡単な形式では、create_async はラムダまたは値を返す関数ポインターを受け取ります。create_async が返すインターフェイスの型は、ラムダの型で決まります。

ラムダにパラメーターがなく void 以外の T 型を返す場合、create_async は IAsyn-cOperation<T> の実装を返します。ラムダが void を返す場合、結果のインターフェイスは IAsyncAction になります。

ラムダは、progress_reporter<P> 型のパラメーターを受け取ります。progress_reporter<P> 型のインスタンスは、呼び出す側に P 型の進行状況通知をポストバックするのに使用します。たとえば、progress_reporter<int> を受け取るラムダは、完了率を整数値で通知します。この場合、ラムダの戻り値の型で、結果のインターフェイスが IAsyncOperationWithProgress<T,P> か IAsyncAction<P> のどちらになるかが決まります。図 5 を参照してください。

図 5 PPL で非同期操作を作成する

IAsyncOperation<float>^ operation = create_async([]() {
  return 42.0f;
});

IAsyncAction^ action = create_async([]() {
    // Do something, return nothing
});

IAsyncOperationWithProgress<float,int>^ operation_with_progress = 
  create_async([](progress_reporter<int> reporter) {
    for(int percent=0; percent<100; percent++) {
      reporter.report(percent);
    }
    return 42.0f;
  });

IAsyncActionWithProgress<int>^ action_with_progress = 
  create_async([](progress_reporter<int> reporter) {
    for(int percent=0; percent<100; percent++) {
      reporter.report(percent);
    }
  });

非同期操作を他の WinRT 言語に公開するには、C++ コンポーネントでパブリックの ref クラスを定義し、4 つの非同期インターフェイスのいずれか 1 つを返す関数を用意します。PPL のサンプル パックには、C++ と JavaScript のハイブリッド アプリケーションの具体例があります (サンプル パックを見つけるには、オンラインで「Asynchrony with PPL」(PPL を使用した非同期) を検索してください)。次に示すのは、画像変換ルーチンを進行中の非同期操作として公開するコードです。

public ref class ImageTransformer sealed
{
public:
  //
  // Expose image transformation as an asynchronous action with progress
  //
  IAsyncActionWithProgress<int>^ GetTransformImageAsync(String^ inFile, String^ outFile);
}

図 6 で示すように、アプリケーションのクライアント部分は、このオブジェクトを使用して JavaScript で実装されます。

図 6 JavaScript における画像変換ルーチンの使用

var transformer = new ImageCartoonizerBackend.ImageTransformer();
...
transformer.getTransformImageAsync(copiedFile.path, dstImgPath).then(
function () {
// Handle completion…
},
function (error) {
// Handle error…
},
function (progressPercent) {
// Handle progress:
UpdateProgress(progressPercent);
}
);

エラー処理とキャンセル

注意深い読者なら、非同期に関する今回の記事で、これまでエラー処理とキャンセルについてまったく触れていないことに気付くでしょう。このテーマを、これ以上無視することはできません。

ファイル読み取りルーチンでは、なんらかの理由で存在しなかったり開くことのできないファイルが示されることは避けられません。dictionary-lookup 関数は、未知の単語に直面することがあります。画像変換は、結果を迅速に得られないため、ユーザーによってキャンセルされる場合があります。このような場合、操作は意図したとおりに完了する前に、途中で終了します。

最近の C++ では、例外を使用してエラーやその他の例外的な状況を示します。例外は、単一のスレッド内ではきわめて有効に機能します。例外がスローされると、呼び出し履歴の下の該当する catch ブロックが見つかるまで履歴をさかのぼります。これに同時実行が重なるとややこしくなります。1 つのスレッドで生じた例外を他のスレッドでキャッチするのが難しいためです。

タスクと継続タスクでは何が起こるでしょう。タスクの本体で例外がスローされると、実行の流れが中断され値を生成できなくなります。継続タスクに渡す値がないと、その継続タスクは実行できません。値を生成しない void タスクにも、前のタスクが正常に完了したかどうかを通知できるようにする必要があります。

そのため、継続タスクにはもう 1 つの形式が用意されています。T 型のタスクでは、エラー処理の継続タスクのラムダは task<T> を受け取ります。前のタスクによって生成される値を取得するには、このパラメーター タスクで get メソッドを呼び出す必要があります。前のタスクが正常に完了した場合は get メソッドも完了し、前のタスクが正常に完了しなかった場合は get は例外をスローします。

ここで重要な点を強調させてください。非同期操作が作成したタスクを含め、PPL のあらゆるタスクで、get を呼び出すのが "構文的には" 適切です。ただし、結果が使用できるようになるまでは、get は呼び出し元のスレッドをブロックする必要があり、当然ながら「速くて滑らかに」という合言葉と矛盾します。そのため、タスクで get を呼び出すことは一般に推奨されず、STA では禁止されています (ランタイムは "invalid operation" (無効な操作) の例外をスローします)。get を呼び出せるのは、継続タスクのパラメーターとしてタスクを取得したときに限られます。図 7 に例を示します。

図 7 エラー処理の継続タスク

task<image> take_picture([]() {
  if (!init_camera())
    throw std::exception("can’t init camera");
  return get_image();
});

take_picture.then([](task<image> antecedent) {
  try
  {
    image img = antecedent.get();
  }
  catch (std::exception ex)
  {
    // Handle exception here
  }
});
var transformer = new ImageCartoonizerBackend.ImageTransformer();
...
transformer.getTransformImageAsync(copiedFile.path, dstImgPath).then(
  function () {
    // Handle completion…
  },
  function (error) {
    // Handle error…
  },
  function (progressPercent) {
    // Handle progress:
    UpdateProgress(progressPercent);
  }
);

プログラムのすべての継続タスクをエラー処理タスクにすることができ、すべての継続タスクで例外を処理することも選択できます。ただし、複数のタスクで構成されたプログラムでは、すべての継続タスクで例外を処理するのは行き過ぎになることもあります。さいわい、他にも方法はあります。キャッチされたフレームまで呼び出し履歴をたどるハンドルされない例外と同様に、タスクがスローする例外は、最終的にハンドルされるところまで継続タスクのチェーンを "伝い落ちて" いきます。そして、例外は必ずハンドルされる必要があります。例外がハンドルされないまま処理するはずだったタスクの有効期限を過ぎた場合、ランタイムは "unobserved exception" (未検出例外) の例外をスローするためです。

ここでファイル読み取りの例に戻り、エラー処理を追加しましょう。WinRT がスローする例外はすべて Platform::Exception 型です。そのため、ここで最後の継続タスクでキャッチするのもこの型になります (図 8 参照)。

図 8 エラー処理を伴うファイルからの文字列の読み取り

task<String^> ReadStringTaskWithErrorHandling(String^ fileName)
{
  StorageFolder^ item = KnownFolders::PicturesLibrary;

  auto holder = ref new Holder();

  task<StorageFile^> getFileTask(item->GetFileAsync(fileName));
  return getFileTask.then([](StorageFile^ storageFile) {
    return storageFile->OpenAsync(FileAccessMode::Read);
  }).then([holder](IRandomAccessStream^ istream) {
    holder->Reader = ref new DataReader(istream);
    return holder->Reader->LoadAsync(istream->Size);
  }).then([holder](task<UINT> bytesReadTask) {
    try
    {
      UINT bytesRead = bytesReadTask.get();
      return holder->Reader->ReadString(bytesRead);
    }
    catch (Exception^ ex)
    {
      String^ result = ""; // return empty string
      return result;
    }
  });
}

例外が継続タスクでキャッチされると、ハンドル済みと見なされ、継続タスクは正常に完了するタスクを返します。そのため、図 8 では、ReadStringWithErrorHandling の呼び出し側には、ファイル読み取りが正常に完了したかどうかを把握できません。つまりここで伝えようとしているのは、例外を早く処理しすぎるのは、必ずしも良いことではないということです。

キャンセルは、タスクの途中終了のもう 1 つの形式です。WinRT では、PPL と同様、キャンセルには操作のクライアントと操作自体の 2 つの当事者の連携が必要です。それぞれ役割が異なり、クライアントはキャンセルを要求し、操作はその要求を承認または拒否します。クライアントと操作の間で自然と競合が起こるため、キャンセル要求は必ず成功するわけではありません。

PPL では、この 2 つの役割は cancellation_token_source と cancellation_token という 2 つの型が担います。cancellation_token_source のインスタンスは、cancel メソッドを呼び出してキャンセルを要求するのに使用します。cancellation_token のインスタンスは、cancellation_token_source からインスタンスが作成され、タスクのコンストラクターである then メソッドか create_async メソッドのラムダに最後のパラメーターとして渡されます。

タスクの本体の内部では、is_task_cancellation_requested メソッドを呼び出して、この実装はキャンセル要求をポーリングし、cancel_current_task メソッドを呼び出して要求を承認します。cancel_current_task メソッドは内部で例外をスローするため、cancel_current_task を呼び出す前にいくつかのリソースをクリーンアップするのが適切です。図 9 に例を示します。

図 9 タスクにおけるキャンセルとキャンセル要求への応答

cancellation_token_source ct;

task<int> my_task([]() {
  // Do some work
  // Check if cancellation has been requested
  if(is_task_cancellation_requested())
  {
        // Clean up resources:
        // ...
        // Cancel task:
        cancel_current_task();
  }
  // Do some more work
  return 1;
}, ct.get_token());
...
ct.cancel(); // attempt to cancel

同じ cancellation_token_source で多くのタスクをキャンセルできることに注意します。タスクのチェーンとグラフを処理する際には、これは非常に便利です。すべてのタスクを個別にキャンセルする代わりに、同じ cancellation_token_source で管理しているすべてのタスクをキャンセルできます。もちろん、タスクのどれかが実際にキャンセル要求に応じるという保証はありません。応じなかったタスクは完了することになりますが、通常の (値に基づく) 継続タスクは実行されません。エラー処理の継続タスクは実行されますが、前のタスクから値を取得しようとしても task_canceled 例外になります。

最後に、運用側におけるキャンセル トークンの使用について見てみましょう。create_async メソッドのラムダは、cancellation_token パラメーターを受け取り、is_canceled メソッドを使用してポーリングし、キャンセル要求に対応して操作をキャンセルします。

IAsyncAction^ action = create_async( [](cancellation_token ct) {
  while (!ct.is_canceled()); // spin until canceled
  cancel_current_task();
});
...
action->Cancel();

 

タスク継続の場合はどうなるかに注目してください。キャンセル トークンを受け取るのは then メソッドですが、create_async の場合はキャンセル トークンはラムダに渡されます。後者の場合、結果の非同期インターフェイスで cancel メソッドを呼び出すことでキャンセルが開始され、PPL によってキャンセル トークンを使ってキャンセル要求に組み込まれます。

まとめ

かつて Tony Hoare は、「我々はプログラムに "速く待つこと" を教えなければならない」というジョークを言いました。ただ現在でも、待つ必要のない非同期プログラミングを習得するのは難しく、その利点もすぐに明らかというわけではないので、開発者は避けています。

Windows 8 では、すべてのブロック操作が非同期で、C++ のプログラマにとっては、PPL により非同期プログラミングが非常に魅力的な選択肢になるでしょう。非同期の世界に飛び込み、プログラムに "速く待つこと" を教えてください。

Artur Laksberg は、マイクロソフトの並列パターン ライブラリと同時実行ランタイムに取り組む開発者のグループのリーダーです。以前は、C++ のコンパイラ フロントエンドに取り組んでおり、プログラミング言語 Axum の実装にかかわっていました。連絡先は arturl@microsoft.com (英語のみ) です。

この記事のレビューに協力してくれた技術スタッフの Genevieve FernandesKrishnan Varadarajan に心より感謝いたします。