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.
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éenoexcept
. Une exception C++ non capturée ne remonte donc plus jusqu’au filtre du processus : elle déclenche directementstd::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ôme | Explication | Impact |
---|---|---|
Crash immédiat sans passage par le filtre global | std::terminate est invoqué dans l’enveloppe du thread CRT | Pas de journalisation ni nettoyage applicatif, indisponibilité |
Dumps WER générés sans logs applicatifs | Le handler global n’est pas appelé | Diagnostic plus difficile, MTTR allongé |
Comportement divergent 2016 vs 2022 | Différence d’implémentation UCRT | Régressions à la migration |
Comparatif avant / après
Contexte | Lancement du thread | Exception non interceptée | Comportement observé |
---|---|---|---|
Windows Server 2016 (UCRT antérieur) | _beginthread/_beginthreadex | throw non capturé dans la routine | L’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 routine | Appel 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
Niveau | Mesure | Avantages | Limites |
---|---|---|---|
Code applicatif | Envelopper 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’erreur | Demande une mise à jour de chaque point d’entrée de thread |
Gestion globale | Installer std::set_terminate() (ou _set_terminate ) pour journaliser, produire un dump, puis sortir proprement | Un seul point de centralisation pour tout le processus | N’empêche pas la terminaison ; « dernier mot » seulement |
Vectored Exception Handling | Placer AddVectoredExceptionHandler en première position (ordre 0) | Reçoit SEH/C++ très tôt, utile pour enrichir les diagnostics | Observabilité, pas de capacité d’empêcher la terminaison |
Retour arrière UCRT | Redistribuer localement une UCRT antérieure (app‑local redist) | Restaure le comportement 2016 dans certains cas | Non supporté, dette technique et risques d’incompatibilités |
Architecture | Remplacer _beginthread[_ex] par CreateThread + initialisation CRT manuelle ou wrapper non noexcept | Rend possible à nouveau la remontée jusqu’au filtre | Complexité et risques de fuite/désinitialisation CRT |
Design robuste | Capturer explicitement toutes les exceptions aux « points limites » (threads, tâches, callbacks) et normaliser vers un bus d’événements | Résilience moderne : fail‑fast local, stabilité globale | Refactorisation 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
- 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. - Centralisez la journalisation dans
std::set_terminate
(et dans vos wrappers de thread) afin d’obtenir un signal diagnostique fiable. - Activez les symboles et générez des mini‑dumps (via WER ou DbgHelp) pour réduire le temps moyen d’investigation.
- 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). - 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
- Cartographier tous les points de création de threads :
_beginthread
,_beginthreadex
,std::thread
, bibliothèques tierces. - Identifier les routines sans
try/catch
à la frontière du thread. - Implémenter un wrapper générique (modèle ci‑dessus) et l’appliquer partout.
- Installer un
std::set_terminate
minimaliste et unVectoredExceptionHandler
pour les diagnostics. - Activer WER LocalDumps en préproduction.
- Écrire des tests de chaos ciblés : forcer une exception dans un thread et vérifier les artefacts (logs, dump, métriques).
- Automatiser l’analyse : pipeline qui chiffre la fréquence, le taux de crash, la latence de redémarrage.
- Documenter la conduite à tenir en cas de terminaison : redémarrage supervisé, backoff, drains réseau.
- Déployer par paliers (canary) sur 2022, surveiller et ajuster.
- Verrouiller la règle d’ingénierie : « toute frontière de thread attrape explicitement. »
Décision rapide : arbre de choix
Contrainte | Choix | Raison |
---|---|---|
Vous pouvez modifier le code | Wrapper de thread + set_terminate | Solution stable, prévisible, maîtrisée |
Vous ne pouvez pas modifier le code mais pouvez packager | App‑local UCRT (en dernier recours) | Restaure l’ancien comportement, mais fragile et non supporté |
Investigation prioritaire | VEH + WER LocalDumps | Maximise la collecte d’informations sans changer la logique |
Nécessité de propagation jusqu’au filtre global | CreateThread + gestion CRT manuelle | Permet (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 <class F, class... Args>
std::thread safe_thread(F&& f, Args&&... args) {
return std::thread([fn = std::bind(std::forward<F>(f), std::forward<Args>(args)...)]() {
try {
fn();
} catch (const std::exception& 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.