C++の忌々しいメモリリークを特定するコツ:変更箇所から逆算する


メモリリークの特定の困難さ

メモリリークは特定が難しいです。それは以下の3つの理由からです。

1. 潜在的なバグが少しずつ、しかし着実にプログラムを蝕んでいく

これは、1回の実行についてもそうですし、もっといえば改修のたびにバグの危険性が高まるということにも当てはまります。

2. エラーが発生する時点ではすでに手遅れなため、原因を直接的に特定しづらい

表面的なエラーメッセージは、segfaultなどですが、これらは狂った歯車によりプログラムが致命的にバグった後の結果に過ぎません。
ガソリンスタンドが爆発したという現象から、その原因は特定できませんよね?それと同じくらい、メモリリークの特定は難しいのです。

3. 特定の助けになるツールはあるが、最短経路ではない

gdb debuggerで詳細が不十分である場合に、最終手段としてValgrindを用いる方法もあります。
しかし、メモリーの挙動のエミュレーションを行うため、動作が非常に遅く、数百万回など特定の関数をコールする場合に、時間が無尽蔵に必要です。

また、メモリリークに起因する目に見えるエラーの(メモリリーク自体ではない)発生箇所はランダムであり、かつ、かなり長い間プログラムを実行しないとバグを再現できないことが多いです。さらに、エラーメッセージに直接の原因が必ず反映されるわけではないため、このアプローチはあくまで最終手段と言えます。

追記: valgrindの代わりにclang sanitizerという動的テストツールもあるそうです

コメント欄でご指摘いただきました。 @yumetodo 様、ありがとうございます。
こちらはQiitaなどで記事をあまり見かけないので、また今度試してみてvalrgindと比較してみたいと思います。

以下の記事によると、いくつかの項目チェックに関してはValgrindよりも速度の面で有利だそうです。

上の記事が引用しているベンチマークは以下です。(孫引き防止のため一応載せてます)

解決の視点:「変更箇所に起因してバグが起きることが多い」という考え方

・当初想定されていたケースの範囲を超えて、未知のケースが与えられたり、機能が追加された
・各モジュールなどが疎結合でない場合に、ほんの少しの変化がプログラム全体に伝播して秩序がぶっ壊れる

Googleでも「直近の変更箇所に重点を置いて、バグ発生確率の高い"ホットスポット"を探していく」という試みが行われてきました。

最近のコミットからバグが起こらない最後のコミットを特定する

以前はgit logでコミットを最近のものから比較するというスマートでないやり方をしていました。
しかし、 @yumetodo さんに以下のような大変有益な知見をいただきました。ありがとうございます。

git logでたどっていく作業はgit bisectを使うことができるかもしれません。

git bisectでバグが混入しはじめた境界のコミットを特定

ひたすらgit bisectにプログラムを実行させ、終わるまマテ茶でも飲んでましょう

メモリリークでは、バグの再現のためには、特定の関数などのイテレーション数を100万回とかにしないといけないこともあります。git bisectで機械的にピックアップしたコミットを実行して、メモリリークが再現できる、できないの境界となる、コミットを探しましょう。

なお、メモリリークが再現できるだけ十分なイテレーション数を確保するために、これらの定数などをテストスクリプトで上書きしてビルドして実行すると便利そうですね。もしくは、そうした関数だけをひたすら呼ぶテストスクリプトを作ってもいいかもしれません。

差分からメモリリークの直接的・間接的原因の仮説を立てる

前のステップでメモリリークが再現できる、できないの境界となる、コミットを見つけたあとは、差分を考察しましょう。

git diff [commitid1] [commitid2]

差分箇所が直接的にバグを及ぼしているのが明確な時は、すぐに特定ができるでしょう。
しかし、そうでないことも多いです。ですので、差分箇所に登場する変数などをリストアップしましょう。

それらを一つずつ観察してみましょう。

  • 元に想定していた変数/関数などの使い方が、差分箇所でもそれに沿って利用されているか?

もしそうでない場合は、例えばその定義箇所に立ち返って、「新しいユースケースに対応させるために、どのような修正を加えるべきか?」などの新しい問いをしてみましょう。この場合、差分箇所以外に修正が必要なことがあります。しかし、そのことを知るためには、差分箇所を観察することは必要不可欠です。

メモリリークにうまく向き合うため普段からすべきこと

日頃からメモリリークを起こしそうな箇所のコミットの粒度を小さめにする

これは、上述のような変更箇所の差分を小さくすることで、一つの差分あたりの列挙しなければいけない可能性が減るからです。特に以下のようなところですが、以下に限られません。

  • []みたいな演算子のなかに複数変数の複雑な式が入っている場合
  • 動的にメモリを確保するときや、そのように確保されたメモリ領域を扱う時

コミットメッセージを適正に利用する

当たり前ですが、コミットメッセージに書いてない作業がコミットに含まれるのは、後で原因を遡るのには良くありません。これも是正しましょう。

メモリリークを無くすための理想: CI/CDを導入する

「変更箇所からバグが生まれやすい」というのはCI/CDの思想のうちの一つだと考えています。
そうした変更箇所のバグを無慈悲に自動的に検知してくれるのがCI/CDにおけるテストであり、ミッションクリティカルなソフトウェアを開発する上では欠かせません。

大規模なプロジェクトでない限り、CI/CDパイプラインを構築する必要性に迫られないと考える人も多いでしょう。
しかし、小規模でも本番に止まることが許されないソフトウェアに対してはCI/CDを導入することも重要なのではないでしょうか。とりわけメモリリークが起きやすいC++を使って重要なソフトウェアを書く場合には、なるべくメモリリークが混入しないような仕組みをCI/CDの力で作る必要があると感じました。

お読みいただきありがとうございました。
読者の皆さんの意識しているベストプラクティス等ございましたら、是非ともコメントでお知らせください!

謝辞

@yumetodo さん、大変有益なコメントをいただきありがとうございました!