Historique de la question. Avant C++20, appliquer des opérations atomiques à des objets non atomiques existants nécessitait des solutions compliquées, car std::atomic exige que les objets soient construits comme atomiques dès le départ. Les programmeurs ont souvent tenté des opérations dangereuses de reinterpret_cast pour traiter des objets ordinaires comme atomiques, violant les règles de strict aliasing et invoquant un comportement indéfini en raison de discordances de durée de vie des objets. L'introduction de std::atomic_ref dans C++20 a comblé cette lacune en fournissant une vue non possèdeuse qui confère temporairement des sémantiques atomiques aux objets existants sans modifier leur type de stockage ou leur durée de vie.
Le problème. std::atomic impose des exigences spécifiques de représentation — telles que des drapeaux à bits sans verrou ou des mutex internes — qui changent généralement la taille ou l'alignement de l'objet par rapport au type sous-jacent T. Par conséquent, un objet de type int n'est pas compatible pour la mise en page avec std::atomic<int>, rendant impossible le punning de pointeur. De plus, std::atomic_ref exige que l'objet référencé satisfasse des contraintes d'alignement strictes ; spécifiquement, l'adresse de l'objet doit être alignée au moins sur alignof(std::atomic_ref<T>), ce qui pour de nombreuses plateformes est égal à alignof(T) mais peut être plus grand pour des instructions atomiques spécifiques au matériel. Violer cette condition d'alignement entraîne un comportement indéfini, se manifestant potentiellement par des lectures déchirées ou des exceptions matérielles sur des architectures strictes comme ARM.
La solution. std::atomic_ref agit comme un wrapper léger maintenant un pointeur vers l'objet cible, appliquant des intrinsics du compilateur ou des instructions matérielles pour faire respecter l'atomicité sans supposer que le stockage soit une instance de std::atomic. Il respecte la durée de vie de l'objet existant tout en fournissant les mêmes garanties de commande mémoire que std::atomic pour la durée de chaque opération. Pour l'utiliser en toute sécurité, les développeurs doivent s'assurer que l'objet est correctement aligné, généralement par le biais de spécificateurs alignas ou en vérifiant que std::atomic_ref<T>::required_alignment est satisfait, permettant ainsi un accès concurrent sans verrou aux structures de données héritées ou aux mises en page compatibles avec C.
#include <atomic> #include <cstdint> #include <iostream> struct alignas(alignof(std::atomic_ref<std::uint64_t>)) Data { std::uint64_t value; }; int main() { Data d{42}; std::atomic_ref<std::uint64_t> ref(d.value); ref.fetch_add(8, std::memory_order_relaxed); std::cout << d.value << " "; // Sortie : 50 }
Description du problème. Dans une application de trading haute fréquence, une structure héritée définissait la mise en page du paquet de flux de marché, contenant un champ de prix double qui devait être mis à jour de manière atomique par le fil du réseau tandis que le fil de stratégie le lisait. L'échange imposait une compatibilité binaire exacte, empêchant la modification de la structure pour utiliser std::atomic<double>, et les exigences de latence interdisaient les verrous de mutex ou les copies en mémoire. Nous étions confrontés à une course de données où des écritures partielles sur le double (non atomique sur x86-64 sans alignement adéquat) provoquaient la lecture de valeurs corrompues par le fil de stratégie lors de pics de forte volatilité.
Différentes solutions envisagées. La première approche impliquait un double buffering avec des drapeaux std::atomic<bool>, maintenant deux copies de la structure et inversant atomiquement un pointeur. Bien que sans verrou, cela doublait la consommation mémoire et introduisait un rebond de ligne de cache entre les nœuds NUMA, dégradant les performances d'environ 15 % dans les micro-benchmarks. La deuxième approche considérait std::memcpy dans une variable locale std::atomic<double>, mais cela violait les contraintes de temps réel en raison de la copie supplémentaire et subissait toujours des lectures déchirées si le memcpy se produisait en cours de mise à jour. La troisième solution utilisait std::atomic_ref pour référencer directement le champ de prix au sein de la structure C, exploitant les instructions matérielles CAS (Compare-And-Swap) sans modifier la mise en page de la structure.
Quelle solution a été choisie et pourquoi. Nous avons choisi std::atomic_ref car il offrait une véritable abstraction à coût zero : l'assemblage généré sur x86-64 était identique aux instructions lock cmpxchg écrites à la main, sans allocations ou redirections supplémentaires. Contrairement à l'approche de double buffering, cela maintenait la résidence dans une seule ligne de cache pour les données chaudes, préservant la localité de cache L1 critique pour une latence au niveau des microsecondes. De plus, cela respectait les contraintes ABI de la bibliothèque C externe tout en éliminant les courses de données grâce à l'atomicité imposée par le matériel.
Le résultat. Après mise en œuvre, le système a atteint des mises à jour cohérentes sans verrou avec une latence sub-microseconde, éliminant les anomalies de valeurs fantômes vérifiées par des exécutions de ThreadSanitizer. La vérification de l'alignement (alignas) garantissait la portabilité vers des serveurs ARM64 sans modifications de code, et le débit s'est amélioré de 12 % par rapport à la référence de double buffering en raison de la pression de cache réduite.
Pourquoi le fait de caster un pointeur non atomique vers std::atomic<T> invoque un comportement indéfini lorsque std::atomic_ref est sûr ?*
Caster par reinterpret_cast crée un pointeur vers un objet de type std::atomic<T>, mais le stockage contient en réalité un objet de type T. Cela viole les règles de strict aliasing du modèle d'objet C++ et les exigences de durée de vie, car std::atomic<T> peut avoir une taille, un alignement ou un état interne (comme un spinlock) différent de T. std::atomic_ref est conçu comme un type de référence distinct qui se réfère explicitement à un objet de type T et applique des opérations atomiques à celui-ci via des intrinsics spécifiques à l'implémentation, sans prétendre que le stockage est un type différent, préservant ainsi la durée de vie et la mise en page de l'objet d'origine.
Est-ce que std::atomic_ref se synchronise avec la construction de l'objet qu'il référence ?
Non. std::atomic_ref fournit l'atomicité uniquement pour les opérations effectuées à travers lui, mais il n'établit pas de relations de fait avant avec le constructeur de l'objet référencé. Si le fil A construit un objet et que le fil B crée immédiatement un std::atomic_ref vers celui-ci, le fil B pourrait voir de la mémoire non initialisée à moins que le fil A n'ait effectué une opération de libération (par exemple, stockage dans un std::atomic<bool>) et que le fil B ait effectué une opération d'acquisition avant d'accéder au atomic_ref. Le atomic_ref lui-même suppose que l'objet est déjà vivant et accessible, mais des écritures non atomiques concurrentes durant la construction demeurent des courses de données sans synchronisation externe.
Peut-on utiliser std::atomic_ref avec des objets const, et quelles sont les limitations ?
Oui, std::atomic_ref<const T> est valide et permet des opérations de lecture atomiques (comme load) sur des objets déclarés const, à condition que l'objet n'ait pas été initialement déclaré comme const d'une manière qui permette les optimisations du compilateur pour mettre en cache les valeurs dans les registres. Cependant, vous ne pouvez pas construire un std::atomic_ref<T> (non-const) à partir d'un const T&, car cela violerait la correction de const. De plus, même avec atomic_ref<const T>, l'objet sous-jacent ne doit pas se trouver en mémoire en lecture seule (par exemple, section .rodata), car les instructions atomiques matérielles nécessitent des lignes de cache modifiables même pour des opérations de lecture sur la plupart des architectures.