Clause
← Toute la documentation

Traduit de l'original anglais · 1e3a39c8-dirty · 2026-10-26 · Lire en anglais

Contrat du tas de processus

Les tas de processus suivent la conception classique d'ERTS : un bloc de tas contigu par processus plus des fragments de débordement, des cellules autodescriptives, de grands binaries dans des tampons partagés hors du tas, et un ramasse-miettes par copie qui ne s'exécute qu'aux points sûrs. Cette note en est le contrat ; runtime.md résume l'API hôte.

Disposition des mots

Un terme est un mot de la cible (32 ou 64 bits) ; les encodages sont décrits dans abi.md. Chaque zone du tas est une suite d'objets qu'un parcoureur analyse à partir de son premier mot :

Aucune cellule n'a besoin d'un alignement plus strict qu'un mot. memory/heap_walk analyse une zone cellule par cellule et ProcessHeap::verify vérifie un tas entier. Les cellules ne contiennent que des mots et des octets, à l'exception du std::shared_ptr du binary hors tas (ci-dessous), si bien qu'une cellule se déplace en copiant ses mots.

TypeMots après l'en-têteMots tracés
cons (sans en-tête)2 mots au totaltête, queue
tuplen emplacements d'élémentstous
map2n emplacements : les clés dans l'ordre exact des termes, chacune suivie de sa valeur ; au plus 32 entréestous
tree_mapnombre d'entrées, mot d'édition, map_node racine : 3 mots (arbres de maps)la racine
map_nodeentrées en dessous, mot d'édition, puis n clés et n valeurs (une feuille) ou n enfants (une branche) ; jamais un termeclés et valeurs ou enfants
native_recordadresse du RecordDefinition du runtime, puis n valeurs de champs dans l'ordre de définitionvaleurs
fun_closureadresse du FunDefinition du runtime, puis n valeurs capturées (funs)valeurs
bignummot de signe, puis les limbes de la magnitude, les moins significatifs d'abordaucun
floating8 octets : 1 mot (64 bits) ou 2 mots (32 bits)aucun
reference8 octets : le numéro de référence (pids et références)aucun
heap_binarylongueur en bits, puis les octets de données arrondis aux mots (au plus 64 octets)aucun
refc_binarydécalage en bits, longueur en bits, premier octet du tampon (lu par le code généré), fin et limite d'ajout, std::shared_ptr (2 mots), lien hors tas : 8 motsaucun
sub_binarydécalage en bits dans l'original, longueur en bits, le heap_binary ou refc_binary original (jamais un sous-binary) : 3 motsl'original
fillern mots inutilisésaucun

Le compte d'une map est en mots (entrées = compte / 2). Les pids et les ports sont des immédiats, admis d'après les numéros émis par le runtime.

Binaries hors tas

Un binary de plus de 64 octets est un tampon immuable qui flotte en dehors de tout tas de processus, partagé par comptage de références (ProcBin et Binary de BEAM).

Réutilisation des tampons

Un tampon libéré dont la taille est une puissance de deux d'au moins 256 octets (les capacités des binaries extensibles) reste attaché au thread qui a abandonné sa dernière référence, pour son prochain tampon de taille identique (comme les blocs de tas). Chaque thread conserve au plus 32 tampons et 8 Mo ; le cache est libéré à la fin du thread. Les octets d'un tampon réutilisé situés après ceux du nouveau binary restent tels quels : aucune vue ne les couvre. Sans cela, les boucles d'ajout aux binaries prenaient des pages neuves pour chaque tampon agrandi (le tas C++ rend les pages des grands blocs libérés). Les nouveaux tampons sont des ByteBuffer (runtime/include/byte_buffer.hpp), qui ne mettent jamais non plus leur espace à zéro ; la réponse d'un pilote de port (un fichier entier lu) est donc écrite une seule fois et reprise telle quelle.

Références à handle

Une référence à handle est une référence qui détient aussi un tampon hors tas, comme une référence magique d'OTP détient une ressource ; les tableaux atomics y vivent (atomics et compteurs).

Arbres de maps

Une map de plus de 32 entrées (MAP_SMALL_MAP_LIMIT d'OTP) est un talon (stub) tree_map de 4 mots qui nomme la racine d'un arbre B+ persistant (terms/map_tree) dont les nœuds sont des cellules map_node du même tas, de même qu'OTP garde les nœuds de son HAMT sur le tas. Les maps plus petites restent des cellules map.

Zones

Dimensionnement et budget

Blocs de tas

Les blocs de tas, les fragments et les vieilles zones proviennent de memory/heap_blocks. Un bloc qu'une collecte libère reste attaché à son thread pour les collectes suivantes de ce thread (comme les carriers d'ERTS conservent la mémoire) : un nouveau bloc prend le plus petit bloc en cache d'au moins sa taille et d'au plus le double, sinon de la mémoire neuve. Chaque thread garde au plus 8 blocs et 16M mots (128 Mo sur les cibles 64 bits) ; les blocs de moins de 4 096 mots ne sont pas conservés, car les listes libres du tas C++ les servent à peu de frais. Un bloc libéré qui ne tient pas évince d'abord les blocs conservés les plus anciens : un tas qui grandit libère des blocs toujours plus grands, et sans éviction les blocs de ses premières petites collectes remplissaient le cache ; un ensemble vivant de millions de mots prenait donc des pages neuves pour chaque jeune bloc, vieille zone et balayage complet. Sans cela, l'espace de destination (to-space) de chaque collecte prenait des pages neuves au système d'exploitation et payait un défaut de page par page touchée, et la libération d'un grand bloc rendait ses pages. Les blocs en cache ne sont imputés à aucun processus ni au compte global du runtime ; un thread libère son cache lorsqu'il se termine.

Limite mémoire du runtime

Admission

Les pointeurs vers un tas de processus ne sont créés que par le compilateur et le runtime à l'intérieur de ce processus, et désignent toujours un début d'objet ; il n'y a pas de pointeurs intérieurs à détecter. L'admission est une vérification de propriété pour les mots rendus à un processus. Term::from_word(word, context) / TermAccess::admit vérifient d'abord que le stockage est vivant (expired_context) ; les atomes et les pids/ports doivent appartenir à ce runtime (les atomes fixes sont partagés). Pour les mots composés :

  1. L'adresse est alignée sur un mot à l'intérieur de l'une des zones du processus, sous son top : le bloc de tas est vérifié en premier, puis les fragments triés par adresse, puis les zones de littéraux (zones). Les adresses hors de ces plages échouent sans chargement ; cela ne détecte pas les anciens mots aux adresses réutilisées.
  2. Un mot boxed désigne un en-tête d'un type admis (pas un remplissage) ; un mot de liste désigne une cellule cons (un mot qui n'est pas un en-tête).

Après admission, TermAccess::object décode le type, le compte et la charge utile via trusted_cell, sans recherche dans le tas ; child enveloppe le mot stocké et validate ne vérifie que la représentation. Les fabriques réadmettent toujours les entrées via ProcessHeap::retain, en rejetant les entrées composées étrangères avec wrong_owner.

Ces vérifications ne s'exécutent qu'en compilation Debug (CHECKED_ADMISSION). Une compilation Release fait confiance à tous les mots composés, d'atomes, de pids et de ports transmis à un builtin, un service ou une fabrique, comme ERTS fait confiance à ses Eterms : le code généré ne produit que des mots de son propre processus, des zones de littéraux et des atomes du runtime ; les recherches de plage et de propriétaire ne faisaient donc qu'ajouter un coût à chaque argument. Les autres immédiats sont toujours classés. Les tests qui fournissent des mots falsifiés ou étrangers n'attendent un rejet que lorsque CHECKED_ADMISSION est actif. map_find peut comparer en lecture seule la clé d'un autre processus sans ce rejet. verify() reste la vérification complète, pour les tests, que chaque emplacement désigne un début d'objet.

Racines et points sûrs

ProcessContext::visit_roots énumère chaque mot racine pour le ramasse-miettes. À un point sûr, rien d'autre ne détient de mots du tas du processus :

PropriétaireMots racinesNotes
Emplacements de termes des cadresLes roots premiers emplacements de chaque cadre de la pileLes cadres du fond n'en ont aucun ; les indices de reprise et de gestionnaire sont des entiers
Emplacements bruts des cadresAucunValeurs natives déversées ; le code généré n'y garde aucun mot du tas à un point sûr (règle de rechargement)
Registresx[0..live) (ProcessStack::keep_registers)Les arguments d'une entrée suspendue ; chaque empilement et dépilement efface live
Canal d'échecCharge utile d'erreur (fvalue de BEAM), liste d'arguments de erlang:error/2,3, terme de trace de pileTerms de GeneratedCallState réécrits par Term::rebind, qui ne stocke que le nouveau mot ; les cadres de trace capturés sont des pointeurs de descripteurs vers le code
État d'interception (trap)Les mots de terme du TrapState d'un builtin qui intercepte (portions)Libérés lorsque le builtin se termine ou échoue
Dictionnaire de processusChaque clé et chaque valeurJusqu'à leur effacement ou la fin du processus
Boîte aux lettresChaque message dans la boîte de réception des signaux et la file de messages, y compris les messages 'EXIT' et 'DOWN'Jusqu'à ce qu'un receive le prenne ; réécrits sur place, donc le curseur de receive (une position dans la liste) et l'échéance du timeout restent valides
Racines explicitesLa plage qu'un hôte passe à collect(roots)Relues après l'appel
Liste hors tasAucunLes liens sont balayés et rechaînés, non tracés

Aucune cellule du tas ne détient d'épingle. Les atomes sont des immédiats et la table des atomes n'est jamais collectée. Les cellules de fun désignent le code via leur FunDefinition non tracé, qui vit aussi longtemps que le runtime ; les modules chargés ne sont jamais déchargés, donc ni les funs ni les descripteurs de trace n'ont besoin d'épingle. Les petits immédiats ne sont pas des racines.

Comme dans le code C d'ERTS, un Term hôte est exactement un mot trivialement copiable, valide jusqu'au prochain ramassage de son tas ou à la fin du processus ; il n'a ni épinglage, ni pointeur de tas, ni jeton de durée de vie, ni époque de ramassage (propriété). Un accès après l'une de ces limites lit de la mémoire déplacée ou libérée et est indéfini, sans vérification stale_term. Les racines enregistrées sont réécrites ; un Term hôte non enregistré doit être reconstruit depuis son mot racine réécrit. Un Term appartient à son processus ; les autres processus ne peuvent le lire que tant que la source reste stable.

Le tas ne se déplace qu'à un point sûr, et jamais tant qu'une réservation est ouverte :

Toute autre requête retourne unsafe_point et ne change rien, pas même le canal d'échec d'un appel généré en cours. L'allocation ne déplace jamais le tas : une requête qui ne tient pas crée un fragment.

Tampons de messages

Un MessageBuffer (message_buffer.hpp) garde un terme en dehors de tout processus : copy_of(value, sender) copie le graphe depuis le tas du processus expéditeur dans son propre HeapStorage. Le ProcessContext de l'expéditeur fournit les options de tas, les zones de littéraux, les atomes, les identités et le compte mémoire du runtime ; le tampon conserve son propre jeton de durée de vie et partage les binaries hors tas. Il utilise le même GraphCopy qu'un envoi ; copy_to le copie dans le tas d'un destinataire. Les immédiats et les racines littérales n'ont besoin d'aucun stockage. Les fabriques, les réservations et les tampons de messages conservent leurs vérifications de durée de vie (expired_context) ; un Term lu dans un tampon n'épingle rien et ne doit pas survivre à son stockage. Le tampon n'est jamais collecté et est libéré avec son dernier propriétaire ; son bloc fait exactement les mots du terme, quel que soit le tas minimal de l'expéditeur. Les envois à d'autres processus (tampons d'arrivée), les messages des timers et les étiquettes de moniteurs l'utilisent, si bien qu'ils ne sont jamais réencodés ; les termes persistants vivent dans leur propre plage d'adresses et sont copiés, sans partage, dans les tampons.

Parcours unique du message. Un envoi à un autre processus parcourt le graphe de son message une seule fois, avant tout verrou (OutgoingMessage, avec le GraphCopy de graph_copy.hpp) ; ce parcours dimensionne chaque copie ultérieure et, comme toute copie en dehors d'un processus, copie les termes persistants.

Remise des messages. Un envoi dont le message occupe au moins MessageBuffer::ADOPTED_WORDS (512) mots passe toujours par un tampon, copié à partir de ce parcours avant tout verrou, si bien que l'expéditeur ne détient aucun verrou pendant la copie. Le destinataire reprend ensuite le tampon au lieu de le copier (MessageBuffer::take, HeapStorage::adopt), lorsque le tampon n'a pas d'autre propriétaire et que le destinataire a un bloc de tas et pas de budget : le bloc rejoint ses fragments aux mêmes adresses, les listes de binaries hors tas et de maps du tampon rejoignent les siennes avec leurs imputations, et le tampon est laissé vide. Personne d'autre ne peut atteindre le bloc avant ou après, donc aucun mot n'est copié deux fois. Les fragments adoptés ne demandent une collecte qu'une fois qu'un receive a pris leur message et que leurs mots atteignent ceux du jeune bloc, comme ERTS ne compte un fragment de message qu'une fois reçu (HeapStorage::received) ; la collecte suivante les traite comme des données jeunes et copie un message encore en file comme toute racine. Les messages plus petits sont copiés une fois, ce qui coûte moins qu'un fragment pour chacun : directement dans le tas d'un destinataire qui ne s'exécute pas, sous le verrou de l'exécuteur, ou juste derrière un nœud d'arrivée pour un processus en cours d'exécution. Le destinataire prend une telle arrivée en copiant ses mots tels quels et en décalant les pointeurs entre eux de la distance de la copie, lisant les objets dans l'ordre des adresses comme le ramasse-miettes parcourt ses copies (arrivées relocalisées) ; le graphe n'est pas parcouru de nouveau.

Collecte dans le code généré

Le code généré n'effectue de collecte qu'à quelques points sûrs (safepoints) où chaque terme vivant se trouve déjà dans une racine. Tout le reste, y compris chaque service qui alloue, est une section critique qui ne déplace jamais le tas.

Déclencheurs

Un point sûr effectue une collecte lorsque le tas le demande (wants_collection()) ; sinon il coûte une vérification.

DéclencheurCondition au point sûrÉquivalent ERTS
Tas pleinUn fragment existe : une allocation n'a pas tenu dans le bloc de tas depuis la dernière collecteLe sommet du tas atteint la fin du tas
Pression des binaries hors tas (une collecte mineure, sauf si la dernière a libéré moins de la moitié, pression des binaries)Les mots hors tas atteignent la limite du tas binaire virtuel : 46 422 mots au départ, après chaque collecte le double des mots hors tas survivants ou les survivants plus les mots vivants du tas, selon la plus grande valeur (jamais moins de 46 422), mais au plus les survivants plus la moitié du budget laissé libre après le bloc de tasbin_vheap_sz / tas binaire virtuel
erlang:garbage_collect/0,1,2Une requête du builtin (introspection), prise en compte au prochain point sûr de la cibleBalayage complet explicite

Un point sûr exécute une collecte mineure, sauf si un balayage complet est dû : une requête garbage_collect, une pression des binaries hors tas, un budget de tas, ou une vieille zone sans place pour les mots mûrs (générations). Le ProcessHeap::collect public est toujours un balayage complet.

Le nouveau bloc est dimensionné pour les mots vivants plus les mots de pile utilisés (ERTS garde la pile à l'intérieur du bloc de tas) : une pile profonde obtient un tas plus grand, si bien qu'une longue récursion effectue des collectes proportionnelles à ses allocations plutôt que de reparcourir toute la pile toutes les quelques centaines de mots.

Points sûrs

PointEmplacementVivant hors des emplacements de termes des cadres
Entrée de fonctionDans CLAUSE_enter_v1 / CLAUSE_tail_v1 (et donc l'invocation hôte), avant l'empilement du cadre de l'appelé ; une entrée en ligne prend ce chemin chaque fois que le tas demande une collecteLes arguments de l'appelé x[0..arity), gardés comme racines (keep_registers)
Tête de boucleCLAUSE_safepoint_v1(context) en tête de chaque boucle de générateur de comprehension, appelé seulement tant que le tas demande une collecteRien

Toute boucle Erlang est soit une récursion, qui passe par une entrée de fonction à chaque itération, soit une comprehension, qui passe par sa tête de boucle, donc les déchets entre deux points sûrs sont bornés par du code linéaire et des résultats de service uniques.

Ne sont pas des points sûrs (sections critiques, qui continuent d'allouer dans des fragments) : tous les autres services du runtime, y compris les services d'allocation, de construction et de correspondance ; CLAUSE_return_v1 ; la propagation des exceptions ; et la remise des messages. Les services peuvent donc détenir des mots bruts du tas en C++ pendant toute leur exécution, et leurs tableaux d'entrée et leurs sorties n'ont pas besoin d'être rechargés.

Processus en attente et suspendus

Un processus qui ne s'exécute pas n'est jamais collecté : il attend dans un receive, est en file après un yield ou un trap, ou n'a pas encore démarré, et tout ce qu'il détient est déjà une racine (ses cadres, les registres de l'entrée ou de la continuation à laquelle il reprendra, l'état de trap et ses messages). Les messages qui lui sont envoyés sont copiés dans des fragments de son tas. Chaque remise réveille un processus en attente, et sa reprise répète l'entrée de sa continuation (le builtin d'attente, une continuation de trap ou la fonction à laquelle il a cédé), ce qui est un point sûr d'entrée de fonction : la première chose que fait un processus repris est une collecte si son tas la demande. Un processus qui attend dans un receive sélectif sautant de nombreux messages effectue donc des collectes à mesure qu'ils arrivent, de même qu'ERTS collecte un processus lors de sa prochaine planification. Le golden executables_mailbox_collection couvre l'accumulation, l'attente avec timeout, la récursion profonde sous charge de messages, et un consommateur accusant réception de 3 000 messages dans les limites de --max-heap 65536.

L'allocation n'est pas un point sûr (contrairement au test_heap de BEAM) : cela exigerait que chaque entrée de service et chaque terme SSA vivant au travers d'une allocation soit dans une racine, un rechargement après chaque service qui alloue et un protocole de nouvelle tentative dans chaque service, alors que les deux points sûrs ci-dessus bornent déjà les déchets.

Règle de rechargement

Aucune valeur SSA (une valeur dans un registre natif) ne détient de mot du tas à travers un point sûr, et aucun pointeur natif n'en traverse un du tout (c'est déjà une erreur dans lower_frames).

Comportement en cas d'échec

Implémentation

Prototype

tests/prototypes/safepoint contient une boucle de style comprehension sous forme post-lower_frames (loop.ll) : un terme Y calculé avant la boucle est stocké dans un emplacement de terme et rechargé après le point sûr de tête de boucle. python tests/prototypes/safepoint/run.py le compile pour x86_64-pc-windows-msvc, aarch64-unknown-linux-gnu, i686-pc-windows-msvc et armv7-unknown-linux-gnueabihf en O0 et O2 et vérifie qu'un chargement du mot de cadre de Y suit l'appel de point sûr, si bien que l'optimiseur ne réutilise jamais le registre qui contenait Y.

Collecte

Une copie de Cheney (memory/heap_collect), en balayage complet ou en collecte mineure (générations) : allouer d'abord le nouveau bloc (un échec donne out_of_memory et laisse le tas intact), puis copier l'objet derrière chaque mot racine et parcourir le nouveau bloc de gauche à droite en copiant les enfants de chaque copie. Un emplacement est d'abord vérifié par rapport à la zone source de l'emplacement précédent (un test de plage), et les immédiats sont distingués en ligne. Les cellules cons sont parcourues sans analyse, et les emplacements d'une copie boxed sont trouvés à partir du seul type de son en-tête (un compte par type des mots non tracés, sans vérification de taille : les copies sont celles d'objets vérifiés) ; les objets d'au plus quatre mots sont copiés mot par mot. L'en-tête d'un objet boxed déplacé est remplacé par un pointeur boxed vers sa copie ; une cellule cons déplacée reçoit une tête nulle et une queue pointant vers sa copie. Le renvoi préserve le partage. Les racines sont réécrites sur place, la liste hors tas est balayée (copies rechaînées dans l'ordre de la liste, cellules mortes détruites), et l'ancien bloc et les fragments sont libérés. Un tas jamais alloué n'est pas collecté. CollectionStats indique les mots avant, les mots vivants, le nouveau bloc de tas, les fragments fusionnés, la capacité en emplacements de la pile et les mots hors tas.

Générations

Collecte générationnelle, comme la fait ERTS, sans sa barrière d'écriture :

Copie entre tas

ProcessHeap::add(value), ou de manière équivalente value.copy_to(heap), retourne un terme du tas de destination (size_object et copy_struct de BEAM). Les cellules sources sont lues via des pointeurs de confiance, et une cellule cons n'a pas besoin d'être analysée :

Mesures

runtime_heap_measurements (CTest en mode complet ; chiffres affichés, sans seuil) construit une liste de 100 000 éléments de tuples {Index, Float} via TermFactory, la parcourt de nouveau via des accesseurs vérifiés, effectue une collecte avec la liste comme seule racine et parcourt la copie, et crée 1 000 contextes qui détiennent chacun un petit tuple. Il indique les temps de construction et de parcours, les mots de tas utilisés et la capacité, les octets annexes (allocations hôtes au-delà du stockage du tas) ainsi que les octets et les mots de tas par contexte.