解放をしない場合の挙動
適切な解放がされないと、プロセスが消えずに残ることになります。
実際に確認をしてみます
以下のように、applicationクラスのインスタンスを作成し、すぐ閉じます。
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        application = new Excel.Application();
        application.Quit();
    }
}
これでおきることは、new Excel.Application()を実行すると
 
  
バックグラウンドにExcelのプロセス現れます
 
  
    
 
  
プログラムではQuitして終了したようにみえても、プロセスには残り続けています
もし、定時実行をし続けようならば、実行するたびにプロセスがのこっていきます・・・
 
  
残ったプロセスは手動で消してください・・・
アプリケーションを表示した際のプロセスの挙動
application.VisibleをtrueにするとExcelが表示されます
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        application = new Excel.Application();
        application.Visible = true;
    }
}
この時の動きは、 new Excel.Application()で バックグラウンドとしてプロセスが作成され
 
  
その後、application.Visible = true でExcelが表示されアプリのプロセスに移動
 ↑バックグラウンドのプロセス一覧
  
↑バックグラウンドのプロセス一覧  
 ↑アプリのプロセス一覧
  
↑アプリのプロセス一覧  
プログラムはここで終了となり、開かれているExcelを閉じると、再びバックグラウンドの方に移動します
 
  
パット見た感じアプリの一覧から消えるので、解放していなくても消えたようにみえるかもしれませんが、バックグラウンド側に残っています
一度でも参照したら解放する
たとえば、exeのあるフォルダに、3シートある sample.xlsxがあったとします
開いて、各シート名を出力してみます
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System;
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        Excel.Workbooks? workbooks = null;
        Excel.Workbook? workbook = null;
        Excel.Sheets? worksheets = null;
        Excel.Worksheet? worksheet = null;
        try
        {
            // 実行ファイルのあるフォルダパス取得
            var folderPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
            if (folderPath == null) { return; }
            // Excelファイルパスを作成
            var filePath = Path.Combine(folderPath, "sample.xlsx");
            // Excelを開く
            application = new Excel.Application();
            // ブックを開く
            workbooks = application.Workbooks;
            workbook = workbooks.Open(filePath);
            // シートを取得
            worksheets = workbook.Sheets;
            for (var i = 1; i <= worksheets.Count; i++)
            {
                // 各シートを取得して名前を表示
                worksheet = worksheets[i];
                Console.WriteLine(worksheet.Name);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            if (worksheet != null) { Marshal.ReleaseComObject(worksheet); }
            if (worksheets != null) { Marshal.ReleaseComObject(worksheets); }
            if (workbook != null)
            {
                workbook.Close();
                Marshal.ReleaseComObject(workbook);
            }
            if (workbooks != null) { Marshal.ReleaseComObject(workbooks); }
            if (application != null)
            {
                application.Quit();
                Marshal.ReleaseComObject(application);
            }
        }
    }
}
これは、バックグラウンドにプロセスが残ります
一番最後のSheet3については、finallyで解放していますが、
Sheet1とSheet2は、参照しているのに解放していないので、プロセスが残ります
参照したら都度解放する必要があります
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System;
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        Excel.Workbooks? workbooks = null;
        Excel.Workbook? workbook = null;
        Excel.Sheets? worksheets = null;
        Excel.Worksheet? worksheet = null;
        try
        {
            // 実行ファイルのあるフォルダパス取得
            var folderPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
            if (folderPath == null) { return; }
            // Excelファイルパスを作成
            var filePath = Path.Combine(folderPath, "sample.xlsx");
            // Excelを開く
            application = new Excel.Application();
            // ブックを開く
            workbooks = application.Workbooks;
            workbook = workbooks.Open(filePath);
            // シートを取得
            worksheets = workbook.Sheets;
            for (var i = 1; i <= worksheets.Count; i++)
            {
                // 各シートを取得して名前を表示
                worksheet = worksheets[i];
                Console.WriteLine(worksheet.Name);
                // 参照したら都度解放する
                Marshal.ReleaseComObject(worksheet);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            if (worksheet != null) { Marshal.ReleaseComObject(worksheet); }
            if (worksheets != null) { Marshal.ReleaseComObject(worksheets); }
            if (workbook != null)
            {
                workbook.Close();
                Marshal.ReleaseComObject(workbook);
            }
            if (workbooks != null) { Marshal.ReleaseComObject(workbooks); }
            if (application != null)
            {
                application.Quit();
                Marshal.ReleaseComObject(application);
            }
        }
    }
}
不要にReleaseComObjectを実行した場合
ループしてWorkSheetのオブジェクトを参照した場合、都度解放をしているのでループを抜けた場合、
参照カウントは0になっています。ただ、ReleaseComObjectを実行してもnullにはならないので、
finallyにきたら、さらにReleaseComObjectを実行することになります。
一般的には、不要にReleaseComObjectを実行するものではないといわれていますが、
それによって意図しない状態になったのを見たことがないのでなんともいえないです。
そのため、途中で開放した場合は、nullにしてしまうのがより良いと考えられているそうです
using System.IO;
using System;
using System.Reflection;
using System.Runtime.InteropServices;
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        Excel.Workbooks? workbooks = null;
        Excel.Workbook? workbook = null;
        Excel.Sheets? worksheets = null;
        Excel.Worksheet? worksheet = null;
        try
        {
            // 実行ファイルのあるフォルダパス取得
            var folderPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
            if (folderPath == null) { return; }
            // Excelファイルパスを作成
            var filePath = Path.Combine(folderPath, "sample.xlsx");
            // Excelを開く
            application = new Excel.Application();
            // ブックを開く
            workbooks = application.Workbooks;
            workbook = workbooks.Open(filePath);
            // シートを取得
            worksheets = workbook.Sheets;
            for (var i = 1; i <= worksheets.Count; i++)
            {
                // 各シートを取得して名前を表示
                worksheet = worksheets[i];
                Console.WriteLine(worksheet.Name);
                // 参照したら都度解放する
                Marshal.ReleaseComObject(worksheet);
                worksheet = null;
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            if (worksheet != null) { Marshal.ReleaseComObject(worksheet); }
            if (worksheets != null) { Marshal.ReleaseComObject(worksheets); }
            if (workbook != null)
            {
                workbook.Close();
                Marshal.ReleaseComObject(workbook);
            }
            if (workbooks != null) { Marshal.ReleaseComObject(workbooks); }
            if (application != null)
            {
                application.Quit();
                Marshal.ReleaseComObject(application);
            }
        }
    }
}
とはいえこれはよいことではあるけども、少しめんどくさい
そこで、解放処理や、Close、Quitをする処理をまとめると・・・
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System;
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        Excel.Workbooks? workbooks = null;
        Excel.Workbook? workbook = null;
        Excel.Sheets? worksheets = null;
        Excel.Worksheet? worksheet = null;
        try
        {
            // 実行ファイルのあるフォルダパス取得
            var folderPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
            if (folderPath == null) { return; }
            // Excelファイルパスを作成
            var filePath = Path.Combine(folderPath, "sample.xlsx");
            // Excelを開く
            application = new Excel.Application();
            application.Visible = true;
            // ブックを開く
            workbooks = application.Workbooks;
            workbook = workbooks.Open(filePath);
            // シートを取得
            worksheets = workbook.Sheets;
            for (var i = 1; i <= worksheets.Count; i++)
            {
                // 各シートを取得して名前を表示
                worksheet = worksheets[i];
                Console.WriteLine(worksheet.Name);
                CleanUpComObject(ref worksheet);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            CleanUpComObject(ref worksheet);
            CleanUpComObject(ref worksheets);
            CleanUpComObject(ref workbook);
            CleanUpComObject(ref workbooks);
            CleanUpComObject(ref application);
        }
    }
    static void CleanUpComObject<T>(ref T comObject, bool shouldClose = true, bool saveChanges = false)
    {
        // フラグによってWorkbookはClose / ApplicationはQuitする
        if (shouldClose && comObject != null)
        {
            // 型をチェックする
            if (comObject is Microsoft.Office.Interop.Excel.Workbook workbook)
            {
                // Workbookの場合
                workbook.Close(saveChanges);
            }
            else if (comObject is Microsoft.Office.Interop.Excel.Application application)
            {
                // Applicationの場合
                application.Quit();
            }
        }
        // Objectを解放
        if (comObject != null && Marshal.IsComObject(comObject))
        {
            Marshal.ReleaseComObject(comObject);
            comObject = default!;
        }
    }
}
もう少し、掘り下げておくとWorksheetのオブジェクトについては
for文のループ中でしかつかっていないので、その中で変数を作り格納しすぐ開放すればよいように思える
for (var i = 1; i <= worksheets.Count; i++)
{
    // 各シートを取得して名前を表示
    var worksheet = worksheets[i];
    Console.WriteLine(worksheet.Name);
    // 何かしらの処理
    CleanUpComObject(ref worksheet);
}
ただ、参照したあとに何かしらの処理で例外が発生したら、参照カウントが下げることができないので
オブジェクトを参照した直後から、解放するまではtry catchで囲わないといけないので、
あちことで変数を作って参照していたらtry catchだらけになることに注意
for (var i = 1; i <= worksheets.Count; i++)
{
    // 各シートを取得して名前を表示
    var worksheet = worksheets[i];
    try
    {
        Console.WriteLine(worksheet.Name);
        // 何かしらの処理
        CleanUpComObject(ref worksheet);
    }
    catch
    {
        throw;
    }
    finally
    {
        CleanUpComObject(ref worksheets);
    }
}
いろいろ調べてみるかぎり、より完全に解放するためには、GCを2度読んであげるとよいとされているそうです
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System;
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        Excel.Workbooks? workbooks = null;
        Excel.Workbook? workbook = null;
        try
        {
            // 実行ファイルのあるフォルダパス取得
            var folderPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
            if (folderPath == null) { return; }
            // Excelファイルパスを作成
            var filePath = Path.Combine(folderPath, "sample.xlsx");
            // Excelを開く
            application = new Excel.Application();
            application.Visible = true;
            // ブックを開く
            workbooks = application.Workbooks;
            workbook = workbooks.Open(filePath);
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            CleanUpComObject(ref workbook);
            CleanUpComObject(ref workbooks);
            CleanUpComObject(ref application);
            // RCW強制解放(残留プロセス対策)
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }
    }
    static void CleanUpComObject<T>(ref T comObject, bool shouldClose = true, bool saveChanges = false)
    {
        // フラグによってWorkbookはClose / ApplicationはQuitする
        if (shouldClose && comObject != null)
        {
            // 型をチェックする
            if (comObject is Microsoft.Office.Interop.Excel.Workbook workbook)
            {
                // Workbookの場合
                workbook.Close(saveChanges);
            }
            else if (comObject is Microsoft.Office.Interop.Excel.Application application)
            {
                // Applicationの場合
                application.Quit();
            }
        }
        // Objectを解放
        if (comObject != null && Marshal.IsComObject(comObject))
        {
            Marshal.ReleaseComObject(comObject);
            comObject = default!;
        }
    }
}
2度呼ぶ理由についての生成AIの説明
✅ 結論から:なぜ2回 GC.Collect(); GC.WaitForPendingFinalizers(); が必要なのか?
これは、COMオブジェクトの参照がすべて解放されたことを確実にするために行われます。
📌 背景知識:COMオブジェクトとRCW(Runtime Callable Wrapper)
・.NET から COM オブジェクト(例:Excel)を使うとき、RCW という「仲介役」が自動的に生成されます。
・RCW は、.NET 側のガベージコレクションによって管理されますが、RCW自体がCOMオブジェクトの参照カウントを持っているため、RCWが解放されるまでCOMオブジェクトは解放されません。
🔄 なぜ2回GCするのか?
1回目の GC.Collect(); GC.WaitForPendingFinalizers(); で何が起きるか
・.NETでGC対象になったRCWオブジェクト(≒Excel COMオブジェクトのラッパー)を収集対象にする
・それにより、RCWのファイナライザ(最終処理)が実行される
・そのファイナライザの中で、COMオブジェクトへの参照が ReleaseComObject() などで解放される
🔁 しかし、ファイナライザが実行された直後にGCが実行されるとは限らないため、もう一度 GC をかけることで:
・ファイナライザによって解放されたRCW自体も収集され
・結果として RCWが保持していたCOMオブジェクトの参照カウントが0になり、COMオブジェクトも確実に解放される
⚠ よくある誤解ポイント
・RCWの解放自体は、明示的に Marshal.ReleaseComObject() を使うことでもできます。これを使えば、2回の GC は不要なこともあります。
・ただし、RCWを複数の変数で共有していたり、Excelオブジェクトの階層(Workbooks→Workbook→Worksheetなど)で参照が残っていたりすると、ReleaseComObjectだけでは不十分で、最終的にGCで回収させる必要が出るため、2回GCが有効になる。
とのこと
おまけ
生成AIさんこのコードで問題ない?
worksheetsをループ処理でシート名を表示するプログラム
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System;
using Excel = Microsoft.Office.Interop.Excel;
namespace SampleCode; // C#10~
internal class SampleExcel
{
    static void Main(string[] args)
    {
        Excel.Application? application = null;
        Excel.Workbooks? workbooks = null;
        Excel.Workbook? workbook = null;
        Excel.Sheets? worksheets = null;
        Excel.Worksheet? worksheet = null;
        try
        {
            // 実行ファイルのあるフォルダパス取得
            var folderPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
            if (folderPath == null) { return; }
            // Excelファイルパスを作成
            var filePath = Path.Combine(folderPath, "sample.xlsx");
            // Excelを開く
            application = new Excel.Application();
            application.Visible = true;
            // ブックを開く
            workbooks = application.Workbooks;
            workbook = workbooks.Open(filePath);
            // シートを取得
            worksheets = workbook.Sheets;
            for (var i = 1; i <= worksheets.Count; i++)
            {
                // 各シートを取得して名前を表示
                worksheet = worksheets[i];
                Console.WriteLine(worksheet.Name);
                CleanUpComObject(ref worksheet);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
        finally
        {
            CleanUpComObject(ref worksheet);
            CleanUpComObject(ref worksheets);
            CleanUpComObject(ref workbook);
            CleanUpComObject(ref workbooks);
            CleanUpComObject(ref application);
            // RCW強制解放(残留プロセス対策)
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }
    }
    static void CleanUpComObject<T>(ref T comObject, bool shouldClose = true, bool saveChanges = false)
    {
        // フラグによってWorkbookはClose / ApplicationはQuitする
        if (shouldClose && comObject != null)
        {
            // 型をチェックする
            if (comObject is Microsoft.Office.Interop.Excel.Workbook workbook)
            {
                // Workbookの場合
                workbook.Close(saveChanges);
            }
            else if (comObject is Microsoft.Office.Interop.Excel.Application application)
            {
                // Applicationの場合
                application.Quit();
            }
        }
        // Objectを解放
        if (comObject != null && Marshal.IsComObject(comObject))
        {
            Marshal.ReleaseComObject(comObject);
            comObject = default!;
        }
    }
}
worksheetは、for文でしか使っていないので、一時変数に格納して不要になればすぐ解放することも考えられるけど、
オブジェクト参照から不要になり解放するまでに、処理があって例外が発生する可能性があると都度try~catchを
書く必要があるから、コードが読みにくくなるのであえて書いていないけどどう?
ご提示の設計・実装方針は現実的かつ推奨されるパターンです。
COMオブジェクト(特にExcel)は、取得したすべてのオブジェクトを明示的に解放することが重要であり、
finallyブロックでまとめて後始末をするやり方は、例外発生時も含めてリソースリークを防ぐ観点からも適切です
ポイント整理
・for文内でworksheetを都度解放するのは理想的ですが、例外処理や可読性の観点から冗長になりやすいです。
・あなたのように、finallyでworksheetも含めてまとめて解放する方法は、例外が発生しても確実にリソースを解放できるため、実運用でよく使われています
・ただし、for文内でCleanUpComObject(ref worksheet)を呼んでいるため、ループのたびに前の参照を解放し、finallyでも念のため解放する形になっています。この重複は問題ありませんが、二重解放を防ぐため、CleanUpComObject内でnullチェックをしているのは良い実装です。