【.NET、C#】COMのガベージコレクションの動作を検証した

.NET Garbage Collection .NET

検証の動機

Qiitaでこういう記事を書きました。COMオブジェクトであるMicrosoft.Office.Interop.Wordを使用した.NETアプリを作成した、というものです。

【C#】WordファイルをPDFに一括変換する.NETアプリを作ってみた - Qiita
タイトル通りの内容ですが、内部で使用している変換にはMicrosot.Office.Interop.Wordを使用しています。Google先生に聞くとこの辺りの情報は豊富ですし、COMオブジェクト故に「Microsot.Offic...

昔から知られていることですが、.NETからCOMオブジェクトの呼び出しはラッパーを仲介するため、処理のオーバーヘッドが高くなります。マネージドなライブラリがあるなら、そちらを用いたほうが効率が良いです。

ですが、どうしてもCOMの使用が必要な場面もあります。その場合の、メモリ開放処理をどう書けばちゃんと解放してくれるのか?を調べてみようと思いました。

一応、用語説明

マネージド

MS以外のベンダー(Oracleとか)では「管理対象」と訳している場合があります(反対語は「非管理対象」)。

マネージドコードと言う場合はCLRによって管理されるコードのことを指し、メモリ自動管理の対象にもなります。反対は「アンマネージド」で、C/C++などで記述されたコードがこれに当たる。COMオブジェクトもアンマネージドなコードで記述されたライブラリ。

COMオブジェクト

Component Object Modelの略ですが、昔のVB6.0の時代ではよく使われていたもので、ソフトウェアの再利用が可能になるもの。今回の場合で言えば、WordのAPIがこのCOMオブジェクトに当たります。CLRのメモリ自動管理の対象になりませんので、開放処理を書いてやる必要があります。

コード

本サイトのプロフィールのエリアにGitHubのリンクがありますが、一応、ソリューション全体のソースは以下です。

メモリ開放が必要なのは、以下の「変換」ボタン押下時の、WordファイルからPDFへの変換処理の箇所です。一番下の、finally句のところです。

/// <summary>
/// 変換ボタンクリック処理
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnConvert_Click(object sender, EventArgs e)
{
    //using RefWord = Microsoft.Office.Interop.Word;として定義
    //WordのCOMオブジェクトをセット
    RefWord.Application word = new RefWord.Application();
    RefWord.Documents docs = word.Documents;
    RefWord.Document doc = null;

    //toolCompleteに状態を表示
    this.toolComplete.Text = string.Empty;
    this.Cursor = Cursors.WaitCursor;

    try
    {
        string[] files = Directory.GetFiles(this.txtInput.Text, "*.doc*");

        //ファイル数の分ルール
        foreach (string f in files)
        {
            this.toolComplete.Text = Path.GetFileName(f);
            var attribute = File.GetAttributes(f);
            var fn = Path.GetFileNameWithoutExtension(f);

            //テンポラリの隠しファイルは飛ばす
            if ((attribute & FileAttributes.Hidden) == FileAttributes.Hidden)
                continue;

            //PDF出力
            doc = docs.Open(f, ReadOnly:true);
            doc.ExportAsFixedFormat(this.txtOutput.Text + "\\" + fn + ".pdf", RefWord.WdExportFormat.wdExportFormatPDF);
            doc.Close();
        }

        this.toolComplete.Text = "完了";

    } catch(Exception ex)
    {
        MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
    } finally
    {
        //↓計測用
        MessageBox.Show(GC.GetTotalMemory(false).ToString());

        //COMオブジェクト開放
        if (word != null) word.Quit();
        Marshal.ReleaseComObject(doc);
        Marshal.ReleaseComObject(docs);
        Marshal.ReleaseComObject(word);

        MessageBox.Show(GC.GetTotalMemory(false).ToString());
        //↑計測用
    }

    this.Cursor = Cursors.Default;
}

GC.GetTotalMemoryは割り当てられているメモリのバイト数を返します。

補足ですが、今回の検証環境は、

  • Windows 10 Pro
  • .NET Framework 4.6.1
  • Office 2019 Word

で実施しました。

結果

開放処理のコードを入れない場合

Marshal.ReleaseComObjectだけでGC.Collect()を入れない場合です。

if (word != null) word.Quit();
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(docs);
Marshal.ReleaseComObject(word);

結果:
前:505,092
後:521,476

増加していますね。まぁ、解放処理がなされていないようです。

最後にGC.Collect()

MSのドキュメントのサンプル通りの書き方です。

if (word != null) word.Quit();
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(docs);
Marshal.ReleaseComObject(word);

GC.Collect();

結果:
前:505,092
後:211,684

ちゃんと開放してくれているようですね。

前後にGC.Collect()

開放処理の前後だと差はあるのでしょうか。

GC.Collect();

if (word != null) word.Quit();
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(docs);
Marshal.ReleaseComObject(word);

GC.Collect();

結果:
前:505,092
後:196,044

後だけの時よりも、占有領域が減っているようです。

最後にGC.Collect()とGC.WaitForPendingFinalizers()

これを試した理由は、この記事を見たからです。

上の記事の例ではベストプラクティスとして、

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

を開放の前後に入れていますが、今回は後だけの場合と前後の場合も検証してみます。
まずは、後だけのパターン。

if (word != null) word.Quit();
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(docs);
Marshal.ReleaseComObject(word);

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

結果:
前:505,092
後:195,104

GC.Collect()だけの時より減少していますね。

前後にGC.Collect()とGC.WaitForPendingFinalizers()

ベストプラクティスとして挙げられていた書き方。

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect

if (word != null) word.Quit();
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(docs);
Marshal.ReleaseComObject(word);

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

結果:
前:505,092
後:195,104

後だけの場合と変化はありません。

結論

COMオブジェクト開放の際は、Marshal.ReleaseComObject(word)だけでなく、ガーベジコレクションを強制的に走らせる処理を行う必要がある、ということになりますね。

WaitForPendingFinalizersについては、以下を参照。

しかし、前後にガベコレを強制させるコードは、かなり可読性がよくないな…。

以上です。