C#のExcel操作でEXCEL.EXEが残る問題 ── COM参照の解放パターンと置き換えの判断

· · Excel, C#, COM, .NET, .NET Framework, Office, レガシー保守, 技術相談

「帳票を Excel で出す機能を作ったら、タスクマネージャーに EXCEL.EXE がずらっと並んでいた」「アプリを終了したのに、次にファイルを開こうとすると『別のプロセスで使用中です』と言われる」「夜間バッチのサーバーに Excel のプロセスが数百個溜まって、メモリを食い尽くして止まった」。C# から Microsoft.Office.Interop.Excel で Excel を操作するコードを書いた開発者は、ほぼ全員がこの現象に一度はぶつかります。当社にも「Quit() を呼んでいるのに Excel が終了しない」という相談が定期的に来ます。

厄介なのは、この問題が「たまにしか起きない」ように見えることです。開発機では消えるのに本番で残る、デバッグ実行だと残るのにリリースでは消える──再現条件が揺れるため、対症療法の Process.Kill が本番コードに紛れ込みがちです。しかし原因は運ではなく、COM の参照カウントと .NET の RCW(Runtime Callable Wrapper)の仕組みで完全に説明できます。この記事では、残るメカニズム、定番の罠「2ドットルール」、解放パターン 2 派の整理と当社の推奨、最終手段のプロセス Kill の正しいやり方、Open XML 系ライブラリへの置き換え判断までを実務目線で整理します。

1. まず結論

  • EXCEL.EXE が残るのはバグではなく、.NET 側がまだ COM 参照を握っているからです。Quit() は「すべての参照が解放されたら終了する」という依頼にすぎず、参照が残っていれば Excel は律儀に待ち続けます。
  • .NET は COM オブジェクトを RCW(Runtime Callable Wrapper) というラッパー経由で扱い、RCW が GC に回収される(または明示的に解放される)まで COM オブジェクトへの参照を保持し続けます1
  • book.Worksheets[1].Range["A1"] のようにドットを 2 つ以上つなぐと、中間オブジェクトの RCW がどの変数にも入らないまま生成され、解放漏れになります。通称「2ドットルール」で、この問題の主犯です。
  • 解放パターンは 2 派あります。(a) 全 COM オブジェクトに Marshal.ReleaseComObject を規律正しく適用する派と、(b) 参照を変数に閉じ込めて GC(GC.Collect + WaitForPendingFinalizers)に回収させる派です。公式ドキュメントは ReleaseComObject を「絶対に必要な場合にのみ使う」ものと位置づけています。2
  • 当社の推奨は、処理を 1 メソッドに隔離した GC パターンを基本とし、解放順序の制御が必要な場合のみ using で解放を規律化する小さなラッパーを導入することです(4 章)。
  • それでも残るケースの保険には、Application.Hwnd からウィンドウハンドルを控えておき、GetWindowThreadProcessId で PID を特定して Kill します。起動前後のプロセス一覧の差分で当てる方法は、ユーザーが開いている Excel を巻き込む事故につながるため使いません。34
  • そもそもサーバーサイド・無人環境での Office 自動化は Microsoft 非サポートです。5 無人実行の帳票生成なら、Excel を起動しない Open XML SDK / ClosedXML への置き換えを先に検討してください(6〜7 章)。67

2. なぜ EXCEL.EXE が残るのか ── 参照カウントとRCW

まず COM 側の原則です。COM オブジェクトの寿命は参照カウントで管理され、Excel の自動化サーバー(EXCEL.EXE)は、外部クライアントに渡した参照がすべて返却されるまで終了しません。VB6 の時代も Release を呼び忘れれば同じようにプロセスが残りました(COM の復習は「COM / ActiveX / OCX とは何か」へ)。

次に .NET 側です。C# のコードは COM オブジェクトを直接触らず、CLR が生成する RCW というプロキシ越しに操作します。RCW は 1 つの COM オブジェクトにつきプロセス内に 1 つ作られ、COM インターフェイスポインターをキャッシュし、自分が GC に回収されるときに COM オブジェクトへの参照を解放します1 つまり寿命管理が「参照カウントを自分で数える」方式から「GC 任せ」方式に置き換わっています。

この 2 つを合成すると、EXCEL.EXE が残る理由がそのまま導けます。

段階 起きていること
new Excel.Application() EXCEL.EXE が起動し、Application の RCW が作られる
セル操作・保存など Workbook、Worksheet、Range … 触ったオブジェクトごとに RCW が増えていく
excel.Quit() Excel に「終了してよい」と伝えるだけ。RCW が握っている参照は 1 本も減らない
メソッドを抜ける RCW への .NET 参照がなくなるが、RCW 自体はまだヒープに生きている
(いつかの)GC RCW が回収され、そこで初めて COM 参照が返却される → EXCEL.EXE が終了する

ポイントは 2 つ。第一に、Quit() は解放ではないこと。参照が残っている限り Excel は待ちます。アプリプロセスが完全に終了すれば参照が切れて Excel も終了するのが普通ですが、常駐アプリや Web アプリのように親プロセスが生き続ける形態では、その「いつか」が来ません。第二に、解放のタイミングが GC 依存で不定なこと。メモリに余裕があれば GC は何十分も走らず、その間 EXCEL.EXE はゾンビとして残ります。「たまに残る」「本番でだけ残る」という再現性のなさは、GC タイミングの揺らぎがそのまま見えているだけです。

なお、Visible = false で起動した Excel はウィンドウを持たないため、残っていてもユーザーには見えません。症状が「2 回目の保存で『ファイルが使用中』エラー」「PC が遅い」として現れ、タスクマネージャーで初めて EXCEL.EXE の行列に気づくのが典型です。調査の最初の一手は tasklist | findstr EXCEL で数を数えることです。

3. 定番の罠「2ドットルール」 ── 見えない中間オブジェクト

「ちゃんと全部の変数を解放しているのに残る」という相談のコードには、ほぼ必ず次のような行があります。

// 一見きれいだが、解放できないRCWを生んでいる
excel.Workbooks.Open(path);
book.Worksheets[1].Range["A1"].Value2 = "hello";

excel.Workbooks は Workbooks コレクションの RCW を生成して返します。この戻り値を変数に受けずに .Open(...) を呼ぶと、Workbooks の RCW は「誰も参照していないが生きている」匿名オブジェクトとしてヒープに残ります。変数がないので Marshal.ReleaseComObject を呼ぶ手段もありません。2 行目はさらに重症で、Worksheets(コレクション)、Worksheets[1](シート)、Range["A1"](レンジ)と、1 行で 3 つの匿名 RCW を生成しています。

これを避ける経験則が、Office 自動化のコミュニティで昔から言われる「2 ドットルール」です。COM オブジェクトに対してドットを 2 つ以上つなげない。すべての中間オブジェクトを一度変数に受ける、と言い換えられます。

// すべての中間オブジェクトに名前を付ける
Excel.Workbooks books = excel.Workbooks;
Excel.Workbook book = books.Open(path);
Excel.Sheets sheets = book.Worksheets;
Excel.Worksheet sheet = (Excel.Worksheet)sheets[1];
Excel.Range cell = sheet.Range["A1"];
cell.Value2 = "hello";

冗長に見えますが、これは「解放すべきものをすべて列挙できる状態にする」ための書き方です。見落としやすい派生パターンも挙げておきます。

  • foreach: foreach (Excel.Worksheet s in book.Worksheets) は、コレクションと列挙子、さらに各要素の RCW を生成します。ReleaseComObject 派ではインデックスの for ループで 1 件ずつ変数に受けるのが定石です。
  • 条件式の中の読み捨て: if (excel.Workbooks.Count > 0) のような式の中でも RCW は生まれます。
  • 複合式の引数: sheets.Add(After: sheets[sheets.Count]) のような式は 1 行で複数の匿名 RCW を作ります。
  • イベント購読: Application や Workbook のイベントにハンドラーを付けると、その接続が参照を保持します。終了前に必ず購読解除します。

4. 解放パターンの整理 ── ReleaseComObject派とGC派

EXCEL.EXE を確実に終了させる書き方は 2 つの流儀があります。どちらも正しく書けば動きます。問題は「正しく書き続けられるか」で、ここに実務上の差が出ます。

4.1 (a) Marshal.ReleaseComObject を規律正しく適用する派

Marshal.ReleaseComObject は、RCW の内部参照カウントをデクリメントし、0 になった時点で RCW が握る COM 参照を即座に解放します。2 GC を待たずに決定的なタイミングで解放できるのが利点です。全オブジェクトに適用した典型形はこうなります。

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

Excel.Application excel = null;
Excel.Workbooks books = null;
Excel.Workbook book = null;
Excel.Sheets sheets = null;
Excel.Worksheet sheet = null;
Excel.Range cell = null;
try
{
    // オブジェクト初期化子(new ... { DisplayAlerts = false })は使わない。
    // セッターの COM 呼び出しが失敗すると excel が未代入のまま finally に入り、
    // 起動済みの EXCEL.EXE に対して Quit も解放もできなくなるため
    excel = new Excel.Application();
    excel.DisplayAlerts = false;
    books = excel.Workbooks;
    book = books.Open(templatePath);
    sheets = book.Worksheets;
    sheet = (Excel.Worksheet)sheets[1];
    cell = sheet.Range["A1"];
    cell.Value2 = "hello";
    book.SaveAs(outputPath);
}
finally
{
    // 生成の逆順で解放する。Close や Quit 自体も COM 呼び出しで失敗しうるため、
    // 途中で例外が出ても Quit と解放に必ず到達するよう入れ子の try/finally にする
    if (cell   != null) Marshal.ReleaseComObject(cell);
    if (sheet  != null) Marshal.ReleaseComObject(sheet);
    if (sheets != null) Marshal.ReleaseComObject(sheets);
    try
    {
        if (book != null) book.Close(SaveChanges: false);
    }
    finally
    {
        if (book  != null) Marshal.ReleaseComObject(book);
        if (books != null) Marshal.ReleaseComObject(books);
        try
        {
            if (excel != null) excel.Quit();
        }
        finally
        {
            if (excel != null) Marshal.ReleaseComObject(excel);
        }
    }
}

この方式の弱点は、見てのとおり規律のコストが高いことです。触った COM オブジェクトを 1 つ残らず変数に受け、例外経路も含めて逆順に解放する。しかも CloseQuit 自体が失敗する(COM エラー、切断済みのブック、応答しない Excel)ケースを考えると、上のように入れ子の try/finally で「途中で失敗しても後続の解放に到達する」ことまで保証しなければなりません──これを全メンバーが全修正で守り続けるのは、経験上かなり難しい要求です。1 か所でも 2 ドットの複合式が紛れ込めば漏れが復活します。

さらに重要なのは、公式ドキュメント自身が乱用に警告を出していることです。Marshal.ReleaseComObject のリファレンスには、リソースをタイムリーに解放する必要がある場合や解放順序に意味がある場合のための手段であり、「絶対に必要な場合にのみ使うこと(use the ReleaseComObject only if it is absolutely required)」と明記されています。2 RCW はプロセス内で 1 COM オブジェクトにつき 1 つを共有する仕組みのため、コードのある場所で解放した RCW を別の場所がまだ使っていると、InvalidComObjectException や、最悪の場合はアクセス違反・プロセスのメモリ破壊につながります。2 Excel 操作をアプリ内の複数モジュールが共有している構造では、この事故が現実に起きます。

なお、同じインターフェイスポインターが何度も CLR に渡ると RCW の参照カウントは 1 を超えることがあり、その場合 1 回の呼び出しでは解放されません。カウントを強制的に 0 にする Marshal.FinalReleaseComObject もありますが2、この API が必要になった時点で寿命管理を把握できていないサインなので、当社は設計の見直しを勧めています。

プロセス残留とは別軸のセキュリティ上の注意も 1 つ。COM 自動化で Workbooks.Open したブックは、マクロ警告なしに VBA が実行され得ます。本記事のサンプルは自アプリが管理する信頼済みテンプレートを開く前提ですが、共有フォルダーのファイルや利用者がアップロードしたファイルなど外部由来のブックを開く可能性があるなら、Open の前に excel.AutomationSecurity = MsoAutomationSecurity.msoAutomationSecurityForceDisableMicrosoft.Office.Core 名前空間)を設定してマクロを強制無効化してください。8 テンプレートの差し替えがそのまま任意コード実行になる、という筋の攻撃を塞げます。この注意は後述の GC パターンのサンプルにもそのまま当てはまります。

4.2 (b) 参照を閉じ込めて GC に回収させる派

もう 1 つの流儀は、RCW の解放を本来の仕組みどおり GC に任せ、その GC を確定的なタイミングで走らせる方式です。RCW は GC に回収されるときに COM 参照を解放するので1、「Excel を触るすべての参照がスコープを抜けた後に、フル GC +ファイナライザー完了待ちを行う」ことで、ReleaseComObject を 1 回も書かずに EXCEL.EXE を終了させられます。

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

public void ExportReport(string templatePath, string outputPath)
{
    try
    {
        // Excelを触る処理は別メソッドに完全に隔離する
        ExportReportCore(templatePath, outputPath);
    }
    finally
    {
        // 例外経路こそEXCEL.EXEが残りやすいので、必ずfinallyで実行する。
        // メソッドを抜けた後なら、RCWへの参照はどこにも残っていない
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();   // ファイナライザーが切り離したRCWを回収する2周目
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ExportReportCore(string templatePath, string outputPath)
{
    var excel = new Excel.Application();
    try
    {
        excel.DisplayAlerts = false;
        Excel.Workbooks books = excel.Workbooks;
        Excel.Workbook book = books.Open(templatePath);
        Excel.Sheets sheets = book.Worksheets;
        Excel.Worksheet sheet = (Excel.Worksheet)sheets[1];
        Excel.Range cell = sheet.Range["A1"];
        cell.Value2 = "hello";
        book.SaveAs(outputPath);
        book.Close(SaveChanges: false);
    }
    finally
    {
        excel.Quit();
    }
}

成立条件は 3 つです。

  1. Excel を触るコードを 1 つのメソッドに隔離すること。JIT がスタック上の参照を生かしている間は GC が回収できないため、GC は必ず「Excel を触ったメソッドの外」で呼びます。NoInlining はインライン展開で隔離が無効化されるのを防ぐためです。
  2. RCW をフィールドや戻り値に逃がさないこと。1 つでもメソッド外に漏れれば、その参照が生きている限り Excel は残ります。
  3. GC.CollectGC.WaitForPendingFinalizersGC.Collect の 3 点セットにすること。RCW の後始末はファイナライザー経由で行われるため、1 回目の Collect で検出し、ファイナライザーの完了を待ち、2 回目の Collect で残骸を回収する 2 周が定石です。

注意として、デバッガーをアタッチした実行では変数の寿命がメソッド末尾まで延長されるため、この方式でも RCW が回収されないことがあります。「デバッグでは残るがリリースでは消える」現象の正体はこれで、動作確認は必ずリリースビルド・デバッガーなしで行ってください。

この方式の利点は、2 ドットルールを破っても漏れないことです。匿名の中間 RCW も含めて、参照が切れたものは GC がまとめて回収します。レビューで見る点が「Excel を触る処理がこのメソッドに閉じているか」の 1 点に集約されるので、規律のコストが劇的に下がります。欠点は、GC.Collect の明示呼び出しというコード臭(アプリ全体のフル GC による一時停止)と、理由を知らないメンバーが善意のリファクタリングで壊すリスクです。3 点セットには必ず理由をコメントで残してください。

4.3 当社の推奨 ── 隔離+GCを基本に、必要ならラッパーで規律化

両論を実務目線で比較します。

  (a) ReleaseComObject 派 (b) GC 派
解放タイミング 決定的(呼んだ瞬間) 準決定的(GC 3 点セットの瞬間)
規律のコスト 高い。全オブジェクトの変数化+逆順解放を全員が守る必要 低い。メソッド隔離だけ守ればよい
やりすぎたときの症状 InvalidComObjectException、アクセス違反2 GC 強制による一時停止
公式ガイダンスとの整合 「絶対に必要な場合のみ」2 RCW 本来の寿命管理(GC 任せ)に沿う1
向く場面 解放順序に意味がある場合。長寿命プロセスで Excel を細かく何度も使う場合 「開いて・書いて・閉じる」を 1 か所で完結できる大半の帳票処理

当社の推奨は、基本は (b) の隔離+GC パターンです。帳票出力のような処理は 1 メソッドに閉じるのが自然で、その形にさえすれば漏れが構造的に起きません。ReleaseComObject の乱用リスクも避けられ、公式の位置づけとも整合します。

解放の順序と即時性が本当に必要な場合だけ (a) を使い、その場合は生の ReleaseComObject を書かせず、using で規律化した小さなラッパーを導入します。

using System.Runtime.InteropServices;

/// <summary>COMオブジェクトをusingスコープで解放するラッパー</summary>
public readonly struct ComScope<T> : IDisposable where T : class
{
    public T Value { get; }
    public ComScope(T value) => Value = value;

    public void Dispose()
    {
        if (Value is not null && Marshal.IsComObject(Value))
            Marshal.ReleaseComObject(Value);
    }
}
using var books = new ComScope<Excel.Workbooks>(excel.Workbooks);
using var book  = new ComScope<Excel.Workbook>(books.Value.Open(templatePath));
using var sheets = new ComScope<Excel.Sheets>(book.Value.Worksheets);
using var sheet = new ComScope<Excel.Worksheet>((Excel.Worksheet)sheets.Value[1]);
using var cell  = new ComScope<Excel.Range>(sheet.Value.Range["A1"]);
cell.Value.Value2 = "hello";
book.Value.SaveAs(outputPath);
book.Value.Close(SaveChanges: false);

using の宣言順と逆順に Dispose が走るため、「生成の逆順で解放」が言語機構で保証されます。.Value の分だけ記述は増えますが、「COM オブジェクトは必ず ComScope で受ける」という 1 行の規約に規律を圧縮できます。逆に言えば、books.Value.Open(...).Worksheets のような複合式を書けば漏れは復活するので、2 ドットルールの教育はどのみち必要です。

どちらのパターンでも共通の注意を 2 つ。第一に、Quit() の前に保存確認ダイアログを出させないこと。DisplayAlerts = falseClose(SaveChanges: false) を明示します。非表示の Excel がダイアログ待ちでハングすると、Quit 自体が完了しません。第二に、Excel の COM は STA 前提なので、複数スレッドから同じ Application を触り回さないこと。スレッドと COM アパートメントの関係は「COM STA/MTA の基礎知識」で整理しています。

5. 最終手段 ── HwndからPIDを特定して確実に始末する

解放パターンを正しく実装しても、「アドインの都合で Excel が終了しない」「例外の異常経路でどうしても 1 プロセス残ることがある」といったケースは残ります。無人実行のバッチでは、残った 1 プロセスが翌日のジョブのファイルロックを引き起こすため、最後の保険としてのプロセス Kill を仕込んでおく価値があります。問題は「どの EXCEL.EXE を殺すか」の特定方法です。

よく見る間違いが、起動前後のプロセス一覧の差分で特定する方法です。Process.GetProcessesByName("EXCEL") を起動前後で比較して増えた分を自分のインスタンスとみなす──これは並行性に対して脆弱です。差分を取る間にユーザーが手で Excel を開けば誤検出しますし、同じ方式のジョブが並列実行されれば互いを取り違えます。ユーザーが編集中の未保存の Excel を Kill してデータを消すのがこの方法の最悪の事故で、実際に相談として持ち込まれたこともあります。

正しい特定方法は、自分が起動した Application オブジェクトの Hwnd プロパティでトップレベルウィンドウのハンドルを取得し、Win32 API の GetWindowThreadProcessId でそのウィンドウを作成したプロセスの ID を得ることです。34 ハンドルは自分のインスタンスに固有なので、他の Excel と取り違える余地がありません。

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

internal static class NativeMethods
{
    [DllImport("user32.dll")]
    internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}

public void ExportReport(string templatePath, string outputPath)
{
    // out引数なら、Excel操作の途中で例外が飛んでもハンドルが呼び出し元に残り、
    // 例外経路(保険が本当に必要な経路)でもGCとKillに必ず到達する
    Process excelProcess = null;
    try
    {
        ExportReportCore(templatePath, outputPath, out excelProcess);
    }
    finally
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        if (excelProcess != null)
        {
            KillIfStillAlive(excelProcess);   // 正常に終了していれば何もしない保険
            excelProcess.Dispose();
        }
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ExportReportCore(string templatePath, string outputPath, out Process excelProcess)
{
    excelProcess = null;
    var excel = new Excel.Application();
    // 起動に成功したら、Hwnd の取得やハンドルのオープンが失敗しても
    // Quit() に必ず到達するよう、直後から try/finally で囲む
    try
    {
        // Quit()の後ではウィンドウが消えて取得できないため、起動直後に控える。
        // GetProcessById は PID の対応付けをするだけで、OS のプロセスハンドルは
        // WaitForExit / Kill などの初回アクセス時に遅延して開かれる。そのため
        // Excel の存命中に SafeHandle へ触れて、ここで強制的にハンドルを開いておく
        // (ハンドルが開いている間、この PID は別プロセスに再利用されない)
        NativeMethods.GetWindowThreadProcessId((IntPtr)excel.Hwnd, out uint pid);
        excelProcess = Process.GetProcessById((int)pid);
        _ = excelProcess.SafeHandle;

        excel.DisplayAlerts = false;
        // …… Excel操作 ……
    }
    finally
    {
        excel.Quit();
    }
}

private void KillIfStillAlive(Process excelProcess)
{
    if (!excelProcess.WaitForExit(5000))   // 正常なら数秒で消える
    {
        logger.LogWarning("EXCEL.EXE (PID {Pid}) が終了しないため強制終了します", excelProcess.Id);
        excelProcess.Kill();
    }
}

実装上の注意点です。

  • Hwnd は起動直後に控えること。Quit() の後はウィンドウが破棄されて取得できません。Application.HwndVisible = false でも取得できます。3
  • Kill は「解放を正しくやった上での保険」です。最初から当てにすると、Excel の一時ファイルの後始末が走らず、次回起動時の自動回復ファイルの蓄積などを招きます。順序は必ず「解放 → Quit → 待機 → それでも生きていたら Kill」です。
  • PID の再利用に注意。Windows の PID は再利用されるため、PID の数値だけを覚えておいて後から解決する設計だと、その間に Excel が終了して同じ PID が別プロセスに割り当てられた場合に無関係なプロセスを Kill する危険があります。ここで Process.GetProcessById を呼ぶだけでは不十分な点に注意してください。OS のプロセスハンドルは WaitForExit / Kill などの初回アクセス時に遅延して開かれるため、上のコードのように Excel の存命中に SafeHandle へ触れて明示的にハンドルを開いておくことで、初めて取り違えを防げます(ハンドルが開いている間、その PID は再利用されません)。
  • この保険がカバーするのは、メソッドが返ってきたのに EXCEL.EXE が残っているケースです。Workbooks.OpenSaveAsQuit といった COM 呼び出し自体がハングした場合(隠れたモーダルダイアログやアドインの応答待ち)は finally に到達せず、この Kill も発動しません。対策は 2 層で考えます。まず DisplayAlerts = false と前述の AutomationSecurity でダイアログ要因を塞ぐ。そのうえで無人バッチでは、Excel を触るジョブ自体を別プロセスにして、外側から時間制限をかけて丸ごと Kill できる構え(タスクスケジューラの「停止するまでの時間」設定や、親プロセスからの子プロセス Kill)にしておきます。プロセス内のタイムアウトはハングした COM 呼び出しを中断できないので、境界はプロセスに置くのが確実です。
  • Kill が発動したら必ずログに残して頻度を監視します。発動が増えてきたら、解放コードの退行か、7 章の置き換え判断のサインです。

6. そもそも論 ── サーバーサイドのOffice自動化は非サポート

ここまでの技法でクライアントアプリの EXCEL.EXE 残留はほぼ制圧できます。しかし、この問題が最も深刻化する場面──サーバーや無人環境での Excel 自動化──については、技法の前に確認すべき公式見解があります。

Microsoft は「Considerations for server-side Automation of Office」という文書で、無人・非対話のクライアントアプリケーションやコンポーネント(ASP、ASP.NET、DCOM、NT サービスを含む)からの Office 自動化を推奨せず、サポートもしないと明言しています。5 Office は対話ユーザーの存在を前提に設計されており、エラー時に確認ダイアログを出して応答を待つ設計、実行ユーザーのプロファイルを前提とするコンポーネント、STA ベースで再入不可のアーキテクチャなど、デスクトップでは問題にならない前提が、サービスや IIS のワーカープロセス上ではことごとく牙をむきます。9 「Windows サービスから動かしたら本番で Open が返ってこない」「IIS 上で月に一度ダイアログ待ちでハングする」といった相談は、すべてこの非サポート構成が原因でした。この構成での EXCEL.EXE 残留は単なる後始末の問題ではなく、ハングしたプロセスが無限に蓄積していく運用問題になります。

Microsoft が代替として挙げているのが、Office をインストール・起動せずにファイルを直接操作する Open XML ファイル形式の編集と、クラウド側で処理する Microsoft Graph API です。9 Open XML SDK は、ECMA-376 / ISO/IEC 29500 として標準化された Office のファイル形式(.xlsx など)を強く型付けされたクラスで読み書きできる Microsoft 製ライブラリで、ZIP と XML の上に構築されているため Excel 本体が不要です。6 プロセス残留・ライセンス・非サポート構成の問題がまとめて消えます。

ただし Open XML SDK は「ファイル形式を直接編集する」ライブラリであり、Excel というアプリケーションの動作は提供しません。公式のデザイン上の考慮事項として、数式の再計算・データ更新などのアプリケーション動作や、他形式(PDF など)への変換機能は提供しないことが明記されています。10 また API はファイル形式の構造に忠実なので、素の SDK でセルを 1 つ書くにも SpreadsheetML の構造理解が必要です。そこを補うのが ClosedXML で、Open XML API の上に「ブック・シート・セル」という直感的な API を被せた MIT ライセンスの OSS です。Excel のインストールなしで .xlsx / .xlsm を扱えます(旧形式の .xls は対象外)。7 帳票出力の文脈での使い分けやテンプレート方式の設計は「Excel帳票出力の作り方」で詳しく書いています。

なお、Microsoft 365 には無人 RPA 用のライセンス(unattended license)が存在しますが、これは無人実行をライセンス上可能にするものであって、動作は依然として “AS IS”──設計外の使い方に伴う予期しない挙動はアプリ側で吸収せよ、という位置づけです。9 「ライセンスを買えばサポートされる構成になる」わけではない点は誤解が多いので注意してください。

7. 判断表 ── COMを使い続けるか、Open XML系へ置き換えるか

以上を踏まえると、「Excel を COM で操作し続けるか、逃がすか」は次の 3 択に整理できます。

  (1) COM Interop を使い続ける(ラップして規律化) (2) Open XML SDK / ClosedXML へ置き換え (3) 設計自体を見直す(Graph 等)
Excel 本体 必要(実行環境ごとにライセンスも) 不要 不要
無人・サーバー実行 非サポート構成5 問題なし(推奨代替)9 問題なし
プロセス残留リスク あり(本記事の技法で管理) なし(プロセスを起動しない) なし
マクロ(VBA)の実行 できる できない(保持もライブラリ依存 ── 下の 2.) できない
数式の再計算・印刷・PDF 変換 できる できない10 Graph に一部あり
ユーザーが開いている Excel との対話 できる できない できない
旧形式 .xls (BIFF) 読み書きできる できない(.xlsx / .xlsm のみ)7 ──
実行速度・並列性 遅い。並列にはインスタンス分離が必要9 速い。通常のライブラリとして並列可 ネットワーク依存

判断の軸は 4 つの質問になります。

  1. ユーザーの目の前の Excel と対話する必要があるか。 「ユーザーが開いているブックに書き込み、操作の続きを引き渡す」機能は COM Interop でしか作れません。この場合は (1) 一択で、4 章の規律化に投資します。デスクトップの対話アプリは非サポート構成にも該当しません。
  2. Excel というアプリの機能(マクロ実行、再計算、印刷、PDF 出力)が必要か。 これらは Open XML 系では代替できません。10 無人実行との組み合わせは最も苦しいパターンで、要件側を崩せないか(マクロのロジックを C# へ移植する、計算済みの値を書き込む等)をまず検討します。注意したいのがマクロ入りテンプレート(.xlsm)の「保持」です。Open XML SDK の低レベル操作なら VBA プロジェクトのパートに触らない限り保持されますが、ClosedXML のような高レベルライブラリはブックをオブジェクトモデルに読み込んでパッケージを再構成して保存するため、VBA プロジェクトが失われることがあります。マクロ入りテンプレートを Open XML 系へ移行する場合は、実物のテンプレートで「開いて・書いて・保存したあともマクロが残り動くこと」の検証を必須にし、保証できないならその帳票だけ COM に残してください。VBA 資産の扱いは「VBA とは何か」の判断がそのまま使えます。
  3. 無人実行か。 サービス・タスクスケジューラ・Web アプリからの実行なら、既定の答えは (2) です。「値とスタイルを流し込んだ .xlsx を作る」だけの帳票生成が要件の大半で、それは ClosedXML で完結します。
  4. 入出力の形式は何か。 取引先から来る .xls をそのまま処理する必要があるなら Open XML 系は使えません。受け口で .xlsx への変換を挟めないかを検討します。

当社が実際の案件でよく提案するのは、「生成は ClosedXML、どうしても Excel 本体が必要な処理だけ COM に隔離する」という分割です。毎日数百枚の帳票生成は ClosedXML でサーバー実行し、月次の「マクロ入りブックの更新」だけは担当者のデスクトップでボタン実行の COM 処理にする──こうすると無人環境から COM が消え、残った COM 部分は対話アプリなのでサポート構成に収まります。全面書き換えより現実的で、リスクの高い部分から順に消せる進め方です。

8. .NET (Core) 時代の注意点

.NET Framework から .NET(.NET 6/8 など)へ移行したアプリで Excel COM 操作を続ける場合の注意点を、確認できた範囲で整理します。

  • COM 相互運用は Windows 専用のままです。 .NET は Linux でも動きますが、組み込みの COM 相互運用サポートは Windows に限定されています。11 Excel 操作を含むプロジェクトは net8.0-windows のようにターゲットを明示し、クロスプラットフォームを期待しないでください。Linux コンテナへ載せられない点は 7 章の置き換え判断に直結します(ClosedXML なら Linux コンテナで動きます)。
  • 参照方法は「COM 参照+相互運用型の埋め込み」が基本です。 Visual Studio で Microsoft Excel オブジェクトライブラリを COM 参照として追加すると、既定で相互運用型の埋め込み(Embed Interop Types)が使われます。使った型だけが自分のアセンブリに埋め込まれるため、実行環境に PIA(プライマリ相互運用アセンブリ)を配布する必要がなく、Office のバージョン差にも強くなります。12
  • dynamic と省略可能引数は引き続き使えます。 C# の Office 相互運用向け機能(名前付き引数・省略可能引数、dynamic による COM 呼び出しの簡略化)は現行の .NET でもサポートされています。12 ただし dynamic で書くと匿名 RCW がさらに見えにくくなるため、解放を規律化したいコードでは明示的な型で書くことを勧めます。
  • プラットフォーム前提の API に注意。 Marshal.ReleaseComObject などの COM 関連 API には Windows 専用属性が付いており、クロスプラットフォームのプロジェクトから呼ぶとアナライザー警告(CA1416)の対象になります。Excel 操作を独立したプロジェクトに切り出すと管理しやすくなります。
  • 逆方向(VBA から .NET を呼ぶ)は別の話です。 .NET 8 の DLL を COM として公開して VBA から使う構成は引き続き可能で、手順は「.NET 8 DLL を VBA から型付きで使う方法」にまとめています。「C# から Excel を操る」のではなく「Excel のマクロから C# のロジックを呼ぶ」構成に反転させると、プロセス寿命の管理を Excel 自身に任せられるため、残留問題が構造的に消えるケースもあります。

まとめると、.NET (Core) 時代になっても Excel COM 操作の書き方と罠は .NET Framework 時代とほぼ同じです。変わったのは Windows 専用であることを明示的に宣言する点と、参照が相互運用型の埋め込みに寄った点で、RCW と解放パターンの議論(2〜5 章)はそのまま通用します。

9. まとめ

EXCEL.EXE が残る問題は、「COM は参照カウントで生き、.NET の RCW は GC で死ぬ」という 2 つの寿命管理の橋渡しを理解すれば、運任せの現象ではなくなります。チェックリストに圧縮すると次のとおりです。

  • Quit() は解放ではない。RCW が握る COM 参照がすべて返るまで Excel は終了しない
  • ドット 2 つ以上の複合式は匿名 RCW を生む。中間オブジェクトは変数に受ける
  • 解放は「メソッド隔離+GC 3 点セット」を基本に、順序制御が必要なら using ラッパーで ReleaseComObject を規律化する。生の ReleaseComObject の散布はしない
  • 保険の Kill は Application.HwndGetWindowThreadProcessId で自分のインスタンスだけを特定し、「Quit → 待機 → タイムアウト時のみ」発動する
  • サーバー・無人実行の Excel 自動化は非サポート構成。無人の帳票生成は ClosedXML / Open XML SDK へ、マクロや再計算が必要な処理は対話環境の COM に隔離する

タスクマネージャーに EXCEL.EXE が並んでいるのを見つけたら、それは技法の問題(4〜5 章)か構成の問題(6〜7 章)のどちらかのサインです。手元のコードがどちらに当たるのか、置き換えるならどの範囲からかの判断に迷う場合は、処理の棚卸しからお手伝いできます。

関連記事

関連する相談領域

合同会社小村ソフトでは、Excel/Office 自動化を含む Windows アプリのトラブル調査(プロセス残留、ハング、ファイルロック)、COM 資産の保守と規律化、Open XML 系ライブラリへの帳票処理の移行設計を扱っています。

参考リンク

  1. Microsoft Learn, Runtime Callable Wrapper. RCW が COM オブジェクトごとにプロセス内で 1 つ作られること、インターフェイスポインターをキャッシュし、GC によって回収されるときに COM オブジェクトへの参照を解放することについて。  2 3 4

  2. Microsoft Learn, Marshal.ReleaseComObject(Object) Method. RCW の参照カウントをデクリメントする動作、解放済み RCW の使用による InvalidComObjectException・アクセス違反・メモリ破壊のリスク、「絶対に必要な場合にのみ使う」という位置づけ、FinalReleaseComObject との関係について。  2 3 4 5 6 7

  3. Microsoft Learn, Application.hWnd property (Excel). Excel の Application オブジェクトの Hwnd プロパティがトップレベルウィンドウのハンドルを返すことについて。  2 3

  4. Microsoft Learn, GetWindowThreadProcessId function (winuser.h). 指定したウィンドウを作成したスレッドの ID と、ウィンドウを作成したプロセスの ID を取得できることについて。  2

  5. Microsoft サポート, Considerations for server-side Automation of Office. 無人・非対話のクライアントアプリケーションやコンポーネント(ASP、ASP.NET、DCOM、NT サービスを含む)からの Office 自動化を Microsoft が推奨せず、サポートしないことについて。  2 3

  6. Microsoft Learn, Welcome to the Open XML SDK for Office. Open XML SDK が ECMA-376 / ISO/IEC 29500 標準の Office ファイル形式を強く型付けされたクラスで操作する System.IO.Packaging ベースのライブラリであることについて。  2

  7. GitHub, ClosedXML/ClosedXML. Open XML API の上に直感的なインターフェイスを提供する MIT ライセンスのライブラリで、Excel のインストールなしに Excel 2007+ (.xlsx, .xlsm) ファイルを扱えることについて。  2 3

  8. Microsoft Learn, _Application.AutomationSecurity Property (Microsoft.Office.Interop.Excel). プログラムからファイルを開く際のマクロセキュリティモード。アプリケーション起動時の既定が msoAutomationSecurityLow(全マクロ有効)であること、msoAutomationSecurityForceDisable で警告なしに全マクロを無効化できることについて。 

  9. Microsoft Learn, Considerations for unattended automation of Office in the Microsoft 365 for unattended RPA environment. 無人自動化での対話 UI・ユーザー識別・STA 単一スレッド設計などの問題点、無人ライセンス下でも動作が AS IS であること、代替として Microsoft Graph と Open XML ファイル形式の直接編集が推奨されることについて。  2 3 4 5

  10. Microsoft Learn, Open XML SDK for Office design considerations. Open XML SDK が Office オブジェクトモデルの代替ではなく、数式の再計算・データ更新などのアプリケーション動作や他形式への変換機能を提供しないことについて。  2 3

  11. Microsoft Learn, Native interoperability ABI support. 組み込みの COM 相互運用システムのサポートが Windows に限定されること、.NET 5+ の ComWrappers / .NET 8+ のソース生成による COM サポートについて。 

  12. Microsoft Learn, How to access Office interop objects. 名前付き引数・省略可能引数・dynamic による Office 相互運用の簡略化と、PIA の代わりに相互運用型の埋め込み(Embed Interop Types)が既定の動作であることについて。  2

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る