49日問題をやっつけろ

2020年1月19日

今日の話題は、ソフトウェア業界を震撼させる「49日問題」です。

ここでの49日という数に重要な意味はありません。プログラマーがフリーランカウンタの取り扱いを間違えると、プログラムの起動から49日後に初めてそのバグが発動するケースがたまたま多いことから、この名前があります。ですから、これは本質的にはフリーランカウンタの問題です。

恥ずかしながら、私も過去にやらかしたことがあります。今回はその教訓も踏まえて、フリーランカウンタを取り扱う際のノウハウをまとめます。

なぜ起こるのか

フリーランカウンタ1は、プログラム上では変数です。その値は常に一定の速度で増え続けており、プログラムはこれを利用して経過時間を推定することができます。

値は常に増え続けると言いましたが、メモリ上の数値表現である以上、無限に増えることはできません。ある周期で、表現できる最大値を超えて最小値に戻るという動きを繰り返すことになります。この瞬間のことを考慮に入れないロジックがプログラムにあると、バグになります。

そして、この周期は数か月レベルの長さを持つことが多いのが厄介で、このことがバグの発見を遅らせます。まさに49日問題と呼ばれる所以です。

やってはいけないこと

まず話をわかりやすくするために、1秒に1増えるフリーランカウンタを想定します。変数 a と b はそのカウンタの値です。a が過去で b が未来とします。

extern uint32_t getFreeRunningCounter();  // フリーランカウンタを取得する

uint32_t a = getFreeRunningCounter();

/* 何らかの処理 */

uint32_t b = getFreeRunningCounter();

比較してはいけない

a と b を比較してはいけません。厳密に言うと、a と b を比較演算子の両辺に分けてはいけません。次のようなコードは、すべて49日問題の原因になります。

// 以下はすべて間違っている!

if (a + 5 <= b) /* 5秒経過している */ { }

if (a <= b - 15) /* 15秒経過している */ { }

やっていいのは、a と b の差を取ることだけです。上のコードは、次のようにすれば正しいコードになります(ただし、テストは入念に!)。

// 以下はすべて正しい

if (b - a >= 5) /* 5秒経過している */ { }

if (b - a >= 15) /* 15秒経過している */ { }

コンピュータ工学的にこの違いは極めて重要ですが、数学的にはこの両者が等価であるという点に注意してください。この紛らわしさが、バグを呼び込む理由の一つになっているからです。

signed で扱ってはいけない

私がやらかしたのが、この過ちでした。C の規格では、整数型の演算がオーバーフローしたときの結果を次のように定めています。

signed未定義とする
unsignedラップアラウンドする

オーバーフローの結果が未定義とは、何を意味するのか。それは、オーバーフローしない限り同じ結果が得られるなら、コンパイラはどんな最適化をしてもよいという意味です。さらに噛み砕いて言うと、数学的に等価ならコンパイラは式を変形してもよい、ということです。

もうおわかりでしょう。私は a と b の比較をしてはいけないという自覚はあったので、次のようなコードを書きました。

#define COMPARE_TIME(a, b) ((b) - (a))

if (COMPARE_TIME(a, b) >= 5)
{
  /* 5秒経過したと判断 */
}

なのに結局、リリースしてから何か月か経過した頃、市場でバグが発動してしまいました。コードをどれだけ見ても、原因がわからない。挙句の果てに、コンパイルされた後のアセンブリコードを見て、愕然としました。コンパイラは、ちょうど次のロジックに相当する命令を吐いていたのです。

if (b >= a + 5)
{
  /* 5秒経過したと判断 */
}

当時はこの対策として、関数マクロを本物の関数ジャンプに置き換えることでインライン化を抑制し、難を逃れました。

しかし本質的には、a と b を単なる int で扱っていたことが悪かったのです。

クラスでカプセル化しよう

ここからは、C++ に限りますが、よりよい対策を提案したいと思います。フリーランカウンタを正しい使い方しかできないようにするには、クラスの力を借りるのが一番です。

フリーランカウンタは、値そのものに意味はありません。そして何よりも、比較演算子の両辺に分けたくありません。この性質をクラスで表現すると、次のようになります。

class FRC
{
private:
  const uint32_t m_count

public:
  explicit FRC(uint32_t count)
  : m_count(count)
  {
  }

  static uint32_t subtract(const FRC &lhs, const FRC &rhs)
  {
    return lhs.m_count - rhs.m_count;
  }
};

namespace {
uint32_t operator-(const FRC &lhs, const FRC &rhs)
{
  return FRC::subtract(lhs, rhs);
}
}

フリーランカウンタの根元の部分でこのクラスを適用したうえでビルドが通っていれば、プログラム全体にわたってカウンタが正しく扱われていることの証明になります。これぞまさに「工学的対策」ですね。

脚注

  1. 大別すると、タイマ割り込みを使ってプログラムで増やすものと、プログラムの介在なく勝手に増え続けるものがありますが、ここではどちらも同じフリーランカウンタとして扱います。