valgrindの思い出 #20

公開日: 2012-02-12


valgrind は強力なツールだ。C/C++のプログラムのバグのうちにメモリ関連のものが占める割合はかなり高い。よくあるのはメモリリークだ。valgrind 上でプログラムを走らせれば、メモリリークはすぐ見つかる。継続ビルドでテストをvalgrind上で走らせていれば、どのリビジョンでメモリリークが発生したか特定できる。これは強力な開発手法だ。

valgrind がさらに力を発揮するのは、メモリの不正なアクセスの検出だ。バッファオーバーラン、use-after-free といったバグは、必ずしもクラッシュするとは限らないので、普通にテストを走らせているだけでは気づかないことが多い。valgrind はこういったやつらもざくざくと見つけてくれる。

以前に valgrind が検出したバグにこんなものがあった[1] 。bool のポインタを関数に渡してそこに結果を書きこんでもらうという一見、問題のなさそうなコードなのだが。この関数が 型のIDとvoid*をとるという type unsafe なAPIなのが問題であった(C言語で書かれたライブラリにはこういうのが多いものだ)。C++のbool は大体1バイトだが、くだんの関数の考える boolean は4バイトなのであった。

要するに、 bool のポインタに、3バイト余計に書き込みすぎていたのだ。しかも little endian だったので、たまたま1バイト目に正しい値が入っていて、テストは問題なく通っていた。こういうバグはvalgrind でも使わないとかなかなかわからないもののひとつだ。そんなこともあってvalgrindに対する私の信頼は揺るぎないものになっていった。valgrindが言うことは全て正しい!

が、ある日、valgrind が見つけた Chrome OS 用 Chrome のメモリリークは非常に謎めいていた。scoped_ptr [2] からのリークなのだ。詳細は省くが、C++にはデストラクタを利用して確実にメモリを解放するという技があり、これを使っている分にはメモリはリークしないはずなのだ。が、valgrindが言っているだから、間違っているのは私の方だろう?

とりあえずやってみたのはデストラクタの中にログを仕込むことだ。すると、デストラクタは呼ばれていないことがわかった。やはり valgrind は正しい!と思ったがコードをよくみると、問題のオブジェクトは無限ループの手前で作られている。試しに無限ループの後ろにログを仕込んでみると、ここは通過しない。じゃあどうやってプログラムが終了しているかというと、別のプロセスからSIGTERMで強制的に終了させられているのだ。

これでデストラクタが呼ばれずメモリが解放されない理由はわかったが、だとすると、他にもじゃんじゃんリークしているはずだ。ループの中ではいろいろなことをやっているし、他のスレッドも走っている。件の scoped_ptr だけがリークとして検出される筋合いはない。ログに含まれるプロセスIDをたどって調べてみると、そもそも valgrind は SIGTERM で終了させられたプロセスからはリークのレポートを行っていないのだ。

というわけで、デストラクタがどうこうという、私の最初の推理は完全に外れた。ちなみに、こういう紛らわしい手がかりみたいなものを英語では red herring と呼ぶ。英語圏のプログラマの間ではよく使われている言葉だ。今調べたら日本語のWikipediaにも「燻製ニシンの虚偽」[3]として載っていた。日本語の方は語呂が悪いので、「まったく red herring だな!」と英語の方を使おう。

それはさておき、valgrind は何でリークを検出しているのか?ログを丹念に調べると新しい発見があった。リークが検出されたプロセスのIDは、Chrome が出力するログの中には現れず、valgrind のログにだけ現れるだけなのだ。再び詳細は省くが、Chromeにはブラウザプロセス、レンダラープロセスなど、複数のプロセスが動いていて、ログを有効にするとこれらのプロセスから大量にログが出力される。が、リークが検出された問題のプロセスは、ブラウザプロセスでもなければ、レンダラープロセスでもない。一体こいつの正体は何なんだ?

この辺で休憩して、ランチに出かけることにした。ベテランプログラマの同僚にこの話をすると、すかさずこんな答えが返ってきた。「ブラウザプロセスの中から、なんかコマンドラインツールみたいのを別プロセスで実行してなかったけ?」確かに!これは怪しいぞ!でも別のプログラムだったら、Chrome の中のコードからリークが検出されるわけないじゃないの?

この線も弱いか。。と思ったが念のため、別プロセスでプログラムを立ち上げる部分にログを入れてみた。別プロセスを立ち上げる処理というのは Linuxだと fork & exec で実現されていて、fork の時点で自分のプロセスの複製が作られる。複製された子プロセスはすかさずexecをして別のプログラムに置き換えられる。ん?プロセスの複製が作られるっていかにも怪しいぞ。親プロセスと子プロセスの両方に、プロセスIDを出力するログを入れてみよう。これでどうだ!

じゃじゃーん。期待を込めてログをチェックすると、大発見があった。リークが検出されているプロセスのIDは、fork で作られた子プロセスのうちのひとつとマッチしたのだ。が、もう一つわかったのは、必ずしもすべての fork がリークの検出に繋がっているわけではないということだ。じゃあどういうときにリークが検出されたのだろうか?

先ほど追加したログの中には実行するプログラムの名前も含めていた。そして、リークが検出されたケースのプログラムの名前をチェックすると、そのプログラムは私のコンピュータにはインストールされていないことがわかった。つまり、valgrind は fork & exec の exec に失敗したケースだけリークを検出していたということだ。しかし exec に失敗した場合は _exit() ですみやかにプロセスを終了している。特にこれで問題ないのでは?

さっそく、valgrind に詳しい同僚に相談してみると、これは valgrind のバグだろうな、ということで valgrind のバグトラッカーにバグをファイルしてくれた [4]。結局、件のリークのレポートは、プログラムが特定のパスに存在しない場合は実行しない、という方法によって沈静化した。

かくして、私の valgrind への絶対的な信頼はやや揺らいだ。が、それでも自分のコードへの信頼と比べると、valgrindへの信頼の方が圧倒的に高いのだけど。

[1] http://codereview.chromium.org/7573001/
[2] http://www.kmonos.net/alang/boost/classes/scoped_ptr.html
[3] http://ja.wikipedia.org/wiki/%E7%87%BB%E8%A3%BD%E3%83%8B%E3%82%B7%E3%83%B3%E3%81%AE%E8%99%9A%E5%81%BD
[4] https://bugs.kde.org/show_bug.cgi?id=282018

Satoru Takabayashi