.NETを使ったOfficeの自動化が面倒なはずがない―そう考えていた時期が俺にもありました。

Table of Content

前書き

.NETを使ったOfficeの自動化が面倒なはずがない

―そう考えていた時期が俺にもありました。

以下のPowerShellのコードを見てみましょう。

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    Write-Host $book.Sheets["Sheet1"].Cells[1,1].Text

    $book.Close()
    $app.Quit();

Excelを立ち上げて、シートのセルを表示し、Excelを終了するコードです。
本来であれば、なんの問題もありません。

しかしながら、起動したExcelのプロセスは終了せずタスクマネージャーに残り続けます。

今回はExcelで説明しましたが、これはWordでもOutlookでも同様です。また、PowerShellでなくC#で同様の実装をしても、この問題は発生します。

何が問題なのか?

Office オートメーションで割り当てたオブジェクトは、自分で解放する必要があります。
解放処理を適切に行わないことで、予期せぬ動作や、メモリの圧迫を引き起こします。
解放処理のベストプラクティスについてはマイクロソフトの中の人が以下のような記事をあげています。

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

.NETでOfficeの操作をする場合は必ず一読しておくことをお勧めします。
上記、記事のポイントとしては以下の通りです。

・作成されたRCWはReleaseComObjectで解放を行う
・Officeのアプリケーションの終了前後にガベージコレクトを実行する

作成されたReleaseComObjectで解放処理を行う。

New 演算子で Excel.Application クラス等を生成すると、RCW (ランタイム呼び出し可能ラッパー) も生成され COM オブジェクトのインスタンスを管理することになります。
ここで作成したRCWはReleaseComObjctを実行して参照カウントを減算します。これを行わないとオブジェクトが解放されずに予期せぬ動作を引き起こします。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application
    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

COMオブジェクトは暗黙的に作成されることがあり、以下のケースがそれにあたります。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    # 暗黙的に「Microsoft.Office.Interop.Excel.Workbooks」オブジェクトが作成される。
    Write-Host $app.WorkBooks.Count

    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

上記の暗黙的に作成されるCOMオブジェクトも解放処理を記載する必要があります。
つまり、以下のように実装する必要があります。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    $books = $app.WorkBooks
    Write-Host $books.Count

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books)
    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

How to properly release Excel COM objectsで述べているように「1 dot good, 2 dots bad」と覚えると楽だと思います。

Officeのアプリケーションの終了前後にガベージコレクトを実行する

Office 開発系サポートの提唱するいわゆる「ベストプラクティス」ではOfficeのアプリケーションの終了前後に明示的にガベージコレクトを実行することを薦めています。

アプリケーションの終了時には、Excel からの COM オブジェクトの参照解放処理が行われます。このタイミングまでに適切に COM オブジェクトが解放されていないと、ガベージコレクトのタイミングによっては予期せぬエラーが生じる場合があります。このため、アプリケーションの終了前にまずガベージコレクトを実行します。

さらに、Application インスタンスを解放する際なのですが、Marshal.ReleaseComObject メソッドを使用して参照カウンタをデクリメントしただけでは、プロセスが終了することを保証できませんので、GC.Collect メソッドでガベージコレクトを強制してオブジェクトを解放しています。

つまりいわゆる「ベストプラクティス」では以下のようになります。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    $books = $app.WorkBooks
    Write-Host $books.Count

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books)

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit()

    # 作成したオブジェクトはReleaseComObjectを用いて解放する。
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app)

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

また、以下のようにReleaseComObjectなしのガベージコレクトだけでExcelのプロセスは終了して一見正しく動いているように見えます。

    # ここでRCWが作成される。
    $app = New-Object -ComObject Excel.Application

    $books = $app.WorkBooks
    Write-Host $books.Count

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit()

    # GCの対象になるようにNULLをいれて変数の参照を切る
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    # アプリケーション終了前のGC
    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

Office オートメーションで割り当てたオブジェクトを解放する – Part2」のTipsではこの危険性を訴えています。

Null 代入の後、GC.Collect メソッドを実行するだけで良いように見える場合があります (例. EXCEL.EXE プロセスが終了するなど) が、これは非常に危険な方法です。
RCW への参照を残しているままの状態では、COM オブジェクト生成や参照等を繰り返した際に、例えば開発者が解放済みと考えている COM オブジェクトに対して接続しに行くなど、正しい COM オブジェクトに対する接続が実施できる保障ができなくなります。

なお、このベストプラクティスはあくまでOfficeアプリケーションのCOM解放処理のベストプラクティスであって、全てのベストプラクティスではありません。
サーバーサイドでガベージコレクトを明示的に実行することは、ベストではありません。

ベストプラクティスとその問題点

前書きに以下のようなコードを記載しました。

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    Write-Host $book.Sheets["Sheet1"].Cells[1,1].Text

    $book.Close()
    $app.Quit();

このベストプラクティスは以下のようになります。

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    $sheets = $book.Sheets
    $sheet = $sheets["Sheet1"]
    $cells = $sheet.Cells
    $cell = $cells[1,1]
    Write-Host $cell.Text

    $book.Close()

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cell) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cells) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheet) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheets) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($book) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books) | Out-Null

    $cell = $null
    Remove-Variable cell -ErrorAction SilentlyContinue
    $cells = $null
    Remove-Variable cells -ErrorAction SilentlyContinue
    $sheet = $null
    Remove-Variable sheet -ErrorAction SilentlyContinue
    $sheets = $null
    Remove-Variable sheets -ErrorAction SilentlyContinue
    $book = $null
    Remove-Variable book -ErrorAction SilentlyContinue
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit();

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

C#の例

using Excel = Microsoft.Office.Interop.Excel;

// 略
        static void test1()
        {
            {
                Excel.Application app = new Excel.Application();
                {
                    Excel.Workbooks books = app.Workbooks;
                    Excel.Workbook book = books.Open(@"test.xlsx");
                    Excel.Sheets sheets = book.Sheets;
                    Excel.Worksheet sheet = sheets["Sheet1"];
                    Excel.Range cells = sheet.Cells;
                    Excel.Range cell = cells[1, 1];
                    Console.WriteLine(cell.Value);

                    book.Close(Type.Missing, Type.Missing, Type.Missing);

                    Marshal.ReleaseComObject(cell);
                    Marshal.ReleaseComObject(cells);
                    Marshal.ReleaseComObject(sheet);
                    Marshal.ReleaseComObject(sheets);
                    Marshal.ReleaseComObject(book);
                    Marshal.ReleaseComObject(books);

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

                app.Quit();
                Marshal.ReleaseComObject(app);
            }

            // Application オブジェクトのガベージ コレクトを強制します。
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }

いわゆるベストプラクティスではコードのステップ数が数倍になっていることがわかります。

また、ReleaseComObjectによるオブジェクトの解放漏れを検知することが非常に厄介です。
上記のコードで仮にReleaseComObjectをいくつか漏らしても、それを検知することは困難です。
先に説明したようにEXCELのプロセスが終了するなどの事象で解放漏れが起きているかどうかを検知することは難しいです。

また、暗黙的に作成されるCOMオブジェクトは、プログラマが意図せぬタイミングでおこなわれます。
たとえば以下のコードを10分ほど読んで見て解放漏れを考えてみてください。

                Excel.Application app = new Excel.Application();
                {
                    Excel.Workbooks books = app.Workbooks;
                    Excel.Workbook book = books.Open(@"test.xlsx");
                    Excel.Sheets sheets = book.Sheets;

                    foreach(Excel.Worksheet sheet in sheets)
                    {
                        Console.WriteLine(sheet.Name);
                        Marshal.ReleaseComObject(sheet);
                        Console.ReadLine();
                    }

                    Console.WriteLine("Excel起動済み");
                    Console.ReadLine();

                    book.Close(Type.Missing, Type.Missing, Type.Missing);

                    Marshal.ReleaseComObject(sheets);
                    Marshal.ReleaseComObject(book);
                    Marshal.ReleaseComObject(books);

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

                app.Quit();
                Marshal.ReleaseComObject(app);
                Console.WriteLine("ReleaseComObject");
                Console.ReadLine();
            }

            // Application オブジェクトのガベージ コレクトを強制します。
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            Console.WriteLine("ガベージコレクション済み");
            Console.ReadLine();

実は下記のコードのforeach句で「System.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariant」からCOMオブジェクトが作成されてしまいます。

                    foreach(Excel.Worksheet sheet in sheets)
                    {
                        Console.WriteLine(sheet.Name);
                        Marshal.ReleaseComObject(sheet);
                        Console.ReadLine();
                    }

そのため、以下のようにforeachを使わずにCOMオブジェクトを操作した方が安全です。

                    for (int i = 1; i <= sheets.Count; ++i)
                    {
                        Excel.Worksheet sheet = book.Sheets[i];
                        Console.WriteLine(sheet.Name);
                        Marshal.ReleaseComObject(sheet);
                        Console.ReadLine();
                    }

このように、挙動を理解した上でソースコードを読み込まないとベストプラクティスのメモリ解放を実現することができないのです。

いわゆる「ベストプラクティス」の問題点の対応策

ソースコードを読まないと適切にメモリ解放をしているかわからない、かつ、タスクマネージャーだけではメモリが正しく解放されたかどうかを検知するのが困難であることがわかりました。
さすがに令和の時代にメモリ解放を気にして実装するのは辛いものがあるので、この状況を改善する方法について検討してみます。

NetOfficeを使用してCOMオブジェクトの解放を任せる

NetOfficeというMIT Licenseのオープンソースのライブラリが存在します。
https://github.com/NetOfficeFw/NetOffice

Officeの各オブジェクトをラッパーしてReleaseComObjectを行わなくてもすむようになっています。


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Excel = NetOffice.ExcelApi;

namespace NetOfficeSample
{
    class Program
    {
        static void Main(string[] args)
        {
            using (Excel.Application app = new Excel.Application()) {
                Excel.Workbook book = app.Workbooks.Open(@"test.xlsx");
                Excel.Worksheet workSheet = (Excel.Worksheet)book.Worksheets[1];
                Console.WriteLine(workSheet.Cells[1, 1].Value);

                Console.WriteLine("起動済み");
                Console.ReadLine();
                book.Close();
                app.Quit();

            }
            Console.WriteLine("Application終了");
            Console.ReadLine();
        }

    }
}

ただし、NetOfficeのコードをみるかぎりガベージコレクトを明示的にやっていないようにみえるので、いわゆるベストプラクティスとは違う部分があります。

RCWのオブジェクトの状況を監視する。

RCW (ランタイム呼び出し可能ラッパー) のオブジェクトの解放状況を監視しながら実装する方法もあります。
そのためには、マネージドヒープを監視するためのライブラリ、Microsoft.Diagnostics.Runtime.dll (nicknamed "CLR MD")を利用します。

https://github.com/microsoft/clrmd

なお、ウィルスバスターなどのウィルス対策ソフトは、このライブラリを使用して別プロセスにアタッチしてマネージドヒープを調べる操作を「怪しい操作」とみなすので、除外リストに入れて対応してください。

以下で説明するCOMの解放漏れをチェックするツールについては下記にソースコードとバイナリがあるので必要に応じて使用してください。
https://github.com/mima3/MemoryCheck

RCWのオブジェクトを列挙するサンプル

マネージドヒープ中のRCWのオブジェクトを列挙するサンプルを以下に記載します。

using Microsoft.Diagnostics.Runtime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace EnumRcw
{
    class Program
    {
        static void Main(string[] args)
        {
            foreach (var process in System.Diagnostics.Process.GetProcessesByName(args[0]))
            {
                int pid = process.Id;
                Console.WriteLine("{0} {1} =======================================================", args[0], pid);
                using (var dataTarget = DataTarget.AttachToProcess(pid, 1000))
                {
                    Console.WriteLine(dataTarget.Architecture);
                    var clrVersion = dataTarget.ClrVersions.First();
                    var dacInfo = clrVersion.DacInfo;
                    ClrRuntime runtime = clrVersion.CreateRuntime();
                    foreach (var obj in runtime.Heap.EnumerateObjects())
                    {
                        ClrType type = obj.Type;
                        ulong size = obj.Size;
                        if (type.IsRCW(obj))
                        {
                            RcwData rcw = type.GetRCWData(obj);
                            if (rcw != null)
                            {
                                string ifname = "";
                                foreach (var i in rcw.Interfaces)
                                {
                                    ifname += i.Type.Name + ",";
                                }
                                Console.WriteLine("{0,16:X} {1,12:n0} {2} {3} {4} {5}", obj.Address, size, type.Name, rcw.RefCount, rcw.Disconnected, ifname);

                            }
                            else
                            {
                                Console.WriteLine("{0,16:X} {1,12:n0} {2} (GetRCWDataに失敗)", obj.Address, size, type.Name);

                            }
                        }
                    }
                }
            }
        }
    }
}

このプログラムは指定のプロセスにアタッチして、マネージドヒープ中のRCWオブジェクトを列挙します。
この際、アタッチ対象のプロセスと同じになるように、プラットフォームのターゲットをX86/X64を明示してください。

image.png

コマンドラインからプロセス名🅂を指定することで、現在のヒープ中のRCWのオブジェクトを列挙します。
以下のサンプルは「foreach(Excel.Worksheet sheet in sheets)」のブロック内におけるRCWのオブジェクトを列挙した例になります。

>EnumRcw プロセス名
memorycheck 18688 =======================================================
Amd64
     25601C57CE8           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     25601C57D08           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     25601C58348           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     25601C58368           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     25601C58C78           32 System.__ComObject 1 False
     25601C58DA8           32 System.__ComObject (GetRCWDataに失敗)

指定のオブジェクトを参照しているオブジェクトを探す

Microsoft.Diagnostics.Runtime.dll では指定のオブジェクトがGCのルートから、どのパスで参照されているか確認することもできます。
https://blog.maartenballiauw.be/post/2017/01/03/exploring-.net-managed-heap-with-clrmd.html

using Microsoft.Diagnostics.Runtime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace WalkHeap
{
    class Program
    {
        static void Main(string[] args)
        {
            string name = args[0];
            foreach (var process in System.Diagnostics.Process.GetProcessesByName(args[0]))
            {
                int pid = process.Id;
                Console.WriteLine("{0} {1} =======================================================", args[0], pid);
                ulong taregetPtr = ulong.Parse(args[1], System.Globalization.NumberStyles.HexNumber);

                using (var dataTarget = DataTarget.AttachToProcess(pid, 1000))
                {
                    var clrVersion = dataTarget.ClrVersions.First();
                    var dacInfo = clrVersion.DacInfo;
                    ClrRuntime runtime = clrVersion.CreateRuntime();
                    var stack = new Stack<ulong>();

                    var heap = runtime.Heap;
                    if (heap.CanWalkHeap)
                    {
                        Console.WriteLine("-----");
                        foreach (var ptr in heap.EnumerateObjectAddresses())
                        {
                            var type = heap.GetObjectType(ptr);
                            if (type == null || taregetPtr != ptr)
                            {
                                continue;
                            }

                            Console.WriteLine("find");

                            // todo: retention path
                            Console.WriteLine("roots...");
                            foreach (var root in heap.EnumerateRoots())
                            {
                                stack.Clear();
                                stack.Push(root.Object);

                                if (GetPathToObject(heap, ptr, stack, new HashSet<ulong>()))
                                {
                                    // Print retention path
                                    var depth = 0;
                                    foreach (var address in stack)
                                    {
                                        var t = heap.GetObjectType(address);
                                        if (t == null)
                                        {
                                            Console.WriteLine("{0} {1,16:X} ", new string('+', depth++), address);
                                            continue;
                                        }

                                        Console.WriteLine("{0} {1,16:X} - {2} - {3} bytes", new string('+', depth++), address, t.Name, t.GetSize(address));
                                    }

                                    break;
                                }
                            }

                            break;
                        }
                    }

                }

            }
        }

        // https://blog.maartenballiauw.be/post/2017/01/03/exploring-.net-managed-heap-with-clrmd.html
        private static bool GetPathToObject(ClrHeap heap, ulong objectPointer, Stack<ulong> stack, HashSet<ulong> touchedObjects)
        {
            // Start of the journey - get address of the first objetc on our reference chain
            var currentObject = stack.Peek();

            // Have we checked this object before?
            if (!touchedObjects.Add(currentObject))
            {
                return false;
            }

            // Did we find our object? Then we have the path!
            if (currentObject == objectPointer)
            {
                return true;
            }

            // Enumerate internal references of the object
            var found = false;
            var type = heap.GetObjectType(currentObject);
            if (type != null)
            {
                type.EnumerateRefsOfObject(currentObject, (innerObject, fieldOffset) =>
                {
                    if (innerObject == 0 || touchedObjects.Contains(innerObject))
                    {
                        return;
                    }

                    // Push the object onto our stack
                    stack.Push(innerObject);
                    if (GetPathToObject(heap, objectPointer, stack, touchedObjects))
                    {
                        found = true;
                        return;
                    }

                    // If not found, pop the object from our stack as this is not the tree we're looking for
                    stack.Pop();
                });
            }

            return found;
        }
    }
}

コマンドラインから以下のようにプロセス名とオブジェクトのアドレスを指定して実行することで、指定のプロセスのオブジェクトが、どのように参照されているか表示します。

>WalkHeap.exe memorycheck 25601C58C78
memorycheck 18688 =======================================================
-----
find
roots...
      25601C58C78 - System.__ComObject - 32 bytes
+      25601C58CD0 - System.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariant - 40 bytes

RCWの解放状況の検証

ヒープ上のRCWの状況を検査する方法が分かったので様々なケースで実験してみようと思います。

OS
 Windows10 64bit

Excel
 Office16 Excel32bit

PowerSehll
 5.1.17134.858(64bit)

操作側のアプリ:
 .NET Framework4.5.2
 64bit
 Releaseビルド
debugビルドだと挙動が変わる可能性があります。

実験1 アプリケーションの解放漏れがあるケース
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t1()
        {
            Excel.Application app = new Excel.Application();
            app.Quit();
            Console.WriteLine("ここでヒープのチェックを行う");
            Console.ReadLine();
        }

結果:

memorycheck 20140 =======================================================
Amd64
     19880007BE0           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,

Microsoft.Office.Interop.Excel.ApplicationClassが解放されずに残っていることがわかります。

実験2 アプリケーションの解放をReleaseComObjectで行った場合
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t1_2()
        {
            Excel.Application app = new Excel.Application();
            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う");
            Console.ReadLine();
        }

結果:

memorycheck 16108 =======================================================
-----

Microsoft.Office.Interop.Excel.ApplicationClassが解放されていることが確認できます。

実験3 アプリケーションの解放をガベージコレクションだけで行った場合
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t1_3()
        {
            Excel.Application app = new Excel.Application();
            app.Quit();
            app = null;
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            Console.WriteLine("ここでヒープのチェックを行う t1_3");
            Console.ReadLine();
        }

結果:

memorycheck 8664 =======================================================
Amd64

Microsoft.Office.Interop.Excel.ApplicationClassが解放されていることが確認できます。
なお、デバッグビルドの場合は以下のようになります。

memorycheck 19908 =======================================================
Amd64
     1B583A77BD8           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,

これはデバッグ実行の場合、オブジェクトの生存期間が異なるためです。

実験4 暗黙の参照が作成されるケース
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t2()
        {
            Excel.Application app = new Excel.Application();
            // Workbooksの暗黙のオブジェクトの作成
            Excel.Workbook book = app.Workbooks.Open(@"test.xlsx");
            Console.WriteLine("ここでヒープのチェックを行う1");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う2");
            Console.ReadLine();
        }

結果:

1回目のメモリの内容
memorycheck 21916 =======================================================
Amd64
     1D04E377CE8           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1D04E377D08           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1D04E378348           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,

2回目のメモリの内容
memorycheck 21916 =======================================================
Amd64
     1D04E377D08           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1D04E378348           32 System.__ComObject (GetRCWDataに失敗)

Microsoft.Office.Interop.Excel.Workbooksのオブジェクトが暗黙的に作成されていることがわかります。
この暗黙的なオブジェクトを解放しない場合、「Microsoft.Office.Interop.Excel._Workbook」のオブジェクトが中途半端に残っていることも確認できます。

実験5 暗黙の参照が作成しないように修正したケース
        static void t2_1()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            Console.WriteLine("ここでヒープのチェックを行う1 t_1");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う2");
            Console.ReadLine();
        }

結果:

1回目のメモリの状態:
memorycheck 22292 =======================================================
Amd64
     12E47377CF0           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     12E47377D10           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     12E47378350           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,

2回目のメモリの状態:
memorycheck 22292 =======================================================
Amd64

作成したオブジェクトが全て削除されていることが確認できます。

実験6 for eachを用いた繰り返しの場合
        static void t3()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            foreach (var sheet in book.Sheets)
            {
                Console.WriteLine("ここでヒープのチェックを行う1(ループ内) t3");
                Console.ReadLine();
                Marshal.ReleaseComObject(sheet);
            }
            Console.WriteLine("ここでヒープのチェックを行う2 ループ完了 t3");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う3");
            Console.ReadLine();
        }

結果:

1:ループ内のメモリの状態
memorycheck 17784 =======================================================
Amd64
     1C74A3B7D50           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1C74A3B7D70           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1C74A3B83B0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     1C74A3B83D0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     1C74A3B8CE0           32 System.__ComObject 1 False
     1C74A3B8E10           32 System.__ComObject 1 False

2: 1C74A3B8CE0の参照箇所を調べる
-----
find
roots...
      1C74A3B8CE0 - System.__ComObject - 32 bytes
+      1C74A3B8D38 - System.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariant - 40 bytes

3: 1C74A3B8E10 の参照箇所を調べる
-----
find
roots...
      1C74A3B8E10 - System.__ComObject - 32 bytes

4: ループ外でのメモリの状態
memorycheck 17784 =======================================================
Amd64
     1C74A3B7D50           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1C74A3B7D70           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1C74A3B83B0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     1C74A3B83D0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     1C74A3B8CE0           32 System.__ComObject 1 False
     1C74A3B8E10           32 System.__ComObject (GetRCWDataに失敗)

5: 1C74A3B8CE0の参照箇所を調べる
-----
find
roots...

6: 1C74A3B8E10の参照箇所を調べる
-----
find
roots...

7: Officeアプリケーション終了後
memorycheck 17784 =======================================================
Amd64

foreachでループを行った場合、1C74A3B8CE0が暗黙的に作成されていることがわかります。このオブジェクトはSystem.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariantから参照されていましたが、ループを抜けると、どこからも参照されずゴミとして残ってしまいます。
※Officeの終了で削除されるが、その間、予期せぬオブジェクトが残り続ける

実験7 for eachを排除した繰り返しの場合
        static void t3_1()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            Excel.Sheets sheets = book.Sheets;

            for (int i = 1; i <= sheets.Count; ++i)
            {
                var sheet = sheets[i];
                Console.WriteLine("ここでヒープのチェックを行う1(ループ内) t3-1");
                Console.ReadLine();
                Marshal.ReleaseComObject(sheet);

            }
            Console.WriteLine("ここでヒープのチェックを行う2 ループ完了 t3");
            Console.ReadLine();
            book.Close();

            Marshal.ReleaseComObject(sheets);
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う3");
            Console.ReadLine();
        }
ループ内のメモリの状態:
memorycheck 17612 =======================================================
Amd64
     29107A77D90           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     29107A77DB0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     29107A783F0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     29107A78410           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     29107A78480           32 System.__ComObject 1 False

ループ外のメモリの状態:
memorycheck 17612 =======================================================
Amd64
     29107A77D90           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     29107A77DB0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     29107A783F0           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,
     29107A78410           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Sheets,
     29107A78480           32 System.__ComObject (GetRCWDataに失敗)

Officeアプリケーション終了後のメモリの状態
memorycheck 17612 =======================================================
-----

foreachで暗黙的に作成されたSystem.Runtime.InteropServices.CustomMarshalers.EnumeratorViewOfEnumVariantからの参照されているSystem.__ComObjectが存在しないことがわかります。

実験8 RCWのオブジェクトを別の変数に格納した場合
        //using Excel = Microsoft.Office.Interop.Excel;
        static void t4()
        {
            Excel.Application app = new Excel.Application();
            Excel.Workbooks books = app.Workbooks;
            Excel.Workbook book = books.Open(@"test.xlsx");
            var book2 = book;
            Console.WriteLine(book.Name);
            Console.WriteLine(book2.Name);

            Console.WriteLine("ここでヒープのチェックを行う1 t_1");
            Console.ReadLine();
            book.Close();
            Marshal.ReleaseComObject(book);
            Marshal.ReleaseComObject(books);

            app.Quit();
            Marshal.ReleaseComObject(app);
            Console.WriteLine("ここでヒープのチェックを行う2");
            Console.ReadLine();
        }

結果:

1回目:
MemoryCheck 15648 =======================================================
Amd64
     1FC94A97CF0           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False Microsoft.Office.Interop.Excel._Application,
     1FC94A97D10           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,Microsoft.Office.Interop.Excel.Workbooks,
     1FC94A98350           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel._Workbook,

2回目:
MemoryCheck 15648 =======================================================
Amd64

1回目のメモリの内容確認でExcel.Workbookをbookとbook2変数に格納していますが、RCWのオブジェクト自体が増加しておらず、かつ、参照カウンタが1のままであることが確認できます。
2回目のbook変数のみにたいしてReleaseComObjectを行うことでRCWオブジェクトが削除されることも確認できます。

実験9:PowerShellで実行した場合

test2.ps1

    Read-Host "初期状態"

    $app = New-Object -ComObject Excel.Application
    $books = $app.Workbooks
    $book = $books.Open("test.xlsx")
    $sheets = $book.Sheets
    $sheet = $sheets["Sheet1"]
    $cells = $sheet.Cells
    $cell = $cells[1,1]
    Write-Host $cell.Text
    #Write-Host $cell.Value # Variant Value (Variant)が表示される
    #$cell.Value = "TEST"
    #Write-Host $cell.Text

    $book.Close()
    Read-Host "①起動済み"

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cell) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($cells) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheet) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($sheets) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($book) | Out-Null
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($books) | Out-Null
    Read-Host "②ReleaseComObject"

    $cell = $null
    Remove-Variable cell -ErrorAction SilentlyContinue
    $cells = $null
    Remove-Variable cells -ErrorAction SilentlyContinue
    $sheet = $null
    Remove-Variable sheet -ErrorAction SilentlyContinue
    $sheets = $null
    Remove-Variable sheets -ErrorAction SilentlyContinue
    $book = $null
    Remove-Variable book -ErrorAction SilentlyContinue
    $books = $null
    Remove-Variable books -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()

    $app.Quit();

    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($app) | Out-Null
    $app = $null
    Remove-Variable app -ErrorAction SilentlyContinue

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
    [System.GC]::Collect()
    Read-Host "すべて終了"

結果:

1: スクリプト実行前
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,

2: スクリプト実行して「初期状態」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E709130           32 System.__ComObject (GetRCWDataに失敗)

※2873E709130はルートから検索できない

3:「①起動済み:」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E7B2618           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Management.Automation.IDispatch,Microsoft.Office.Interop.Excel._Application,
     2873E7BA850           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E87F860           32 System.__ComObject 1 False Microsoft.Office.Interop.Excel.Workbooks,System.Management.Automation.ComInterop.IDispatch,
     2873E891FE0           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E8DD090           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873E8EF398           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E93BB10           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873E94AE08           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E9A66A0           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873E9B7928           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873E9F5C90           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,
     2873EA05D48           32 System.__ComObject 2 False System.Runtime.InteropServices.ComTypes.ITypeInfo,
     2873EA6ED78           32 System.__ComObject 1 False System.Management.Automation.ComInterop.IDispatch,

※2873E709130は消えている

4:「②ReleaseComObject:」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E6B77C0           32 System.__ComObject (GetRCWDataに失敗)
     2873E6C5288           32 System.__ComObject (GetRCWDataに失敗)
     2873E6DBB58           32 System.__ComObject (GetRCWDataに失敗)
     2873E6E0058           32 System.__ComObject (GetRCWDataに失敗)
     2873E6F11D8           32 System.__ComObject (GetRCWDataに失敗)
     2873E715028           32 System.__ComObject (GetRCWDataに失敗)
     2873E7B2618           32 Microsoft.Office.Interop.Excel.ApplicationClass 1 False System.Management.Automation.IDispatch,Microsoft.Office.Interop.Excel._Application,
     2873E7BA850           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,

5:「すべて終了」が表示された時点の状態
powershell 1656 =======================================================
Amd64
     2873E065E98           32 System.__ComObject 1 False Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics,
     2873E7BA850           32 System.__ComObject 1 False System.Runtime.InteropServices.ComTypes.ITypeInfo,

2873E7BA850の検索結果は以下の通り
      2873E7BA850 - System.__ComObject - 32 bytes
+      2873E7BAEA8 - System.Management.Automation.ComProperty - 64 bytes
++      2873E7E1738 - System.Collections.Generic.Dictionary+Entry<System.String,System.Management.Automation.ComProperty>[] - 10368 bytes
+++      2873E7BA950 - System.Collections.Generic.Dictionary<System.String,System.Management.Automation.ComProperty> - 80 bytes
++++      2873E7BA918 - System.Management.Automation.ComTypeInfo - 56 bytes
+++++      2873E7EC398 - System.Management.Automation.DotNetAdapterWithComTypeName - 32 bytes
++++++      2873E7EC3B8 - System.Management.Automation.PSObject+AdapterSet - 32 bytes
+++++++      2873E7EC3D8 - System.Collections.Concurrent.ConcurrentDictionary+Node<System.Type,System.Management.Automation.PSObject+AdapterSet> - 48 bytes
++++++++      2873E3FD9B8 - System.Collections.Concurrent.ConcurrentDictionary+Node<System.Type,System.Management.Automation.PSObject+AdapterSet>[] - 272 bytes
+++++++++      2873E3FDAC8 - System.Collections.Concurrent.ConcurrentDictionary+Tables<System.Type,System.Management.Automation.PSObject+AdapterSet> - 48 bytes
++++++++++      2873E3FD8B8 - System.Collections.Concurrent.ConcurrentDictionary<System.Type,System.Management.Automation.PSObject+AdapterSet> - 64 bytes

PowerShellでは初期状態で「Windows.Foundation.Diagnostics.IAsyncCausalityTracerStatics」のCOMオブジェクトが存在しています。
いわゆるベストプラクティスを守ったスクリプトを実行しても、操作終了後に「System.Runtime.InteropServices.ComTypes.ITypeInfo」が残ります。このオブジェクトはSystem.Collections.Concurrent.ConcurrentDictionary<System.Type,System.Management.Automation.PSObject+AdapterSet>から参照されています。

※以下のs_adapterMappingのように見えるが詳細は不明。
https://github.com/PowerShell/PowerShell/blob/c684902fba5b5e63fa750e66270a25b6889b4ec6/src/System.Management.Automation/engine/MshObject.cs

まとめと意識の低い対応策

.NETでOfficeオートメーションを使用する場合、いわゆるベストベストプラクティスは以下のようになります。

・アプリケーションで作成したRCWオブジェクトはReleaseComObjectで解放します
・RCWオブジェクトは暗黙的に作成される場合があります。
 例:
  foreach句
  「app.WorkBooks.Count」で2ドット含む場合
・Officeオートメーションの解放だけを考えた場合、Officeの終了前後でガベージコレクトを実行する。

正直面倒…さらにいうとサーバー側のプログラムでガベージコレクトを実行するのは、よくないです。

ここでは、意識を低くして対応策を考えてみます。

Office オートメーションで割り当てたオブジェクトを解放する – Part1」によると以下のような記述があります。

なお、コンソール アプリケーション等では、処理が一通り実行された後、プロセスが終了する際に CLR ランタイムが終了するのに合わせて、Application オブジェクトの解放および EXCEL.EXE プロセスの終了が実施されます。そのため、解放漏れがあったとしても、プログラム終了時に解放されるため、ほとんどの場合大きな影響がない傾向があります。

すなわち、 Officeの操作をして、すぐプロセスを終了すると影響が少なくできると考えられます。
なので、別プロセスで起動して単一のOfficeの操作を行い、すぐ終了するという設計にすることで、Officeの解放漏れを抑えられると考えられます。

たとえばPowerShellを実行する場合も以下のように別プロセスで実行してしまいます。

# PowerShellのコマンドプロンプトを落とすまで影響がのこる
./test1.ps1

# 別プロセスで実行することで影響を抑える
powershell ./test1.ps1

あとはわりきって、完璧なリソース解放はあきらめて、操作中はOfficeを使用しないでくださいとか運用でカバーしたり、予期せぬOfficeのプロセスが起動してたら終了させるなどの意識の低い対処案が考えられます。

参考

「.NETを使ったOfficeの自動化が面倒なはずがない―そう考えていた時期が俺にもありました。」への3件のフィードバック

  1. はじめまして。自分もエクセルのプロセスが残る件に関しては以前より対応してきていますが、後半のRCWを列挙するコードは確認に有用かと利用させていただいたところ、当方の環境(Win10x64、エクセルも64版)ではプロセスにはアタッチできていると思われるものの、var clrVersion = dataTarget.ClrVersions.First();で、ClrVesionsがLength=0となりうまく取得できなくエラーになってしまいます。いろいろ調べましたが解決策に当たらず、念のためgithubのファイルもダウンロードして実行させていただきましたが、やはり同じエラーとなるようです。何か心当たりありましたらご教示いただければと思います。よろしくお願いします。

    • 初めまして。
      ちょっと手元に環境がないので現在のwindows10で動作するか検証できないですが、当該プログラムはMicrosoft.Diagnostics.Runtime(clrmdと言われる)を使用しています。

      そのため、問題の切り分けのため、以下のサンプルが動作するか否かを検証してみると、なんらかのヒントが掴めるかもしれません。
      https://github.com/microsoft/clrmd/tree/master/src/Samples

      問題が解決することをお祈りいたします。

レガシー環境のためのWindows Script Host(WSH)の解説 – へっぽこ実験ブログ へ返信する コメントをキャンセル

メールアドレスが公開されることはありません。 が付いている欄は必須項目です