LoginSignup
4
3

COM の解放について (C#、Excel)

Last updated at Posted at 2024-06-01

はじめに

C# から Excel 操作を行う際に Book の解放エラーでハマったことがあったのでその対策と、ついでに自作の COM 解放の便利クラス、COM リークの目視確認の方法など、COM の解放に関するいくつかのトピックを紹介します。

Book 解放時にエラーが発生する

もう何年も前の話ですが、C# の dynamic 型を使って簡単な Excel 操作の遅延バインド版を試したところ、何故か Book の解放でエラーとなりました。

dynamic objExcel = Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application"));
dynamic objBooks = objExcel.WorkBooks;
dynamic objBook = objBooks.Add();

// ... 実際の処理

objBook.Close(false);
Marshal.ReleaseComObject(objBook);  // ここで例外発生
Marshal.ReleaseComObject(objBooks);

objExcel.Quit();
Marshal.ReleaseComObject(objExcel);

結論から言えば、エラー行を以下のように修正することで正常に動作しました。

Marshal.ReleaseComObject((object)objBook);  // object 型にキャスト

この問題は book オブジェクトだけで発生しています。

とはいえ、objBook.Close() 以降は ReleaseComObject() で解放する以外のことはしないため、object に強制キャストして回避できるならそれでよさそうです。

これは Marshal.ReleaseComObject() が問題なのではなく、object 型への暗黙の変換で例外が発生しているようです。たとえば、以下のような自作関数を作って呼び出す場合でも、同様に object への明示キャストが必要な点には注意してください。

void MyReleaes(object obj)
{
    try{
        Marshal.ReleaseComObject(obj);
    }
    catch (Exception) {}
}

一方、objBook.Close() せずに ReleaseComObject() するとエラーにはなりません。
すなわち、ReleaseComObject() や objBook 自体ではなく、ラップする dynamic 型の挙動に問題がありそうです。(Close 後の Book オブジェクトに対し、不必要な情報の問い合わせを行うなど)

仮に dynamic 型の挙動に問題があるなら Book 以外のオブジェクトもすべて解放時は object 型にキャストした方が安全なのかもしれません。とはいえ、明示的な破棄(相当)動作が可能なのは、Book と Application(Excel本体) くらいなので、最低限、この 2 つくらいは気を付けた方がよさそうです。

後述の COM 解放ヘルパを使用すると、対象オブジェクトの解放処理はすべて object として行われるので、この問題は自動的に解消します。

COM 解放ヘルパ

C# での COM の操作は、オブジェクトの解放が面倒です。
その面倒さを軽減する解放ヘルパ ComReleaseManager を紹介します。

解放ヘルパの仕組みは単純です。複数の COM オブジェクトを登録することができ、Dispose メソッドを呼ぶことで、登録したオブジェクトを登録時とは逆の順番でまとめて解放するだけです。解放ヘルパは IDisposable を継承するため、using ブロックとともに使用することで、ブロック終端での解放処理が保証されます。

COM オブジェクトの解放が面倒なのは、オブジェクトを作成するタイミングと解放するタイミングが異なるためです。解放ヘルパを使用することで、作成した COM オブジェクトを作成のタイミングで解放ヘルパに登録することができ、登録したオブジェクトはブロックの終端で自動的に解放されます。明示的な解放処理を記述する必要がなくなりソースコード自体も簡素化できます。

ComReleaseManager を使用した使い方の例を示します。

using System;
using System.Runtime.InteropServices;

class C1
{
    // 基本形
    public static void foo(dynamic objExcel)
    {
        // using ブロック付きで解放ヘルパのインスタンスを作成する
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            // オブジェクトを取得したら即座に解放ヘルパクラスに登録する
            // Add() メソッドは登録したオブジェクト自体を返すので、戻り値をそのまま変数に代入できる
            dynamic objBooks = crm.Add(objExcel.WorkBooks);
            dynamic objBook = crm.Add(objBooks.Add());
            dynamic objSheets = crm.Add(objBook.Worksheets);
            dynamic objSheet = crm.Add(objSheets.Item["sheet1"]);
            dynamic objRange = crm.Add(objSheet.Range["A1"]);
            objRange.Value = 123;

            objBook.Close(false);
        }
        // 登録したオブジェクトはこのタイミングで自動的に解放される
    }

    // メソッドチェーン風
    public static void bar(dynamic objExcel)
    {
        // using ブロック付きで解放ヘルパのインスタンスを作成する
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            // ベースのオブジェクトを Assign() で設定後、Evaluate() メソッドでつなげていき、
            // 最後に Value() でオブジェクトを取得する
            // 中間の一時オブジェクトは Evaluate() メソッド内で解放対象として登録される
            dynamic objBook = crm.Assign(objExcel)
                    .Evaluate((Func<dynamic, object>)(x => x.Workbooks))
                    .Evaluate((Func<dynamic, object>)(x => x.Add()))
                    .Value();
            dynamic objRange = crm.Assign(objBook)
                    .Evaluate((Func<dynamic, object>)(x => x.Worksheets))
                    .Evaluate((Func<dynamic, object>)(x => x.Item["sheet1"]))
                    .Evaluate((Func<dynamic, object>)(x => x.Range["C3"]))
                    .Value();
            objRange.Value = 123;

            objBook.Close(false);
        }
        // 登録したオブジェクトはこのタイミングで自動的に解放される
    }

    public static void Main(string[] args)
    {
        dynamic objExcel = null;
        try
        {
            objExcel = Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application"));

            foo(objExcel);
            bar(objExcel);
        }
        finally
        {
            if (objExcel != null)
            {
                ComReleaseManager.GCCollect();
                objExcel.Quit();
                ComReleaseManager.Release((object)objExcel);
                objExcel = null;
                ComReleaseManager.GCCollect();
            }
        }
    }
}

上記サンプルでは、Excel オブジェクトに対しては解放ヘルパを使用していません。
万が一例外が発生した場合でも Excel オブジェクトが Quit() されるように finally ブロック内で後処理を行っていますが、このような try-finally を用いるパターンは、using とは相性が悪く個別に対応する必要があります。実際のアプリでは Book も同じように処理した方がよいかもしれません。

なお、解放オブジェクトの登録を代入式の右辺や左辺に書くこともできます。以下は左辺に書いた例です。無理やりですが。

((dynamic)(crm.Add(objRange.Item[2, 2]))).value = 11;
((dynamic)(crm.Assign(objSheet)
    .Evaluate((Func<dynamic, object>)(x => x.Range["e5"]))
    .Evaluate((Func<dynamic, object>)(x => x.Offset[2, 2]))
    .Value())).value = 22;

メソッドチェーン風の書き方については、最初はもっとシンプルな書き方ができると考えていました。

// 使用例(理想)
dynamic objRange = crm.Assign(objBook)
        .EValuate(x => x.Worksheets)
        .Evaluate(x => x.Item["sheet1"])
        .Evaluate(x => x.Range["C3"])
        .Value();

残念ながら、想定していた上記コードはエラーとなりコンパイルが通りません。(C# 7.3)

CS1977: 最初にデリゲートまたは式ツリー型にキャストしていない場合は、ラムダ式を、動的ディスパッチされる操作の引数として使用することはできません。

明示的なキャストを追加することでコンパイルが通り動作するようになりますが、記述が増えてしまい微妙な感じになります。

// 使用例(現実)
dynamic objRange = crm.Assign(objBook)
        .Evaluate((Func<dynamic, object>)(x => x.Worksheets))
        .Evaluate((Func<dynamic, object>)(x => x.Item["sheet1"]))
        .Evaluate((Func<dynamic, object>)(x => x.Range["C3"]))
        .Value();

変数の数が増えることが許せない場合には使えるかもしれません。

ComReleaseManager のソースは以下に置いてあります。C# と VB.NET のクラスが含まれます。

目視でリークを確認する

ツールを使用して、目視でリーク(COM オブジェクトの解放漏れ)を確認する方法について説明します。

RCWChecker

外部から対象プロセスにアタッチしてメモリ上の RCW 情報を直接可視化する簡単なツールです。

C# で取得した COM オブジェクトは、ランタイム呼び出し可能ラッパー(RCW)と呼ばれるプロキシを介してネイティブな COM の 呼び出しを行います。
すなわち、RCW は COM オブジェクトと対応するオブジェクトです。しかも、実行中のプロセスの外部から RCW を監視できる ClrMD というライブラリがあるらしく、これを使用することで取得した COM オブジェクトの解放状況をリアルタイムで可視化することができるようです。

ClrMD は、プロセスやクラッシュダンプの調査など、.NET Framework/.NET アプリの高度なデバッグ作業に使用するものであって、特に RCW に特化したものではありません。しかし、COM 操作においては扱うオブジェクトの種類や数が限定されることもあり、RCW を可視化するだけでリーク調査などで有効に活用できそうです。

ClrMD

RCW についての説明

ClrMD を使用したコード例など

上記サイトには外部のプロセスに接続してメモリ上の RCW 情報をダンプするコード例が掲載されています。ただ、コンソールアプリであるため使いづらく、これを GUI アプリに変更し、自分用に少し拡張を加えたものが RCWChecker です。

RCWChecker のソースは以下に置いてあります。このプロジェクトには以下で説明する test1、test2 のソースも含まれます。

主要部分(RCW 情報のダンプ処理部分)のソースを示します。

private string GetRCWData(string appMsg = "")
{
    int objCount = 0;
    System.Diagnostics.Process[] procs = System.Diagnostics.Process.GetProcessesByName(processName);
    if (procs.Length == 0)
    {
        ShowText($"[ERROR] process {processName} not found.");
        DetachProcess();
        return null;
    }
    // If multiple processes are found, only the first one is processed.
    StringBuilder diagMsg = new StringBuilder();
    using (DataTarget dataTarget = DataTarget.AttachToProcess(procs[0].Id, true))
    {
        ClrInfo runtimeInfo = dataTarget.ClrVersions[0];
        ClrRuntime runtime = runtimeInfo.CreateRuntime();
        ClrHeap heap = runtime.Heap;
        foreach (ClrObject obj in heap.EnumerateObjects())
        {
            if (obj.HasRuntimeCallableWrapper)
            {
                RuntimeCallableWrapper rcw = obj.GetRuntimeCallableWrapper();
                if (rcw != null)
                {
                    List<string> ifnames = new List<string>();
                    foreach (ComInterfaceData i in rcw.Interfaces)
                    {
                        ifnames.Add(i.Type.Name);
                    }
                    string ifname = String.Join(",", ifnames);
                    if (!IsHideITypeInfo || ifname != "System.Runtime.InteropServices.ComTypes.ITypeInfo")
                    {
                        objCount += 1;
                        diagMsg.AppendLine(String.Format("{0,16:X} {1} {2} {3} {4}", rcw.Address, obj.Type.Name, rcw.RefCount, rcw.IsDisconnected, ifname));
                    }
                }
                else
                {
                    diagMsg.AppendLine(String.Format("{0,16:X} {1} (failed to GetRCWData())", obj.Address, obj.Type.Name));
                }
            }
        }
    }
    return String.Format("[{0}]== memorycheck ({1}, {2}) {3} : {4}\r\n", objCount, processName, procs[0].Id, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), appMsg) + diagMsg.ToString();
}

意外と簡単です。

指定されたプロセス名で該当プロセスにアタッチし、おそらくメモリのスナップショットを取得しています。その後、スナップショット情報を元に内容をダンプしていると思われますが、40 行足らずで記述できています。他にもいろいろ活用できるかもしれません。

プロジェクトはライブラリ Microsoft.Diagnostics.Runtime を参照しますが、個別に nuget から入手する際はバージョンに注意してください。最新版は .NET のみの対応で .NET Framework には対応していません。.NET Framework でビルドするには、2.0.226801 のバージョンを指定して取得する必要があります。

プロジェクト設定の対象プラットフォームは、調査対象のアプリケーションに合わせ x86 または x64 を明示してください。anycpu だとうまく動かなかったり、動いたように見えて途中でエラーになることもあります。調査対象のアプリケーションが anycpu の場合、テスト用に x64 または x86 でビルドしたものを別途用意してください。

このツールは他のアプリケーションにアタッチして該当アプリケーションのメモリ情報を読み取ります。ウィルスチェックプログラムが危険なアプリケーションと認識して動作をブロックする可能性が高いため、ツールをビルドしたり実行したりする場合には、ツールが存在するフォルダ全体をウィルスチェックの対象外として指定してください。対象外として指定する方法はご使用のウィルスチェックプログラムの設定手順に従ってください。

ツールの基本的な使い方(手動アタッチ&目視確認)

手動アタッチ&リークの目視確認を行う手順を示します。

テスト対象のプログラム (test1.exe) は簡単な GUI アプリです。ボタンをクリックすると、Excel を起動してファイル "test1.xlsx" を作成し、Excel を解放します。

test1 のボタンクリックイベントは以下のように記述されています。
コードを単純化するため、エラーの考慮はあえて最小限にして、また、結果の目視確認がしやすいように途中で 3 秒間 Sleep しています。

private void WriteExcelButton_Click(object sender, EventArgs e) {
        string outputFileName = Path.Combine(Path.GetDirectoryName(Application.ExecutablePath), "test1.xlsx");
    try {
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            dynamic objExcel = crm.Add(Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application")));
            dynamic objBooks = crm.Add(objExcel.WorkBooks);
            dynamic objBook = crm.Add(objBooks.Add());
            dynamic objWorksheets = crm.Add(objBook.Worksheets);
            dynamic objSheet = crm.Add(objWorksheets["sheet1"]);
            dynamic objRange = crm.Add(objSheet.Range["A1"]);
            objRange.Value = 123;

            Thread.Sleep(3000);

            objExcel.DisplayAlerts = false;
            objBook.SaveAs(outputFileName);
            objExcel.DisplayAlerts = true;
            objBook.Close(false);
            objExcel.Quit();
        }
    }
    catch (Exception ex) {
        MessageBox.Show(ex.Message);
    }
    ComReleaseManager.GCCollect();
}

実際の動作を確認します。
まず、RCWChecker を起動します。以下の画面が表示されます。

image.png

次に test1.exe を起動します。

image-10.png

実行中の test1 をアタッチします。

image-1.png

RCWChecker の ProcessName 欄に「test1」と入力し、「Attach」ボタンをクリックします。

image-2.png

プロセス test1 がアタッチされました。
現時点では RCW 情報(COM オブジェクト)が存在しないため、memorycheck というヘッダ行のみが表示されています。プロセスがアタッチされている状態では、画面は2秒毎に自動更新され、常に最新の RCW 情報が表示されます。

なお、ここでいうアタッチとは指定されたプロセスを定期的に監視する、程度の意味合いで、常時プロセスに接続しているわけではありません。

ここで、テストプログラムの「WriteExcel」ボタンをクリックします。

image-3.png

image-4.png

一瞬、複数の RCW 情報が表示されますが、2秒後に画面が更新されると、すべてのオブジェクトが消えています。これは Excel ファイル出力のために複数の COM オブジェクトを取得した後、処理終了後に正しくオブジェクトの解放処理が行われていることを意味します。

再度ボタンをクリックしても、同様に一瞬オブジェクトが表示され、また解放されることが確認できます。

もし、ボタンクリック毎にオブジェクトが増加するようであればリークが疑われます。

image-5.png

「Detach」ボタンをクリックすると test1 がデタッチされ、画面更新が止まります。あるいはプログラム test1 を終了させることでも、自動的にデタッチされます。

手動アタッチ&目視確認の手順は、テスト対象プログラムに一切手を加えなくてもリーク確認ができる利点があります。しかし逆にリークが存在した場合の原因の特定は難しく、特定のタイミングで RCW 情報を取得してデバッグしたい、各タイミング毎にログに記録したい、といった要件には向きません。次は、それらが可能になるメッセージ連携について説明します。

ツールの基本的な使い方(メッセージ連携)

任意のタイミングでメモリ上の RCW 情報を取得してログファイルに記録できるメッセージ連携の手順について説明します。

テスト対象のプログラム (test2.exe) は簡単なコンソールアプリです。実行すると、Excel を起動してファイル "test2.xlsx" を作成し、終了します。

test2(主要部分抜粋)は以下のようなコードになります。

test2.cs
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;

class Test2
{
    public static void WriteExcel(dynamic objExcel)
    {
        string outputFileName = Path.Combine(Path.GetDirectoryName(Application.ExecutablePath), "test2.xlsx");
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            dynamic objBooks = crm.Add(objExcel.WorkBooks);
            dynamic objBook = crm.Add(objBooks.Add());
            dynamic objWorksheets = crm.Add(objBook.Worksheets);
            dynamic objSheet = crm.Add(objWorksheets["sheet1"]);
            dynamic objRange = crm.Add(objSheet.Range["A1"]);
            objRange.Value = 123;
            objExcel.DisplayAlerts = false;
            objBook.SaveAs(outputFileName);
            objExcel.DisplayAlerts = true;

            objBook.Close(false);
        }
    }

    public static void Main(string[] args)
    {
        RCWCheckerConnect.Attach();
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            dynamic objExcel = crm.Add(Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application")));

            RCWCheckerConnect.Dump("pre WriteExcel()");
            WriteExcel(objExcel);
            RCWCheckerConnect.Dump("post WriteExcel()");

            objExcel.Quit();
        }
        ComReleaseManager.GCCollect();
    }
}

test2.cs 内では、RCWChecker に対して3回のメッセージ送信を行っています。

  1. RCWCheckerConnect.Attach(); // プロセス test2 のアタッチ要求
  2. RCWCheckerConnect.Dump("pre WriteExcel()"); // RCW ダンプ要求 (WriteExcel() 関数呼び出し前)
  3. RCWCheckerConnect.Dump("post WriteExcel()"); // RCW ダンプ要求 (WriteExcel() 関数呼び出し後)

最初の Attach() では、RCWCheker に自プロセスのアタッチを要求しています。RCWChekcer はメッセージを受けて、自動的にアタッチ処理を行います。
次に Dump() ですが、WriteExcel() 関数呼び出しの前後でメモリダンプを要求しています。関数呼び出しの前後でメモリ状態を確認することで、オブジェクトの増減を確認し、リーク有無の判断材料になります。

このメモリダンプは RCWChecker がログファイルとして出力します。

実際の動作を確認します。
最初に RCWChecker を起動します。

image.png

次に test2.exe を実行します。

E:\dev> test2

E:\dev> 

プログラムは起動して数秒で終了します。

ただ、test2 は RCWCheker にメッセージを送信することで、ログを記録していますので、プログラムが終了してもログが確認可能です。

ログは RCWChecker.exe と同じフォルダに yyyyMMdd-HHmmss.log として作成されます。
日時はプロセスアタッチ時のものです。

20240522-151200.log
-----------------------------------------------------------
[0]== memorycheck (test2, 25372) 2024-05-22 15:12:00 : (attach)

-----------------------------------------------------------
[1]== memorycheck (test2, 25372) 2024-05-22 15:12:00 : [test2.cs:34] pre WriteExcel()
        1CFB37B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False 

-----------------------------------------------------------
[1]== memorycheck (test2, 25372) 2024-05-22 15:12:01 : [test2.cs:36] post WriteExcel()
        1CFB37B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

ログファイルには複数のダンプ情報が記録されていますが、各ヘッダ行の後ろにダンプ取得メッセージ発行元のファイル名と行番号、コメントが付加されていますので、どのタイミングのメモリダンプなのかは容易に識別可能です。

メッセージ連携を利用することで、ソースコードの任意の行で RCW のダンプ情報を取得できます。また、関数の呼び出し前後でダンプ情報を取得すれば、RCW オブジェクトの数を比較するだけで、オブジェクトの種別や意味を理解することなくリーク有無の簡易的な判断や、リーク箇所絞り込みを容易に行うことができます。ダンプ中に含まれるオブジェクトの数はヘッダ行の先頭に表示されています。

RCWChecker は実験的な簡易ツールです。現状ごく小規模なプログラムでのみ動作実績があります。規模の大きな実用アプリで使用する際は十分に注意してください。問題があれば、使用を中止するか、用途に合わせて自分でツールを改善してみてください。

ツールを使用した COM オブジェクトの挙動確認

もう少し細かく見ていきます。

RCW のダンプ情報について

実は上記で説明した RCWChecker の表示はすべての COM オブジェクトではなく、ITypeInfo のオブジェクトを除いたものでした。(ソースの IsHideITypeInfo フラグを true にしてビルド)

ここでは、すべての COM オブジェクトを可視化した状態(IsHideITypeInfo フラグを false)で、個々のオブジェクトの詳細を確認してみます。

test3.cs を使用してメモリ状況の遷移を確認します。

RCWCheckerConnect.Attach();
using (ComReleaseManager crm = new ComReleaseManager())
{
    dynamic objExcel = crm.Add(Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application")));
    RCWCheckerConnect.Dump("01");
    dynamic objBooks = crm.Add(objExcel.WorkBooks);
    RCWCheckerConnect.Dump("02");
    dynamic objBook = crm.Add(objBooks.Add());
    RCWCheckerConnect.Dump("03");
    dynamic objWorksheets = crm.Add(objBook.Worksheets);
    RCWCheckerConnect.Dump("04");
    dynamic objSheet = crm.Add(objWorksheets["sheet1"]);
    RCWCheckerConnect.Dump("05");
    dynamic objRange = crm.Add(objSheet.Range["A1"]);
    RCWCheckerConnect.Dump("06");
    objRange.Value = 123;
    RCWCheckerConnect.Dump("07");

    objBook.Close(false);
    objExcel.Quit();
}
RCWCheckerConnect.Dump("08");

上記の結果のログです。

-----------------------------------------------------------
[0]== memorycheck (test3, 16276) 2024-05-24 21:28:31 : (attach)

-----------------------------------------------------------
[1]== memorycheck (test3, 16276) 2024-05-24 21:28:32 : [test3.cs:12] 01
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False 

-----------------------------------------------------------
[3]== memorycheck (test3, 16276) 2024-05-24 21:28:32 : [test3.cs:14] 02
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1356D50 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1357890 System.__ComObject 1 False 

-----------------------------------------------------------
[5]== memorycheck (test3, 16276) 2024-05-24 21:28:32 : [test3.cs:16] 03
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1356D50 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1357890 System.__ComObject 1 False System.Dynamic.IDispatch
         13566C0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         13567B0 System.__ComObject 1 False 

-----------------------------------------------------------
[3]== memorycheck (test3, 16276) 2024-05-24 21:28:32 : [test3.cs:18] 04
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1357890 System.__ComObject 1 False System.Dynamic.IDispatch
         13567B0 System.__ComObject 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[6]== memorycheck (test3, 16276) 2024-05-24 21:28:32 : [test3.cs:20] 05
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1357890 System.__ComObject 1 False System.Dynamic.IDispatch
         13567B0 System.__ComObject 1 False System.Dynamic.IDispatch
         13577A0 System.__ComObject 1 False System.Dynamic.IDispatch
         13566C0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1356D50 System.__ComObject 1 False 

-----------------------------------------------------------
[8]== memorycheck (test3, 16276) 2024-05-24 21:28:32 : [test3.cs:22] 06
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1357890 System.__ComObject 1 False System.Dynamic.IDispatch
         13567B0 System.__ComObject 1 False System.Dynamic.IDispatch
         13577A0 System.__ComObject 1 False System.Dynamic.IDispatch
         13566C0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1356D50 System.__ComObject 1 False System.Dynamic.IDispatch
         13573E0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1356A80 System.__ComObject 1 False 

-----------------------------------------------------------
[8]== memorycheck (test3, 16276) 2024-05-24 21:28:33 : [test3.cs:24] 07
         13576B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1357890 System.__ComObject 1 False System.Dynamic.IDispatch
         13567B0 System.__ComObject 1 False System.Dynamic.IDispatch
         13577A0 System.__ComObject 1 False System.Dynamic.IDispatch
         13566C0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1356D50 System.__ComObject 1 False System.Dynamic.IDispatch
         13573E0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         1356A80 System.__ComObject 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[2]== memorycheck (test3, 16276) 2024-05-24 21:28:33 : [test3.cs:29] 08
         13566C0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo
         13573E0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo

上記ログから以下のようなことが読み取れます。

  • Excel アプリケーションクラス (Microsoft.Office.Interop.Excel.ApplicationClass) のみ、具体的なクラス名が表示されている。その他のクラスは System.__ComObject として表示される。
  • 各行の2番目の項目は CLR が認識するクラス、5番目の項目は COM インタフェースの型名であるが、COM インタフェースの型名がすべて IDispatch としか認識されない。
  • IDispatch のオブジェクトはソース上で想定される変数と 1対1 で対応している。
  • オブジェクトの取得毎に2つのCOMオブジェクト (IDispatch と ITypeInfo) が生成される。
  • IDispatch はソース上の変数と対応付けられていて、ReleaseComObject() で解放される。
  • ITypeInfo は IDispatch と同じタイミングで生成されるが、解放は同期しない。
    IDispatch よりも早く解放される場合もあれば遅く解放される場合もあるが、割と早く解放されている。
  • オブジェクトを取得して変数に代入した直後は型不明 (おそらく IUnknown) の COM オブジェクトとして認識される。
    その後 COM オートメーションの機能を利用してメソッド呼び出しまたはプロパティアクセスを行うと、同じオブジェクトが IDispatch として再認識される、ように見える。
  • Worksheets オブジェクトのみ、なぜかタイミングが遅れて表示されている。
  • 各行の先頭は RCW オブジェクトのアドレスであるが、短いスパンで同じアドレスが再利用されているように見える。

まず、アプリケーションクラスのみ、具体的なクラス名が表示されている点。
ここで表示しているのが、.NETランタイムで認識される型のためと思われます。Excel アプリケーションのみは明示的に型を指定してインスタン化していますが、その他は動的にオブジェクトを取得しています。
事前バインドで変数の型を明示した上でオブジェクトを取得していれば、他のオブジェクトも型名が表示される可能性はありそうですが、あえて遅延バインドを選択している限り仕方ないのでしょう。

COM インタフェースの型名が取得できない原因は不明です。参考にしたサイトでは(Microsoft.Office.Interop.Excel.Workbooks)のような具体的な名前が取得できていました。環境(OS/.NET Frameworkバージョン)、Office(バージョン、32/64bit、ストアアプリ版/パッケージ版)、または、ClrMDライブラリのバージョンの何らかの違いが影響している可能性があります。型の区別ができないのは不便なので取得する方法を探したいところです。要検証。

ITypeInfo は COM オブジェクトではあるものの、dynamic 型の変数が動的に型情報を読み取るためにランタイムが一時的に生成したものであり、比較的短時間で自動的に解放されているように推測されます。ランタイムが自動生成するものであるため、明示的な操作はできませんが、型の情報のみを扱うオブジェクトであればこれがリークして問題になることはなさそうです。気にはなりますが、操作可能なオブジェクトではないため仮に問題ないとしておきます。

Worksheets オブジェクトの取得時のみ、なぜか RCW 生成のタイミングが遅れているように見えるのが気になりますが、大きな問題ではなさそうなので、これも目をつぶっておきます。

表示する RCW アドレスはオブジェクトの追跡のためのユニーク識別子のつもりで表示していますが、異なるオブジェクトで再利用されているように思われるので検証の際は注意が必要です。RCW でなく .NET のオブジェクト(System.__ComObject が示すもの)のアドレスを表示することもできますが、こちらはこちらでアドレスがころころ変更されて(そのように見える)追跡がやりにくいので、RCW のアドレスにしています。

ITypeInfo は COM オブジェクトではありますが、ランタイム(コンパイラ?)が暗黙で生成するもので、またリーク判断のノイズになりそうなので、ここからは再度 ITypeInfo を非表示にして検証を続けます。

解放ヘルパクラスを使用した場合

次は、解放ヘルパクラスを使用した場合にオブジェクトが正しく解放されているか確認します。

以下、テストコードです。

using System;
using System.Runtime.InteropServices;

class Test4
{
    public static void foo()
    {
        RCWCheckerConnect.Dump("10");
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            dynamic objExcel = crm.Add(Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application")));
            dynamic objBooks = crm.Add(objExcel.WorkBooks);
            dynamic objBook = crm.Add(objBooks.Add());
            dynamic objWorksheets = crm.Add(objBook.Worksheets);
            dynamic objSheet = crm.Add(objWorksheets["sheet1"]);
            dynamic objRange = crm.Add(objSheet.Range["A1"]);
            objRange.Value = 123;

            RCWCheckerConnect.Dump("11");

            objBook.Close(false);
            objExcel.Quit();
        }
        RCWCheckerConnect.Dump("12");
    }

    public static void bar()
    {
        RCWCheckerConnect.Dump("20");
        using (ComReleaseManager crm = new ComReleaseManager())
        {
            dynamic objExcel = crm.Add(Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application")));
            dynamic objBook = crm.Assign(objExcel)
                    .Evaluate((Func<dynamic, object>)(x => x.Workbooks))
                    .Evaluate((Func<dynamic, object>)(x => x.Add()))
                    .Value();
            dynamic objRange = crm.Assign(objBook)
                    .Evaluate((Func<dynamic, object>)(x => x.Worksheets))
                    .Evaluate((Func<dynamic, object>)(x => x.Item["sheet1"]))
                    .Evaluate((Func<dynamic, object>)(x => x.Range["C3"]))
                    .Value();
            objRange.Value = 123;

            RCWCheckerConnect.Dump("21");

            objBook.Close(false);
            objExcel.Quit();
        }
        RCWCheckerConnect.Dump("22");
    }

    public static void Main(string[] args)
    {
        RCWCheckerConnect.Attach();
        foo();
        bar();
    }
}

結果ログです。

-----------------------------------------------------------
[0]== memorycheck (test4, 21560) 2024-05-25 00:43:06 : (attach)

-----------------------------------------------------------
[0]== memorycheck (test4, 21560) 2024-05-25 00:43:06 : [test4.cs:8] 10

-----------------------------------------------------------
[6]== memorycheck (test4, 21560) 2024-05-25 00:43:07 : [test4.cs:19] 11
          DEC0A0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
          DED540 System.__ComObject 1 False System.Dynamic.IDispatch
          DEC460 System.__ComObject 1 False System.Dynamic.IDispatch
          DEC730 System.__ComObject 1 False System.Dynamic.IDispatch
          DED450 System.__ComObject 1 False System.Dynamic.IDispatch
        1C664B90 System.__ComObject 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[0]== memorycheck (test4, 21560) 2024-05-25 00:43:07 : [test4.cs:24] 12

-----------------------------------------------------------
[0]== memorycheck (test4, 21560) 2024-05-25 00:43:07 : [test4.cs:29] 20

-----------------------------------------------------------
[6]== memorycheck (test4, 21560) 2024-05-25 00:43:08 : [test4.cs:45] 21
          DEC0A0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
          DEC460 System.__ComObject 1 False System.Dynamic.IDispatch
          DED450 System.__ComObject 1 False System.Dynamic.IDispatch
        1C663BA0 System.__ComObject 1 False System.Dynamic.IDispatch
        1C664D70 System.__ComObject 1 False System.Dynamic.IDispatch
        1C663F60 System.__ComObject 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[0]== memorycheck (test4, 21560) 2024-05-25 00:43:08 : [test4.cs:50] 22

通常の呼び出しとメソッドチェーン風の呼び出しで、それぞれ正しく解放されました。

ガベージコレクタを実行した場合

次に解放漏れがあったとしてもガベージコレクタで正しく解放されるかを確認します。

using System;
using System.Runtime.InteropServices;

class Test5
{
    public static void foo()
    {
        RCWCheckerConnect.Dump("1");
        dynamic objExcel = Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application"));
        dynamic objBooks = objExcel.WorkBooks;
        dynamic objBook = objBooks.Add();
        dynamic objWorksheets = objBook.Worksheets;
        dynamic objSheet = objWorksheets["sheet1"];
        dynamic objRange = objSheet.Range["A1"];
        objRange.Value = 123;

        objBook.Close(false);
        objExcel.Quit();
        RCWCheckerConnect.Dump("2");
    }

    public static void Main(string[] args)
    {
        RCWCheckerConnect.Attach();
        foo();
        ComReleaseManager.GCCollect();
        RCWCheckerConnect.Dump("3");
    }
}

結果ログです。

-----------------------------------------------------------
[0]== memorycheck (test5, 25000) 2024-05-26 00:28:17 : (attach)

-----------------------------------------------------------
[0]== memorycheck (test5, 25000) 2024-05-26 00:28:17 : [test5.cs:8] 1

-----------------------------------------------------------
[6]== memorycheck (test5, 25000) 2024-05-26 00:28:18 : [test5.cs:19] 2
         1549AD0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         154A160 System.__ComObject 1 False System.Dynamic.IDispatch
         154A430 System.__ComObject 1 False System.Dynamic.IDispatch
         154A9D0 System.__ComObject 1 False System.Dynamic.IDispatch
         1549080 System.__ComObject 1 False System.Dynamic.IDispatch
         1549710 System.__ComObject 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[0]== memorycheck (test5, 25000) 2024-05-26 00:28:18 : [test5.cs:27] 3

一見問題なさそうに見えます。
ただ、ガベージコレクタについては「Excel を連続起動すると挙動が変わる?」で追加のテストを行います。その結果と合わせて判断してください。

イベントを使用する場合

イベントを使用する場合の問題について確認します。

public static void SheetActivateEvent(object sheet)
{
    ComReleaseManager.Release(sheet);
}

public static void WriteExcel(dynamic objExcel)
{
    using (ComReleaseManager crm = new ComReleaseManager())
    {
        dynamic objBooks = crm.Add(objExcel.WorkBooks);
        dynamic objBook = crm.Add(objBooks.Add());
        dynamic objWorksheets = crm.Add(objBook.Worksheets);
        dynamic objSheet = crm.Add(objWorksheets("sheet1"));
        dynamic objSheet2 = crm.Add(objWorksheets.Add());
        dynamic objRange = crm.Add(objSheet.Range["A1"]);
        objRange.Value = 123;
        objBook.Close(false);
    }
}

public static void AddEvent(dynamic objExcel)
{
    EventInfo eventInfo = objExcel.GetType().GetEvent("SheetActivate");
    Type eventType = eventInfo.EventHandlerType;
    MethodInfo methodInfo = typeof(Test2).GetMethod("SheetActivateEvent");
    Delegate d = Delegate.CreateDelegate(eventType, methodInfo);
    eventInfo.AddEventHandler(objExcel, d);
}

public static void Main(string[] args)
{
    RCWCheckerConnect.Attach();
    using (ComReleaseManager crm = new ComReleaseManager())
    {
        dynamic objExcel = crm.Add(Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application")));
        RCWCheckerConnect.Dump("01");
        AddEvent(objExcel);
        RCWCheckerConnect.Dump("02");
        WriteExcel(objExcel);
        RCWCheckerConnect.Dump("03");
        objExcel.Quit();
    }
    RCWCheckerConnect.Dump("04");
    ComReleaseManager.GCCollect();
    RCWCheckerConnect.Dump("05");
}
-----------------------------------------------------------
[0]== memorycheck (event1, 9748) 2024-05-28 20:37:02 : (attach)

-----------------------------------------------------------
[1]== memorycheck (event1, 9748) 2024-05-28 20:37:03 : [event1.cs:44] 01
          FA4860 Microsoft.Office.Interop.Excel.ApplicationClass 1 False 

-----------------------------------------------------------
[2]== memorycheck (event1, 9748) 2024-05-28 20:37:03 : [event1.cs:46] 02
          FA4860 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch,System.Runtime.InteropServices.ComTypes.IConnectionPointContainer
          FA4FE0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.IConnectionPoint

-----------------------------------------------------------
[7]== memorycheck (event1, 9748) 2024-05-28 20:37:03 : [event1.cs:48] 03
          FA4860 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch,System.Runtime.InteropServices.ComTypes.IConnectionPointContainer
          FA4FE0 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.IConnectionPoint
          FA50D0 Microsoft.Office.Interop.Excel.WorkbookClass 7 False 
          FA3D20 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Window
          FA43B0 System.__ComObject 1 False 
          FA3A50 System.__ComObject 2 False 
          FA3F00 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Range

-----------------------------------------------------------
[5]== memorycheck (event1, 9748) 2024-05-28 20:37:04 : [event1.cs:51] 04
          FA50D0 Microsoft.Office.Interop.Excel.WorkbookClass 7 False 
          FA3D20 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Window
          FA43B0 System.__ComObject 1 False 
          FA3A50 System.__ComObject 2 False 
          FA3F00 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Range

-----------------------------------------------------------
[0]== memorycheck (event1, 9748) 2024-05-28 20:37:05 : [event1.cs:53] 05

イベントの処理において、よくある注意事項で見かけるものは、引数のオブジェクト(今回の場合は Sheet)を解放しなければならない、というものです。
しかし、結果を見る限り、イベント内で引数の Sheet オブジェクトを解放しているにも拘らず、5つのオブジェクトがリークします。Microsoft.Office.Interop.Excel.WorkbookClass と表記されているオブジェクトでは参照カンウタが 7 になっており、何が起こったのか想像もできません。

イベントを登録したままなのがいけないのかと、eventInfo.RemoveEventHandler() でイベント登録を解除してみても、結果はほぼ変わりませんでした。1つのオブジェクトが減って、別の1つのオブジェクトが増加するくらいです。

登録のやり方に問題があるのか、C# では限界があるのか、.NET に問題があるのか、引き続き調査が必要です。VB.NET や事前バインドを使った場合に結果がどうなるか試してもよいかもしれません。

一応、直後のガベージコレクタの実行で、一連のオブジェクトがすべて解放されているように見えるのが救いです。

Excel を連続起動すると挙動が変わる?

先ほど、ガベージコレクタの強制呼び出しを行った場合、ReleaseComObject() の呼び出しを行わなくてもメモリ上の RCW オブジェクトがすべて解放されることを確認しました。もちろん Excel も正常に終了しました。

しかし、Excel を連続で起動すると、少し変わった結果が得られます。そのことについて説明します。

テストプログラムは以下を使用します。

using System;
using System.Runtime.InteropServices;
using System.Reflection;
using System.Threading;

class C1
{
    public static void GCCollect()
    {
        System.GC.Collect();
        System.GC.WaitForPendingFinalizers();
        System.GC.Collect();
    }

    public static void Foo(dynamic objExcel)
    {
        dynamic objWorkbooks = objExcel.Workbooks;
        dynamic objBook = objWorkbooks.Add();
        dynamic objWorksheets = objBook.WorkSheets;
        dynamic objSheet = objWorksheets.Item("sheet1");
        dynamic objRange = objSheet.Range["C3"];
        objRange.Value = "hello!";
        Marshal.ReleaseComObject(objRange);
        Marshal.ReleaseComObject(objSheet);
        Marshal.ReleaseComObject(objWorksheets);
        objBook.Close(false);
        Marshal.ReleaseComObject((object)objBook);
        Marshal.ReleaseComObject(objWorkbooks);
    }

    public static void ExecExcel()
    {
        dynamic objExcel = null;
        try
        {
            objExcel = Activator.CreateInstance(Type.GetTypeFromProgID("Excel.Application"));
            objExcel.Visible = false;
            Foo(objExcel);
        }
        finally
        {
            if (objExcel != null) {
                GCCollect();
                objExcel.Quit();
                Marshal.ReleaseComObject(objExcel);
                objExcel = null;
                GCCollect();
            }
        }
    }

    public static void Main(string[] args)
    {
        RCWCheckerConnect.Attach();
        for (int i = 0; i < 1; i++) {
            ExecExcel();
            RCWCheckerConnect.Dump("post ExecExcel()");
        RCWCheckerConnect.Dump("pre exit()");
        }
    }
}

[test1]
まずは、そのまま実行します。
アプリは 1 秒、Excel は 3 秒程度で終了します。
Excel はタスクマネージャ上の表示が消えた時間を確認します。
今回のテストでは厳密な時間を計測する必要がなく面倒なので時間は目測です。

[test2]
次にガベージコレクタと objExcel の解放を抑止して実行します。
該当3行をコメントアウトします。

            if (objExcel != null) {
                // GCCollect();
                objExcel.Quit();
                // Marshal.ReleaseComObject(objExcel);
                objExcel = null;
                // GCCollect();
            }

問題なく実行されます。
アプリは1秒、Excel は3秒で終了します。
リークはありますが、Excel は問題なく終了します。

[test3]
次にガベージコレクタと objExcel の解放を元に戻し、
ループの繰り返し数を 1 から 10 に変更します。

        for (int i = 0; i < 10; i++) {
            ExecExcel();
            RCWCheckerConnect.Dump("post ExecExcel()");
        }

問題なく実行されます。
アプリは7秒、Excel は10秒程度で終了します。

[test4]
繰り返し数は 10 のままで、objExcel の解放を抑止します。

            if (objExcel != null) {
                GCCollect();
                objExcel.Quit();
                // Marshal.ReleaseComObject(objExcel);
                objExcel = null;
                GCCollect();
            }

アプリは正常に終了はしましたが、Excel の終了に時間がかかります。
アプリは 8 秒、Excel は終了に 1 分 8 秒程度かかりました。
RCWChecker のログでは 1 つのオブジェクトのみリークしていました。
なお、アプリ終了後 2つの Excel はすぐに終了します。8 個の Excel がタスクマネージャに残り続け、1分を超えると、順番に終了していきます。

[test5]
繰り返し数は 10 のまま、objExcel の解放の抑止に加え、2箇所のガベージコレクタも抑止します。

            if (objExcel != null) {
                // GCCollect();
                objExcel.Quit();
                // Marshal.ReleaseComObject(objExcel);
                objExcel = null;
                // GCCollect();
            }

アプリは 8 秒で終了しましたが、Excel は 10 分以上待っても終了しません。
明らかにゾンビ化しているようです。
オブジェクトのリークは 2 つのみです。
なお 10 分の段階で、タスクマネージャ上で 8 個の Excel が確認できます。
残り 2 つはアプリ終了時には終了していたようです。

この時の RCWChecker のログを確認してみます。

-----------------------------------------------------------
[0]== memorycheck (loop, 21888) 2024-05-29 20:36:42 : (attach)

-----------------------------------------------------------
[1]== memorycheck (loop, 21888) 2024-05-29 20:36:43 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[2]== memorycheck (loop, 21888) 2024-05-29 20:36:44 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[3]== memorycheck (loop, 21888) 2024-05-29 20:36:45 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1817590 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[4]== memorycheck (loop, 21888) 2024-05-29 20:36:45 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1817590 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1810 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[5]== memorycheck (loop, 21888) 2024-05-29 20:36:46 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1817590 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1810 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2AD0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[6]== memorycheck (loop, 21888) 2024-05-29 20:36:47 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1817590 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1810 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2AD0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2350 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[7]== memorycheck (loop, 21888) 2024-05-29 20:36:48 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1817590 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1810 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2AD0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2350 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1EA0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[8]== memorycheck (loop, 21888) 2024-05-29 20:36:48 : [loop.cs:57] post ExecExcel()
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         18173B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1817590 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1810 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2AD0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2350 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D1EA0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1D0D2DA0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[1]== memorycheck (loop, 21888) 2024-05-29 20:36:49 : [loop.cs:57] post ExecExcel()
        1D0FB370 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[2]== memorycheck (loop, 21888) 2024-05-29 20:36:50 : [loop.cs:57] post ExecExcel()
        1D0FB370 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[2]== memorycheck (loop, 21888) 2024-05-29 20:36:50 : [loop.cs:60] pre exit()
        1D0FB370 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
         1818D00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

9 回目のループでオブジェクト数が減少しています。.NET ランタイム自身が行うガベージコレクタが動作しているように思われます。アプリ終了時点で生存中のオブジェクトは 2 つです。

普通に考えて、ガベージコレクトが行われて 8個の Excel.ApplicationClass クラスのオブジェクトが回収されたということは、関連するファイナライザが動作し、8個の Excel への解放処理が正常に行われたことが推測されます。もちろん事前に Quit() も呼んでいます。

.NET Framework のコンソールアプリの場合、アプリ終了時にも .NET ランタイムが行うガベージコレクタが行われる、という情報もあります。

しかし、最終的には、システムとアプリ終了時の2箇所のガベージコレクトを潜り抜けて 大半の Excel オブジェクトがリークし、Excel がゾンビ化します。

[test6]
繰り返し数は 10 のまま、objExcel の解放は抑止しますが、ガベージコレクタの位置を変更します。
ExecExcel() 関数内でなく、アプリ最後に1回だけ行います。

            if (objExcel != null) {
                //GCCollect();
                objExcel.Quit();
                //Marshal.ReleaseComObject(objExcel);
                objExcel = null;
                //GCCollect();
            }
        RCWCheckerConnect.Attach();
        for (int i = 0; i < 10; i++) {
            ExecExcel();
            RCWCheckerConnect.Dump("post ExecExcel()");
        }
        GCCollect();
        RCWCheckerConnect.Dump("pre exit()");

アプリは 24 秒、Excel は 26 秒程度で終了します。

ただ、アプリ終了時間は 24 秒ですが、タスクマネージャで見ると 7 秒ほどで 10 個の Excel が表示されています。終了まで 24 秒かかるのは時間がかかりすぎと感じます。

追加でガベージコレクタの時間を計ってみます。
同じテスト項目ですが、時間計測のコードだけを追加して再度実行します。

public static void Main(string[] args)
{
    var start = DateTime.Now;
    RCWCheckerConnect.Attach();
    for (int i = 0; i < 10; i++) {
        ExecExcel();
        RCWCheckerConnect.Dump("post ExecExcel()");
    }

    Console.WriteLine("1: {0}秒",((DateTime.Now - start).TotalMilliseconds/1000)); 
    System.GC.Collect();
    Console.WriteLine("2: {0}秒",((DateTime.Now - start).TotalMilliseconds/1000)); 
    System.GC.WaitForPendingFinalizers();
    Console.WriteLine("3: {0}秒",((DateTime.Now - start).TotalMilliseconds/1000)); 
    System.GC.Collect();
    Console.WriteLine("4: {0}秒",((DateTime.Now - start).TotalMilliseconds/1000)); 

    RCWCheckerConnect.Dump("pre exit()");
}
-----------------------------------------------------------
[0]== memorycheck (loop, 25780) 2024-05-30 21:13:10 : (attach)

-----------------------------------------------------------
[1]== memorycheck (loop, 25780) 2024-05-30 21:13:11 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[2]== memorycheck (loop, 25780) 2024-05-30 21:13:12 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[3]== memorycheck (loop, 25780) 2024-05-30 21:13:12 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECD10 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[4]== memorycheck (loop, 25780) 2024-05-30 21:13:13 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECD10 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBA50 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[5]== memorycheck (loop, 25780) 2024-05-30 21:13:14 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECD10 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBA50 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBF00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[6]== memorycheck (loop, 25780) 2024-05-30 21:13:14 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECD10 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBA50 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBF00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECEF0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[7]== memorycheck (loop, 25780) 2024-05-30 21:13:15 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECD10 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBA50 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBF00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECEF0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C924550 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[8]== memorycheck (loop, 25780) 2024-05-30 21:13:16 : [loop.cs:58] post ExecExcel()
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBC30 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECD10 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBA50 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8EBF00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C8ECEF0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C924550 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
        1C924A00 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[1]== memorycheck (loop, 25780) 2024-05-30 21:13:17 : [loop.cs:58] post ExecExcel()
        1C9230B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[2]== memorycheck (loop, 25780) 2024-05-30 21:13:18 : [loop.cs:58] post ExecExcel()
        1C9230B0 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch
          F30020 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Dynamic.IDispatch

-----------------------------------------------------------
[0]== memorycheck (loop, 25780) 2024-05-30 21:13:34 : [loop.cs:70] pre exit()
E:\dev> test6
1: 7.6765876秒
2: 7.6815943秒
3: 24.312406秒
4: 24.312406秒

System.GC.WaitForPendingFinalizers();
の行で 17 秒ほどかかっているようです。明らかに異常です。

test5 同様、こちらも RCW ダンプ情報によると、ループの 9 回目でオブジェクトが減少しており、ガベージコレクタが動作した形跡があります。

しかし、タスクマネージャの目視確認では、コンソールへの "1: ..." と "2: ..." の表示までは Excel が 10 個そのまま残っており、その後、1 つずつ順番に消えていっているように見えました。システムのファイナライザで本当に解放処理が行われているのか疑問が深まります。

さらに追加のテストを行ってみます。
直前に行ったテストコードから、WaitForPendingFinalizers() の行のみをコメントアウトして実行してみます。

1: 7.9197266秒
2: 7.9207295秒
3: 7.9247296秒
4: 7.9257237秒

アプリの終了は早まりましたが、8 個の Excel がゾンビ化しました。

どうも GC.Collect() にしても、システムのガベージコレクタにしても、ファイナライザの実行保証はされないようです。
もちろん、WaitForPendingFinalizers() がそのための API であるため、これを使えば実行保証されることは分かりますが、Microsoft のファイナライザの説明では、アプリ終了時にファイナライザが呼び出されるタイミングがありそうに思えます。

それとも、今回のテストでは、Excel 終了の負荷が重すぎて、2 個しか終了させられなかった、という挙動を示しているのかもしれません。

結果を表にしてみます。

テスト 繰り返し excelの解放 GC.Collect(※) 結果
(終了までの時間)
結果
test1 1 する ループ内 アプリ1秒
excel 3秒
正常
test2 1 しない しない アプリ1秒
excel 3秒
正常
test3 10 する ループ内 アプリ7秒
excel 10秒
正常
test4 10 しない ループ内 アプリ8秒
excel 1分8秒
Excel終了に時間
かかりすぎ
(2個のみ正常終了)
test5 10 しない しない アプリ8秒
excel 終了せず
Excel 8個がゾンビ化
test6 10 しない ループ外 アプリ24秒
excel 26秒
ファイナライザに
時間かかりすぎ(17秒)

※: GC.Collect() は GC.Collect()+WaitForPendingFinalizers()+GC.Collect() を表します。

今回のテストから以下のことが言えそうです。

  • 起動する Excel が 1 つだけであれば、Excel を解放しても、あるいはしなくても、問題なく正常に終了する(ように見える)。ファイナライザが動作していそう。
  • Quit() 済の Excel オブジェクトの解放はプログラマから見た場合、ReleaseComObject() するかしないかの 2 択である。解放をランタイムに任せると、何故か [正常終了/Excel終了遅延/Excelゾンビ化/ファイナライザ異常負荷] の4択の結果が得られる。それぞれ特定のテストコードと対応し再現性がある。
  • test5/test6 において、システムのガベージコレクタによって COM オブジェクトが回収されてもアプリ終了時にファイナライザは(少なくとも完全には)動作しないことが分かる。ファイナライザの動作を強制するなら明示的に WaitForPendingFinalizers() を呼び出す必要がある。
  • アプリ終了時の自動解放処理でも 1 つか 2 つの Excel は解放できる可能性はある。しかし、非常に限定的で WaitForPendingFinalizers() の代わりにはならない。

COM の解放についてよく言われていることに以下のようなものがあります。

  • COM オブジェクトにはファイナライザが実装されており、ガベージコレクタによって解放処理がなされる。
  • ファイナライザでは、ComReleaseObject() と等価な処理が行われ、同じように正常な解放処理がなされる。
  • コンソールアプリケーションの場合、アプリ終了時にガベージコレクタが動作し、解放処理が行われる。

テスト結果を素直にとらえれば、上記 3 点は否定はされないまでも疑問符が付きそうです。

単なる個人の感想として述べるなら、Excel が終了遅延することやファイナライザが Excel インスタンス 1 つ毎に 2 秒近くもかかることは、正常系でなく異常系に分類できそうです。

であれば、ガベージコレクタは ComReleaseObject() の代わりにはなりません。

また、今回のテストはコンソールアプリケーションですが、大量の Excel がリークしてゾンビ化しました。

ガベージコレクタやファイナライザは、もともと 100% 完全に動作が保証されているものではありません。しかし、test5/test6 の結果を見ると、WaitForPendingFinalizers() を呼ばない限り、明らかに不完全な形でしかファイナライザは動作せず、Excel の大半がゾンビ化します。1 つの Excel だけの場合は問題なく終了すること、10 回のループでも 2 個の Excel は終了していることから、アプリ終了時にファイナライザが呼ばれるタイミングはありそうですが、実行が保証できないのであれば、アプリ設計の前提として当てにしてよいものではなさそうです。

最後に 1 つ重要な点を挙げるならば、正しく解放処理(ReleaseComObject() 呼び出し)を行った test3 に問題は発生していないということです。

すなわち、解放処理は正しく行いましょう、というのがまとめになります。
頭の悪そうな結論で申し訳ありません。

今回の正常パターンとは、狭義では Excel オブジェクトを解放することを意味しますが、広義では、いわゆるベストプラクティスとされるものを意識して実装したもの、という認識でテストパターンを作成しました。あらためてそのパターンを見直してみるのがよいかもしれません。

COM 解放のベストプラクティスとされるパターンについては以下で説明されています。

Office オートメーションで割り当てたオブジェクトを解放する - Part1 | Microsoft Learn

おわりに

COM や Excel の記事は多くありますが、話題になることが少なそうなネタを意識して書いてみました。逆に、基本的なことはあえて省略したため、前提知識がないと読みづらいかもしれません。

この記事で説明したのは自分の環境での検証結果です。OS や Office など、環境が違えば結果も異なるかもしれません。以下に自分がテストした環境を示します。

OS:         Windows 10 22H2 (build: 19045.4412)
Excel:      Microsoft Excel for Microsoft 365 MSO (バージョン 2404 ビルド 16.0.17531.20152) 64 ビット 
開発環境:   Visual Studio 2022
テストプログラムのビルド方法:
            csc -platform:x64 test*.cs RCWCheckerConnect.cs ComReleaseManager.cs
4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3