【C#】Excel操作でEXCEL.EXEプロセスが残る問題の解決【現場の備忘録】

プログラム

C#でExcelを操作(Interop)したあと、Quit()で閉じたはずなのにタスクマネージャーにEXCEL.EXEが残り続ける。何度も実行すると幽霊プロセスがどんどん溜まってメモリを食う。

正常終了時だけでなく、デバッグ中にブレークポイントで止めたまま「デバッグの停止」(Shift+F5)で強制終了したときも高確率で発生する。むしろ開発中はこっちの方が遭遇率が高いかもしれない。Quit()ReleaseComObjectを通る前にプロセスがぶった切られるので、EXCEL.EXEだけがぽつんと取り残される。

環境はVisualStudio2022.Net8.0、作成プロジェクトはコンソールアプリ、OSはWindows11。Excel操作にはパッケージはNuGetでMicrosoft.Office.Interop.Excelを使用することを想定している。

原因:COMオブジェクトの解放漏れ

Excel Interopの各オブジェクト(Application、Workbook、Worksheet…)はCOMオブジェクト。これらは明示的に解放しないと参照が残り、Excel本体(EXCEL.EXE)が終了できない。

特にやりがちなのが「二重ドット」。中間のオブジェクトを変数に受けずに繋げて書くと、その中間COMオブジェクトを解放できず残ってしまう。

悪い例(二重ドット)
// NG:Worksheets[1] などの中間オブジェクトが変数に無く、解放できない
Excel.Application app = new Excel.Application();
Excel.Workbook book = app.Workbooks.Open(@"C:\temp\book.xlsx"); // Workbooks が宙に浮く
Excel.Worksheet sheet = book.Worksheets[1];    // Worksheets が宙に浮く
string v = sheet.Cells[1, 1].Value.ToString(); // Cells が宙に浮く

book.Close(false);
app.Quit();
// これだと EXCEL.EXE が残りやすい

解決:1つずつ変数に受けて、逆順にReleaseComObject

ポイントは2つ。1. 途中のオブジェクトも全部変数に受ける(二重ドットを作らない)2. 使い終わったら生成と逆の順番でMarshal.ReleaseComObjectで解放する

using System.Runtime.InteropServices;
using Excel = Microsoft.Office.Interop.Excel;

Excel.Application app = null;
Excel.Workbooks books = null;
Excel.Workbook book = null;
Excel.Sheets sheets = null;
Excel.Worksheet sheet = null;
Excel.Range range = null;

try
{
    app = new Excel.Application();
    books = app.Workbooks;                    // 中間も変数に
    book = books.Open(@"C:\temp\book.xlsx");
    sheets = book.Worksheets;                 // 中間も変数に
    sheet = (Excel.Worksheet)sheets[1];
    range = (Excel.Range)sheet.Cells[1, 1];

    string v = range.Value?.ToString();

    book.Close(false);
    app.Quit();
}
finally
{
    // 生成と逆の順番で解放していく
    if (range  != null) Marshal.ReleaseComObject(range);
    if (sheet  != null) Marshal.ReleaseComObject(sheet);
    if (sheets != null) Marshal.ReleaseComObject(sheets);
    if (book   != null) Marshal.ReleaseComObject(book);
    if (books  != null) Marshal.ReleaseComObject(books);
    if (app    != null) Marshal.ReleaseComObject(app);

    // 念のためガベージコレクションを促す(解放しきれなかった参照を回収)
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

finallyで解放しているので、途中で例外が出ても確実にプロセスを片付けられる。GC.Collectを2回呼んでいるのは、解放しきれなかった参照を確実に回収するためのおまじない。

デバッグを強制停止した場合の対処

なぜこれで残るかというと、Excel(EXCEL.EXE)はデバッグ対象の.NETプロセスの子プロセスではなく、COM経由で起動される別プロセスだから。Visual Studioの「デバッグの停止」は自分がデバッグしているプロセスを強制終了するだけで、finallyブロックは実行されない。当然Quit()ReleaseComObjectも呼ばれないので、Excel側は「まだ誰かに掴まれている」状態のまま取り残される。

根本対策はブレークポイントで止めたまま「デバッグの停止」を使わないこと、これ尽きる。とはいえ調査中はどうしても途中で止めたい。そして止めたらうっかり停止してしまう。なので起動したExcelのプロセスIDを控えておいて、後から個別に確実に始末できるようにしておくと安心。

using System.Diagnostics;
using System.Runtime.InteropServices;

[DllImport("user32.dll")]
static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);

app = new Excel.Application();

// 起動したExcelのウィンドウハンドルからPIDを取得しておく
GetWindowThreadProcessId((IntPtr)app.Hwnd, out uint excelPid);
Console.WriteLine($"起動したExcelのPID: {excelPid}");

// デバッグを強制停止してしまった後などに、このPIDを使って
// 自分が起動したExcelだけをピンポイントで終了できる
// Process.GetProcessById((int)excelPid).Kill();

タスクマネージャーにEXCEL.EXEが複数並んでいると「どれが自分の起動したものか」分からず、うっかり別の作業中のExcelを閉じてしまう事故が起きやすい。PIDをログに出しておく癖をつけておくと、後始末のときに迷わない。

控えておいたPIDは、コード内からProcess.Kill()で終了させる以外に、コマンドプロンプト(またはPowerShell)から直接終了させることもできる。デバッグを止めてアプリ自体が落ちてしまった後の後始末には、こちらの方が手軽。

REM 指定PIDのプロセスを強制終了
taskkill /PID 1234 /F
 
REM PIDと合わせて子プロセスもまとめて終了したい場合
taskkill /PID 1234 /F /T

/PID を使わず名前で taskkill /IM EXCEL.EXE /F とやると、今開いている作業中の他のExcelまで巻き込んで全部落ちるので注意。ログに残しておいたPIDを指定して、狙い撃ちで終了させるのが安全。

さいごに

この問題はExcel Interop特有のクセ。「二重ドットを作らない」「逆順で解放」「finallyで確実に」の3点を守れば、EXCEL.EXEは残らなくなる。

そもそもExcelを起動しないClosedXMLやEPPlusのようなライブラリを使えば、この解放地獄自体が発生しない。用途が合うならライブラリ移行も検討の価値あり。

コメント

タイトルとURLをコピーしました