あめだまふぁくとりー

Boost.Graphとかできますん

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 でタイマキャンセルを正しく実行する方法を紹介しました.

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