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のようなライブラリを使えば、この解放地獄自体が発生しない。用途が合うならライブラリ移行も検討の価値あり。


コメント