ホーム | Software Lab | Profile | Favorites | Diary | Links | About This Site | Feedback

 

 

第9回は、 ちょっと予定を変更してメモリリークのつぶし方とかをやります。

今回のポイント
メモリリークを発見する方法とそのつぶし方

  •  メモリリーク

    えっと、恥ずかしながらメモリリークしているソースをアップしていた訳ですが、ここは開き直ってその修正方法とかをやりたいと思います。以前のソースを修正したものに差し替えてもいいんだけど、せっかくだからネタにしてみました。

    ではさっそくですが、メモリリークって何でしょうか?

    プログラムを動かすときに、計算の過程とかさまざまな情報を確保するのにメモリを使うのは想像できると思います。ただ、そのメモリはどこから手に入れるのでしょうか?256MB のメモリがあったとして、僕は10MB のメモリが必要なんだけど、じゃあとりあえず128MB 目から使おう〜なんてコトをやったとすると、他のプログラムがそこを使っていたり、あとから別のプログラムに書き換えられたりするとおかしなことになるのは想像できると思います。

    そのため、何らかのしくみが必要になってきます。Windows ではその辺を OS が管理しているわけですが、プログラムでアドレスを意識しなくてもいいように、それぞれのプログラムをプロセスという単位に分けて処理し、それぞれのプロセスに独立したメモリ空間を提供しています。Win32 アーキテクチャの場合は 32bit のアドレス空間(4GB)が割り当てられます。

    ただし、アドレス空間が割り当てられるだけで、実際には使うときに実際のメモリに割り当てられます。

    Windows NT 系(2000/XP)では、プロセス内で確保し、開放していないメモリはプロセス(プログラム)の終了時に必ず開放されることが OS によって保証されています(Advanced Windows 第4章 プロセスを参照)

    ですから、極端に言ってしまうと、先の S2H_Conv アプリケーションにはメモリリークがありますが、終了時には OS がすべてよきに計らってくれる訳です(別にだからメモリリークしていいというわけではない)。ただ、アプリケーションを使い続けている限り、一度確保したメモリは開放されない訳で、長く使えば使うほど、誰にも管理されていない無駄なメモリが残ることになります。

    実際、どんなときにメモリリークするか理解していますか(>自戒をこめて自分)?簡単な例を挙げると

    int main() {
        TCHAR* pText = new TCHAR(20);
    };

    のように、new してメモリを確保したのにも関わらず、delete していない場合があげられます。

    ですから、最低限アプリケーションでは new と delete の対応が取れていることを確認する必要があります。ただ、これも同じ関数内で new して delete するような単純なケースはいいのですが、new したオブジェクトを返したりする場合に、誰がそのメモリを開放するのか?といった問題が残るコトがあります。

    CHoge* GetNewHoge() {
        return new CHoge();
    }

    さて、今回は掲示板でメモリリークを指摘していただいた訳なんですが、何が問題だったのでしょうか?

    それは SHGetFileInfo などのシェル関数です。MSDN のヘルプを見ればわかりますが、

    The calling application is responsible for freeing this pointer with
     the Shell's IMalloc interface (see SHGetMalloc).

    つまり、この関数を呼び出したアプリケーションは、シェルの IMalloc インタフェースから受け取ったポインタを開放する責任があるということです。S2H_Conv ではツリービューの lParam に格納しているので、それを開放すればいいことになります。

    ここでちょっと勘違いしていて(私が)、本来は CIDL という ITEMIDLIST のラップクラスを作ったので、そのインスタンスへのポインタを追加しておいて、そのラップクラスのデストラクタで解放すれば、面倒なメモリ管理をしなくていいなぁ、と思っていたのですが、 ラップしたクラスのポインタではなく、ITEMIDLIST のポインタそのものを使っていたので、意味がありませんでした。その辺を改善するため、使うほう(CFolderView) では CIDL のインスタンスを宣言するのではなく、new で動的に作成するように書き直しました。こうしないと関数内で宣言したインスタンスは関数のスコープと寿命をともにしてしまうからです。

    さらに、ツリービューを削除するとき(ソースでは以下の部分

    CFolderView::~CFolderView()
    {
        if ( m_pcTreeViewCtrl ) {
            delete m_pcTreeViewCtrl;
        }
    }

    lParam に追加したインスタンスは開放され、デストラクタが呼ばれてめでたしめでたし・・・と思ったのですが、残念ながら、ツリービューのインスタンスは削除されますが、たくさん突っ込んだ ITEMIDLIST のラップクラス CIDL* の指すインスタンスは破棄されないようです。

    さて、困りましたね。

    ツリービューの全部の要素をたどって削除する。っていうプランがすぐに浮かんだんですが、ちょっと全部をたどるアルゴリズムが面倒そうなのでパス(いいアルゴリズムがある人は紹介してくださいね)。ここでは、せっかくの WTL 講座なのでツリービューのイベントを使ってみましょう(WTL 関係ないか)

    ツリービューはその要素が削除されるときに、TVN_DELETEITEM という通知メッセージを親ウィンドウに送ります。つまり、そのメッセージを捕まえて、渡された情報を元に、lParam に格納してある CIDL を開放すればいいことになります。

    TVN_DELETEITEM メッセージを捕まえるため、FolderView.h を以下のように修正します。

    BEGIN_MSG_MAP(CFolderView)
        MESSAGE_HANDLER(WM_CREATE, OnCreate)
        MESSAGE_HANDLER(WM_SIZE, OnSize)
    
        NOTIFY_HANDLER(IDC_FOLDER_TREE, TVN_ITEMEXPANDING, OnItemExpanding)
        NOTIFY_HANDLER(IDC_FOLDER_TREE, TVN_DELETEITEM, OnDeleteItem)
        NOTIFY_HANDLER(IDC_FOLDER_TREE, NM_RCLICK, OnRClick)
    END_MSG_MAP()

    これで、アイテムが削除されるときに OnDeleteItem が呼ばれます。この関数の内容は、以下のようになります。

    LRESULT CFolderView::OnDeleteItem(int /*idCtrl*/, LPNMHDR pnmh, BOOL& /*bHandled*/)
    {
        ATLASSERT(m_pcTreeViewCtrl != NULL);
    
        LPNMTREEVIEW lpNmtv = reinterpret_cast<LPNMTREEVIEW>(pnmh);
    
        TVITEM tvItem = lpNmtv->itemOld;
    
        ATLASSERT(tvItem.lParam != NULL);
        delete reinterpret_cast<CIDL*>(tvItem.lParam);
    
        return 0;
    }

    さて、これでめでたく ITEMIDLIST を開放することができた・・・はずです。

    皆さんは自分のプロジェクトにメモリリークがないことを確信できますか?実は僕は不安だったので、VC が提供するメモリリークをチェックするコードを埋め込んでみました。この機能はとても便利ですので、是非試してみてください(今回のプロジェクトにはその部分を残してあります)

  •  メモリリークの自動検出を使う

    C++ は動的なメモリ確保をシンプルな構文でできるようになっていますが、そのメモリの管理を完全に行えるか?という点に関して、難しいなぁと感じることがあるかと思います(少なくとも私は)。マイクロソフトはメモリリークに関するデバッグを容易にするためにいくつかの機能を提供しています。

    MSDN の Visual C++ のデバッグという記事が非常によくまとまっているので、開発者は一度目を通しておくことをお勧めしておきます。

    たいていのメモリはヒープ上に確保されるので、ここの確保状況を追跡できれば、メモリリークが発生している部分がわかる可能性があります。

    CRT のデバッグ技術>CRT デバッグヒープ

    という項目を読むと一連の技術についての知識が得られると思います。WTL でこの機能を使うためには、まず stdafx.h(プリコンパイルヘッダ)にヒープのデバッグを有効にする宣言と必要なインクルードファイルを記述します。

    // stdafx.h : include file for standard system include files,
    // or project specific include files that are used frequently, but
    // are changed infrequently
    //
    
    #pragma once
    
    // Change these values to use different versions
    #define WINVER 0x0400
    #define _WIN32_WINNT 0x0400
    #define _WIN32_IE 0x0500
    #define _RICHEDIT_VER 0x0100
    
    #define CRTDBG_MAP_ALLOC
    #include <stdlib.h>
    #include <crtdbg.h>
    
    #include <atlbase.h>
    #include <atlapp.h>

    プリコンパイルヘッダの先頭、atlbase.h より前に宣言する必要があることに注意してください。

    そして、ヒープの追跡を開始したい場所に、_CrtSetDbgFlag 関数を使って、どのような情報を追跡したいのかというフラグを設定します。そして、_CrtDumpMemoryLeaks 関数を呼ぶと読んだ時点で開放されていないオブジェクトが IDE の出力タブにレポートされます。

    ここで注意しなくてはいけないのが、どこでこの関数を呼ぶかということです。フラグをセットした場所と、レポート関数を読んだ位置がうまく対応していないと、その時点で確保されていながら開放されていないもの(たとえそれがその後正しく開放されるとしても)はすべてメモリリークとみなされてしまうからです。

    そこで便利なのが、_CrtSetDbgFlag の _CRTDBG_LEAK_CHECK_DF というフラグです。これは、プログラムが終了した時点でのメモリリークを報告してくれるフラグなので、こちらから明示的に _CrtDumpMemoryLeaks を呼ぶ必要がない訳です。

    試行錯誤した結果、S2H_Conv.cpp 内の Run 関数の先頭でフラグをセットすることにしました。もしメモリリークが検出されると、

    Detected memory leaks!
    Dumping objects ->
    {159} normal block at 0x003F6128, 16 bytes long.
    Data: < K ( N? > 04 BE 4B 00 28 BA 16 00 C8 4E 3F 00 00 00 00 00 
    {157} normal block at 0x003F6088, 16 bytes long.
    Data: < K @I N? > 04 BE 4B 00 40 49 17 00 C8 4E 3F 00 00 00 00 00 
        ・
        ・
    {63} normal block at 0x003F4F58, 16 bytes long.
    Data: < K N? > 04 BE 4B 00 E8 AF 16 00 C8 4E 3F 00 00 00 00 00 
    Object dump complete.

    というようにメモリリークの情報が出力されます。

    上記で強調されている部分の説明をすると、このプログラムで159回目に確保した 0x003F6128 から始まる 16 Bytes の通常ブロックのメモリが開放されておらず、そのデータは 04 BE ・・・ となっていますよ。ということです。

    これで、メモリリークがあるかないかわかりますね。でも一つ問題があるのです。それは何番目に確保されたか?どのアドレスだったか?がわかったとしても、どの変数なのか、どの時点なのかはわかりません。

    幸いなことに VS の IDE のヒープの位置は変わらないらしく、プログラムを何度繰り返してもたいていは同じ位置にメモリが確保されるため、上記メモリが変更されたときにブレークするようにブレークポイントを設定することで追跡ができるのですが、何度も同じアドレスが使われる場合はデバッグがすごく難しくなります。

    そこで役に立つのが _CrtSetBreakAlloc 関数です。出力されたレポートを見ればわかるんですが、確保した番号がわかるということは確保した時点でブレークすることが可能な訳です。この関数は確保された番号を指定することでその位置で停止させることができます。

    上記で言えば、たとえば _CrtSetBreakAlloc(159) と指定することで、159 回目のメモリ確保があった時点でプログラムが停止します。停止した場所は dbgheap.c というソースの中ですが、ここで呼び出し履歴(コールスタック)を確認すると、その中に operator new の呼び出しがあると思います。

    その一つくらい前の呼び出しを確認するとずばりその位置で確保したメモリが開放されていないよ!ということになります。

  •  次回は・・・

    ちょっと寄り道してしまったのですが、メモリリークのないプログラムを書く参考になればと思います。

    ここまでの内容はこちらからダウンロードできます(117 KB)

    次回こそは RichEdit のキーワード部分の着色か、C++ 変換エンジンの実装部分をやりたいと思います。

    第10回

最終更新日 : 2008/01/25