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)機制完全解釋的。本文將從實務角度整理進程殘留的機制、經典陷阱「兩個點規則」、兩種釋放模式的整理與本公司的建議、最終手段進程 Kill 的正確做法,一直到是否該替換為 Open XML 系列函式庫的判斷。
1. 先講結論
- EXCEL.EXE 殘留並不是 bug,而是因為 .NET 這一側還握著 COM 參照。
Quit()只是「等所有參照都釋放後就結束」的請求,只要參照還在,Excel 就會忠實地持續等待。 - .NET 透過稱為 RCW(Runtime Callable Wrapper) 的包裝器來處理 COM 物件,在 RCW 被 GC 回收(或被明確釋放)之前,會持續保有對 COM 物件的參照。1
- 像
book.Worksheets[1].Range["A1"]這樣連續使用兩個以上的點,會使中間物件的 RCW 在沒有進入任何變數的情況下被建立,造成釋放遺漏。俗稱「兩個點規則」,是這個問題的主要元兇。 - 釋放模式分為兩派。(a) 對所有 COM 物件規律地套用
Marshal.ReleaseComObject的一派,以及 (b) 把參照關在變數裡、交給 GC(GC.Collect+WaitForPendingFinalizers)回收的一派。官方文件將 ReleaseComObject 定位為「只在絕對必要時使用」。2 - 本公司的建議是,以將處理隔離到單一方法的 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 代理來操作。每個 COM 物件在進程內會建立一個對應的 RCW,快取 COM 介面指標,並且在自身被 GC 回收時,釋放對該 COM 物件的參照。1 也就是說,生命週期管理已經從「自己計算參照計數」的方式,變成了「交給 GC」的方式。
把這兩點結合起來,就能直接推導出 EXCEL.EXE 殘留的原因。
| 階段 | 發生的事情 |
|---|---|
new Excel.Application() |
EXCEL.EXE 啟動,建立 Application 的 RCW |
| 操作單元格、儲存等 | 每碰過一個 Workbook、Worksheet、Range … 物件,RCW 就多一個 |
excel.Quit() |
只是告訴 Excel「可以結束了」。RCW 握有的參照一個也沒減少 |
| 離開方法 | 對 RCW 的 .NET 參照消失,但RCW 本身仍活在堆積上 |
| (某次的)GC | RCW 被回收,此時 COM 參照才被歸還 → EXCEL.EXE 結束 |
重點有兩個。第一,Quit() 並非釋放。只要參照還在,Excel 就會等待。應用程式進程完全結束後,參照自然斷開、Excel 隨之結束是常態,但在常駐應用程式或 Web 應用程式那種父進程持續存活的形態下,那個「某次」永遠不會來。第二,釋放的時機取決於 GC、並不固定。如果記憶體還算充裕,GC 可能幾十分鐘都不會執行,這段期間 EXCEL.EXE 就以殭屍狀態殘留著。「偶爾會殘留」「只有在正式環境才殘留」這種缺乏重現性的現象,只是 GC 時機波動的表面呈現而已。
順帶一提,以 Visible = false 啟動的 Excel 因為沒有視窗,就算殘留也不會被使用者看見。典型的症狀是「第二次儲存時出現『檔案正被使用』的錯誤」「電腦變慢」,然後才在工作管理員裡第一次注意到一整排的 EXCEL.EXE。調查的第一步就是用 tasklist | findstr EXCEL 數一下數量。
3. 經典陷阱「兩個點規則」 ── 看不見的中間物件
「明明已經好好釋放了所有變數,卻還是會殘留」這類諮詢裡的程式碼,幾乎都會出現以下這種寫法。
// 表面看起來很簡潔,但其實產生了無法釋放的 RCW
excel.Workbooks.Open(path);
book.Worksheets[1].Range["A1"].Value2 = "hello";
excel.Workbooks 會產生並回傳 Workbooks 集合的 RCW。如果不把這個回傳值存到變數就直接呼叫 .Open(...),Workbooks 的 RCW 就會以「沒有人參照但仍然存活」的匿名物件形式留在堆積上。因為沒有變數,也就沒有辦法呼叫 Marshal.ReleaseComObject。第二行更嚴重,Worksheets(集合)、Worksheets[1](工作表)、Range["A1"](範圍),一行就產生了 3 個匿名 RCW。
為了避免這種情況,Office 自動化社群長久以來流傳的經驗法則就是「兩個點規則」。可以換句話說成:不要對 COM 物件連續使用兩個以上的點。所有中間物件都要先接到一個變數裡。
// 為所有中間物件命名
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 迴圈,逐一接到變數裡。 - 條件式裡被隨手丟棄的值:像
if (excel.Workbooks.Count > 0)這樣的運算式中,同樣會產生 RCW。 - 複合運算式的引數:像
sheets.Add(After: sheets[sheets.Count])這樣的運算式,會在一行內建立多個匿名 RCW。 - 事件訂閱:為 Application 或 Workbook 的事件加上處理函式時,該連結會持續保有參照。結束前務必要取消訂閱。
4. 釋放模式整理 ── ReleaseComObject 派與 GC 派
要確實讓 EXCEL.EXE 結束的寫法有兩種流派。兩者只要寫得正確都能運作。問題在於「能不能持續寫得正確」,這正是實務上產生差異的地方。
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 })。
// 若 setter 的 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 呼叫,可能會失敗,
// 因此用巢狀的 try/finally,確保即使中途發生例外,仍一定能走到 Quit 與釋放
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 物件無一遺漏地接到變數裡,並包含例外路徑,依照相反順序釋放。而且考慮到 Close 或 Quit 本身也可能失敗(COM 錯誤、連線已中斷的工作簿、無回應的 Excel),還必須像上面那樣用巢狀 try/finally 來保證「即使中途失敗,也一定能走到後續的釋放」──要讓所有團隊成員在每次修改時都持續遵守這一點,依經驗來看是相當困難的要求。只要有一處混入了兩個點的複合運算式,遺漏就會再次出現。
更重要的是,官方文件本身就對濫用發出了警告。Marshal.ReleaseComObject 的參考文件明確指出,它是用於需要及時釋放資源、或釋放順序具有意義的情境的手段,並寫明「只在絕對必要時才使用 ReleaseComObject(use the ReleaseComObject only if it is absolutely required)」。2 由於 RCW 在進程內是以每個 COM 物件共用一個的機制運作,如果程式碼某處釋放的 RCW,還在另一處被使用,就會導致 InvalidComObjectException,最嚴重甚至會造成存取違規、進程記憶體損毀。2 在應用程式內有多個模組共用 Excel 操作的結構下,這種事故確實會發生。
補充一點,如果同一個介面指標多次傳入 CLR,RCW 的參照計數可能會超過 1,這種情況下只呼叫一次是不會釋放的。雖然有可以強制將計數歸零的 Marshal.FinalReleaseComObject2,但一旦需要用到這個 API,就代表已經無法掌握生命週期管理,因此本公司建議此時應重新檢視設計。
除了進程殘留之外,還有一個不同軸線的安全性注意事項。透過 COM 自動化以 Workbooks.Open 開啟的工作簿,可能在沒有巨集警告的情況下就執行 VBA。本文的範例前提是開啟自家應用程式所管理的可信範本,但如果有可能開啟來自共用資料夾的檔案、使用者上傳的檔案等外部來源的工作簿,請在 Open 之前設定 excel.AutomationSecurity = MsoAutomationSecurity.msoAutomationSecurityForceDisable(Microsoft.Office.Core 命名空間),強制停用巨集。8 這樣可以封鎖「置換範本即等同於任意程式碼執行」這種攻擊路徑。這項注意事項同樣適用於下一節的 GC 模式範例。
4.2 (b) 把參照關起來、交給 GC 回收的一派
另一種流派,是讓 RCW 的釋放依照原本的機制交給 GC,並在確定的時間點觸發那次 GC。因為 RCW 在被 GC 回收時會釋放 COM 參照1,只要「等所有操作 Excel 的參照都離開作用範圍後,執行一次完整 GC 加上等待終結器完成」,就能一次 ReleaseComObject 都不寫,讓 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
}
}
[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();
}
}
成立條件有三個。
- 把操作 Excel 的程式碼隔離到單一方法中。只要 JIT 讓堆疊上的參照保持存活,GC 就無法回收,所以 GC 一定要在「操作 Excel 的方法之外」呼叫。
NoInlining就是為了防止內嵌展開使這個隔離失效。 - 不要讓 RCW 逸出到欄位或回傳值。只要有一個逸出到方法外,只要那個參照還活著,Excel 就會殘留。
- 一定要是
GC.Collect→GC.WaitForPendingFinalizers→GC.Collect這三個步驟。因為 RCW 的收尾是透過終結器進行的,慣例做法是先用第一次 Collect 偵測、等待終結器完成,再用第二次 Collect 回收殘餘物,共兩輪。
要注意的是,如果附加了偵錯器執行,變數的生命週期會延長到方法結尾,即使用這種方式,RCW 也可能不會被回收。「偵錯時會殘留,但發行組建時會消失」這種現象的真相就是這個,驗證行為時務必使用發行組建、且不附加偵錯器。
這種做法的優點是即使違反兩個點規則也不會遺漏。包括匿名的中間 RCW,GC 都會把參照已斷開的物件一併回收。審查時只需要專注在「操作 Excel 的邏輯是否封閉在這個方法裡」這一點,規範成本因此大幅降低。缺點是明確呼叫 GC.Collect 這種程式碼異味(整個應用程式的完整 GC 造成的暫停),以及不了解原因的成員出於善意進行重構時可能會破壞它的風險。務必在這三個步驟旁加上說明原因的註解。
4.3 本公司的建議 ── 以隔離+GC 為基本,需要時再用包裝器規範化
從實務角度比較兩種做法。
| (a) ReleaseComObject 派 | (b) GC 派 | |
|---|---|---|
| 釋放時機 | 確定性(呼叫的瞬間) | 準確定性(GC 三步驟執行的瞬間) |
| 規範成本 | 高。所有物件都要變數化+逆序釋放,全員都要遵守 | 低。只需遵守方法隔離即可 |
| 用過頭時的症狀 | InvalidComObjectException、存取違規2 | 因強制 GC 造成的暫停 |
| 與官方指引的一致性 | 「只在絕對必要時」2 | 符合 RCW 本來的生命週期管理(交給 GC)1 |
| 適用情境 | 釋放順序具有意義的情境。長時間執行的進程中反覆細緻地使用 Excel | 「開啟・寫入・關閉」能在一處完結的大部分報表處理 |
本公司的建議是,基本上以 (b) 的隔離+GC 模式為主。像報表輸出這種處理,封閉在單一方法裡是很自然的做法,只要做成這種形式,遺漏就不會在結構上發生。也能避免 ReleaseComObject 被濫用的風險,並且與官方的定位一致。
只有在釋放順序與即時性真的有必要時才使用 (a),且此時不讓開發者直接寫原生的 ReleaseComObject,而是引入以 using 規範化的小型包裝器。
using System.Runtime.InteropServices;
/// <summary>在 using 作用範圍內釋放 COM 物件的包裝器</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);
因為 Dispose 會以宣告 using 的相反順序執行,「依建立的相反順序釋放」就由語言機制來保證了。雖然會多出 .Value 這部分的寫法,但可以把規範壓縮成「COM 物件一定要用 ComScope 接住」這一條規則。反過來說,只要寫出像 books.Value.Open(...).Worksheets 這樣的複合運算式,遺漏依然會再次出現,因此兩個點規則的教育無論如何都是必要的。
兩種模式都有兩項共通的注意事項。第一,在 Quit() 之前,不要讓儲存確認對話框跳出來。明確設定 DisplayAlerts = false 與 Close(SaveChanges: false)。如果隱藏的 Excel 因為等待對話框而當機,Quit 本身就無法完成。第二,因為 Excel 的 COM 是以 STA 為前提,不要從多個執行緒操作同一個 Application。執行緒與 COM 單元(apartment)的關係,整理在「COM STA/MTA 基礎知識」中。
5. 最終手段 ── 用 Hwnd 定位 PID 確實收尾
即使正確實作了釋放模式,「因為附加元件的緣故 Excel 不會結束」「在例外的異常路徑中,無論如何都會殘留一個進程」這類情況仍然存在。在無人執行的批次作業中,殘留的進程會導致隔天工作的檔案鎖定問題,因此值得預先準備作為最後保險的進程 Kill。問題在於「該殺哪一個 EXCEL.EXE」的定位方法。
常見的錯誤做法,是用啟動前後的進程清單差異來定位。也就是比較呼叫前後的 Process.GetProcessesByName("EXCEL"),把增加的部分視為自己的實例──這種做法對並行性非常脆弱。在取差異的期間,如果使用者手動開啟了 Excel,就會誤判;如果同種作業被並行執行,彼此之間也會互相搞混。誤殺使用者正在編輯、尚未儲存的 Excel、導致資料遺失,是這種方法最糟糕的事故,而且確實有實際案例被當作諮詢帶進來過。
正確的定位方法,是取得自己啟動的 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 或開啟把柄失敗,
// 也要從緊接著的地方就用 try/finally 包起來,確保一定能走到 Quit()
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.Hwnd即使在Visible = false的情況下也能取得。3- Kill 是「在正確完成釋放之後的一道保險」。如果一開始就依賴它,Excel 的暫存檔案就無法正常收尾,容易導致下次啟動時自動復原檔案不斷累積等問題。順序一定要是「釋放 → Quit → 等待 → 若仍存活才 Kill」。
- 要注意PID 的重複使用。由於 Windows 的 PID 會被重複使用,如果設計上只記住 PID 的數值、之後才去解析,一旦這段期間 Excel 已經結束、同一個 PID 又被分配給別的進程,就會有誤殺無關進程的風險。這裡光是呼叫
Process.GetProcessById是不夠的,請務必留意。因為 OS 的進程把柄是在WaitForExit/Kill等第一次存取時才延遲開啟,所以必須像上面的程式碼那樣,在 Excel 還存活時就存取SafeHandle、明確地把把柄開啟起來,才能真正防止混淆(把柄開著的期間,該 PID 不會被重複使用)。 - 這道保險能涵蓋的,是方法已經回傳、但 EXCEL.EXE 仍然殘留的情況。至於
Workbooks.Open、SaveAs、Quit這類 COM 呼叫本身就當機(hang)的情況(隱藏的強制回應對話框、或等待附加元件回應),根本不會走到finally,這道 Kill 也不會被觸發。對策要分兩層來思考。首先用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 是 Microsoft 出品的函式庫,能以強型別類別讀寫依 ECMA-376 / ISO/IEC 29500 標準化的 Office 檔案格式(如 .xlsx),建構在 ZIP 與 XML 之上,因此不需要 Excel 本體。6 進程殘留、授權、不受支援架構這幾個問題會一併消失。
不過,Open XML SDK 是「直接編輯檔案格式」的函式庫,並不提供 Excel 這個應用程式本身的行為。官方的設計考量中明確寫明,它不提供公式重新計算、資料更新等應用程式行為,也不提供轉換成其他格式(如 PDF)的功能。10 而且因為 API 忠實反映檔案格式的結構,就算只用原生 SDK 寫一個儲存格,也需要理解 SpreadsheetML 的結構。彌補這一點的是 ClosedXML,它是在 Open XML API 之上,套上一層「工作簿・工作表・儲存格」這種直覺式 API 的 MIT 授權開源專案。不需要安裝 Excel 就能處理 .xlsx / .xlsm(不支援舊格式 .xls)。7 關於報表輸出情境下的取捨使用方式與範本式設計,詳見「Excel 報表輸出的做法」。
補充一點,Microsoft 365 有提供無人 RPA 用的授權(unattended license),但這只是讓無人執行在授權層面成為可能,其運作方式依然是 “AS IS”──也就是設計之外的用法所伴隨的預期外行為,要由應用程式端自行吸收。9 「買了授權就會變成受支援的架構」是常見的誤解,請務必留意。
7. 判斷表 ── 該繼續用 COM,還是改用 Open XML 系列
綜合以上內容,「是否要繼續用 COM 操作 Excel,還是要脫離它」可以整理成以下三個選項。
| (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 個問題。
- 是否需要與使用者眼前的 Excel 互動? 「寫入使用者已開啟的工作簿,並把後續操作交還給使用者」這種功能,只能用 COM Interop 做出來。這種情況下就只能選 (1),並投資在第 4 章的規範化上。桌面互動式應用程式也不屬於不受支援的架構。
- 是否需要 Excel 應用程式本身的功能(巨集執行、重新計算、列印、PDF 輸出)? 這些功能 Open XML 系列都無法取代。10 與無人執行搭配是最痛苦的組合,應先檢討需求端是否能調整(例如把巨集邏輯移植到 C#、改成寫入已計算好的值等)。需要特別注意的是含巨集範本(.xlsm)的「保留」問題。若使用 Open XML SDK 的低階操作,只要不去動 VBA 專案的部分就能保留下來;但像 ClosedXML 這類高階函式庫,會把工作簿讀入物件模型、重新組裝封裝後再儲存,因此 VBA 專案有可能會遺失。若要把含巨集的範本遷移到 Open XML 系列,務必用實際的範本做「開啟・寫入・儲存後,巨集是否仍然保留且能正常運作」的驗證,如果無法保證,就把那份報表留在 COM 上。VBA 資產的處理方式,可以直接沿用「VBA 是什麼」中的判斷。
- 是無人執行嗎? 如果是從服務、工作排程器、Web 應用程式執行,預設答案就是 (2)。「產生填入數值與樣式的 .xlsx」這種需求占了報表產生的大多數情境,用 ClosedXML 就能完全解決。
- 輸出入的格式是什麼? 如果必須直接處理交易對象送來的 .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 使用的架構依然可行,做法整理在「讓 VBA 以型別安全的方式使用 .NET 8 DLL」中。若把架構反過來,不是「從 C# 操作 Excel」,而是「從 Excel 的巨集呼叫 C# 的邏輯」,就能把進程生命週期的管理交給 Excel 自己,有些情況下殘留問題會因此在結構上消失。
總結來說,即使進入 .NET (Core) 時代,Excel COM 操作的寫法與陷阱,跟 .NET Framework 時代幾乎相同。改變的地方在於必須明確宣告僅限 Windows,以及參照方式偏向了內嵌互通型別,關於 RCW 與釋放模式的討論(第 2~5 章)依然完全適用。
9. 總結
EXCEL.EXE 殘留的問題,只要理解「COM 靠參照計數存活,.NET 的 RCW 靠 GC 死亡」這兩種生命週期管理之間的橋接關係,就不再是靠運氣的現象。壓縮成檢查清單如下。
Quit()並非釋放。要等 RCW 握有的所有 COM 參照都被歸還,Excel 才會結束- 兩個點以上的複合運算式會產生匿名 RCW。中間物件要接到變數裡
- 釋放以「方法隔離+GC 三步驟」為基本,若需要控制順序,就用 using 包裝器規範化 ReleaseComObject。不要把原生的 ReleaseComObject 散落各處
- 作為保險的 Kill,要用
Application.Hwnd→GetWindowThreadProcessId只定位自己這個實例,並且只在「Quit → 等待 → 逾時時」才觸發 - 伺服器・無人執行的 Excel 自動化不受支援。無人的報表產生應改用 ClosedXML / Open XML SDK,需要巨集或重新計算的處理則隔離到互動式環境的 COM 中
如果在工作管理員裡發現一整排的 EXCEL.EXE,那就是技巧層面的問題(第 4~5 章)或架構層面的問題(第 6~7 章)其中之一的訊號。如果不確定手上的程式碼屬於哪一種,或是不確定該從哪個範圍開始替換,都可以從盤點現有處理流程開始協助您判斷。
相關文章
- Excel 報表輸出的做法 - COM/Open XML/範本
- VBA 是什麼 - 限制、未來性、該替換的情境與現實的遷移模式
- COM / ActiveX / OCX 是什麼 - 差異與關係整理說明
- 應對 VBScript 淘汰的 VBA・內部工具盤點指南
相關諮詢領域
合同會社小村軟體處理包含 Excel/Office 自動化的 Windows 應用程式問題調查(進程殘留、當機、檔案鎖定)、COM 資產的維護與規範化,以及遷移到 Open XML 系列函式庫的報表處理設計。
參考連結
-
Microsoft Learn, Runtime Callable Wrapper. 說明每個 COM 物件在進程內會建立一個對應的 RCW、快取介面指標,並在被 GC 回收時釋放對該 COM 物件的參照。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Marshal.ReleaseComObject(Object) Method. 說明遞減 RCW 參照計數的行為、誤用已釋放 RCW 導致 InvalidComObjectException・存取違規・記憶體損毀的風險、「只在絕對必要時使用」的定位,以及與 FinalReleaseComObject 的關係。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Application.hWnd property (Excel). 說明 Excel 的 Application 物件的 Hwnd 屬性會回傳最上層視窗的把柄。 ↩ ↩2 ↩3
-
Microsoft Learn, GetWindowThreadProcessId function (winuser.h). 說明可取得建立指定視窗的執行緒 ID,以及建立該視窗的進程 ID。 ↩ ↩2
-
Microsoft 支援, Considerations for server-side Automation of Office. 說明 Microsoft 不建議、也不支援從無人值守、非互動式的客戶端應用程式或元件(包括 ASP、ASP.NET、DCOM、NT 服務)自動化 Office。 ↩ ↩2 ↩3
-
Microsoft Learn, Welcome to the Open XML SDK for Office. 說明 Open XML SDK 是以 System.IO.Packaging 為基礎、以強型別類別操作 ECMA-376 / ISO/IEC 29500 標準 Office 檔案格式的函式庫。 ↩ ↩2
-
GitHub, ClosedXML/ClosedXML. 說明這是在 Open XML API 之上提供直覺式介面的 MIT 授權函式庫,不需安裝 Excel 即可處理 Excel 2007+ (.xlsx, .xlsm) 檔案。 ↩ ↩2 ↩3
-
Microsoft Learn, _Application.AutomationSecurity Property (Microsoft.Office.Interop.Excel). 說明程式化開啟檔案時的巨集安全性模式、應用程式啟動時預設為 msoAutomationSecurityLow(所有巨集皆啟用)、以及 msoAutomationSecurityForceDisable 可在不顯示警告的情況下停用所有巨集。 ↩
-
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
-
Microsoft Learn, Open XML SDK for Office design considerations. 說明 Open XML SDK 並非 Office 物件模型的替代品,不提供公式重新計算、資料更新等應用程式行為,亦不提供轉換為其他格式的功能。 ↩ ↩2 ↩3
-
Microsoft Learn, Native interoperability ABI support. 說明內建的 COM 互通系統支援僅限於 Windows,以及 .NET 5+ 的 ComWrappers/.NET 8+ 透過原始碼產生提供的 COM 支援。 ↩
-
Microsoft Learn, How to access Office interop objects. 說明具名引數・選擇性引數・dynamic 如何簡化 Office 互通,以及以內嵌互通型別(Embed Interop Types)取代 PIA 作為預設行為。 ↩ ↩2
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
業務應用程式的日期時間與時區 ── 從 DateTime 的陷阱到 UTC 儲存原則、測試設計
伺服器搬遷後時間整整差了 9 個小時、只有海外據點的日期會變成前一天──本文從 DateTime 的 Kind 與隱式轉換整理日期時間事故的根本原因。內容涵蓋與 DateTimeOffset 的取捨、UTC 儲存與 ISO 8601 原則、TimeZoneInfo 與夏令時...
WPF 高 DPI 對應──「不是應該對 DPI 很強嗎」卻仍模糊、暈邊的原因與對策
WPF 以 DIP(1/96 英吋)進行版面配置,一開始就是 System DPI Aware,但移到 DPI 不同的螢幕時整體會暈邊,點陣圖與細線也會模糊。本文從實務角度整理原因判斷、.NET Framework 4.6.2/.NET 上的 Per-Monitor DPI...
WinForms 的高DPI支援 - 4K螢幕顯示模糊、版面跑掉的原因與實務對策
本文從 DPI 虛擬化與 DPI 感知模式(System Aware / Per-Monitor V2)的角度,整理 WinForms 應用程式在4K螢幕、150%縮放環境下顯示模糊、版面跑掉的原因,並說明 .NET 與 .NET Framework 各自的設定方式、Aut...
Windows 的行程間通訊該怎麼選 ── 具名管道 / TCP / gRPC / 共享記憶體 / COM 判斷表
整理 Windows 應用程式之間該如何選擇溝通方式。以判斷表整理具名管道、本機 TCP、gRPC、共享記憶體、檔案協作、COM 各自的強項與陷阱,並從實務角度說明 UI+服務分離・32bit/64bit 橋接・權限邊界等典型架構,以及具名管道的實作範例。
把OSI參考模型看得明明白白 ── 解剖一個HTTP請求的七層結構
不靠死記硬背來理解OSI參考模型,而是看真實的東西。本文用C#組出一個承載HTTP GET請求的Ethernet訊框並加以解剖,透過十六進位傾印和Wireshark確認L2〜L7如何以位元組序列的形式一層層物理地嵌套在一起。文章從實務角度整理各層與.NET API(Http...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
ActiveX 遷移
整理保留、包裝或替換 COM / ActiveX / OCX 資產的階段性判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
常見問題
整理諮詢這個主題時常見的問題。
- 已經呼叫了 Quit(),為什麼 EXCEL.EXE 還不會結束?
- 因為 Quit() 並非釋放操作,而只是「等所有參照都釋放後就可以結束」的請求。.NET 透過稱為 RCW(Runtime Callable Wrapper)的包裝器來處理 COM 物件,在 RCW 被 GC 回收(或被明確釋放)之前,它會一直握住 COM 參照。只要參照還在,Excel 就會忠實地持續等待。由於釋放時機取決於 GC、並不固定,「開發機上會消失,但在正式環境卻會殘留」這種無法重現的現象,其實正是 GC 時機波動所造成的表面症狀。
- Excel COM 操作中的「兩個點規則」是什麼?
- 這是一條經驗法則:不要對 COM 物件連續使用兩個以上的點(.),所有中間物件都要先接到一個變數裡。像 book.Worksheets[1].Range["A1"] 這種複合運算式,會在一行內產生 Worksheets(集合)、Worksheets[1](工作表)、Range["A1"](範圍)等多個匿名 RCW。由於這些物件沒有存到變數裡,也就沒有辦法呼叫 Marshal.ReleaseComObject,因此是造成釋放遺漏的主要元兇。foreach 或條件式裡被隨手丟棄的值、複合運算式的引數,同樣也會產生匿名 RCW,需要特別留意。
- 要確實讓 EXCEL.EXE 結束,該怎麼寫程式?
- 建議的做法是,把操作 Excel 的邏輯完全隔離到單一方法中,離開該方法後執行 GC.Collect → GC.WaitForPendingFinalizers → GC.Collect 這套三步驟。由於 RCW 本來就是在被 GC 回收時釋放 COM 參照,只要用這種形式,即使違反了兩個點規則,連匿名 RCW 也會一併被回收。另一種做法是對所有物件套用 Marshal.ReleaseComObject,但官方文件明確指出「只在絕對必要時使用」,誤用已釋放的 RCW 會導致 InvalidComObjectException 或記憶體損毀。只有在需要控制釋放順序時,才引入以 using 規範化的包裝器。
- 可以在伺服器或批次作業中自動化操作 Excel 嗎?
- Microsoft 明確表示,不建議也不支援從無人值守、非互動式的客戶端應用程式或元件(包括 ASP.NET、DCOM、NT 服務)自動化 Office。Office 的設計前提是有互動使用者存在,因此在服務或 IIS 上執行時,會發生對話框等待造成的當機,或進程無限累積的問題。若是無人執行的報表產生需求,建議改用不需啟動 Excel、直接操作檔案的 Open XML SDK 或 ClosedXML。只有真正需要巨集執行、重新計算等 Excel 本體功能的處理,才將其隔離到互動式環境中的 COM 操作裡,這是比較現實的分工方式。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。