Windows Server 2022 : pourquoi les exceptions C++ de _beginthread/_beginthreadex n’atteignent plus SetUnhandledExceptionFilter (et comment corriger)

Après une montée de version vers Windows Server 2022, des applications C++ auparavant stables crashent immédiatement lorsque des exceptions non interceptées surgissent dans des fils créés via _beginthread/_beginthreadex. Voici pourquoi cela arrive et comment corriger durablement.

Sommaire

Problème constaté

En passant de Windows Server 2016 à Windows Server 2022, les exceptions C++ non interceptées qui surviennent dans des threads créés par _beginthread ou _beginthreadex n’atteignent plus le gestionnaire global enregistré par SetUnhandledExceptionFilter. Le processus est désormais terminé immédiatement.

Ce changement n’est pas dû à votre compilateur mais à la version du UCRT (Universal C Runtime) livrée avec l’OS. Autrement dit, c’est l’environnement d’exécution qui évolue : l’implémentation récente du CRT appelle std::terminate (puis _invoke_watson), court-circuitant votre filtre global.

Pourquoi ce comportement change-t-il ?

  • Dans les versions modernes du CRT, la fonction « trampoline » interne qui enveloppe la routine passée à _beginthread/_beginthreadex est marquée noexcept. Une exception C++ non capturée ne remonte donc plus jusqu’au filtre du processus : elle déclenche directement std::terminate.
  • Le contrat de SetUnhandledExceptionFilter ne garantit la capture que des exceptions SEH au niveau processus. La propagation des exceptions C++ (gérées par le runtime) dépend du CRT et peut changer selon les versions. Windows Server 2022 reflète précisément ce choix d’implémentation.

Symptômes typiques

SymptômeExplicationImpact
Crash immédiat sans passage par le filtre globalstd::terminate est invoqué dans l’enveloppe du thread CRTPas de journalisation ni nettoyage applicatif, indisponibilité
Dumps WER générés sans logs applicatifsLe handler global n’est pas appeléDiagnostic plus difficile, MTTR allongé
Comportement divergent 2016 vs 2022Différence d’implémentation UCRTRégressions à la migration

Comparatif avant / après

ContexteLancement du threadException non interceptéeComportement observé
Windows Server 2016 (UCRT antérieur)_beginthread/_beginthreadexthrow non capturé dans la routineL’exception peut remonter jusqu’à SetUnhandledExceptionFilter (selon CRT), permettant journalisation personnalisée.
Windows Server 2022 (UCRT récent)_beginthread/_beginthreadex (idem std::thread sur MSVC)throw non capturé dans la routineAppel immédiat à std::terminate → terminaison du processus sans passer par le filtre global.

Reproduire en 30 secondes

Le code ci-dessous reproduit l’écart. Sur 2016 il est possible que votre filtre global voie l’exception ; sur 2022, le processus termine sans l’appeler.


#include <windows.h>
#include <process.h>
#include <exception>
#include <iostream>

LONG WINAPI MyUnhandledFilter(EXCEPTION_POINTERS*) {
std::cerr << "SetUnhandledExceptionFilter called" << std::endl;
return EXCEPTION_EXECUTE_HANDLER;
}

unsigned __stdcall thread_fn(void*) {
// Exception C++ non interceptée
throw std::runtime_error("boom");
return 0;
}

int main() {
SetUnhandledExceptionFilter(MyUnhandledFilter);
uintptr_t h = _beginthreadex(nullptr, 0, &thread_fn, nullptr, 0, nullptr);
WaitForSingleObject((HANDLE)h, INFINITE);
CloseHandle((HANDLE)h);
return 0;
} 

Conséquences en production

  • Indisponibilité accrue : les processus se ferment brutalement, rompant la haute disponibilité si l’orchestration n’est pas résiliente.
  • Perte d’observabilité : vos traces, métriques et artefacts (fichiers temporaires, transactions) ne sont pas flushés.
  • Blocage de migration : tant que la capture globale n’est pas remplacée par une gestion explicite des exceptions côté thread, le passage à 2022 reste risqué.

Raisons techniques détaillées

Le runtime C de Microsoft enveloppe la fonction de départ passée à _beginthread/_beginthreadex pour initialiser/terminer le CRT. Dans les UCRT récents, cette enveloppe est déclarée noexcept. En C++, une exception non interceptée qui traverse un cadre noexcept déclenche std::terminate, puis la voie d’erreur « Watson » qui peut produire un minidump système, sans passer par le filtre global du processus.

À l’inverse, le mécanisme SetUnhandledExceptionFilter cible le monde SEH (Structured Exception Handling) à l’échelle du processus. La remontée d’exceptions C++ jusque-là était un effet d’implémentation du CRT et non un contrat Windows. D’où l’écart entre 2016 et 2022.

Solutions et contournements

NiveauMesureAvantagesLimites
Code applicatifEnvelopper la routine passée à _beginthread/_beginthreadex dans try { ... } catch (...) { /* log; cleanup */ _exit(EXIT_FAILURE); }Empêche le crash « silencieux », choix précis du traitement d’erreurDemande une mise à jour de chaque point d’entrée de thread
Gestion globaleInstaller std::set_terminate() (ou _set_terminate) pour journaliser, produire un dump, puis sortir proprementUn seul point de centralisation pour tout le processusN’empêche pas la terminaison ; « dernier mot » seulement
Vectored Exception HandlingPlacer AddVectoredExceptionHandler en première position (ordre 0)Reçoit SEH/C++ très tôt, utile pour enrichir les diagnosticsObservabilité, pas de capacité d’empêcher la terminaison
Retour arrière UCRTRedistribuer localement une UCRT antérieure (app‑local redist)Restaure le comportement 2016 dans certains casNon supporté, dette technique et risques d’incompatibilités
ArchitectureRemplacer _beginthread[_ex] par CreateThread + initialisation CRT manuelle ou wrapper non noexceptRend possible à nouveau la remontée jusqu’au filtreComplexité et risques de fuite/désinitialisation CRT
Design robusteCapturer explicitement toutes les exceptions aux « points limites » (threads, tâches, callbacks) et normaliser vers un bus d’événementsRésilience moderne : fail‑fast local, stabilité globaleRefactorisation parfois significative

Patron recommandé : wrapper de routine de thread

Le correctif le plus sûr est d’attraper explicitement toute exception à la frontière du thread. Un wrapper générique évite de modifier chaque appelant.


// Wrapper générique pour exécuter une routine de thread avec capture
template <class F>
unsigned __stdcall thread_entry(void* p) noexcept {
    std::unique_ptr<F> fn(static_cast<F*>(p));
    try {
        (*fn)();
        return 0;
    } catch (const std::exception& e) {
        // TODO: log structuré + métriques
        // flush/sync (attention aux appels non async-signal-safe)
        _exit(EXIT_FAILURE); // sortie contrôlée du process si politique fail-fast
    } catch (...) {
        // TODO: log "unknown"
        _exit(EXIT_FAILURE);
    }
}

template 
HANDLE start_thread(F&& f) {
auto pf = new F(std::forward(f));
unsigned tid = 0;
HANDLE h = (HANDLE)_beginthreadex(nullptr, 0,
&thread_entry, pf, 0, &tid);
if (!h) { delete pf; throw std::runtime_error("thread creation failed"); }
return h;
}

// Usage
// HANDLE h = start_thread([]{ /* votre logique */ throw std::runtime_error("boom"); }); 

Installer un terminate handler global

Comme la terminaison est inévitable lorsqu’une exception traverse un cadre noexcept, installez un std::set_terminate pour capturer un maximum d’informations.


#include <exception>
#include <windows.h>
#include <DbgHelp.h>
#include <cstdio>

void write_minidump(EXCEPTION_POINTERS* ep);

void my_terminate() {
// Essayez d'obtenir le contexte si possible (optionnel)
// Générez un dump local via DbgHelp (MiniDumpWriteDump)
write_minidump(nullptr);
// Journalisez l'ultime message
std::fputs("std::terminate invoked; shutting down...\n", stderr);
// Sortie contrôlée
::TerminateProcess(::GetCurrentProcess(), static_cast(-1));
}

int main() {
std::set_terminate(&my_terminate);
// ...
} 

Conseil : évitez de faire trop de travail dans terminate (pas de JSON complexe, pas de locks lourds). Visez « capturer puis sortir ».

Activer un vectored exception handler (VEH) pour le diagnostic


PVOID gVehHandle = nullptr;

LONG CALLBACK VehHandler(EXCEPTION_POINTERS* ep) {
// Journaliser code, adresse, thread id
// Retournez EXCEPTION_CONTINUE_SEARCH pour ne pas interférer
return EXCEPTION_CONTINUE_SEARCH;
}

void installVeh() {
gVehHandle = AddVectoredExceptionHandler(0, &VehHandler); // ordre 0 = en premier
} 

Le VEH ne doit pas tenter d’« absorber » l’exception ici ; employez-le pour enrichir vos traces avant la fin du processus.

À propos d’std::thread

Sur MSVC, std::thread est implémenté au-dessus de _beginthreadex. Vous retrouverez donc le même changement de comportement en 2022 si une exception s’échappe de la fonction passée à std::thread. Appliquez le même patron de capture, ou convertissez vos routines en retournant des objets expected/result plutôt que d’employer des exceptions trans‑thread.

Configuration des mini-dumps système (WER)

Activez des dumps locaux pour accélérer le diagnostic quand std::terminate est appelé avant votre journalisation.


Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps]
"DumpFolder"=hex(2):25,00,50,00,52,00,4f,00,47,00,52,00,41,00,4d,00,44,00,41,00,54,00,41,00,25,00,5c,00,44,00,75,00,6d,00,70,00,73,00,00,00
"DumpType"=dword:00000002
"DumpCount"=dword:00000010 
  • DumpType=2 : minidump avec mémoire.
  • DumpFolder : emplacement d’écriture (ci-dessus, %PROGRAMDATA%\Dumps).
  • Nettoyez régulièrement pour éviter le remplissage de disque.

Bonnes pratiques complémentaires

  1. Ne comptez pas sur SetUnhandledExceptionFilter pour des erreurs de logique : c’est un filet de sûreté pour le post‑mortem, pas un mécanisme de contrôle de flux.
  2. Centralisez la journalisation dans std::set_terminate (et dans vos wrappers de thread) afin d’obtenir un signal diagnostique fiable.
  3. Activez les symboles et générez des mini‑dumps (via WER ou DbgHelp) pour réduire le temps moyen d’investigation.
  4. Options de compilation : assurez-vous que les exceptions C++ sont activées (/EHsc) et que la STL n’est pas compilée en mode « no‑exceptions » (_HAS_EXCEPTIONS=1 si vous utilisez ce paramètre).
  5. Documentez la différence de comportement OS/CRT dans vos guides d’exploitation pour éviter des régressions lors des futures montées de version.

Plan de migration concret

  1. Cartographier tous les points de création de threads : _beginthread, _beginthreadex, std::thread, bibliothèques tierces.
  2. Identifier les routines sans try/catch à la frontière du thread.
  3. Implémenter un wrapper générique (modèle ci‑dessus) et l’appliquer partout.
  4. Installer un std::set_terminate minimaliste et un VectoredExceptionHandler pour les diagnostics.
  5. Activer WER LocalDumps en préproduction.
  6. Écrire des tests de chaos ciblés : forcer une exception dans un thread et vérifier les artefacts (logs, dump, métriques).
  7. Automatiser l’analyse : pipeline qui chiffre la fréquence, le taux de crash, la latence de redémarrage.
  8. Documenter la conduite à tenir en cas de terminaison : redémarrage supervisé, backoff, drains réseau.
  9. Déployer par paliers (canary) sur 2022, surveiller et ajuster.
  10. Verrouiller la règle d’ingénierie : « toute frontière de thread attrape explicitement. »

Décision rapide : arbre de choix

ContrainteChoixRaison
Vous pouvez modifier le codeWrapper de thread + set_terminateSolution stable, prévisible, maîtrisée
Vous ne pouvez pas modifier le code mais pouvez packagerApp‑local UCRT (en dernier recours)Restaure l’ancien comportement, mais fragile et non supporté
Investigation prioritaireVEH + WER LocalDumpsMaximise la collecte d’informations sans changer la logique
Nécessité de propagation jusqu’au filtre globalCreateThread + gestion CRT manuellePermet (avec prudence) d’éviter la couche noexcept du CRT

FAQ d’ingénierie

Pourquoi mon filtre global fonctionne encore sur une machine de développement ?

Probablement parce que l’environnement (version du UCRT) est différent de l’environnement de production. Les binaires identiques n’ont pas forcément le même comportement si le CRT diffère.

Est-ce que __try/__except peut m’aider ?

Le SEH gère des exceptions de type système (accès mémoire, div/0, etc.). Les exceptions C++ lancées avec throw ne suivent pas le même chemin contractuel. Entourez plutôt votre logique applicative avec try/catch.

Et si je transforme toutes mes exceptions en codes de retour ?

C’est une approche valable pour les frontières entre threads : renvoyez un objet « résultat » (expected) et publiez l’erreur dans un bus de supervision. L’important est d’empêcher l’évasion d’exceptions au‑delà du cadre de la routine de thread.

Pourquoi pas juste « catch(…){} » et continuer ?

Évitez d’ignorer silencieusement. Journalisez, nettoyez, faites remonter un signal (métriques/alertes), puis choisissez : arrêter le thread, redémarrer un service, ou _exit si la corruption potentielle rend la suite dangereuse.

Exemples prêts à l’emploi

Wrapper léger pour std::thread


template &lt;class F, class... Args&gt;
std::thread safe_thread(F&amp;&amp; f, Args&amp;&amp;... args) {
    return std::thread([fn = std::bind(std::forward&lt;F&gt;(f), std::forward&lt;Args&gt;(args)...)]() {
        try {
            fn();
        } catch (const std::exception&amp; e) {
            // log(e.what());
            _exit(EXIT_FAILURE);
        } catch (...) {
            // log("unknown");
            _exit(EXIT_FAILURE);
        }
    });
}

Signaler proprement l’erreur au superviseur


struct ThreadResult {
    bool ok;
    std::string message;
};

ThreadResult do_work();

unsigned __stdcall thread_fn(void* p) {
try {
auto r = do_work();
if (!r.ok) {
// Publier sur un canal de supervision
// metrics::counter("thread_error").inc();
// logs...
return 1;
}
return 0;
} catch (const std::exception& e) {
// publish + exit
_exit(EXIT_FAILURE);
}
} 

Pièges et idées reçues

  • « Le compilateur est en cause » : non, c’est la bibliothèque d’exécution (UCRT) de l’OS.
  • « Mon filtre global doit tout voir » : il n’a jamais été garanti pour les exceptions C++.
  • « Je peux forcer la continuité après un std::terminate » : non. Le modèle recommandé est de capturer avant, diagnostiquer, puis s’arrêter proprement.

Checklist de livraison

  • Chaque routine de thread est enveloppée d’un try/catch(...).
  • std::set_terminate est installé et minimal.
  • VEH déployé en préprod (ordre 0) pour enrichir les traces.
  • WER LocalDumps activé / dossier surveillé.
  • Tests de chaos « exception dans un thread » passés au vert.
  • Runbook d’exploitation mis à jour (différences 2016/2022).
  • Monitoring : alertes sur crash, corrélation avec les logs d’avant-terminaison.

Annexe : migration CreateThread avec prudence

Si vous choisissez CreateThread, vous quittez la zone de confort du CRT. Pour limiter les dégâts, isolez ce choix derrière un wrapper et documentez les invariants.


struct ThreadContext {
    void (*fn)(void*);
    void* arg;
};

DWORD WINAPI raw_thread_fn(LPVOID p) {
ThreadContext* ctx = static_cast(p);
try {
ctx->fn(ctx->arg);
delete ctx;
return 0;
} catch (...) {
// log + exit
_exit(EXIT_FAILURE);
}
}

HANDLE start_raw(void (*fn)(void*), void* arg) {
auto ctx = new ThreadContext{fn, arg};
DWORD tid = 0;
HANDLE h = ::CreateThread(nullptr, 0, &raw_thread_fn, ctx, 0, &tid);
if (!h) { delete ctx; throw std::runtime_error("CreateThread failed"); }
return h;
} 

Attention : la CRT n’étant pas initialisée par CreateThread, certaines API du runtime C peuvent se comporter différemment (TLS, locale, etc.). Testez soigneusement.

Annexe : stratégies d’observabilité

  • Métriques : compteur d’« exceptions interceptées aux frontières de thread », distribution par cause, corrélations.
  • Traces : enrichissez avec l’ID de thread, l’adresse de l’exception (si SEH), la stack (si accessible).
  • Dumps : minidumps process et, si possible, dumps ciblés sur crash critiques.

En résumé

Le passage à Windows Server 2022 modifie la manière dont les exceptions C++ non interceptées se comportent lorsqu’elles surviennent dans des threads CRT (_beginthread/_beginthreadex). Sous l’effet d’un UCRT moderne, ces exceptions ne rejoignent plus SetUnhandledExceptionFilter et entraînent une terminaison immédiate via std::terminate. La bonne stratégie consiste à gérer explicitement les exceptions à la frontière de chaque thread, à centraliser la télémétrie dans un terminate handler, et à outiller les diagnostics (VEH, WER). Les contournements comme l’app‑local UCRT doivent rester exceptionnels. En appliquant ces principes, vous retrouvez une résilience prédictible et vous déverrouillez la migration vers Windows Server 2022 sans surprises.

Sommaire