Le problème d'EXCEL.EXE qui reste actif lors de la manipulation d'Excel en C# — schémas de libération des références COM et décision de remplacement

· Mis à jour le: · · Excel, C#, COM, .NET, .NET Framework, Office, Maintenance legacy, Conseil technique

« On a développé une fonctionnalité qui exporte des rapports vers Excel, et le Gestionnaire des tâches s’est retrouvé avec toute une rangée d’EXCEL.EXE. » « J’ai fermé l’application, mais quand j’essaie de rouvrir le fichier, on me dit qu’il est “utilisé par un autre processus.” » « Des centaines de processus Excel se sont accumulés sur le serveur du traitement de nuit, ont épuisé toute la mémoire, et tout s’est arrêté. » Pratiquement tous les développeurs qui ont écrit du code C# pilotant Excel via Microsoft.Office.Interop.Excel se sont heurtés à ce phénomène au moins une fois. Nous recevons nous aussi régulièrement des demandes du type « j’appelle Quit(), mais Excel ne se termine pas ».

Ce qui rend ce problème délicat, c’est qu’il semble « ne se produire que de temps en temps ». Il disparaît sur la machine de développement mais reste en production ; il reste actif en exécution debug mais disparaît en version release — comme les conditions de reproduction fluctuent, le remède symptomatique Process.Kill finit souvent par se glisser dans le code de production. Mais la cause n’est pas une question de chance : elle s’explique entièrement par le comptage de références COM et le mécanisme du RCW (Runtime Callable Wrapper) de .NET. Cet article détaille, d’un point de vue pratique : le mécanisme qui laisse le processus subsister, le piège classique de la « règle des deux points », un comparatif des deux écoles de libération et notre recommandation, la bonne manière de procéder au Kill de processus en dernier recours, et jusqu’à la décision de remplacer COM par une bibliothèque de la famille Open XML.

1. L’essentiel d’abord

  • Si EXCEL.EXE reste actif, ce n’est pas un bug : c’est parce que le côté .NET tient encore une référence COM. Quit() n’est qu’une demande signifiant « se terminer une fois toutes les références libérées » ; tant qu’une référence subsiste, Excel attend consciencieusement.
  • .NET manipule les objets COM via une enveloppe appelée RCW (Runtime Callable Wrapper), et conserve une référence à l’objet COM jusqu’à ce que le RCW soit récupéré par le GC (ou explicitement libéré).1
  • Enchaîner deux points ou plus, comme dans book.Worksheets[1].Range["A1"], crée des RCW pour les objets intermédiaires sans jamais les assigner à une variable, ce qui empêche leur libération. C’est ce qu’on appelle communément la « règle des deux points », et c’est le principal responsable de ce problème.
  • Il existe deux écoles pour la libération : (a) l’application disciplinée de Marshal.ReleaseComObject à tous les objets COM, et (b) le confinement des références dans des variables pour laisser le GC (GC.Collect + WaitForPendingFinalizers) les récupérer. La documentation officielle positionne ReleaseComObject comme un outil à « utiliser uniquement en cas d’absolue nécessité ».2
  • Notre recommandation est d’adopter par défaut le schéma GC avec le traitement isolé dans une seule méthode, et de n’introduire un petit wrapper disciplinant la libération via using que lorsque le contrôle de l’ordre de libération est réellement nécessaire (chapitre 4).
  • En cas de résidu malgré tout, prévoyez en assurance de récupérer le handle de fenêtre via Application.Hwnd, d’identifier le PID avec GetWindowThreadProcessId, puis de faire un Kill ciblé. N’utilisez pas la méthode consistant à comparer la liste des processus avant/après le lancement : elle risque d’entraîner accidentellement le Kill d’un Excel ouvert par l’utilisateur.34
  • Plus fondamentalement, l’automatisation d’Office côté serveur ou en environnement sans surveillance n’est pas prise en charge par Microsoft.5 Pour la génération de rapports sans surveillance, envisagez d’abord de remplacer COM par Open XML SDK / ClosedXML, qui ne lancent jamais Excel (chapitres 6 à 7).67

2. Pourquoi EXCEL.EXE reste actif — comptage de références et RCW

Commençons par le principe côté COM. La durée de vie d’un objet COM est gérée par comptage de références, et le serveur d’automatisation d’Excel (EXCEL.EXE) ne se termine pas tant que toutes les références remises aux clients externes n’ont pas été rendues. À l’époque de VB6 déjà, oublier d’appeler Release laissait le processus en vie de la même façon (pour un rappel sur COM, voir « Qu’est-ce que COM / ActiveX / OCX ? »).

Passons maintenant au côté .NET. Le code C# ne touche jamais directement un objet COM : il opère à travers un proxy généré par le CLR, appelé RCW. Un seul RCW est créé par objet COM au sein d’un processus ; il met en cache le pointeur d’interface COM et libère sa référence à l’objet COM au moment où il est lui-même récupéré par le GC.1 Autrement dit, la gestion de la durée de vie passe d’un mode « je compte moi-même les références » à un mode « je laisse faire le GC ».

En combinant ces deux faits, la raison pour laquelle EXCEL.EXE reste actif en découle directement.

Étape Ce qui se passe
new Excel.Application() EXCEL.EXE démarre, et le RCW de l’objet Application est créé
Opérations sur les cellules, enregistrement, etc. Un RCW s’ajoute pour chaque objet touché — Workbook, Worksheet, Range, etc.
excel.Quit() Indique seulement à Excel « vous pouvez vous terminer ». Aucune des références détenues par les RCW n’est libérée
Sortie de la méthode La référence .NET vers le RCW disparaît, mais le RCW lui-même est toujours vivant sur le tas
GC (à un moment donné) Le RCW est récupéré, et c’est alors seulement que la référence COM est rendue → EXCEL.EXE se termine

Deux points à retenir. Premièrement, Quit() n’est pas une libération. Tant qu’une référence subsiste, Excel attend. En temps normal, une fois le processus applicatif complètement terminé, la référence disparaît et Excel se termine aussi — mais dans des formes où le processus parent continue de vivre, comme une application résidente ou une application web, ce « moment donné » ne vient jamais. Deuxièmement, le moment de la libération est indéterminé, car il dépend du GC. Quand la mémoire est abondante, le GC peut ne pas s’exécuter pendant des dizaines de minutes, durant lesquelles EXCEL.EXE reste en zombie. L’absence de reproductibilité — « ça reste parfois », « ça ne reste qu’en production » — n’est rien d’autre que les fluctuations du moment où le GC se déclenche, rendues visibles.

Notez qu’un Excel lancé avec Visible = false n’a pas de fenêtre, donc même s’il reste actif, l’utilisateur ne le voit pas. Le symptôme se manifeste typiquement par « une erreur “fichier utilisé” au deuxième enregistrement » ou « le PC est lent », et ce n’est qu’en ouvrant le Gestionnaire des tâches qu’on remarque la rangée d’EXCEL.EXE. Le premier réflexe d’investigation consiste à compter les processus avec tasklist | findstr EXCEL.

3. Le piège classique de la « règle des deux points » — les objets intermédiaires invisibles

Le code derrière la plainte « je libère pourtant correctement toutes les variables, et ça reste actif » contient presque toujours une ligne comme celle-ci.

// Semble propre à première vue, mais génère des RCW impossibles à libérer
excel.Workbooks.Open(path);
book.Worksheets[1].Range["A1"].Value2 = "hello";

excel.Workbooks crée et renvoie le RCW de la collection Workbooks. Si vous appelez .Open(...) sur cette valeur de retour sans la capturer dans une variable, le RCW de Workbooks reste sur le tas comme un objet anonyme que personne ne référence, mais qui est toujours vivant. Comme il n’y a pas de variable, il n’existe aucun moyen d’appeler Marshal.ReleaseComObject dessus. La deuxième ligne est encore plus grave : elle crée trois RCW anonymes en une seule ligne — Worksheets (la collection), Worksheets[1] (la feuille) et Range["A1"] (la plage).

La règle empirique permettant d’éviter cela, connue de longue date dans la communauté de l’automatisation Office, est la « règle des deux points ». On peut la reformuler ainsi : ne jamais enchaîner deux points ou plus sur un objet COM ; capturer systématiquement chaque objet intermédiaire dans une variable.

// Donner un nom à chaque objet intermédiaire
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";

Cela peut sembler verbeux, mais c’est une façon d’écrire le code qui permet d’« énumérer tout ce qui doit être libéré ». Voici quelques variantes faciles à manquer.

  • foreach : foreach (Excel.Worksheet s in book.Worksheets) crée des RCW pour la collection, l’énumérateur, et chaque élément. Dans l’école ReleaseComObject, la pratique standard consiste à utiliser une boucle for indexée qui capture chaque élément dans une variable, un par un.
  • Utilisation jetable dans une expression conditionnelle : un RCW est créé même à l’intérieur d’une expression comme if (excel.Workbooks.Count > 0).
  • Un argument sous forme d’expression composée : une expression comme sheets.Add(After: sheets[sheets.Count]) crée plusieurs RCW anonymes en une seule ligne.
  • Abonnements aux événements : attacher un gestionnaire à un événement d’Application ou de Workbook maintient une référence vivante via cette connexion. Désabonnez-vous toujours avant de terminer.

4. Comparatif des schémas de libération — l’école ReleaseComObject et l’école GC

Il existe deux écoles pour écrire du code qui termine EXCEL.EXE de façon fiable. Les deux fonctionnent si elles sont écrites correctement. La vraie question est de savoir si l’on peut continuer à les écrire correctement, et c’est là que la différence pratique se manifeste.

4.1 (a) L’application disciplinée de Marshal.ReleaseComObject

Marshal.ReleaseComObject décrémente le compteur de références interne du RCW, et dès qu’il atteint zéro, la référence COM détenue par le RCW est immédiatement libérée.2 L’avantage est de pouvoir libérer à un moment déterministe, sans attendre le GC. Voici la forme typique lorsqu’elle est appliquée à tous les objets.

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
{
    // Ne pas utiliser d'initialiseur d'objet (new ... { DisplayAlerts = false }).
    // Si l'appel COM du setter échoue, excel resterait non assigné en arrivant
    // dans le finally, rendant impossible tout Quit ou toute libération de
    // l'EXCEL.EXE déjà lancé
    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
{
    // Libérer dans l'ordre inverse de la création. Close et Quit eux-mêmes sont
    // des appels COM qui peuvent aussi échouer ; imbriquer des blocs try/finally
    // garantit d'atteindre Quit et la libération même si une exception survient
    // en cours de route
    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);
        }
    }
}

La faiblesse de cette approche, comme on le voit, est que le coût de la discipline est élevé. Il faut capturer sans exception chaque objet COM touché dans une variable, et le libérer en ordre inverse, y compris sur les chemins d’exception. Et si l’on tient compte du fait que Close ou Quit eux-mêmes peuvent échouer (erreur COM, classeur déconnecté, Excel qui ne répond pas), il faut en plus garantir, comme ci-dessus avec des try/finally imbriqués, qu’un échec en cours de route atteigne quand même les libérations suivantes — exiger que chaque membre de l’équipe respecte cela à chaque modification est, par expérience, une contrainte particulièrement difficile à tenir. Il suffit qu’une seule expression composée à deux points se glisse quelque part pour que la fuite revienne.

Plus important encore : la documentation officielle elle-même met en garde contre l’abus de cette méthode. La référence de Marshal.ReleaseComObject précise qu’il s’agit d’un outil destiné aux cas où les ressources doivent être libérées rapidement ou où l’ordre de libération a de l’importance, et indique explicitement qu’il faut « utiliser ReleaseComObject uniquement si c’est absolument nécessaire » (use the ReleaseComObject only if it is absolutely required).2 Comme le RCW est un mécanisme partagé — un seul par objet COM au sein d’un processus —, si du code à un endroit libère un RCW qu’une autre partie utilise encore, on obtient une InvalidComObjectException, ou, dans le pire des cas, une violation d’accès ou une corruption de la mémoire du processus.2 Dans une architecture où plusieurs modules de l’application partagent les opérations Excel, cet accident se produit réellement.

Notez que si le même pointeur d’interface est transmis plusieurs fois au CLR, le compteur de références du RCW peut dépasser 1, auquel cas un seul appel ne suffit pas à le libérer. Il existe aussi Marshal.FinalReleaseComObject, qui force le compteur à 02, mais le moment où cette API devient nécessaire est en soi un signe qu’on a perdu le contrôle de la gestion du cycle de vie ; nous recommandons alors de revoir la conception.

Une remarque de sécurité, sur un axe distinct de celui du résidu de processus. Un classeur ouvert via Workbooks.Open par automatisation COM peut exécuter du VBA sans aucun avertissement de macro. L’exemple de cet article suppose l’ouverture d’un modèle de confiance géré par votre propre application, mais s’il existe une possibilité d’ouvrir un classeur d’origine externe — un fichier d’un dossier partagé, un fichier déposé par un utilisateur —, réglez excel.AutomationSecurity = MsoAutomationSecurity.msoAutomationSecurityForceDisable (espace de noms Microsoft.Office.Core) avant l’appel à Open, afin de forcer la désactivation des macros.8 Cela bloque l’attaque où le simple remplacement d’un modèle se transforme directement en exécution de code arbitraire. Cette précaution s’applique tout autant à l’exemple du schéma GC présenté plus loin.

4.2 (b) Confiner les références et laisser le GC les récupérer

L’autre approche consiste à laisser la libération du RCW au GC, exactement comme le mécanisme a été conçu, et à déclencher ce GC à un moment déterministe. Comme le RCW libère sa référence COM au moment où il est récupéré par le GC1, il suffit d’« exécuter un GC complet suivi d’une attente de fin des finaliseurs, une fois que toutes les références touchant Excel sont sorties de leur portée » pour terminer EXCEL.EXE sans écrire le moindre appel à ReleaseComObject.

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

public void ExportReport(string templatePath, string outputPath)
{
    try
    {
        // Isoler complètement le traitement qui manipule Excel dans une méthode séparée
        ExportReportCore(templatePath, outputPath);
    }
    finally
    {
        // C'est justement sur le chemin d'exception qu'EXCEL.EXE a tendance à
        // subsister, donc toujours exécuter ceci dans un finally. Une fois la
        // méthode terminée, aucune référence vers un RCW ne subsiste nulle part
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();   // Deuxième passage pour récupérer les RCW détachés par le finaliseur
    }
}

[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();
    }
}

Trois conditions doivent être réunies pour que cela fonctionne.

  1. Isoler le code qui manipule Excel dans une seule méthode. Tant que le JIT maintient une référence vivante sur la pile, le GC ne peut pas la récupérer ; il faut donc toujours appeler le GC « en dehors de la méthode qui a touché Excel ». NoInlining sert à empêcher que l’inlining n’annule cette isolation.
  2. Ne jamais laisser un RCW s’échapper dans un champ ou une valeur de retour. Si ne serait-ce qu’un seul fuit hors de la méthode, Excel restera actif tant que cette référence sera vivante.
  3. Utiliser la combinaison en trois temps GC.CollectGC.WaitForPendingFinalizersGC.Collect. Comme le nettoyage du RCW passe par un finaliseur, la pratique standard consiste en deux passages : le premier Collect le détecte, on attend la fin du finaliseur, puis le second Collect balaie ce qu’il en reste.

Attention : lorsqu’un débogueur est attaché, la durée de vie des variables est prolongée jusqu’à la fin de la méthode, si bien que même cette approche peut échouer à récupérer le RCW. C’est là la véritable explication du phénomène « ça reste en debug mais disparaît en release » ; effectuez toujours la vérification du comportement en version release, sans débogueur attaché.

L’avantage de cette approche est que même une infraction à la règle des deux points n’entraîne pas de fuite. Tout ce dont la référence est sortie de portée, y compris les RCW intermédiaires anonymes, est récupéré en bloc par le GC. Comme le seul point à vérifier en revue se résume à « le traitement qui touche Excel est-il bien confiné à cette méthode ? », le coût de la discipline chute radicalement. Les inconvénients sont l’odeur de code que représente l’appel explicite à GC.Collect (une pause causée par un GC complet de toute l’application), et le risque qu’un membre de l’équipe qui n’en connaît pas la raison casse le tout par un refactoring bien intentionné. Laissez toujours un commentaire expliquant la raison de la combinaison en trois temps.

4.3 Notre recommandation — isolation + GC par défaut, un wrapper pour discipliner si nécessaire

Comparons les deux approches d’un point de vue pratique.

  (a) École ReleaseComObject (b) École GC
Moment de la libération Déterministe (à l’instant de l’appel) Semi-déterministe (au moment de la combinaison GC en trois temps)
Coût de la discipline Élevé. Tout le monde doit systématiquement transformer chaque objet en variable et le libérer en ordre inverse Faible. Il suffit de respecter l’isolation de la méthode
Symptômes d’un usage excessif InvalidComObjectException, violation d’accès2 Pauses dues au GC forcé
Cohérence avec les recommandations officielles « Uniquement en cas d’absolue nécessité »2 Conforme à la gestion de durée de vie native du RCW (laissée au GC)1
Cas d’usage adapté Quand l’ordre de libération compte. Processus de longue durée utilisant Excel fréquemment par petites touches La majorité des traitements de génération de rapports, où « ouvrir, écrire, fermer » tient dans un seul endroit

Notre recommandation est de privilégier par défaut le schéma (b), isolation + GC. Un traitement comme la génération de rapports se prête naturellement à être confiné dans une seule méthode, et dès que cette forme est respectée, les fuites deviennent structurellement impossibles. Cela évite aussi le risque d’abus de ReleaseComObject, et s’accorde avec la position officielle.

N’utilisez (a) que lorsque l’ordre de libération et l’immédiateté sont réellement nécessaires, et dans ce cas, ne laissez jamais écrire de ReleaseComObject brut : introduisez un petit wrapper disciplinant la libération via using.

using System.Runtime.InteropServices;

/// <summary>Wrapper qui libère un objet COM à la fin d'une portée 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);

Comme Dispose s’exécute dans l’ordre inverse des déclarations using, la règle « libérer dans l’ordre inverse de la création » est garantie par le mécanisme même du langage. L’écriture est un peu plus longue à cause du .Value, mais la discipline se résume à une seule règle : « tout objet COM doit obligatoirement être capturé via ComScope ». À l’inverse, écrire une expression composée comme books.Value.Open(...).Worksheets fait revenir la fuite, donc la formation à la règle des deux points reste de toute façon nécessaire.

Deux précautions communes aux deux approches. Premièrement, ne laissez jamais apparaître de boîte de dialogue de confirmation d’enregistrement avant Quit() : réglez explicitement DisplayAlerts = false et appelez Close(SaveChanges: false). Si un Excel invisible se bloque en attendant une boîte de dialogue, Quit lui-même ne se termine jamais. Deuxièmement, comme le COM d’Excel repose sur le modèle STA, ne manipulez jamais la même instance Application depuis plusieurs threads. La relation entre threads et appartements COM est détaillée dans « Les bases de COM STA/MTA ».

5. Le dernier recours — identifier de façon fiable le PID à partir du Hwnd

Même avec un schéma de libération correctement implémenté, il reste des cas comme « Excel ne se termine pas à cause d’un complément » ou « un chemin d’exception anormal laisse parfois inévitablement un processus derrière lui ». Dans un traitement par lots sans surveillance, ce processus résiduel unique peut provoquer un verrouillage de fichier pour le job du lendemain ; il vaut donc la peine de prévoir un Kill de processus comme filet de sécurité ultime. Reste la question de savoir comment identifier « quel EXCEL.EXE tuer ».

Une erreur fréquente consiste à identifier le processus en comparant la liste des processus avant et après le lancement : comparer Process.GetProcessesByName("EXCEL") avant et après, et considérer l’augmentation comme sa propre instance — cette méthode est fragile face à la concurrence. Si l’utilisateur ouvre Excel manuellement pendant qu’on prend le différentiel, on obtient un faux positif ; et si des jobs suivant le même schéma s’exécutent en parallèle, ils se confondent mutuellement. Tuer un Excel non enregistré que l’utilisateur est en train d’éditer, et perdre ses données, est le pire accident possible avec cette méthode — un cas qui nous a d’ailleurs été rapporté en tant que véritable demande de consultation.

La méthode correcte consiste à récupérer le handle de la fenêtre de premier niveau via la propriété Hwnd de l’objet Application que l’on a soi-même lancé, puis à obtenir l’ID du processus ayant créé cette fenêtre avec l’API Win32 GetWindowThreadProcessId.34 Le handle étant propre à sa propre instance, il n’y a aucune possibilité de le confondre avec un autre 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)
{
    // Grâce au paramètre out, le handle survit et revient à l'appelant même si
    // une exception est levée en cours d'opération Excel, ce qui garantit
    // d'atteindre le GC et le Kill aussi sur le chemin d'exception (précisément
    // là où ce filet de sécurité est réellement nécessaire)
    Process excelProcess = null;
    try
    {
        ExportReportCore(templatePath, outputPath, out excelProcess);
    }
    finally
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        if (excelProcess != null)
        {
            KillIfStillAlive(excelProcess);   // Filet de sécurité sans effet si le processus s'est déjà terminé normalement
            excelProcess.Dispose();
        }
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ExportReportCore(string templatePath, string outputPath, out Process excelProcess)
{
    excelProcess = null;
    var excel = new Excel.Application();
    // Dès qu'un lancement réussit, entourer immédiatement le tout d'un
    // try/finally afin d'atteindre toujours Quit(), même si la récupération du
    // Hwnd ou l'ouverture du handle échoue
    try
    {
        // À capturer juste après le lancement, car la fenêtre disparaît et devient
        // impossible à récupérer après Quit(). GetProcessById se contente
        // d'associer le PID ; le handle de processus de l'OS n'est ouvert que
        // paresseusement au premier accès — par exemple via WaitForExit / Kill.
        // On touche donc SafeHandle ici, pendant qu'Excel est encore vivant, pour
        // forcer l'ouverture immédiate du handle (tant que le handle reste ouvert,
        // ce PID ne sera pas réutilisé par un autre processus)
        NativeMethods.GetWindowThreadProcessId((IntPtr)excel.Hwnd, out uint pid);
        excelProcess = Process.GetProcessById((int)pid);
        _ = excelProcess.SafeHandle;

        excel.DisplayAlerts = false;
        // …… opérations Excel ……
    }
    finally
    {
        excel.Quit();
    }
}

private void KillIfStillAlive(Process excelProcess)
{
    if (!excelProcess.WaitForExit(5000))   // En temps normal, il disparaît en quelques secondes
    {
        logger.LogWarning("EXCEL.EXE (PID {Pid}) ne s'est pas terminé, arrêt forcé en cours", excelProcess.Id);
        excelProcess.Kill();
    }
}

Points d’attention pour l’implémentation.

  • Capturez Hwnd juste après le lancement. Après Quit(), la fenêtre est détruite et ne peut plus être récupérée. Application.Hwnd reste accessible même avec Visible = false.3
  • Le Kill est « un filet de sécurité au-dessus d’une libération correctement effectuée », pas un substitut. En compter dessus dès le départ empêche le nettoyage des fichiers temporaires d’Excel de s’exécuter, ce qui peut entraîner une accumulation de fichiers de récupération automatique au prochain lancement. L’ordre doit toujours être « libération → Quit → attente → Kill uniquement s’il est encore vivant ».
  • Attention à la réutilisation des PID. Comme les PID Windows sont réutilisés, une conception qui se contente de mémoriser la valeur numérique du PID pour la résoudre plus tard risque de tuer un processus sans rapport si Excel s’est terminé entre-temps et que le même PID a été réattribué à un autre processus. Notez qu’un simple appel à Process.GetProcessById ici ne suffit pas : le handle de processus de l’OS n’est ouvert que paresseusement au premier accès, via des appels comme WaitForExit / Kill. Ce n’est qu’en touchant SafeHandle pendant qu’Excel est encore vivant, pour ouvrir explicitement le handle, comme dans le code ci-dessus, que l’on évite véritablement cette confusion (tant que le handle reste ouvert, ce PID n’est pas réutilisé).
  • Ce filet de sécurité couvre le cas où la méthode est déjà revenue mais EXCEL.EXE reste actif. Si l’appel COM lui-même se bloqueWorkbooks.Open, SaveAs, Quit, en attente d’une boîte de dialogue modale cachée ou d’un complément qui ne répond pas —, finally n’est jamais atteint, et ce Kill ne se déclenche pas non plus. Réfléchissez à la contre-mesure en deux couches. D’abord, bloquez les sources de boîtes de dialogue avec DisplayAlerts = false et l’AutomationSecurity mentionnée plus haut. Ensuite, pour les traitements par lots sans surveillance, structurez le job qui manipule Excel comme un processus séparé, afin de pouvoir le tuer entièrement de l’extérieur sous une limite de temps (le réglage « durée avant arrêt » du planificateur de tâches, ou un Kill du processus enfant par le processus parent). Un timeout interne au processus ne peut pas interrompre un appel COM bloqué ; il est donc plus fiable de placer la frontière au niveau du processus.
  • Chaque fois que le Kill se déclenche, consignez-le systématiquement dans les journaux et surveillez sa fréquence. Une augmentation de la fréquence est le signe soit d’une régression du code de libération, soit d’un signal en faveur de la décision de remplacement abordée au chapitre 7.

6. Revenons à la base — l’automatisation d’Office côté serveur n’est pas prise en charge

Les techniques présentées jusqu’ici permettent de maîtriser presque entièrement le résidu d’EXCEL.EXE dans une application cliente. Mais pour le scénario où ce problème devient le plus sévère — l’automatisation d’Excel sur un serveur ou en environnement sans surveillance —, il existe une position officielle à vérifier avant même de recourir à une quelconque technique.

Dans un document intitulé « Considerations for server-side Automation of Office », Microsoft déclare explicitement ne pas recommander, ni prendre en charge, l’automatisation d’Office depuis des applications ou composants clients sans surveillance et non interactifs (y compris ASP, ASP.NET, DCOM et les services NT).5 Office est conçu en présupposant la présence d’un utilisateur interactif : une conception qui affiche une boîte de dialogue de confirmation en cas d’erreur et attend une réponse, des composants qui présupposent le profil de l’utilisateur d’exécution, une architecture basée sur STA et non réentrante — autant de postulats sans conséquence sur un poste de travail, mais qui se retournent systématiquement contre vous sur un service ou un processus de travail IIS.9 Des demandes du type « lancé depuis un service Windows, Open ne revient jamais en production » ou « ça bloque une fois par mois sur IIS en attente d’une boîte de dialogue » avaient toutes cette configuration non prise en charge pour cause. Dans une telle configuration, le résidu d’EXCEL.EXE n’est pas un simple problème de nettoyage : il devient un problème d’exploitation où des processus bloqués s’accumulent sans limite.

Ce que Microsoft cite comme alternatives, ce sont l’édition directe du format de fichier Open XML, sans installer ni lancer Office, et l’API Microsoft Graph, qui traite les données côté cloud.9 L’Open XML SDK est une bibliothèque Microsoft qui permet de lire et d’écrire les formats de fichiers Office (comme .xlsx), standardisés en ECMA-376 / ISO/IEC 29500, via des classes fortement typées ; construite sur ZIP et XML, elle ne nécessite pas Excel lui-même.6 Les problèmes de résidu de processus, de licence et de configuration non prise en charge disparaissent d’un seul coup.

Cependant, l’Open XML SDK est une bibliothèque qui « édite directement le format de fichier », et ne fournit pas le comportement de l’application Excel elle-même. Ses considérations de conception officielles précisent explicitement qu’elle ne fournit ni comportements applicatifs tels que le recalcul de formules ou la mise à jour de données, ni fonction de conversion vers d’autres formats (PDF, etc.).10 Par ailleurs, l’API reste fidèle à la structure du format de fichier, si bien que même écrire une seule cellule avec le SDK brut exige de comprendre la structure de SpreadsheetML. C’est ce que comble ClosedXML, une bibliothèque OSS sous licence MIT qui superpose à l’API Open XML une interface intuitive de type « classeur, feuille, cellule ». Elle permet de manipuler des fichiers .xlsx / .xlsm sans Excel installé (l’ancien format .xls n’est pas pris en charge).7 Le choix entre ces approches dans le contexte de la génération de rapports, ainsi que la conception d’une approche par modèle, sont détaillés dans « Comment construire une sortie de rapport Excel ».

Notez que Microsoft 365 dispose bien d’une licence pour le RPA sans surveillance (unattended license), mais celle-ci ne fait que rendre l’exécution sans surveillance possible sur le plan de la licence ; le comportement reste positionné comme « AS IS » — c’est à l’application d’absorber tout comportement inattendu résultant d’un usage hors conception.9 Ce point prête fréquemment à confusion : acheter la licence ne transforme pas la configuration en configuration prise en charge.

7. Tableau de décision — continuer à utiliser COM, ou basculer vers la famille Open XML

Compte tenu de tout cela, la question « continuer à piloter Excel via COM, ou s’en écarter » se résume aux trois options suivantes.

  (1) Continuer à utiliser COM Interop (encapsulé et discipliné) (2) Remplacer par Open XML SDK / ClosedXML (3) Repenser la conception (Graph, etc.)
Excel lui-même Nécessaire (avec une licence par environnement d’exécution) Non nécessaire Non nécessaire
Exécution sans surveillance / serveur Configuration non prise en charge5 Aucun problème (alternative recommandée)9 Aucun problème
Risque de résidu de processus Présent (géré via les techniques de cet article) Absent (aucun processus lancé) Absent
Exécution de macros (VBA) Possible Impossible (et même la conservation dépend de la bibliothèque — voir point 2 ci-dessous) Impossible
Recalcul de formules, impression, conversion PDF Possible Impossible10 Partiellement disponible via Graph
Interaction avec un Excel ouvert par l’utilisateur Possible Impossible Impossible
Ancien format .xls (BIFF) Lecture/écriture possibles Impossible (.xlsx / .xlsm uniquement)7
Vitesse d’exécution / parallélisme Lent. Isolation des instances requise pour le parallélisme9 Rapide. Parallélisable comme une bibliothèque classique Dépend du réseau

L’arbitrage se résume à quatre questions.

  1. Faut-il interagir avec l’Excel ouvert devant l’utilisateur ? Une fonctionnalité qui « écrit dans un classeur ouvert par l’utilisateur et lui rend la main pour qu’il continue » ne peut être construite qu’avec COM Interop. Dans ce cas, (1) est la seule option : investissez dans la discipline décrite au chapitre 4. Une application interactive de bureau ne relève d’ailleurs pas non plus de la configuration non prise en charge.
  2. A-t-on besoin des fonctionnalités de l’application Excel elle-même (exécution de macros, recalcul, impression, sortie PDF) ? Ces fonctionnalités ne peuvent pas être remplacées par la famille Open XML.10 La combinaison avec une exécution sans surveillance est le cas le plus difficile ; commencez par examiner si les exigences elles-mêmes peuvent être infléchies (porter la logique de la macro vers C#, écrire des valeurs déjà calculées, etc.). Un point qui mérite attention est la « conservation » d’un modèle contenant des macros (.xlsm). Avec les opérations bas niveau du Open XML SDK, le projet VBA est conservé tant qu’on ne touche pas à sa partie ; mais une bibliothèque de haut niveau comme ClosedXML charge le classeur dans un modèle objet et reconstruit puis réenregistre le paquet, ce qui peut faire perdre le projet VBA. Si vous migrez un modèle contenant des macros vers la famille Open XML, rendez obligatoire la vérification, sur le modèle réel, que « la macro subsiste et fonctionne encore après ouverture, écriture et enregistrement » ; si vous ne pouvez pas le garantir, laissez ce rapport en particulier sur COM. Pour la gestion des actifs VBA, l’arbitrage présenté dans « Qu’est-ce que VBA ? » s’applique directement ici aussi.
  3. S’agit-il d’une exécution sans surveillance ? Pour une exécution depuis un service, le planificateur de tâches ou une application web, la réponse par défaut est (2). L’écrasante majorité des besoins de génération de rapports se résume à « produire un .xlsx rempli de valeurs et de styles », ce que ClosedXML accomplit entièrement à lui seul.
  4. Quels sont les formats d’entrée et de sortie ? S’il faut traiter tel quel un fichier .xls provenant d’un partenaire commercial, la famille Open XML n’est pas utilisable. Examinez s’il est possible d’insérer une conversion vers .xlsx au point d’entrée.

Ce que nous proposons souvent dans nos projets concrets, c’est le découpage suivant : « générer avec ClosedXML, et isoler dans COM uniquement les traitements qui exigent absolument Excel lui-même ». Faites tourner côté serveur la génération quotidienne de centaines de rapports avec ClosedXML, et réservez le traitement COM — déclenché par un bouton sur le poste du responsable — uniquement à la « mise à jour mensuelle du classeur contenant des macros ». Cela fait disparaître COM de l’environnement sans surveillance, et la partie COM restante, étant une application interactive, rentre dans une configuration prise en charge. C’est plus réaliste qu’une réécriture totale, et cela permet d’éliminer les risques en commençant par les parties les plus à risque.

8. Points d’attention à l’ère de .NET (Core)

Voici, dans la limite de ce que nous avons pu vérifier, les points d’attention pour poursuivre des opérations COM Excel dans une application migrée de .NET Framework vers .NET (.NET 6/8, etc.).

  • L’interopérabilité COM reste réservée à Windows. .NET fonctionne aussi sous Linux, mais son support intégré de l’interopérabilité COM se limite à Windows.11 Pour un projet incluant des opérations Excel, déclarez explicitement une cible comme net8.0-windows, et n’attendez pas de support multiplateforme. L’impossibilité de le déployer dans un conteneur Linux se répercute directement sur la décision de remplacement du chapitre 7 (ClosedXML, lui, fonctionne dans un conteneur Linux).
  • La méthode de référence de base est « référence COM + intégration des types d’interopérabilité ». Lorsque vous ajoutez la bibliothèque d’objets Microsoft Excel comme référence COM dans Visual Studio, l’intégration des types d’interopérabilité (Embed Interop Types) est utilisée par défaut. Seuls les types réellement utilisés sont intégrés à votre propre assembly, ce qui évite de devoir distribuer un PIA (Primary Interop Assembly) dans l’environnement d’exécution, et rend le code plus résistant aux différences de version d’Office.12
  • dynamic et les arguments optionnels restent utilisables. Les fonctionnalités C# conçues pour l’interopérabilité Office (arguments nommés et optionnels, simplification des appels COM via dynamic) restent prises en charge dans les versions actuelles de .NET.12 Cela dit, écrire avec dynamic rend les RCW anonymes encore plus difficiles à repérer ; nous recommandons donc d’utiliser des types explicites dans le code où l’on souhaite discipliner la libération.
  • Attention aux API supposant une plateforme donnée. Les API liées à COM comme Marshal.ReleaseComObject portent un attribut réservé à Windows, et les appeler depuis un projet multiplateforme déclenche un avertissement de l’analyseur (CA1416). Extraire les opérations Excel dans un projet indépendant facilite la gestion de ce point.
  • La direction inverse (appeler .NET depuis VBA) est une tout autre histoire. Une configuration où une DLL .NET 8 est exposée en tant que COM et consommée depuis VBA reste possible ; la procédure est détaillée dans « Comment consommer une DLL .NET 8 depuis VBA avec typage ». En inversant l’architecture — au lieu de « piloter Excel depuis C# », faire en sorte que « la macro d’Excel appelle la logique C# » — on peut laisser Excel lui-même gérer la durée de vie du processus, ce qui fait parfois disparaître structurellement le problème de résidu.

En résumé, même à l’ère de .NET (Core), la façon d’écrire les opérations COM Excel et les pièges qu’elles comportent restent quasiment identiques à ceux de l’ère .NET Framework. Ce qui a changé, c’est la nécessité de déclarer explicitement le caractère réservé à Windows, et le fait que les références penchent désormais vers l’intégration des types d’interopérabilité ; la discussion sur les RCW et les schémas de libération (chapitres 2 à 5) reste, elle, entièrement valable.

9. Conclusion

Le problème d’EXCEL.EXE qui reste actif cesse d’être un phénomène livré au hasard dès que l’on comprend le pont entre deux systèmes de gestion de durée de vie : « COM vit par comptage de références, et le RCW de .NET meurt via le GC ». Résumé sous forme de check-list :

  • Quit() n’est pas une libération. Excel ne se termine pas tant que toutes les références COM détenues par les RCW n’ont pas été rendues
  • Une expression composée à deux points ou plus génère un RCW anonyme. Capturez les objets intermédiaires dans des variables
  • Pour la libération, privilégiez par défaut « isolation en méthode + combinaison GC en trois temps » ; si le contrôle de l’ordre est nécessaire, disciplinez ReleaseComObject via un wrapper using. Ne dispersez pas de ReleaseComObject brut dans le code
  • Pour le Kill de secours, identifiez uniquement votre propre instance via Application.HwndGetWindowThreadProcessId, et déclenchez-le seulement selon « Quit → attente → uniquement en cas de timeout »
  • L’automatisation d’Excel côté serveur ou sans surveillance est une configuration non prise en charge. Basculez la génération de rapports sans surveillance vers ClosedXML / Open XML SDK, et confinez les traitements nécessitant macros ou recalcul dans un environnement COM interactif

Si vous trouvez une rangée d’EXCEL.EXE dans le Gestionnaire des tâches, c’est le signe soit d’un problème de technique (chapitres 4 à 5), soit d’un problème de configuration (chapitres 6 à 7). Si vous hésitez à déterminer de quel côté se situe votre code, ou par où commencer un éventuel remplacement, nous pouvons vous accompagner dès l’inventaire des traitements.

Articles connexes

Domaines de conseil associés

KomuraSoft LLC prend en charge l’investigation de dysfonctionnements dans les applications Windows impliquant l’automatisation Excel/Office (résidus de processus, blocages, verrous de fichiers), la maintenance et la mise en discipline des actifs COM, ainsi que la conception de migrations des traitements de rapports vers les bibliothèques de la famille Open XML.

Références

  1. Microsoft Learn, Runtime Callable Wrapper. Sur le fait qu’un seul RCW est créé par objet COM au sein d’un processus, qu’il met en cache le pointeur d’interface, et qu’il libère sa référence à l’objet COM lorsqu’il est récupéré par le GC.  2 3 4

  2. Microsoft Learn, Marshal.ReleaseComObject(Object) Method. Sur la décrémentation du compteur de références du RCW, les risques d’InvalidComObjectException, de violation d’accès et de corruption de mémoire liés à l’utilisation d’un RCW déjà libéré, sa position en tant qu’outil à « utiliser uniquement en cas d’absolue nécessité », et sa relation avec FinalReleaseComObject.  2 3 4 5 6 7

  3. Microsoft Learn, Application.hWnd property (Excel). Sur le fait que la propriété Hwnd de l’objet Application d’Excel renvoie le handle de sa fenêtre de premier niveau.  2 3

  4. Microsoft Learn, GetWindowThreadProcessId function (winuser.h). Sur le fait qu’elle permet d’obtenir l’ID du thread ayant créé une fenêtre donnée, ainsi que l’ID du processus qui l’a créée.  2

  5. Microsoft Support, Considerations for server-side Automation of Office. Sur le fait que Microsoft ne recommande ni ne prend en charge l’automatisation d’Office depuis des applications ou composants clients sans surveillance et non interactifs, y compris ASP, ASP.NET, DCOM et les services NT.  2 3

  6. Microsoft Learn, Welcome to the Open XML SDK for Office. Sur le fait que l’Open XML SDK est une bibliothèque basée sur System.IO.Packaging qui manipule les formats de fichiers Office standardisés en ECMA-376 / ISO/IEC 29500 via des classes fortement typées.  2

  7. GitHub, ClosedXML/ClosedXML. Sur le fait qu’il s’agit d’une bibliothèque sous licence MIT offrant une interface intuitive au-dessus de l’API Open XML, capable de manipuler des fichiers Excel 2007+ (.xlsx, .xlsm) sans Excel installé.  2 3

  8. Microsoft Learn, _Application.AutomationSecurity Property (Microsoft.Office.Interop.Excel). Sur le mode de sécurité des macros utilisé lors de l’ouverture programmatique d’un fichier, sur le fait que le réglage par défaut au démarrage de l’application est msoAutomationSecurityLow (toutes les macros activées), et sur le fait que msoAutomationSecurityForceDisable désactive toutes les macros sans avertissement. 

  9. Microsoft Learn, Considerations for unattended automation of Office in the Microsoft 365 for unattended RPA environment. Sur les problèmes liés à l’UI interactive, à l’identification de l’utilisateur et à l’architecture STA monothread en automatisation sans surveillance, sur le fait que le comportement reste AS IS même sous licence sans surveillance, et sur Microsoft Graph et l’édition directe du format de fichier Open XML recommandés comme alternatives.  2 3 4 5

  10. Microsoft Learn, Open XML SDK for Office design considerations. Sur le fait que l’Open XML SDK ne remplace pas le modèle objet Office, et qu’il ne fournit ni comportements applicatifs tels que le recalcul de formules ou la mise à jour de données, ni conversion vers d’autres formats.  2 3

  11. Microsoft Learn, Native interoperability ABI support. Sur le fait que le support du système d’interopérabilité COM intégré se limite à Windows, et sur le support COM via ComWrappers dans .NET 5+ et la génération de source dans .NET 8+. 

  12. Microsoft Learn, How to access Office interop objects. Sur la simplification de l’interopérabilité Office via les arguments nommés, les arguments optionnels et dynamic, et sur le fait que l’intégration des types d’interopérabilité (Embed Interop Types) est le comportement par défaut à la place d’un PIA.  2

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Pourquoi EXCEL.EXE continue-t-il de tourner alors que j'appelle Quit() ?
Parce que Quit() n'est pas une libération, mais simplement une demande signifiant « vous pouvez vous fermer une fois que toutes les références auront été libérées ». .NET manipule les objets COM via une enveloppe appelée RCW (Runtime Callable Wrapper), qui conserve la référence COM jusqu'à ce qu'elle soit récupérée par le GC (ou explicitement libérée). Tant qu'une référence subsiste, Excel attend consciencieusement. Comme le moment de la libération dépend du GC et reste donc indéterminé, l'absence de reproductibilité observée — « ça disparaît sur la machine de développement mais reste en production » — s'explique elle aussi entièrement par les fluctuations du moment où le GC se déclenche.
Qu'est-ce que la « règle des deux points » dans l'automatisation COM d'Excel ?
C'est une règle empirique qui consiste à ne jamais enchaîner deux points ou plus sur un objet COM, et à capturer systématiquement chaque objet intermédiaire dans une variable. Une expression composée comme book.Worksheets[1].Range["A1"] génère en une seule ligne plusieurs RCW anonymes : Worksheets (la collection), Worksheets[1] (la feuille) et Range["A1"] (la plage). Comme ils ne sont assignés à aucune variable, il n'existe aucun moyen d'appeler Marshal.ReleaseComObject dessus, ce qui en fait la cause principale des fuites de libération. Attention : le même piège se cache dans les boucles foreach, dans les expressions jetables au sein d'une condition, et dans les arguments d'expressions composées, qui génèrent eux aussi des RCW anonymes.
Comment écrire le code pour être sûr que EXCEL.EXE se termine bien ?
La méthode recommandée consiste à isoler complètement le traitement qui manipule Excel dans une seule méthode, puis à exécuter, une fois cette méthode terminée, la combinaison GC.Collect → GC.WaitForPendingFinalizers → GC.Collect. Puisque le RCW libère nativement sa référence COM au moment où il est récupéré par le GC, cette approche permet de récupérer même les RCW anonymes en cas d'infraction à la règle des deux points. Il existe aussi une école qui applique Marshal.ReleaseComObject à tous les objets, mais la documentation officielle le positionne comme un outil à « n'utiliser que si c'est absolument nécessaire » : une mauvaise utilisation d'un RCW déjà libéré peut provoquer une InvalidComObjectException, voire une corruption de mémoire. N'introduisez un wrapper disciplinant l'usage via using que si le contrôle de l'ordre de libération est réellement nécessaire.
Peut-on automatiser Excel depuis un serveur ou un traitement par lots ?
Non. Microsoft indique explicitement ne pas recommander, ni prendre en charge, l'automatisation d'Office depuis des applications ou composants clients sans surveillance et non interactifs (y compris ASP.NET, DCOM et les services NT). Office est conçu en présupposant la présence d'un utilisateur interactif : sur un service ou sous IIS, cela provoque des blocages en attente d'une boîte de dialogue et une accumulation infinie de processus. Pour la génération de rapports sans surveillance, l'alternative recommandée consiste à basculer vers le Open XML SDK ou ClosedXML, qui manipulent directement le fichier sans lancer Excel. Une répartition réaliste consiste à isoler dans un environnement COM interactif uniquement les traitements qui exigent réellement les fonctionnalités de l'application Excel, comme l'exécution de macros ou le recalcul.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog