あめだまふぁくとりー

Boost.Graphとかできますん

Neovim 上で Sixel 画像を表示する

Neovim 上で Sixel 画像を表示する(といっても,実際は Noevim をバイパスして,ターミナルエミュレータが表示するわけだが).

環境は macOS,iTerm2 を使用した. Sixel 画像の生成には timg を使用したが,いろいろ面倒なので img2sixel を使った方が楽にできる(詳細は後述).

chansend だとうまくいかない

Neovim から Sixel 画像を表示するには,こちら にも記載のある通り,tty に対して直接 Sixel データを書き込むとよい.

上記の参照先では tty にデータを書き込むのに chansend を使用すれば良いとあるのだが,実際にやってみると画像が全く表示されなかったり,一部だけ表示されたりでうまくいかない(Vimechoraw を使った場合はうまくいく). 特に画像が大きくなればなるほど失敗する傾向にある.

調べた限りだと,chansend による tty への書き込みは失敗する場合や部分的にしか書き込めない場合が多分あるようである. なので,以下のようにして chansend を複数回呼ぶようにして見た.

  • chansend の戻り値(書き込めたデータの長さ)を見て,書き込めなかった分はリトライする
  • 一度に書き込むデータを最大 32 Byte になるよう,書き込むデータを分割する

これをすることでだいぶ安定して画像を表示できるようになったが,それでも画像が乱れる場合があったので chansend を使う方法を諦めることとした(ちなみにリトライする際に sleep を入れたら乱れが余計にひどくなった).

非同期 API を使用する

Neovim には libUV を使用した非同期 API が用意されており,こちらを使用すると綺麗に画像を表示することができた. 既存の画像表示プラグインもこちらの方法を使用している.

local stderr = vim.loop.new_tty(2, false)
stderr:write(sixel_data)

ただし,stderr:write は書き込みが完了していないデータが存在する場合は失敗するので連続して画像を書き込む場合は,先行する書き込みが完了してから書き込みを行う必要がある.

-- 書き込み待ちキュー
local write_queue = {}
local stderr = vim.loop.new_tty(2, false)
local write_callback
write_callback = function(err)
    if err then
        return
    end
    table.remove(write_queue, 1)
    if #write_queue > 0 then
        -- 書き込み待ちデータが存在する場合は,待ちデータの書き込みを実行
        stderr:write(write_queue[1], write_callback)
    end
end

-- 書き込み処理
table.insert(write_queue, sixel_data)
if #write_queue == 1 then
    -- 書き込み待ちデータが存在しない場合のみ書き込みを実行
    stderr:write(write_queue[1], write_callback)
end

timg を使用した Sixel 画像の表示

ここまでの内容で Neovim 上で画像を表示することはできるようになったのが,Neovim から timg を起動して Sixel 画像を生成する場合にも注意点がある.

Sixel の DCS が二重になっている

これは Neovim は関係ないのだが,timg が生成する Sixel データはなぜか DCS が二重になっており,そのままでは iTerm2 では画像を表示できなかった(WezTerm の場合は表示できた).

通常の Sixel の DCS は DCS q ST なのだが timg が出力するものは DSC q DCS q ST ST となっていた.

invisible-island.net

しょうがないので二回目の DCS q から一回目の ST までを抜き取るようにして,抜き取った部分だけを tty に書き込むようにした.

GIF アニメーションがされない

ターミナルエミュレータから直接 timg を実行した場合は GIF アニメーションが再生されるのに,jobstart で timg に Sixel 形式で画像を出力させてみると再生されないことがわかった.

Neovim の terminal から timg を実行すると以下のメッセージが表示された.

Terminal does not support pixel size query, but with sixel graphics this is needed to show animations or columns.
File an issue with your terminal implementation to implement ws_xpixel, ws_ypixel on TIOCGWINSZ or "\033[16t" query.
Can't show animations or have columns in grid.
(Suggestion: switch back to --pixelation=quarter for now)

timg が DCS 16 t でセルあたりのピクセルサイズを取得しにきているがそれに対して応答していないため,GIF アニメーションができていないことがわかった(どうやら,カーソル位置を画像の先頭に持ってくるのに必要なようだ).

他にも情報を取得しにきていたので,以下のように諸々返すようにしたら GIF アニメーションも再生できるようになった.

local buffer = ""
local jobid = vim.fn.jobstart({ "timg", "-p", "s", "-U", path }, {
    clear_env = true,
    pty = true,
    width = 200,
    height = 80,
    on_stdout = function(jobid, data, _)
        for _, line in pairs(data) do
            if line == "" then
                return
            elseif line == "\x1b[16t" then
                -- Pixel サイズ 28x14
                vim.fn.chansend(jobid, "\x1b[6;28;14")
                return
            elseif line == "\x1b[>q\x1b[5n" then
                -- 端末情報
                vim.fn.chansend(jobid, "\x1bP>|libvterm(0.3)\x1b\\\x1b[0n")
                return
            elseif line == "\x1b]11;?\x1b\\" then
                -- 背景色
                vim.fn.chansend(jobid, "\x1b]11;rgb:3333/3333/3333\x1b\\")
                return
            end

            local pos1, pos2 = line:find("\x1b\\")
            if pos1 == nil then
                buffer = buffer .. line
                return
            end

            buffer = buffer .. line:sub(1, pos2)
            local sixel_pos = buffer:find("\x1bPq\x1bPq")
            if sixel_pos ~= nil then
                buffer = buffer:sub(sixel_pos + 3)
                -- 25: hide cursor
                -- 80: show sixel in other window
                -- 7730: sixel scroll left mode
                -- 8452: sixel scroll right mode
                buffer = "\x1b[?25l\x1b[80h\x1b[?7730h\x1b[?8452l" .. buffer
                -- 表示位置指定
                buffer = "\x1b[%d;%dH":format(0, 0)
                table.insert(write_queue, buffer)
                if #write_queue == 1 then
                    stderr:write(write_queue[1], write_callback)
                end
            end
            buffer = line:sub(pos2 + 1)
        end
    end,
    stdout_buffered = false,
})

後片付け

これで表示は完全にできるようになったのだが,これを使って画像のプレビューを何度もしていると iTerm2 の仮想メモリが上昇し続けていることに気がついた. 表示した画像は normal! <C-L> で消しているだが,どうやら iTerm2 は内部で表示した Sixel 画像の情報をずっと保持しているままのようだった.

⌘+K で iTerm2 のバッファを消してあげると使用メモリは減ったのだが,これを毎回手動でやる気にはなれない.

iTerm2 のドキュメントを見てみると,特定の OSC を送信するとバッファを削除できることがわかった. これを画像削除時に送信してやることで仮想メモリの上昇を抑えることができた.

if vim.env.TERM_PROGRAM == "iTerm.app" then
    vim.fn.chansend(vim.v.stderr, "\x1b\\\x1b]1337;ClearScrollback\x1b\\")
end

余談

画像表示には iTerm2 プロトコルを使用することもできたのだが,アニメーション再生が iTerm2 プロトコルだともっさりしていたので今回は Sixel を使用するようにした. ただ,Sixel だと背景透過ができず,iTerm2 プロトコルだとできるのでパフォーマンスを改善できるなら iTerm2 プロトコルを使用したい.

iTerm2 上の Neovim だと Ctrl + backslash が効かない

iTerm2 で Neovim を再起動するとなぜか Ctrl + backslash が効かなくなるため,これまで Mac 標準のターミナルを今まで使用していたのだが,iTerm2 の設定でうまく回避することができた.

iTerm2 の Key Bindings の設定で,「Send Hex Codes」Action を「^¥」に割り当て上げるだけで OK. 指定するコードは 0x1c となる. ちなみに Ctrl + backslash のコードは,insert モードで <C-v><C-\> を入力して,入力した文字の上で g8 すればわかる.

ついでに Alt + backslash は,「Send Escape Sequence」Action で \ を送信してあげれば良い.

Mac 標準のターミナルだと im-select で IME を OFF にすると,次回 ON にした際の初入力で再変換が誤発して非常にストレスフルだったので,これで安心して日本語入力ができるようになった.

vfiler.vimの乗り換えました

Neovimの設定をLuaで一新したので,使用していたプラグインも見直しを行いました. ファイラーはこれまでDefxを使用していましたが,以下の理由でvfiler.vimを採用しました.

  • Luaで設定が書けること(Luaの設定ファイル内でVimScriptを書く方法がよくわかっていないので).
  • 依存関係がNeovim内で閉じていること(Defxはpynvimが必要だったりして,新しく環境を構築するときにいろいろと面倒だったため).
  • 2画面ファイラーとして使えること(VimFilerの使いやすさが忘れられないため).

機能的に足りないところもあるのですが,view:get_item()を使うと現在のカーソルのエンティティの情報が取れるので,これでいろいろマッピングを追加しました(ドキュメントには書いていないのでもしかしらた非推奨の使い方かもしれません).

ファイル名の表示

サイドバーとして表示している場合,横幅の関係上ファイル名が省略されて表示されてしまいます.なので,Ctrl+gで現在のカーソルのエンティティの名前を表示するようにしました.

            ["<C-g>"] = function(vfiler, context, view)
                local item = view:get_item()
                print(item.name)
            end,

ファイルかディレクトリかでの処理の分岐

デフォルトだと,ディレクトリにしか効果のないアクションもあるので,カーソルのエンティティがファイルかディレクトリかを判断してアクションを変えるようにしました.

            ["o"] = function(vfiler, context, view)
                local vfiler_action = require("vfiler/action")
                local item = view:get_item()
                if item.type == "directory" then
                    if item.opened == true then
                        vfiler_action.close_tree_or_cd(vfiler, context, view)
                    else
                        vfiler_action.open_tree(vfiler, context, view)
                        -- 開いたツリーの中にカーソルが移動するので元に戻す
                        vfiler_action.move_cursor_up(vfiler, context, view)
                    end
                else
                    vfiler_action.open_by_vsplit(vfiler, context, view)
                end
            end,

ターミナルとの連携

vfiler.viimからターミナルを開いて,選択中のエンティティを入力済みにするようにしました. これにはview:get_item()の代わりにview:selected_items()を使用しています.

local open_vfiler_terminal = function(dirpath, args)
    local termname = "term://" .. vim.fn.getbufinfo("%")[1].name
    local termbufs = vim.fn.getbufinfo(termname)
    local job_id = nil
    if next(termbufs) == nil then
        vim.cmd [[
            botright new
            resize 15
        ]]
        job_id = vim.fn.termopen({vim.opt.shell:get()}, { cwd = dirpath })
        vim.cmd("keepalt file " .. termname)
    else
        local termbufinfo = termbufs[1]
        if termbufinfo.hidden == 0 then
            -- 表示済みの場合は表示中のWindowにフォーカスする
            local wids = vim.fn.win_findbuf(termbufinfo.bufnr)
            if next(wids) ~= nil then
                vim.fn.win_gotoid(wids[1])
            end
        else
            -- 非表示の場合は画面分割で開く
            vim.cmd("botright sbuffer " .. termbufinfo.bufnr)
            vim.cmd [[resize 15]]
        end
        job_id = termbufinfo.variables.terminal_job_id
        -- VFilerで開いているディレクトリに移動する
        vim.fn.chansend(
            job_id,
            vim.api.nvim_replace_termcodes("<C-U>", true, true, true)
            .. " cd " .. vim.fn.shellescape(dirpath)
            .. vim.api.nvim_replace_termcodes("<CR>", true, true, true))
    end
    if args ~= "" then
        vim.fn.chansend(
            job_id,
            args .. vim.api.nvim_replace_termcodes("<C-A>", true, true, true))
    end
    vim.cmd [[startinsert]]
end
            ["H"] = function(vfiler, context, view)
                local selected_items = view:selected_items()
                local args = ''
                for _, item in pairs(selected_items) do
                    if item.selected then
                        args = args .. ' ' .. vim.fn.shellescape(item.path)
                    end
                end
                vfiler_action.clear_selected_all(vfiler, context, view)
                open_vfiler_terminal(selected_items[1].parent.path, args)
            end,

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 までで書きたいことは書けたと思いますので, 本シリーズは今回で終了です.