あめだまふぁくとりー

Boost.Graphとかできますん

Sphinx における日本語文書の不自然な空白を除去する拡張を公開した

Sphinx で日本語文章を書いたドキュメントを HTML として表示すると, 改行箇所やインラインマークアップの前後で空白が表示されてしまいます. 今までは japanesesupport.py を使用していたのですが, pip でインストールできないのが面倒くさかったので同機能の拡張を自作してパッケージ化しました.

pypi.org

japanesesupport.py には色々問題があったため, 一から自作しています.

半角文字間の空白の維持

japanesesupport.py では英単語間で改行を挟むとその英単語間の空白まで除去されてしまいます.

sphinxcontrib-trimblank では除去対象の空白の前後が半角かどうかチェックするようにしています. ASCII コードの範囲だけでチェックすると必要以上に空白が消えてしまうので, 全角となる Unicode の文字範囲を適当に調べて実装しています (この点は改善の余地があると思います).

パラグラフ以外での空白の除去

japanesesupport.py は Docutils の paragraph ノード 直下のテキストしか空白除去の対象にしていません. このため, インラインマークアップ中の改行等が除去されません.

sphinxcontrib-trimblank はテキストを包含できる他のノードも対象にいれています (もちろん, literal_block 等の改行を維持するノードは対象外にしています).

改行とインラインマークアップ前後以外の空白の除去

私は Sphinx で文章を書く場合は日本語と半角英数字との間に空白を入れるようにしているのですが, 以下のような文章を japanesesupport.py で処理すると二つ目の japanesesupport.py の直前の空白は残りますが直後の空白は消えてしまいます.

japanesesupport.py でこの文章を処理すると, 二つ目の japanesesupport.py
の直前には空白があるのに対し, 直後には空白がない文章が生成されてしまう.

f:id:amedama41:20190811173058p:plain
japanesesupport.py での処理結果

sphinxcontrib-trimblankでは一貫性のある文章を出力するため,改行とインラインマークアップ前後以外の空白も除去します.

f:id:amedama41:20190811173116p:plain
sphinxcontrib-trimblank での処理結果

ただし, HTML など特定種別のドキュメントでは日本語と半角英数字との間の空白だけは除去したくない人もいるかと思います. その場合, conf.py に以下のオプションを追加することで日本語と半角英数字間の空白を維持できます.

# html, singlehtml でビルドする場合は半角英数字との間の空白は維持する
trimblank_keep_alnum_blank = ['html', 'singlehtml']

f:id:amedama41:20190811173125p:plain
日本語と半角英数字間の空白を維持した場合の処理結果

ドキュメントの種別に関わらず, 維持したい場合はオプションの値をリストの代わりに True を指定できます.

CentOS7でPlantUMLのレイアウトが上手く制御できない

CentOS7でPlantUMLサーバを立ち上げて見たのですが,ユースケース図やクラス図の配置が上手く制御できませんでした. 結論としてGraphvizのバージョンを上げることで解決できました.

制御が正しく機能しない例

PlantUMLでは,要素間の関係にleft, right, up, downを指定することで要素間の位置関係をコントロールできます. 例えば,以下のコードをPlantUML Web Serverコンパイルすると次の図が生成されます.

@startuml
A -up- A1
A -right- A2
A -down- A3
A -left- A4
@enduml

f:id:amedama41:20190720095046p:plain
出力例(正しいレイアウト)

しかし,CentOS7で立ち上げたPlantUMLサーバでは同じコードでも以下の図が生成されてしまいました.

f:id:amedama41:20190720095606p:plain
出力例(誤ったレイアウト)

どうやら,leftrightが正しく動作していないようです.A - A2のように関係に一つのハイフンを使った場合も同様です(ハイフン一つだと-right-と同じ意味になります).

環境の確認

公式のサーバと自前のCentOS7環境で出力結果が異なる原因を探るため,動作環境の確認をしました. 以下のコードでPlantUMLとGraphvizのバージョンを確認できます.

@startuml
version
@enduml

以下の表に確認結果を載せます.

自前環境 公式サーバ
PlantUML 1.2019.06 1.2019.08
Graphviz 2.30.1 2.38.0
JRE 1.8.0 1.7.0

自前環境ではPlantUMLとGraphvizのバージョンが古いことがわかりました. PlantUMLはMavenでインストールしたものを,GraphvizJREはCentOS7のデフォルトのリポジトリからインストールしたものを使っていました.

まず,PlantUMLのバージョンを1.2019.08に更新してみましたが結果は変わりませんでした. なので,次にGraphvizを更新してみました.

Graphvizの更新

CentOS7のデフォルトのリポジトリには2.30.1しかありません. Googleで検索すると,過去にGraphvizの公式がRHEL用のGraphvizリポジトリを提供していたようですが,現在はリポジトリの定義ファイルはリンク切れになっていました. なので,ソースコードから自前でビルドすることにしました.Graphvizの公式ダウンロードページからは2.40.1のソースしかダウンロードできず,PlantUMLのFAQには2.40では問題が出る可能性があると書いてあるので,GtiLabからソースを持ってきました.

# ソースコードをクローン
git clone https://gitlab.com/graphviz/graphviz.git
# tagがないのでログで2.38のコミットハッシュを検索してcheckout
cd graphviz
git checkout -b v2.38 f54ac2c
# PlantUMLの連携にlibexpatが必要 (FAQ参照)
sudo yum install expat-devel
# Graphviz公式ページ記載の手順でビルド.今回は/opt/graphvizにインストールする
./autogen
./configure --prefix=/opt/graphviz
make
sudo make install

あとは,PlantUMLサーバがアップデートしたGraphvizを使うようにすれば完了です.

mvn jetty:run -DGRAPHVIZ_DOT=/opt/graphviz/bin/dot

この状態で,問題となったコードをコンパイルすると公式と同じ結果を出力できるようになりました.

まとめ

CentOS7のGraphvizは古いのでPlantUMLが期待通りの結果を出力してくれません. Graphvizを自前でビルドしてバージョンアップするか,CentOS以外のOSを使ってやるとよいです.

sphinx-docxbuilderを復活させた

f:id:amedama41:20190407155426p:plain

どうしても Office Word を使いたくなかったので, sphinx-docxbuilder を復活させまし た. ソースは GitHub にあげています.

最初はちょっと手を入れる程度だったのですが, テーブル周りに色々問題があったりで修正しすぎてソースコードの原型はほとんどなくなりました.

インストール

pip でインストールできます

pip install docxbuilder

使い方

conf.pyextensions 設定に "docxbuilder" を追加して, make docx を実行するだけです.

設定

ドキュメントのファイル名等は docx_documents 設定で行います.

# index.rst をドキュメントのルートとし, index.rst の TOC ツリー以外は除外する
# 出力ファイル名は docxbuilder.docx で title, creator, subject プロパティを設定する.
docx_documents = [
  ('index', 'docxbuilder.docx', { 'title': 'Title', 'creator': 'Author', 'subject': 'A manual of docxbuilder', }, True),
]

設定値はタプルのリストになっており, タプルの最初の値はルートとなるドキュメントファイル (通常は index), 二つ目は出力ファイル名, 三つ目はドキュメントのプロパティ, 四つ目はルートドキュメントファイルの TOC ツリー以外の要素を除外するかどうかを示します.

デフォルトスタイルファイルの表紙は title, creator, subject プロパティを参照しているので, この三つのプロパティは指定しておくとよいです.


スタイルファイルは docx_style 設定で指定できます.

docx_style = 'path/to/custom_style.docx' # カスタムスタイルファイルを指定する.

Word のスタイルはパラグラフを分割させないような指定もできるのですが, スタイルファイルで指定するのは面倒なので, パラグラフや図表の配置は docxbuilder の中でできるだけいい感じになるように頑張っています. 表については docx_table_options 設定や特定のクラスを指定することで, 表が複数のページに分割されないようにしたり, 表を横長のページに配置することも可能です.


docx_coverpage 設定で表紙を追加するかどうか指定します.

docx_coverpage = True # 表紙を挿入

表紙は自動生成するのではなく, スタイルファイルの表紙が挿入されます. また, 表紙の定義は "Cover Pages" タグが指定されたページなのですが, このタグを明示的に指定する方法は多分ないので, 自前の表紙を作る際は Word の表紙挿入機能でプリデザインの表紙を挿入して, それを編集する必要があります.


docx_pagebreak_before_section 設定で,セクションタイトルの前で改ページすることが可能です.

docx_pagebreak_before_section = 2  # セクションレベルが 2 以下 (章と節) のタイトルはページの先頭に配置

指定した数値以下のセクションレベルを持つタイトルはページの先頭に配置されるように改ページされます. 最小のレベルは 1 です. デフォルトの設定値は 0 なので一切改ページされません.


目次は最初のセクションの前にある場合もあり, 目次の後に改ページしたいかもしれません. その場合は, docx_pagebreak_after_table_of_contents 設定を使用します.

docx_pagebreak_after_table_of_contents = 0 # セクションレベル 0 以下のセクションに登場する目次の後で改ページ

表の配置については docx_table_options 設定を使用します.

docx_table_options = {
  'landscape_columns': 6,      # 列数が 6 以上の表は横長のページに配置
  'in_single_page': True,      # 可能な限り表が複数のページに分割されないようにする
  'row_splittable': True,      # セルが複数ページに跨ってもよい
  'header_in_all_page': True,  # 表が複数ページに分割される場合, すべてのページに表のヘッダーを表示する
}

この設定はすべての表に適用されます. 個別に設定したい場合は表にクラスを指定します (後述).

ドキュメントのカスタマイズ

ドキュメントのスタイル

ドキュメントの見た目はスタイルファイルを使って変更可能です. スタイルファイルは Word のファイルで, ドキュメントに適用するスタイルや目次, ページ設定 (余白やヘッダ等) を定義します.

docxbuilder で使用するスタイルには文字スタイル, パラグラフスタイル, 表スタイルの三種類のスタイルがあります.

主に使用するスタイルは こちら を参照してください. docxbuilder はスタイル名 (Emphasis, Body Text 等) とスタイル種別 (文字, パラグラフ, 表) をチェックしてスタイルを適用するので, この二つの定義は間違えないようにしてください.

また, List Bullet と List Style は箇条書きに適用されるスタイルで, このスタイルにはアウトラインの定義が必須です.

クラスによるカスタマイズ

図表が横長になる場合, 横長のページにそれらを配置したくなるケースがあります. その場合, docx-landscape クラスを指定するとよいです.

.. rst-class: docx-landscape
.. list-table::

   * - cell1-1
     - cell1-2
   * - cell2-1
     - cell2-1

図に使用できるクラスは docx-landscape クラスだけですが, 表には他にも docx-in-single-page, docx-row-splittable, docx-header-in-all-page が使用可能です (効果は docx_table_options のものと同じです).

今後の予定

現状数式は latex の数式そのままが出力されるので, Word の数式として出力したいです (これ を使って latex 数式を MathML に変換して, MathML を OMML に変換する予定).

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

はじめに

Boost 1.66 で Boost.Asio が Networking TS に対応するようです. なのでその 3 からかなり間が空きましたが, その 4 の記事を書きます. 前回 Executor について説明したので, 今回は Associated Executor について説明します.

Boost.Asio でのハンドラ呼び出し

Boost.Asio では非同期処理完了時のハンドラ呼び出しに asio_handler_invoke という非メンバ関数を使用することになっています. この関数はデフォルトでは引数で受け取った関数オブジェクトを単に呼び出すだけです.

template <class Function>
void asio_handler_invoke(Function& function, ...) {
  function();
}

ただし, この関数も asio_handler_allocate 等と同様, 引数として別にハンドラを指すポインタを取るので, 関数の呼び出し方をカスタマイズすることができます.

Strand では実際に asio_handler_invoke をフックすることで, ハンドラが並列に実行されないことを担保しています.

template <class Function, class Handler>
void asio_handler_invoke(
      Function& function
    , wrapped_handler<strand, Handler>* handler) {
  // wrapped_handler は strand.wrap(handler) で得られるハンドラの型
  handler->strand.dispatch(function); // 実際はもう少しいろいろなことをやる
}

Networking TS でのハンドラ呼び出し

Networking TS では asio_handler_invoke は廃止し, 代わりに Associated executor を使用します.

Associated executor は その 1 で説明した Associated allocator と同様にハンドラごとに関連付けられた Executor です.

Associated executor はやはり Associated allocator と似たように, associated_executor を使用して取得できます.

my_handler h;
auto exec = associated_executor<my_handler>::get(h);

Networking TS では非同期処理完了時にこの Associated executor にハンドラを post / dispatch します. なので, 独自に Executor を使用するようにすることで, asio_handler_invoke と同様のことを実現できます. Associated executor が strand なら, ハンドラは呼び出される前に strand.post / dispatch されます.

Executor のハンドラへの関連付け

既存の Executor をハンドラに関連付けたい場合は, bind_executor を使用します.

bind_executor は前回の紹介では strand.wrap に相当するものと説明しましたが, 実際はハンドラに Executor を関連付けること以上のことは行いません.

これはどうゆうことかというと, bind_executor の結果の関数オブジェクトを関数呼び出し演算子で呼び出しても, 関連付けた Executor を通してハンドラを実行しないということです.

この差については少し注意したほうがよさそうですが, 実際はユーザが直接関数呼び出し演算子で呼び出すことはあまりないと思いますので, あまり気にすることもないかと思います.

auto wrapped_handler = strand.wrap(handler);
wrapped_handler(); // strand の中で実行

auto bind_handler = bind_executor(strand, handler);
bind_handler(); // その場で実行. strand の中では実行しない.

Associated executor への変更による影響

Networking TS では非同期処理完了時にハンドラは Associated executor に dispatch, または post されます. 前回説明したように Executor の dispatch / post はハンドラをそのまま実行します (Boost.Asio では asio_invoke_handler はさらに asio_handler_invoke を呼び出す場合があります).

これはどういうことかというと, ハンドラが入れ子になっている場合, 内部の Associated executor は無視されるということです.

下記のコードを例にして説明します.

auto bind_h
  = bind_executor(strand1, bind_executor(strand2, h));
socket.async_read(buffer, bind_h);

このコードでは strand1 と strand2 を二重にバインドして, ソケットからの非同期読み出しを行なっています. bind_h の外側の Associated executor は strand1 であり, 内側は strand2 です. つまり, bind_h の直接の Associated executor は strand1 になります.

そのため, 読み出しが完了すると, strand1.dispatch(func) が実行され, strand1 の中で func() が呼び出されます (func は std::bind(bind_h, read_size, error) に相当する関数オブジェクト). 上記で説明した通り, bind_executor の戻り値の関数オブジェクトは, バインドした Executor を無視してハンドラを呼び出します. よって, bind_executor(strand1, bind_executor(strand2, h))() は即座に bind_executor(strand2, h)() を実行し, それは即座に h() を実行することになります.

つまり, 上記のコードではハンドラは strand1 の中で実行されることになります.

Boost.Asio の場合は, asio_handler_invoke が連鎖する設計になっていたため, 以下のコードでは strand2 の中で実行されます.

auto wrapped_h = strand1.wrap(strand2.wrap(h));
socket.async_read_some(buffer, wrapped_h);

Networking TS と Boost.Asio では最終的に使用される Executor が異なるので注意する必要があります.

Networking TS の改善点

Networking TS では, executor::dispatch / post とbind_executor の設計を見直し, Executor のネストを無視するようにしました. これにより, Boost.Asio に比べて速度の面と安全性の面で改善された箇所があるので, 以降はそれらについて説明します.

Strand の効率化

Boost.Asio の場合

Boost.Asio で strand でラップしたハンドラが非同期処理完了時に呼び出される流れは以下のようになります (ここでは strand.wrap(handler) の型を wrapper とします).

  1. wrapper 用の asio_handler_invoke の中で strand にラップした関数オブジェクトを dispatch.
  2. strand のコンテキストで asio_handler_invoke 経由で wrapper::operator() を実行. strand に handler を dispatch する.
  3. strand のコンテキストで asio_handler_invoke 経由で handler を実行.

このように, 1 と 2 の処理で無駄に一回多く dispatch を行っています.

また, 2 の処理で呼び出す asio_handler_invoke を 1 で呼び出したものと違うものにするため, 実際には 1 の dispatch 時に関数オブジェクトをリラップして関数オブジェクトの型を変更しています. ラップしない場合, 2 で 1 と同じ asio_handler_invoke が呼び出され, 無限再帰になってしまいます.

Networking TS の場合

Networking TS ではこれらが改善され, Strand を通した呼び出しが少しだけ効率的になります.

  1. Associated executor である strand にメンバ関数 dispatch を使用して dispatch.
  2. strand のコンテキストで元のハンドラを実行.

Networking TS では executor_binder::operator() は単に元のハンドラを呼び出すだけなので, 不要な dispatch は発生しませんし, リラップの必要性もありません.

パフォーマンス測定

実際にどれくらい改善されたか測定します. Boost.Asio でも以下のように strand::dispatch をラムダ式の中で呼び出せば, Networking TS と同等の改善が得られます (注意:この方法は async_write 等の composed operation と一緒に使用できないので, プロダクトコードで使用するのはおすすめしません).

socket.async_read_some(buffer, [&] { strand.dispatch(handler); });

測定用のコードは Gist に置いてあります.

ここでは, あらかじめ io_service に 10,000,000 個のハンドラを post しておき, それらがすべて実行されるまでの時間を測りました (使用したコンパイラは clang でオプションは -std=c++11 -stdlib=libc++ -pedantic -Wall -O3 です).

f:id:amedama41:20171209101405p:plain

わずかに改善されているのがわかりますね.

安全性の改善

Strand の効率化の説明で見た通り, Boost.Asio の Strand 用の asio_handler_invoke は無限再帰呼び出しを回避するため注意深く設計されています. ユーザ自身が asio_handler_invoke を定義する場合も, このことに注意して設計, 実装する必要があります.

しかしながら, これは意外と容易ではありません. Boost.Asio では様々な関数が asio_handler_invoke を使い, 様々なクラスが自身の asio_handler_invoke をフックしています. それらの関係図全体が見えていないと, 安全な asio_handler_invoke を設計できたとは自信を持って言うことはできないでしょう.

Networking TS では, Associated executor を使用する関数 (非メンバ関数版の dispatch / post) と使用しない関数 (メンバ関数版の dispatch / post) を分離し, 後者を呼び出すことで再帰を発生しないようにしました.

ユーザは周りに気にせず安全に自身の Associated executor を実装できるようになります.

まとめ

Networking TS では Boost.Asio の asio_handler_invoke を Executor に置き換えました.

また, Executor のネストを無視するように設計変更したことで, Strand の効率や, ユーザによるカスタマイズの安全性を改善しました.

とりあえず, Networking TS の Boost.Asio からの変更点については, その 4 までで書きたいことは書けたと思いますので, 本シリーズは今回で終了です.

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 がどう影響するのかを見ていこうと思います.

Networking TS の Boost.Asio からの変更点 - その 2: async_result

はじめに

前回は Boost.Asio から Networking TS への変更点として Associated allocator について説明しました.

今回は, async_result について説明します.

Boost.Asio における非同期処理スタイル

Boost.Asio の非同期関数はすべて継続渡しスタイルをベースに設計, 実装されており, 継続となる関数オブジェクトを最終引数として受け取ります.

socket.async_read_some(
      asio::buffer(buf)
      // 継続渡し: 読み込み完了時に実行される
    , [&](auto ec, std::size_t nread) {
  asio::async_write(
      socket, asio::buffer(buf, nread)
      // 継続渡し: 書き込み完了時に実行される
    , [&](auto ec, std::size_t) { ... });
});

しかしながら, 非同期処理のスタイルは継続渡し以外にもコルーチンを使用したものなどあり, それぞれの利点, 欠点があります.

そのため, Boost.Asio では継続渡しスタイルをベースにしながらも, 他のスタイルもサポートしています. そのために使用されるのが async_resulthandler_type の二つのクラステンプレートです.

これらの説明をするためにコルーチンを使用した例を見てみます.

// コルーチンの例
asio::spawn(io_service, [&](asio::yield_context yield) {
  unsigned char buf[255];
  auto nread = socket.async_read_some(asio::buffer(buf), yield);
  asio::async_write(socket, asio::buffer(buf, nread), yield);
});

この例では非同期関数に yield_context 型のオブジェクト yield を最終引数に渡していますが, このオブジェクトは関数オブジェクトではありません. 継続渡しスタイルベースで実装されている非同期関数に非関数オブジェクトを渡すことができるのは, 非同期関数が内部でこれを関数オブジェクトに変換しているからです. この変換に使用されるのが handler_type です.

handler_type はテンプレート引数に非同期関数の最終引数の型を受け取ります. この型は CompletionToken と呼ばれ, handler_type は CompletionToken によって特殊化されます.

特殊化された handler_type は関数オブジェクトの型を依存型として返すので, 非同期関数はこれを継続として使用します. ちなみに, handler_type はデフォルトでは CompletionToken をそのまま返すので, 継続渡しスタイルの場合, 継続として渡した関数オブジェクトがそのまま使用されることになります.

async_result

コルーチンの例では非同期関数 async_read_some に戻り値があることがわかります (使用していませんが async_write も戻り値はあります). 一番初めの継続渡しの例では戻り値はありません (コード上からはわかりませんが).

このように非同期関数の戻り値も CompletionToken によって変わります. 戻り値を決めるのが async_result の役目です.

async_result は戻り値の型, 及び戻り値の生成を責務としており, handler_type で変換された関数オブジェクトの型で特殊化されます.

Boost.Asio では各非同期スタイルに対して handler_typeasync_result を特殊化するだけでよいので, 非同期関数はただ一つ定義すればよいことになります.

Networking TS における async_result

Networking TS でも Boost.Asio 同様 CompletionToken の仕組みを使用します.

しかし, Networking TS では handler_type は存在しません. CompletionToken から関数オブジェクトへの変換も async_result が受け持ちます. このため, async_result は変換後の関数オブジェクトの型ではなく CompletionToken で特殊化されます.

この変更がどのように影響するかを次節で見ていきます.

async_result と handler_type 統合による影響

Boost.Asio の場合

async_result への統合への影響を説明するためにまた例を出します.

今度の例は, ある strand 内で非同期関数を呼び出す, という非同期関数です. マルチスレッドを使用している場合, このような処理をしたくなる場合があると思います.

template <class Stream, class ConstBufferSequence, class CompletionToken>
auto async_write_in_some_strand(
      Stream& stream, ConstBufferSequence const& buffers
    , asio::io_service::strand strand
    , CompletionToken&& token)
{
  using handler = typename asio::handler_type<
      CompletionToken, void(system::error_code, std::size_t)>::type;
  handler h{std::forward<CompletionToken>(token)};
  asio::async_result<handler> result{h};

  // strand の中で非同期関数を呼び出す.
  strand.post([&stream, buffers, h]{
    asio::async_write(stream, buffers, h);
  });

  return resutl.get();
}

この関数は一見問題なさそうに見えますが, 実は致命的なバグがあります.

コルーチンを使用してこの関数を呼び出すと未定義の動作を招きます. 大体の場合, この関数を呼び出したコンテキストにコルーチンが戻ってきません.

これは特殊化された async_result が, async_write_in_some_strandasio::async_write の両方で使用されるためです (Boost.Asio の async_resulthandler_type で変換した型で特殊化されることを思い出してください). handler で特殊化された async_resultメンバ関数 get (上記の result.get()) は, コルーチンから一度抜けるように実装されているので, コルーチンから二回抜けようとするわけです.

上記の関数を正しく動作させるためには, asio::async_write で特殊化された async_result が使用されないように, handler_type で取得した関数オブジェクトをラップしてあげる必要があります.

template <class Stream, class ConstBufferSequence, class CompletionToken>
auto async_write_in_some_strand(
      Stream& stream, ConstBufferSequence const& buffers
    , asio::io_service::strand strand
    , CompletionToken&& token)
{
  using handler = typename asio::handler_type<
      CompletionToken, void(system::error_code, std::size_t)>::type;
  handler h{std::forward<CompletionToken>(token)};
  asio::async_result<handler> result{h};

  strand.post([&stream, buffers, h]{
    asio::async_write(
        stream, buffers
        // 特殊化した async_result が使われないようにラップする
      , [h](auto&&... args) { h(std::forward<decletype(args)>(args)...); });
  });

  return resutl.get();
}

上記では簡略化のためラムダ式を使用しましたが, asio_handler_allocate 等の他のカスタマイゼーションポイントを有効にするには きちんとそれらの関数を転送するラッパを書く必要があります.

Networking TS の場合

Networking TS ではこのような誤りは起こりにくくなっています.

template <class Stream, class ConstBufferSequence, class CompletionToken>
auto async_write_in_some_strand(
      Stream& stream, ConstBufferSequence const& buffers
    , asio::io_service::strand strand
    , CompletionToken&& token)
{
  using aresult = asio::async_result<
      CompletionToken, void(system::error_code, std::size_t)>;
  typename aresult::handler_type h{std::forward<CompletionToken>(token)};
  aresult result{h};

  strand.post([&stream, buffers, h]{
    // async_result は CompletionToken に対して特殊化されており,
    // aresult::handler_type に対しては特殊化されていないのでラップする必要はない.
    asio::async_write(stream, buffers, h);
  });

  return result.get();
}

async_result は CompletionToken で特殊化されており, 内部で呼び出す非同期関数には変換後の関数オブジェクトが渡されるため, 特殊化された async_result が再び使用されることはありません.

まとめ

Boost.Asio では継続渡し以外の非同期処理スタイルをサポートするため, async_resulthandler_type の二つの関数テンプレートをカスタマイゼーションポイントとして使用しています. しかし, これらを使用して複雑な非同期処理関数を書く際, 陥りやすい問題があることを説明しました.

Networking TS ではこれらを一つに統合したことで, 複雑な非同期処理関数でも謝りなく記述できるようになりました.

次は Executor 周りについて説明しようと思います.

Boost.Asio でのタイマのキャンセル処理の仕方

先日, マイナンバーのカード管理システムの障害の原因と対応についての発表がありました.

地方公共団体情報システム機構 カード管理システムの中継サーバに生じた障害原因の特定と対応について

その原因 2 では, Windows のタイマをキャンセルしたのにタイムアウト処理が実行されたとあります.

Boost.Asio を使った場合でも同様のことが発生しうるので, 本記事では正しいキャンセルの方法を紹介します.

正しくないキャンセルの方法

#include <iostream>
#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>

int main()
{
  using namespace boost::asio;
    
  io_service io_srv{};

  steady_timer timer{io_srv, std::chrono::nanoseconds{1}};
  timer.async_wait([](boost::system::error_code ec) {
    if (ec) {
      std::cout << "timer is cancelled - " << ec << std::endl;
    }
    else {
      std::cout << "timer is not canncelled" << std::endl;
    }
  });

  io_srv.post([&]{
    std::cout << "start work" << std::endl;
    timer.cancel();
    std::cout << "cancel timer" << std::endl;
  });

  io_srv.run();
}

上記のコードを実行すると timer.cancel() の後にタイムアウト処理が実行されてしまいます (タイムアウト用のハンドラに渡すエラーコードがキャンセルを示す値にならない).

melpon.org

これはキャンセル処理実行以前にタイムアウトが発生し, タイムアウトハンドラが実行待ちキューに積まれている場合に発生します. メンバ関数 cancel は実行待ちキューに積まれたハンドラに対しては影響がありません.

複数スレッドで io_service を走らせる場合, このようなことは容易に起こりえることが想像できますが, 上記のコードはシングルスレッドのコードです. シングルスレッドのコードでもキャンセルが間に合わないことがあることに注意してください.

タイマを再利用しない場合

タイマオブジェクトを再利用しない場合, 状態変数を追加せずにキャンセルを検知できます.

#include <iostream>
#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>

int main()
{
  using namespace boost::asio;
    
  io_service io_srv{};

  steady_timer timer{io_srv, std::chrono::nanoseconds{1}};
  timer.async_wait([&](boost::system::error_code ec) {
    if (ec || steady_timer::clock_type::now() < timer.expires_at()) {
      std::cout << "timer is cancelled - " << ec << std::endl;
    }
    else {
      std::cout << "timer is not canncelled" << std::endl;
    }
  });

  io_srv.post([&]{
    std::cout << "start work" << std::endl;
    timer.expires_at(steady_timer::time_point::max());
    std::cout << "cancel timer" << std::endl;
  });

  io_srv.run();
}

大きな変更点は二箇所で, 一つはキャンセルの仕方です. メンバ関数 cancel の代わりに, タイムアウト時間を設定する expires_at を使用します. 使用するタイマオブジェクトが取り得る最大時間をこの関数に渡しています.

もう一つの変更点はタイムアウトハンドラでのタイムアウトを判定する条件です. 現在時刻とタイマオブジェクトのタイムアウト時間を比較するようにしています.

キャンセルした場合, タイマオブジェクトには遥か遠い未来の時間が設定されているので, 現在時刻と比較することでキャンセルされたかどうかがわかります. 逆にキャンセルしていない場合は, 現在時刻はタイムアウト時間よりも後になります.

なお, タイムアウト時間を設定する expires_at にはメンバ関数 cancel と同様に タイムアウト待ちのハンドラをキャンセルにするので, エラーコードがキャンセル値になる場合はあります.

この方法で一つ注意しておくことは, タイマオブジェクトの寿命です. タイムアウトハンドラの中でタイマオブジェクトを操作するので, 動的にタイマオブジェクトを生成する場合は そのタイマを指す shared_ptr をハンドラに持たせる必要があるでしょう.

タイマを再利用する場合

一つのタイマオブジェクトを何度も再利用する場合は, 各タイムアウト処理に対して ID を振ってあげるとよいです.

#include <cstddef>
#include <iostream>
#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>

int main()
{
  using namespace boost::asio;
    
  io_service io_srv{};

  steady_timer timer{io_srv, std::chrono::nanoseconds{1}};
  std::size_t timer_id = 0;
    
  timer.async_wait([&, current_id = timer_id](boost::system::error_code ec) {
    if (ec || current_id != timer_id) {
      std::cout << "timer is cancelled - " << ec  << std::endl;
    }
    else {
      std::cout << "timer is not canncelled" << std::endl;
    }
  });

  io_srv.post([&]{
    std::cout << "start work" << std::endl;
    ++timer_id;
    timer.cancel();
    std::cout << "cancel timer" << std::endl;
  });

  io_srv.run();
}

タイマオブジェクトごとにタイマ ID を意味するカウンタ変数を定義し, タイムアウトハンドラに現在の ID を持たせ, タイムアウトの判定処理では 保持するタイマ ID と現在のタイマ ID が一致するかチェックします.

キャンセル処理ではタイマ ID をインクリメントしてあげれば, ハンドラが保持する ID と現在の ID に差が生じるのでタイムアウトを検知できます.

まとめ

Boost.Asio でタイマキャンセルを正しく実行する方法を紹介しました.

タイマのキャンセルが期待通りに実行できるかという問題は他のライブラリ, 言語でも起こりえるものだと思います. ドキュメントに目を通したり, 試験用のコードを書いてみるなどして, 理解を深めてからシステムの実装に当たることをおすすめします.