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";

看起来有点啰嗦,但这正是为了「让所有该释放的东西都能被列举出来」而采用的写法。以下列出几种容易被忽略的变体模式。

  • foreachforeach (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 对象无一遗漏地接到变量里,并包含异常路径,按相反顺序释放。而且考虑到 CloseQuit 本身也可能失败(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.msoAutomationSecurityForceDisableMicrosoft.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();
    }
}

成立条件有三个。

  1. 把操作 Excel 的代码隔离到单一方法中。只要 JIT 让栈上的引用保持存活,GC 就无法回收,所以 GC 一定要在「操作 Excel 的方法之外」调用。NoInlining 就是为了防止内联展开使这个隔离失效。
  2. 不要让 RCW 逸出到字段或返回值。只要有一个逸出到方法外,只要那个引用还活着,Excel 就会残留。
  3. 一定要是 GC.CollectGC.WaitForPendingFinalizersGC.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 = falseClose(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 获取创建该窗口的进程 ID34 由于句柄是自己这个实例特有的,完全不会与其他 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.OpenSaveAsQuit 这类 COM 调用本身就挂起的情况(隐藏的强制响应对话框、或等待加载项响应),根本不会走到 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 服务)自动化 Office5 Office 的设计前提是存在交互用户,因此诸如发生错误时弹出确认对话框并等待响应的设计、以运行用户的配置文件为前提的组件、以 STA 为基础且不可重入的架构等,这些在桌面环境不会造成问题的前提,到了服务或 IIS 的工作进程上,全都会反过来带来麻烦。9 「从 Windows 服务运行后,生产环境中 Open 永远不会返回」「在 IIS 上每月会有一次因等待对话框而挂起」这类咨询,全都是这种不受支持的架构所造成的。这种架构下的 EXCEL.EXE 残留,已经不只是单纯的收尾问题,而会演变成挂起的进程无限累积的运维问题。

Microsoft 提出的替代方案,是不安装、不启动 Office,直接编辑文件的 Open XML 文件格式,以及在云端处理的 Microsoft Graph API9 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 个问题。

  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 使用的架构依然可行,做法整理在「让 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.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. 说明每个 COM 对象在进程内会创建一个对应的 RCW、缓存接口指针,并在被 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. 说明 Microsoft 不建议、也不支持从无人值守、非交互式的客户端应用程序或组件(包括 ASP、ASP.NET、DCOM、NT 服务)自动化 Office。  2 3

  6. Microsoft Learn, Welcome to the Open XML SDK for Office. 说明 Open XML SDK 是以 System.IO.Packaging 为基础、以强类型类操作 ECMA-376 / ISO/IEC 29500 标准 Office 文件格式的库。  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 互操作,以及以嵌入互操作类型(Embed Interop Types)取代 PIA 作为默认行为。  2

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

已经调用了 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表