あめだまふぁくとりー

Boost.Graphとかできますん

Networking TS の Boost.Asio からの変更点 - その 3: Executor

はじめに

前回までに説明した Associated allocator や async_result は少し地味目な変更点でした. 今回説明する Executor は Boost.Asio から Networking TS の中でも大きめの変更になっています.

一般化された io_service と strand

Executor は Networking TS で明示された概念であり, 簡単にいうと io_service と strand で共通するインタフェース部分のことです. つまり, ハンドラを実行する dispatch や post, それらを関数呼び出しの形で行うための wrap がそれに該当します.

Executor はハンドラをどのように呼び出すか定義するもので, 例えば strand の場合は複数のハンドラを直列に呼び出すように制御します.

io_service には Executor の機能の他にも, ハンドラを実際に実行する run などの関数があります. これらはハンドラをどこで実行するかを定義するものとみなすことができます. そのため, これを ExecutionContext と Networking TS では呼びます. ExecutionContext にはその ExecutionContext にハンドラを追加するための Executor が必ず対応付けられています.

io_service は Executor と ExecutionContext という概念に分割されたことで, io_context に名前が変わりました.

/* Boost.Asio */
io_service io_svc{};

/* Networking TS */
io_context io_ctx{};
io_context::executor_type executor = io_ctx.get_executor();

これらの一般化の裏には io_service 以外の実行コンテキストも使えるようにするといった方針があると思われます. 実際に Networking TS では io_context の他にも, 暗黙的に別スレッドでハンドラを実行する system_context と system_executor が定義されています.

また, ジェネリックプログラミングを容易にするという目的もあるかもしれません. これまでは io_service はコピーができない等 strand と異なる部分があったため, これらを取り替えるコードを書くには手間がありました. Networking TS では Executor はコピー可能と定義されているため, 任意の Executor を使用するコードは容易に書けます.

実際に Executor 自身もこの一般化によってメンバ関数の一部を非メンバ関数に移動することができています. 以下ではメンバ関数の変更点を見ていきます.

dispatch / post の非メンバ関数

Boost.Asio では io_service や strand のメンバ関数 dispatch と post は他の非同期関数と同様の振る舞いをしていました. 具体的には,

  • CompletionToken を引数に取り, async_result で戻り値を決めハンドラに変換する,
  • メモリを割り当てる場合は, asio_handler_allocate/deallocate を使用する,
  • ハンドラを呼び出す場合は, asio_handler_invoke を通して呼び出す,

ということをしていました.

Networking TS ではこれらの振る舞いはすべて変更され,

  • CompletionToken ではなくハンドラとしての関数を引数に取り, 戻りの型は常に void である,
  • メモリを割り当てる場合は, 第二引数で渡されたアロケータを使用する,
  • ハンドラの呼び出す場合は, h(args) の形でそのまま呼び出す,

となっています.

Boost.Asio 版と同じ振る舞いをさせたい場合は, 非メンバ関数版の dispatch, post を使用します.

/* Boost.Asio */
auto fut0 = io_service.post(use_future); // OK.

/* Networking TS */
auto fut1 = executor.post(use_future); // NG: CompletionToken は変換されず, 
                                       // 戻り値の型は void.
                                       // さらに第二引数も必要.
auto fut2 = post(executor, use_future); // OK.

メンバ関数版はハンドラを右辺値 (xvalue or prvalue) として受け取れればよいとなっているので, メンバ関数は他の非同期関数から間接的に使用されるものであり, 通常は非メンバ関数を使うと思っていいでしょう.

この変更がどう影響してくるのかは次回に説明します.

defer の追加

また, Networking TS では dispatch と post の他に defer という関数が追加されています. これは機能的には post と全く同じですが, defer は処理を遅延させたいことを明示させるために存在するようです. おそらく, Boost.Asio の asio_handler_is_continuation 辺りの機能が関連しているんではないかと思います.

wrap の非メンバ関数

strand::wrap は知っているかもしれませんが, 実は io_service::wrap も存在していました. Executor に一般化したことで wrap はメンバ関数である必要性がなくなり, bind_executor と呼ばれる非メンバ関数に変更されました. また, 戻り値の型も未指定から executor_binder クラステンプレートの実体になりました.

/* Boost.Asio */
auto f0 = io_service.wrap([]{ std::cout << "asio"; });
f0(); // io_service のコンテキストで呼び出される

/* Networking TS */
auto f1 = bind_executor([]{ std::cout << "Networking TS"; }, executor);
f1(); // executor のコンテキストで呼び出され...?

bind_executor で生成された関数オブジェクトは Boost.Asio の wrap で生成したものと振る舞いが違う点もあり, 同じと思って使用すると期待どおりの結果にならない場合もあります. この点の説明も次回に回します.

strand の一般化

io_service 等の概念を Executor に一般化したことで, strand も一般化されます. Boost.Asio では strand は io_service の実行コンテキストにおいて, ハンドラが直列に実行されることを保証しますが, Networking TS では任意の Executor (に関連する ExecutionContext) に対しての保証をします.

/* Boost.Asio */
io_service::strand s0{io_svc};

/* Networking TS */
strand<io_context::executor_type> s1{io_ctx.get_executor()};

io_service 以外にも strand を使用することができるようになりましたが, それによって strand の振る舞いの一部も変更されています.

strand::dispatch がハンドラを即座に呼ぶ条件

Boost.Asio での strand::dispatch は

  1. io_service::run 等が実行されているスレッドで呼び出された, かつ,
  2. この strand に追加されているハンドラが同時に実行されない状況にある,

という二つの条件を満たす場合, 引数で渡されたハンドラを dispatch の呼び出の中で実行します.

この二つ目の条件は, Boost.Asio のドキュメントには「strand に追加したハンドラの中から dispatch を呼び出した場合」と記載されていますが, 他にも strand にハンドラが一つも追加されていない場合でも満たされます.

Networking TS ではこの後者の場合はなくなります. strand が一般化されたことで, 一つ目の条件を満たされているか判断できなくなったためです (前者の場合が満たされている場合については, 自動的に一つ目の条件も満たされるので判断は不要です). Boost.Asio では strand は io_service が密に結合することで, 前者の条件判定を可能にしていました.

内部ハンドラのメモリ割り当て

strand は, strand に追加されたハンドラを実行するための内部ハンドラを一つだけ保持し, その内部ハンドラをベースとなる Executor に追加することで直列実行を実現しています. このため, ベース Executor がそのハンドラを保持しておくためのメモリ割り当てが必要となります.

Boost.Asio では strand と io_service が密に結合していたため, ハンドラ用のメモリ割り当ては strand オブジェクトを生成したときだけで十分でした (必要なメモリのサイズ, データ構造を strand が知っていた).

Networking TS では strand は任意の Executor に対応する必要があるので, 内部ハンドラ追加のたびにメモリ割り当てが必要です. このことから

  • 動的にメモリを割り当て / 解放するためのコスト,
  • メモリ割り当て失敗による例外に対する例外安全性,

といった課題が予想されます.

幸い, 現状の Networking TS の実装は一度割り当てたメモリをできるだけ再利用するので, 割り当てコストは小さく, 例外が投げられる可能性が発生する機会も多くないようです.

まとめ

Networking TS では io_service と strand の共通部分は Executor に, io_service の残りの部分は ExecutionContext に一般化されました. それに伴い, 各メンバ関数が非メンバ関数になったり, それらの機能の振る舞いにも変更が入りました.

また, io_service を前提としていた strand は Executor に対して一般化されました. 本記事では紹介しませんでしたが, 他にも io_service::work が一般化されていたりします.

Boost.Asio から Networking TS に移行する場合はこれらの変更点がソースコード上に明確に現れることでしょう.

次回は Associated Executor の説明をして, 本記事で説明した dispatch や bind_executor がどう影響するのかを見ていこうと思います.