Clause
← Toda la documentación

Traducido del original en inglés · 1e3a39c8-dirty · 2026-10-26 · Leer en inglés

Contrato del heap de proceso

Los heaps de proceso siguen el diseño clásico de ERTS: un bloque de heap contiguo por proceso más fragmentos de desbordamiento, celdas autodescriptivas, binaries grandes en búferes compartidos fuera del heap y un recolector de copia que solo se ejecuta en puntos seguros. Esta nota es el contrato; runtime.md resume la API del host.

Disposición de palabras

Un término es una palabra del destino (32 o 64 bits); las codificaciones están en abi.md. Cada área del heap es una secuencia de objetos que un recorredor analiza a partir de su primera palabra:

Ninguna celda necesita una alineación mayor que una palabra. memory/heap_walk analiza un área celda por celda y ProcessHeap::verify comprueba un heap completo. Las celdas contienen solo palabras y bytes, excepto el std::shared_ptr del binary fuera del heap (más abajo), por lo que una celda se mueve copiando sus palabras.

TipoPalabras después de la cabeceraPalabras trazadas
cons (sin cabecera)2 palabras en totalcabeza, cola
tuplen slots de elementostodas
map2n slots: claves en orden exacto de términos, cada una seguida de su valor; como máximo 32 entradastodas
tree_mapnúmero de entradas, palabra de edición, map_node raíz: 3 palabras (árboles de maps)la raíz
map_nodeentradas por debajo, palabra de edición, y después n claves y n valores (una hoja) o n hijos (una rama); nunca es un términoclaves y valores, o hijos
native_recorddirección del RecordDefinition del runtime, y después n valores de campo en el orden de la definiciónvalores
fun_closuredirección del FunDefinition del runtime, y después n valores capturados (funs)valores
bignumpalabra de signo, y después los limbs de la magnitud, del menos significativo al más significativoninguna
floating8 bytes: 1 palabra (64 bits) o 2 palabras (32 bits)ninguna
reference8 bytes: el número de referencia (pids y referencias)ninguna
heap_binarylongitud en bits, y después los bytes de datos redondeados a palabras (como máximo 64 bytes)ninguna
refc_binarydesplazamiento en bits, longitud en bits, el primer byte del búfer (leído por el código generado), fin y límite de anexado, std::shared_ptr (2 palabras), enlace fuera del heap: 8 palabrasninguna
sub_binarydesplazamiento en bits dentro del original, longitud en bits, el heap_binary o refc_binary original (nunca un sub-binary): 3 palabrasel original
fillern palabras sin usarninguna

El recuento de map está en palabras (entradas = recuento / 2). Los pids y los puertos son inmediatos, admitidos frente a los números emitidos por el runtime.

Binaries fuera del heap

Un binary de más de 64 bytes es un búfer inmutable que flota fuera de todos los heaps de proceso, compartido mediante recuento de referencias (ProcBin y Binary de BEAM).

Reutilización de búferes

Un búfer liberado cuyo tamaño sea una potencia de dos de al menos 256 bytes (las capacidades de los binaries ampliables) se queda con el hilo que descartó su última referencia, para el siguiente búfer de ese hilo de exactamente ese tamaño (como los bloques de heap). Cada hilo conserva como máximo 32 búferes y 8 MB; la caché se libera cuando termina el hilo. Los bytes de un búfer reutilizado posteriores a los del nuevo binary se dejan como estaban: ninguna vista los abarca. Sin esto, los bucles de anexado de binaries tomaban páginas nuevas para cada búfer ampliado (el heap de C++ devuelve las páginas de los bloques grandes liberados). Los búferes nuevos son ByteBuffer (runtime/include/byte_buffer.hpp), que tampoco ponen nunca a cero su espacio, por lo que la respuesta de un controlador de puerto (la lectura de un archivo completo) se escribe una vez y se asume tal como está.

Referencias handle

Una referencia handle es una referencia que además retiene un búfer fuera del heap, igual que una referencia mágica de OTP retiene un recurso; los arrays de atomics viven en ellas (atomics y counters).

Árboles de maps

Un map con más de 32 entradas (MAP_SMALL_MAP_LIMIT de OTP) es un stub tree_map de 4 palabras que nombra la raíz de un árbol B+ persistente (terms/map_tree) cuyos nodos son celdas map_node del mismo heap, igual que OTP mantiene en el heap los nodos de su HAMT. Los maps más pequeños siguen siendo celdas map.

Áreas

Dimensionamiento y presupuesto

Bloques de heap

Los bloques de heap, los fragmentos y las áreas antiguas provienen de memory/heap_blocks. Un bloque que libera una recolección se queda con su hilo para las siguientes recolecciones de ese hilo (igual que los carriers de ERTS conservan memoria): un bloque nuevo toma el menor bloque en caché de al menos su tamaño y como máximo el doble, y si no, memoria nueva. Cada hilo conserva como máximo 8 bloques y 16M palabras (128 MB en destinos de 64 bits); los bloques de menos de 4.096 palabras no se conservan, ya que las listas libres del heap de C++ los sirven de forma barata. Un bloque liberado que no cabe libera primero los bloques conservados más antiguos: un heap que crece libera bloques cada vez mayores y, sin expulsión, los bloques de sus primeras recolecciones pequeñas llenaban la caché, así que un conjunto vivo de millones de palabras tomaba páginas nuevas para cada bloque joven, área antigua y barrido completo. Sin esto, el espacio de destino de cada recolección tomaba páginas nuevas del sistema operativo y pagaba un fallo de página por cada página que tocaba, y liberar un bloque grande devolvía sus páginas. Los bloques en caché no se cargan a ningún proceso ni a la cuenta de todo el runtime; un hilo libera su caché cuando termina.

Límite de memoria del runtime

Admisión

Los punteros a un heap de proceso solo los crean el compilador y el runtime dentro de ese proceso, y siempre nombran el inicio de un objeto; no hay punteros interiores que detectar. La admisión es una comprobación de propiedad para las palabras que se devuelven a un proceso. Term::from_word(word, context) / TermAccess::admit comprueban primero que el almacenamiento siga vivo (expired_context); los átomos y los pids/puertos deben pertenecer a ese runtime (los átomos fijos se comparten). Para las palabras compuestas:

  1. La dirección está alineada a palabra dentro de una de las áreas del proceso, por debajo de su top: primero se comprueba el bloque de heap, después los fragmentos ordenados por dirección y después las áreas de literales (áreas). Las direcciones fuera de estos rangos fallan sin ninguna carga; esto no detecta palabras antiguas en direcciones reutilizadas.
  2. Una palabra boxed nombra una cabecera de un tipo admitido (no relleno); una palabra de lista nombra una celda cons (una palabra que no es una cabecera).

Tras la admisión, TermAccess::object decodifica el tipo, el recuento y la carga útil mediante trusted_cell, sin buscar en el heap; child envuelve la palabra almacenada y validate comprueba solo la representación. Las factorías siguen readmitiendo las entradas mediante ProcessHeap::retain, rechazando entradas compuestas ajenas con wrong_owner.

Estas comprobaciones se ejecutan solo en compilaciones Debug (CHECKED_ADMISSION). Una compilación Release confía en toda palabra compuesta, de átomo, pid o puerto entregada a un builtin, servicio o factoría, como ERTS confía en sus Eterms: el código generado solo produce palabras de su propio proceso, de áreas de literales y de átomos del runtime, por lo que las búsquedas de rango y propietario solo añadían coste en cada argumento. Los demás inmediatos se siguen clasificando. Las pruebas que suministran palabras falsificadas o ajenas solo esperan su rechazo cuando se cumple CHECKED_ADMISSION. map_find puede comparar en solo lectura una clave de otro proceso sin ese rechazo. verify() sigue siendo la comprobación completa de que cada slot nombra el inicio de un objeto, para las pruebas.

Raíces y puntos seguros

ProcessContext::visit_roots enumera cada palabra raíz para el recolector. En un punto seguro, nada más retiene palabras del heap del proceso:

PropietarioPalabras raízNotas
Slots de términos del marcoLos primeros roots slots de cada marco de la pilaLos marcos inferiores no tienen ninguno; los índices de reanudación y de manejador son enteros
Slots en bruto del marcoNingunaValores nativos volcados; el código generado no guarda ahí ninguna palabra del heap en un punto seguro (regla de recarga)
Registrosx[0..live) (ProcessStack::keep_registers)Los argumentos de una entrada suspendida; cada push y pop limpia live
Canal de fallosCarga útil de error (fvalue de BEAM), lista de argumentos de erlang:error/2,3, término de la traza de pilaTerms de GeneratedCallState reescritos por Term::rebind, que almacena solo la nueva palabra; los marcos de traza capturados son punteros a descriptores en el código
Estado de trapLas palabras de término del TrapState de un builtin que hace trap (porciones)Se liberan cuando el builtin termina o falla
Diccionario del procesoCada clave y cada valorHasta que se borran o el proceso termina
BuzónCada mensaje en la bandeja de señales y en la cola de mensajes, incluidos los mensajes 'EXIT' y 'DOWN'Hasta que un receive lo toma; se reescriben en el sitio, por lo que el cursor del receive (una posición en la lista) y el plazo del timeout siguen siendo válidos
Raíces explícitasEl intervalo que el host pasa a collect(roots)Se vuelven a leer después de la llamada
Lista fuera del heapNingunaLos enlaces se barren y se vuelven a enlazar, no se trazan

Ninguna celda del heap retiene una fijación. Los átomos son inmediatos y la tabla de átomos nunca se recolecta. Las celdas de fun nombran el código a través de su FunDefinition no trazado, que vive tanto como el runtime; los módulos cargados nunca se descargan, por lo que ni los funs ni los descriptores de traza necesitan una fijación. Los inmediatos pequeños no son raíces.

Como en el código C de ERTS, un Term del host es exactamente una palabra trivialmente copiable, válida hasta la siguiente recolección de su heap o la salida del proceso; no tiene fijación, puntero al heap, testigo de vida ni época de recolección (propiedad). El acceso después de cualquiera de esos límites lee memoria movida o liberada y es indefinido, sin comprobación stale_term. Las raíces registradas se reescriben; un Term del host no registrado debe reconstruirse a partir de su palabra raíz reescrita. Un Term pertenece a su proceso; los demás procesos solo pueden leerlo mientras el origen permanezca estable.

El heap solo se mueve en un punto seguro, y nunca mientras haya una reserva abierta:

Cualquier otra solicitud devuelve unsafe_point y no cambia nada, ni siquiera el canal de fallos de una llamada generada en ejecución. La asignación nunca mueve el heap: una solicitud que no cabe crea un fragmento.

Búferes de mensajes

Un MessageBuffer (message_buffer.hpp) mantiene un término fuera de todo proceso: copy_of(value, sender) copia el grafo desde el heap del proceso emisor a un HeapStorage propio. El ProcessContext del emisor proporciona las opciones del heap, las áreas de literales, los átomos, las identidades y la cuenta de memoria del runtime; el búfer conserva su propio testigo de vida y comparte los binaries fuera del heap. Usa la misma GraphCopy que un envío; copy_to lo copia al heap de un receptor. Los inmediatos y las raíces literales no necesitan almacenamiento. Las factorías, las reservas y los búferes de mensajes conservan sus comprobaciones de vida (expired_context); un Term leído de un búfer no fija nada y no debe sobrevivir a su almacenamiento. El búfer nunca se recolecta y se libera con su último propietario; su bloque tiene exactamente las palabras del término, sea cual sea el heap mínimo del emisor. Lo usan los envíos a otros procesos (búferes de llegadas), los mensajes de los temporizadores y las etiquetas de monitores, por lo que nunca se vuelven a codificar; los términos persistentes viven en su propio rango de direcciones y se copian, sin compartirse, a los búferes.

Un solo recorrido del mensaje. Un envío a otro proceso recorre el grafo de su mensaje una vez, antes de cualquier bloqueo (OutgoingMessage, con el GraphCopy de graph_copy.hpp); ese recorrido dimensiona todas las copias posteriores y, como cualquier copia fuera de un proceso, copia los términos persistentes.

Traspaso de mensajes. Un envío cuyo mensaje ocupa al menos MessageBuffer::ADOPTED_WORDS (512) palabras siempre pasa por un búfer, copiado a partir de ese recorrido antes de cualquier bloqueo, de modo que el emisor no retiene ningún bloqueo mientras copia. Después, el receptor asume el búfer en lugar de copiarlo (MessageBuffer::take, HeapStorage::adopt), cuando el búfer no tiene otro propietario y el receptor tiene un bloque de heap y ningún presupuesto: el bloque se une a sus fragmentos en las mismas direcciones, las listas de binaries fuera del heap y de maps del búfer se unen a las suyas con sus cargas, y el búfer queda vacío. Nadie más puede alcanzar el bloque antes ni después, así que ninguna palabra se copia dos veces. Los fragmentos adoptados solo solicitan una recolección una vez que un receive ha tomado su mensaje y sus palabras alcanzan las del bloque joven, igual que ERTS cuenta un fragmento de mensaje solo una vez recibido (HeapStorage::received); la siguiente recolección los trata como datos jóvenes y copia un mensaje que siga en cola como cualquier raíz. Los mensajes más pequeños se copian una vez, más barato que un fragmento para cada uno: directamente al heap de un receptor que no se ejecuta, bajo el bloqueo del ejecutor, o justo detrás de un nodo de llegada para uno en ejecución. El receptor toma una llegada así copiando sus palabras tal como están y desplazando los punteros entre ellas la distancia de la copia, leyendo los objetos en orden de dirección como el recolector recorre sus copias (llegadas reubicadas); el grafo no se vuelve a recorrer.

Recolección en código generado

El código generado solo recolecta en unos pocos safepoints donde cada término vivo ya está en una raíz. Todo lo demás, incluido cada servicio que asigna, es una sección crítica que nunca mueve el heap.

Disparadores

Un safepoint recolecta cuando el heap lo solicita (wants_collection()); en otro caso cuesta una comprobación.

DisparadorCondición en el safepointEquivalente en ERTS
Heap llenoExiste algún fragmento: una asignación no cupo en el bloque de heap desde la última recolecciónEl top del heap alcanza el final del heap
Presión de binaries fuera del heap (una recolección menor salvo que la última liberara menos de la mitad, presión de binaries)Las palabras fuera del heap alcanzan el límite del heap binario virtual: 46.422 palabras al principio, después de cada recolección el doble de las palabras fuera del heap supervivientes o los supervivientes más las palabras vivas del heap, lo que sea mayor (nunca menos de 46.422), pero como máximo los supervivientes más la mitad del presupuesto que queda libre después del bloque de heapbin_vheap_sz / heap binario virtual
erlang:garbage_collect/0,1,2Una solicitud del builtin (introspección), atendida en el siguiente safepoint del destinoBarrido completo explícito

Un safepoint ejecuta una recolección menor salvo que toque un barrido completo: una solicitud de garbage_collect, presión de binaries fuera del heap, un presupuesto de heap o un área antigua sin espacio para las palabras maduras (generaciones). El ProcessHeap::collect público es siempre un barrido completo.

El nuevo bloque se dimensiona para las palabras vivas más las palabras de pila en uso (ERTS mantiene la pila dentro del bloque de heap): una pila profunda obtiene un heap mayor, de modo que una recursión larga recolecta en proporción a su asignación en lugar de volver a recorrer toda la pila cada pocos cientos de palabras.

Safepoints

PuntoDóndeVivo fuera de los slots de términos del marco
Entrada de funciónEn CLAUSE_enter_v1 / CLAUSE_tail_v1 (y por tanto en la invocación del host), antes de apilar el marco del llamado; una entrada en línea toma este camino siempre que el heap solicita una recolecciónLos argumentos del llamado x[0..arity), conservados como raíces (keep_registers)
Cabecera de bucleCLAUSE_safepoint_v1(context) en la cabecera de cada bucle generador de una comprehension, llamado solo mientras el heap solicita una recolecciónNada

Todo bucle de Erlang es o bien una recursión, que pasa por una entrada de función en cada paso, o bien una comprehension, que pasa por su cabecera de bucle, de modo que la basura entre dos safepoints está acotada por el código lineal y los resultados de servicios individuales.

No son safepoints (secciones críticas, que siguen asignando en fragmentos): cualquier otro servicio del runtime, incluidos los servicios de asignación, construcción y coincidencia; CLAUSE_return_v1; la propagación de excepciones; y la entrega de mensajes. Por tanto, los servicios pueden retener palabras del heap en bruto en C++ durante toda su ejecución, y sus arrays de entrada y sus salidas no necesitan recarga.

Procesos en espera y suspendidos

Un proceso que no se está ejecutando nunca se recolecta: está esperando en un receive, en cola tras una cesión (yield) o un trap, o aún no ha empezado, y todo lo que retiene ya es una raíz (sus marcos, los registros de la entrada o continuación en la que se reanudará, el estado de trap y sus mensajes). Los mensajes que se le envían se copian en fragmentos de su heap. Cada entrega despierta a un proceso en espera, y reanudarlo repite la entrada de su continuación (el builtin de espera, una continuación de trap o la función en la que cedió), que es un safepoint de entrada de función: lo primero que hace un proceso reanudado es recolectar cuando su heap lo solicita. Un proceso que espera en un receive selectivo que salta muchos mensajes recolecta, por tanto, a medida que llegan, igual que ERTS recolecta un proceso la próxima vez que se planifica. El golden executables_mailbox_collection cubre la acumulación, la espera con timeout, la recursión profunda bajo carga de mensajes y un consumidor que confirma 3.000 mensajes dentro de --max-heap 65536.

La asignación no es un safepoint (a diferencia de test_heap de BEAM): requeriría que cada entrada de servicio y cada término SSA vivo a través de cualquier asignación estuviera en una raíz, una recarga después de cada servicio que asigna y un protocolo de reintento en cada servicio, mientras que los dos safepoints anteriores ya acotan la basura.

Regla de recarga

Ningún valor SSA (un valor en un registro nativo) retiene una palabra del heap a través de un safepoint, y ningún puntero nativo atraviesa uno en absoluto (ya es un error en lower_frames).

Comportamiento ante fallos

Implementación

Prototipo

tests/prototypes/safepoint contiene un bucle al estilo de una comprehension en forma posterior a lower_frames (loop.ll): un término Y calculado antes del bucle se almacena en un slot de término y se recarga después del safepoint de la cabecera del bucle. python tests/prototypes/safepoint/run.py lo compila para x86_64-pc-windows-msvc, aarch64-unknown-linux-gnu, i686-pc-windows-msvc y armv7-unknown-linux-gnueabihf en O0 y O2 y comprueba que una carga de la palabra del marco de Y sigue a la llamada de safepoint, de modo que el optimizador nunca reutiliza el registro que contenía Y.

Recolección

Una copia de Cheney (memory/heap_collect), de barrido completo o una recolección menor (generaciones): primero se asigna el nuevo bloque (un fallo es out_of_memory y deja el heap intacto), después se copia el objeto detrás de cada palabra raíz y se recorre el nuevo bloque de izquierda a derecha, copiando los hijos de cada copia. Primero se comprueba un slot frente al área de origen del slot anterior (una comprobación de rango), y los inmediatos se distinguen en línea. Las celdas cons se recorren sin análisis, y los slots de una copia boxed se localizan solo por el tipo de su cabecera (un recuento por tipo de palabras no trazadas, sin comprobaciones de tamaño: las copias son de objetos verificados); los objetos de hasta cuatro palabras se copian palabra a palabra. La cabecera de un objeto boxed movido se sustituye por un puntero boxed a su copia; una celda cons movida recibe una cabeza a cero y una cola que apunta a su copia. El reenvío preserva la compartición. Las raíces se reescriben en el sitio, se barre la lista fuera del heap (las copias se vuelven a enlazar en el orden de la lista y las celdas muertas se destruyen), y se liberan el bloque antiguo y los fragmentos. Un heap que nunca se asignó no se recolecta. CollectionStats informa de las palabras anteriores, las palabras vivas, el nuevo bloque de heap, los fragmentos fusionados, la capacidad de slots de la pila y las palabras fuera del heap.

Generaciones

Recolección generacional, como la hace ERTS, sin su barrera de escritura:

Copia entre heaps

ProcessHeap::add(value), o equivalentemente value.copy_to(heap), devuelve un término del heap de destino (size_object y copy_struct de BEAM). Las celdas de origen se leen mediante punteros de confianza, y una celda cons no necesita análisis:

Mediciones

runtime_heap_measurements (CTest en modo completo; los números se imprimen, no actúan como puerta) construye una lista de 100.000 elementos de tuplas {Index, Float} mediante TermFactory, la recorre de vuelta mediante accesores comprobados, recolecta con la lista como única raíz y recorre la copia, y crea 1.000 contextos que retienen cada uno una tupla pequeña. Informa de los tiempos de construcción y de recorrido, de las palabras de heap usadas y de capacidad, de los bytes laterales (asignaciones del host más allá del respaldo del heap) y de los bytes y palabras de heap por contexto.