- safe_c.h est un fichier d’en-tête personnalisé de 600 lignes qui ajoute au langage C des fonctionnalités de sûreté et de confort issues de C++ et de Rust, utilisé pour implémenter un grep sûr pour les threads (
cgrep) sans fuite mémoire
- Grâce à RAII, aux smart pointers et à l’attribut de nettoyage automatique (
cleanup), la gestion des ressources est automatisée sans appels manuels à free()
- Avec des vecteurs, des vues, un type
Result et des macros de contrat, il permet de gérer en toute sûreté les dépassements de tampon, le traitement des erreurs et la validation des préconditions
- Des mécanismes comme le déverrouillage automatique des mutex, des macros de création de threads et l’optimisation de prédiction de branchement garantissent la sûreté sans sacrifier la concurrence ni les performances
- Au final, il démontre qu’il est possible d’écrire du code C sans fuites ni segfaults avec des performances identiques (niveau
-O2)
Aperçu de safe_c.h
safe_c.h est un fichier d’en-tête qui transpose dans du code C des fonctionnalités de C++ et de Rust
- Il fournit le même comportement de RAII (nettoyage automatique) même sur des compilateurs qui ne prennent pas en charge l’attribut C23
[[cleanup]] (GCC 11, Clang 18, etc.)
- La macro
CLEANUP(func) libère automatiquement les ressources à la sortie de la fonction
- Les macros
LIKELY() et UNLIKELY() optimisent la prédiction de branchement sur le hot path
Gestion mémoire : UniquePtr et SharedPtr
UniquePtr est un smart pointer à propriétaire unique qui appelle automatiquement free() à la fin du scope
- Avec la macro
AUTO_UNIQUE_PTR(), la mémoire est libérée automatiquement même en cas d’erreur ou de retour anticipé
SharedPtr est une structure à comptage automatique de références qui détruit la ressource quand la dernière référence est libérée
shared_ptr_init() et shared_ptr_copy() gèrent automatiquement l’incrémentation et la décrémentation des références
- Il est utilisé pour gérer des structures partagées en toute sûreté entre threads
Prévention des buffer overflows : Vector et View
- La macro
DEFINE_VECTOR_TYPE() permet de créer des vecteurs extensibles et sûrs vis-à-vis du type
- La réallocation, la gestion de capacité et le nettoyage (
cleanup) sont pris en charge automatiquement
- Avec
AUTO_TYPED_VECTOR(), la mémoire est libérée automatiquement à la fin du scope
StringView et Span sont des structures de référence non propriétaires pour manipuler des tranches de chaînes ou de tableaux sans malloc séparé
DEFINE_SPAN_TYPE() permet de définir un Span par type
- Les vérifications de bornes garantissent un accès sûr aux tableaux
Gestion des erreurs : type Result et RAII
- La structure
Result est un type de retour distinguant succès et échec, similaire à Result<T, E> de Rust
DEFINE_RESULT_TYPE() génère des structures de résultat adaptées à chaque type
RESULT_IS_OK() et RESULT_UNWRAP_ERROR() permettent une gestion explicite des erreurs
- Combiné à l’attribut
CLEANUP, il automatise la libération des ressources à la sortie de la fonction
- La macro
AUTO_MEMORY() nettoie automatiquement la mémoire allouée par malloc
Contrats et chaînes sûres
- Les macros
requires() / ensures() permettent d’exprimer les préconditions et postconditions des fonctions
- En cas d’échec, un message d’erreur clair est affiché
safe_strcpy() est une fonction de copie avec vérification de la taille du tampon, pour éviter les overflows
- En cas d’échec, elle renvoie
false pour une gestion sûre de l’erreur
Concurrence : déverrouillage automatique et macros de thread
- Une fonction de déverrouillage automatique de mutex basée sur
CLEANUP aide à prévenir les deadlocks
pthread_mutex_unlock() est appelée automatiquement à la sortie du scope
- Les macros
SPAWN_THREAD() et JOIN_THREAD() simplifient la création et la jonction des threads
- Elles sont utilisées pour implémenter le pool de threads de traitement de fichiers de
cgrep
Optimisation des performances
- Les macros
LIKELY() / UNLIKELY() fournissent une prédiction de branchement sur le hot path
- Un effet d’optimisation de niveau PGO peut ainsi être obtenu même sur des builds en
-O2
- L’ajout de fonctionnalités de sûreté n’entraîne pas de baisse de performances
Conclusion
cgrep, construit avec safe_c.h, représente 2 300 lignes de code C et élimine plus de 50 appels manuels à free()
- Il implémente un code C sûr, sans fuite mémoire ni segfault, tout en conservant le même assembleur et la même vitesse d’exécution
- C’est un exemple de combinaison entre la simplicité et la liberté du C et une sûreté moderne
- L’auteur prévoit d’expliquer dans un prochain article pourquoi
cgrep est plus de deux fois plus rapide que ripgrep et consomme 20 fois moins de mémoire
safe_c.h est présenté comme adapté aux nouveaux projets, tout en signalant qu’une approche fondée sur les macros peut rendre le débogage plus difficile
- Sa justesse et sa sûreté ont été vérifiées avec divers analyseurs statiques (
GCC analyzer, ASAN, UBSAN, Clang-tidy, etc.)
1 commentaires
Commentaires sur Hacker News
Cet article montre le problème de coût qui apparaît lorsqu’on implémente des abstractions sûres (safe abstractions) en C
L’implémentation des pointeurs partagés utilise un mutex POSIX, ce qui (1) n’est pas indépendant de la plateforme et (2) impose le surcoût du mutex même en mono‑thread
Autrement dit, ce n’est pas une « zero-cost abstraction »
Le
shared_ptrde C++ a le même problème, mais Rust le résout en distinguant deux types,RcetArcshared_ptrde C++ n’utilise pas de mutex mais des opérations atomiquesC’est similaire à
Arcen Rust, et l’implémentation du billet est simplement inefficaceCela dit, C++ n’a pas de type équivalent à
Rc, donc il y a toujours un coût lorsqu’on veut un simple pointeur à comptage de référencesshared_ptrn’est pas thread-safeÀ l’exécution, il recherche les symboles pthread pour choisir un chemin atomique ou non atomique
Je pense qu’il vaudrait mieux utiliser les atomiques en permanence
Le cross-platform est surtout un « nice to have » dans la plupart des cas
Le surcoût des mutex est agaçant, mais il reste acceptable sur des CPU modernes
Je sais que Rust est excellent, mais l’écosystème C est tellement vaste qu’il est difficile de le remplacer complètement
Dans ce cas, je vois mal quel avantage apporte le mutex
Il existe un projet appelé FUGC, un ramasse-miettes créé par Fil (aka pizlonator), pour rendre C memory-safe
Il peut être appliqué à du code existant avec très peu de modifications et transforme C/C++ en langages memory-safe
Voir le post HN associé et le site officiel
J’ai l’impression que cet article présente un peu mal le cœur du sujet de la memory safety
La libération automatique des variables locales ou les vérifications de bornes ne suffisent pas
Le vrai problème, c’est la gestion de la durée de vie mémoire de l’ensemble du programme
Par exemple, quand on retourne un
UniquePtrou qu’on copie unSharedPtr, n’oublie-t-on pas le comptage de références ? Qui gère la durée de vie des éléments d’une intrusive list ?Au fond, cette approche ne me semble pas très différente de l’ancien motif
#define xfree(p)UniquePtrest possible, puisqu’on peut retourner une structure par valeurEn revanche, la copie de
SharedPtrn’incrémente pas automatiquement le comptage de références#define xfree(p)est mauvaisOn dit que C23 a introduit l’attribut [[cleanup]], mais en réalité c’est une extension GCC et il faut l’écrire
[[gnu::cleanup()]]Voir cet exemple de code
Il y avait une blague du genre : « C++ : regardez comme les autres langages peinent à imiter ne serait-ce qu’une partie de mes pouvoirs »
Je me demande pourquoi imiter C++ avec des macros, mais c’est quand même une tentative intéressante
Mais au final, en voyant qu’on en arrive à imiter jusqu’à des fonctionnalités de C++17, je me demande s’il ne vaudrait pas mieux simplement utiliser C++
C reste relativement facile à manipuler, mais C++ est tellement complexe qu’il est difficile de l’aborder sans frontend
En passant à C++, tout se complique avec la chaîne de build, le name mangling, la dépendance à libstdc++, etc.
À l’inverse, si on utilise C++ dans un style C, on n’a pas ce type de contrainte
Ce n’est pas compatible avec la gestion d’exceptions basée sur
setjmp/longjmpEn revanche, on peut l’intégrer avec une paire de macros cleanup inspirée de
pthread_cleanup_pushde POSIXOn implémente des routines de nettoyage basées sur la pile avec
cleanup_push(fn, type, ptr, init)etcleanup_pop(ptr)Cette approche a l’avantage de détecter les erreurs d’équilibrage à la compilation
Il ne faut pas le confondre avec le vrai
safec.hde safeclibVoir les en-têtes de safeclib
C’est considéré comme un échec de conception à cause du global constraint handler, et la plupart des toolchains ne le prennent pas en charge
Voir ce document associé
Si on utilise le langage Nim, on obtient toutes les fonctionnalités fournies par
safe_c.hNim compile vers C et offre à la fois sûreté et performances
Il fournit nativement diverses fonctionnalités, comme le comptage de références automatique basé sur ARC,
defer,Option[T], le bounds-checking,likely/unlikely, etc.Voir le site officiel, l’introduction à ARC, les view types, la documentation d’Option, le template likely
Si cette approche vise la portabilité, il est plus prudent en pratique d’en rester à C99
Le compilateur C de MSVC est exigeant, mais il est quasiment indispensable pour le cross-platform
J’ai moi aussi créé un en-tête similaire, mais je n’y ai pas inclus d’utilitaires de cleanup à cause des problèmes de portabilité
Si le code C compile aussi en C++, cela fonctionne bien
Un gestionnaire de paquets est fourni en plus
L’article mentionne plusieurs fois cgrep, mais il n’y a aucun lien vers son code
Il existe de nombreux projets du même nom sur GitHub, mais la plupart sont écrits dans d’autres langages
cgrepil s’agit, et j’aimerais bien l’essayer moi-même