Ir mecanismos de asignación

Cuando intenté entender por primera vez cómo funcionan las herramientas de asignación de memoria en Go, lo que quería tratar parecía una misteriosa caja negra. Al igual que con cualquier otra tecnología, lo más importante aquí está oculto detrás de muchas capas de abstracciones, a través de las cuales debe pasar para comprender algo.



El autor del material, cuya traducción estamos publicando, decidió llegar al fondo de los medios de asignación de memoria en Go y hablar sobre él.

Memoria física y virtual


Todos los medios para asignar memoria tienen que funcionar con el espacio de direcciones de la memoria virtual, que es controlado por el sistema operativo. Veamos cómo funciona la memoria, comenzando en el nivel más bajo, con celdas de memoria.
Aquí se explica cómo imaginar una celda RAM.


Diseño de celda de memoria

Si, muy simplificado, imagina una celda de memoria y lo que la rodea, entonces obtenemos lo siguiente:

  1. La línea de dirección (el transistor actúa como un interruptor) es lo que da acceso al condensador (línea de datos).
  2. Cuando aparece una señal (línea roja) en la línea de dirección, la línea de datos le permite escribir datos en la celda de memoria, es decir, cargar el condensador, lo que permite almacenar un valor lógico correspondiente a 1 en él.
  3. Cuando no hay señal en la línea de dirección (línea verde), el capacitor está aislado y su carga no cambia. Para escribir en la celda 0, debe seleccionar su dirección y enviar un 0 lógico a través de la línea de datos, es decir, conectar la línea de datos con un signo menos, descargando así el condensador.
  4. Cuando el procesador necesita leer el valor de la memoria, la señal se envía a lo largo de la línea de dirección (el interruptor se cierra). Si el condensador está cargado, la señal pasa por la línea de datos (se lee 1), de lo contrario, la señal no pasa por la línea de datos (se lee 0).


El esquema de interacción de la memoria física y el procesador.

El bus de datos es responsable de transportar los datos entre el procesador y la memoria física.

Ahora hablemos de la línea de dirección y los bytes direccionables.


Líneas de dirección de bus entre procesador y memoria física

  1. A cada byte en RAM se le asigna un identificador numérico único (dirección). Cabe señalar que el número de bytes físicos presentes en la memoria no es igual al número de líneas de dirección.
  2. Cada línea de dirección puede especificar un valor de 1 bit, por lo que indica un bit en la dirección de un determinado byte.
  3. Nuestro circuito tiene 32 líneas de dirección. Como resultado, cada byte direccionable utiliza un número de 32 bits como su dirección. [00000000000000000000000000000000] - la dirección de memoria más baja. [1111111111111111111111111111111111] - la dirección de memoria más alta.
  4. Como cada byte tiene una dirección de 32 bits, nuestro espacio de direcciones consta de 2 32 bytes direccionables (4 GB).

Como resultado, resulta que el número de bytes direccionables depende del número total de líneas de dirección. Por ejemplo, si hay 64 líneas de dirección (procesadores x86-64), puede direccionar 2 64 bytes (16 exabytes) de memoria, pero la mayoría de las arquitecturas que usan punteros de 64 bits realmente usan líneas de dirección de 48 bits (AMD64) y líneas de direcciones de 42 bits (Intel), que teóricamente permite que las computadoras estén equipadas con 256 terabytes de memoria física (Linux permite, en la arquitectura x86-64, cuando se usan páginas de direcciones de nivel 4, asignar hasta 128 TB de espacio de direcciones a los procesos, Windows le permite asignar hasta 192 TB).
Dado que el tamaño de la RAM física es limitado, cada proceso se ejecuta en su propio "entorno limitado", en el denominado "espacio de direcciones virtuales", denominado memoria virtual.

Las direcciones de bytes en el espacio de direcciones virtuales no coinciden con las direcciones que utiliza el procesador para acceder a la memoria física. Como resultado, necesitamos un sistema que nos permita convertir direcciones virtuales en físicas. Eche un vistazo a cómo se ven las direcciones de memoria virtual.


Representación virtual del espacio de direcciones

Como resultado, cuando el procesador ejecuta una instrucción que se refiere a una dirección de memoria, el primer paso es traducir la dirección lógica a una dirección lineal. Esta conversión es realizada por la unidad de gestión de memoria.


Representación simplificada de la relación entre memoria virtual y física.

Dado que las direcciones lógicas son demasiado grandes para ser convenientes para trabajar con ellas por separado (esto depende de varios factores), la memoria está organizada en estructuras llamadas páginas. En este caso, el espacio de direcciones virtuales se divide en pequeñas áreas, páginas, que en la mayoría de los sistemas operativos tienen un tamaño de 4 KB, aunque generalmente este tamaño se puede cambiar. Esta es la unidad más pequeña de administración de memoria en la memoria virtual. La memoria virtual no almacena nada, simplemente establece la correspondencia entre el espacio de direcciones del programa y la memoria física.

Los procesos solo ven las direcciones de memoria virtual. ¿Qué sucede si un programa necesita más memoria dinámica (también llamada memoria de montón, o "montón")? Aquí hay un ejemplo de código de ensamblador simple en el que se solicita memoria adicional asignada dinámicamente al sistema:

_start:        mov $12, %rax #    brk        mov $0, %rdi # 0 -  ,            syscall b0:        mov %rax, %rsi #  rsi    ,           mov %rax, %rdi #     ...        add $4, %rdi # ..  4 ,           mov $12, %rax #    brk        syscall 

Así es como se puede representar en forma de diagrama.


Aumenta la memoria asignada dinámicamente

El programa solicita memoria adicional utilizando la llamada al sistema brk (sbrk / mmap, etc.). El núcleo actualiza la información sobre la memoria virtual, pero todavía no se han presentado páginas nuevas en la memoria física, y aquí hay una diferencia entre la memoria virtual y la física.

Asignador de memoria


Después de que, en términos generales, discutimos el trabajo con el espacio de direcciones virtuales, hablamos sobre cómo solicitar memoria dinámica adicional (memoria en el montón), nos será más fácil hablar sobre los medios para asignar memoria.

Si el montón tiene suficiente memoria para satisfacer nuestras solicitudes de código, entonces el asignador de memoria puede ejecutar estas solicitudes sin acceder al núcleo. De lo contrario, tiene que aumentar el tamaño del montón utilizando una llamada al sistema (utilizando brk, por ejemplo), mientras solicita un gran bloque de memoria. En el caso de malloc, "grande" significa el tamaño descrito por el parámetro MMAP_THRESHOLD , que, por defecto, es de 128 Kb.

Sin embargo, un asignador de memoria tiene más responsabilidades que simplemente asignar memoria. Una de sus responsabilidades más importantes es reducir la fragmentación de la memoria interna y externa, y asignar bloques de memoria lo más rápido posible. Suponga que nuestro programa ejecuta secuencialmente solicitudes para asignar bloques continuos de memoria usando una función de la forma malloc(size) , después de lo cual esta memoria se libera usando una función de la forma free(pointer) .


Demostración de fragmentación externa

En el diagrama anterior, en el paso p4, no tenemos suficientes bloques de memoria ubicados secuencialmente para cumplir con la solicitud de asignación de seis de esos bloques, aunque la cantidad total de memoria libre lo permite. Esta situación lleva a la fragmentación de la memoria.

¿Cómo reducir la fragmentación de la memoria? La respuesta a esta pregunta depende del algoritmo de asignación de memoria específico, en el que se utiliza la biblioteca base para trabajar con la memoria.

Ahora veremos la herramienta de asignación de memoria TCMalloc, en la que se basan los mecanismos de asignación de memoria Go.

TCMalloc


TCMalloc se basa en la idea de dividir la memoria en varios niveles para reducir la fragmentación de la memoria. Dentro de TCMalloc, la administración de memoria se divide en dos partes: trabajar con memoria de subprocesos y trabajar con el montón.

▍ Memoria de hilo


Cada página de memoria se divide en una secuencia de fragmentos de ciertos tamaños, seleccionados de acuerdo con las clases de tamaño. Esto reduce la fragmentación. Como resultado, cada subproceso tiene a su disposición una memoria caché para objetos pequeños, lo que permite una asignación muy eficiente de la memoria para objetos menores o iguales a 32 KB.


Flujo de caché

▍Bunch


Un montón gestionado TCMalloc es una colección de páginas en las que un conjunto de páginas consecutivas se puede representar como un rango de páginas (intervalo). Cuando necesita asignar memoria para un objeto que es mayor que 32 KB, el montón se usa para asignar memoria.


Apila y trabaja con páginas

Cuando no hay suficiente espacio para colocar pequeños objetos en la memoria, recurren al montón de memoria. Si el montón no tiene suficiente memoria libre, se solicita memoria adicional del sistema operativo.

Como resultado, el modelo presentado de trabajar con memoria admite el conjunto de memoria de espacio de usuario; su uso mejora significativamente la eficiencia de asignación y liberación de memoria.

Cabe señalar que la herramienta de asignación de memoria Go se basó originalmente en TCMalloc, pero difiere ligeramente de ella.

Ir asignador de memoria


Sabemos que el tiempo de ejecución de Go planea ejecutar goroutines en procesadores lógicos. De manera similar, la versión de TCMalloc utilizada por Go divide las páginas de memoria en bloques cuyos tamaños corresponden a ciertas clases de tamaños de las cuales existen 67.

Si no está familiarizado con el planificador Go , puede leer sobre esto aquí .


Ir clases de tamaño

Dado que el tamaño mínimo de página en Go es de 8192 bytes (8 Kb), si dicha página se divide en bloques de 1 KB, obtendremos 8 de esos bloques.


Un tamaño de página de 8 KB se divide en bloques correspondientes a un tamaño de clase de 1 KB

Secuencias de página similares en Go se controlan utilizando una estructura llamada mspan.

▍Estructura mspan


La estructura mspan es una lista doblemente vinculada, un objeto que contiene la dirección de inicio de la página, información sobre el tamaño de la página y el número de páginas incluidas en ella.


Estructura mspan

▍ estructura mcache


Al igual que TCMalloc, Go proporciona a cada procesador lógico un caché de subprocesos local, conocido como mcache. Como resultado, si goroutine necesita memoria, puede obtenerla directamente de mcache. Para hacer esto, no necesita realizar bloqueos, ya que en un momento dado solo se ejecuta una goroutin en un procesador lógico.

La estructura mcache contiene, en forma de caché, estructuras mspan de varias clases de tamaño.


Interacción entre procesador lógico, mcache y mspan en Go

Dado que cada procesador lógico tiene su propio mcache, no hay necesidad de bloqueos al asignar memoria desde mcache.

Cada clase de tamaño se puede representar mediante uno de los siguientes objetos:

  • Un objeto de escaneo es un objeto que contiene un puntero.
  • Un objeto noscan es un objeto en el que no hay puntero.

Una de las fortalezas de este enfoque es que cuando se realiza la recolección de basura, no es necesario eludir los objetos noscan, ya que no contienen objetos para los que se asigna memoria.

¿Qué se mete en mcache? Los objetos cuyo tamaño no exceda los 32 KB van directamente a mcache usando mspan de la clase de tamaño correspondiente.

¿Qué sucede si mcache no tiene una célula libre? Luego obtienen un nuevo mspan de la clase de tamaño deseada de la lista de objetos mspan llamada mcentral.

▍ Estructura central


La estructura central recopila todos los rangos de página de una clase de tamaño particular. Cada objeto central contiene dos listas de objetos mspan.

  1. Lista de objetos mspan en los que no hay objetos libres, o aquellos mspan que están en mcache.
  2. Lista de objetos mspan que tienen objetos libres.


Estructura central

Cada estructura central existe dentro de la estructura mheap.

▍ Estructura del mapa


La estructura mheap está representada por un objeto que maneja la gestión del montón en Go. Solo hay un objeto global de este tipo que posee un espacio de direcciones virtual.


Estructura Mheap

Como puede ver en el diagrama anterior, la estructura mheap contiene una matriz de estructuras centrales. Este conjunto contiene estructuras centrales para todas las clases de tamaño.

 central [numSpanClasses]struct { mcentral mcentral   pad     [sys.CacheLineSize unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte } 

Dado que tenemos una estructura central para cada clase de tamaño, cuando mcache solicita la estructura mspan de mcentral, se aplica un bloqueo en el nivel central individual, como resultado, las solicitudes de otras estructuras mspan solicitantes de mcache de otros tamaños pueden ser atendidas al mismo tiempo.

La alineación (almohadilla) asegura que las estructuras centrales estén separadas entre sí por el número de bytes correspondientes al valor CacheLineSize . Como resultado, cada mcentral.lock tiene su propia línea de caché, lo que evita los problemas asociados con el uso compartido de memoria falsa.

¿Qué sucede si la lista central está vacía? Luego, mcentral recibe una secuencia de páginas de mheap para asignar fragmentos de memoria de la clase de tamaño requerida.

  • free[_MaxMHeapList]mSpanList es una matriz de spanList. La estructura de mspan en cada spanList consta de 1 ~ 127 (_MaxMHeapList - 1) páginas. Por ejemplo, free [3] es una lista vinculada de estructuras mspan que contiene 3 páginas. La palabra "libre" en este caso indica que estamos hablando de una lista vacía en la que no se asigna memoria. Una lista puede ser, en lugar de estar vacía, una lista en la que se asigna memoria (ocupada).
  • freelarge mSpanList es una lista de estructuras de mspan gratuitas. El número de páginas por elemento (es decir, mspan) es superior a 127. Para admitir esta lista, se utiliza la estructura de datos mtreap. La lista de estructuras mspan ocupadas se llama busylarge.

Los objetos de más de 32 Kb se consideran objetos grandes, la memoria para ellos se asigna directamente desde mheap. Las solicitudes para asignar memoria para tales objetos se realizan mediante un bloqueo, como resultado, en un momento dado, una solicitud similar puede procesarse desde un solo procesador lógico.

El proceso de asignación de memoria para objetos.


  • Si el tamaño del objeto excede los 32 Kb, se considera grande, la memoria para él se asigna directamente desde mheap.
  • Si el tamaño del objeto es inferior a 16 Kb, se utiliza el mecanismo de mcache llamado pequeño asignador.
  • Si el tamaño del objeto está en el rango de 16-32 Kb, resulta qué clase de tamaño (sizeClass) usar, entonces se asigna un bloque adecuado en mcache.
  • Si no hay bloques disponibles en la clase de tamaño correspondiente a mcache, se llama a mcentral.
  • Si mcentral no tiene bloques libres, llaman a mheap y buscan el mspan más adecuado. Si el tamaño de memoria requerido por la aplicación resulta ser más grande de lo que se puede asignar, el tamaño de memoria solicitado se procesará para que sea posible devolver tantas páginas como sea necesario por el programa, formando una nueva estructura de mspan.
  • Si la memoria virtual de la aplicación aún no es suficiente, se accede al sistema operativo para un nuevo conjunto de páginas (se requiere al menos 1 MB de memoria).

De hecho, a nivel del sistema operativo, Go solicita la asignación de piezas de memoria aún más grandes llamadas arenas. La asignación simultánea de grandes fragmentos de memoria le permite encontrar un compromiso entre la cantidad de memoria asignada a la aplicación y el costoso acceso al sistema operativo en términos de rendimiento.

La memoria solicitada en el montón se asigna desde la arena. Considere este mecanismo.

Memoria virtual ir


Eche un vistazo al uso de la memoria con un programa simple escrito en Go:

 func main() {   for {} } 


Información del proceso del programa

El espacio de direcciones virtuales de incluso un programa tan simple es de aproximadamente 100 MB, mientras que el índice RSS es de solo 696 Kb. Primero, intentemos averiguar el motivo de esta discrepancia.


Información de mapas y smap

Aquí puede ver las áreas de memoria, cuyo tamaño es aproximadamente igual a 2 MB, 64 MB, 32 MB. ¿Qué tipo de memoria es esta?

▍Arena


Resulta que la memoria virtual en Go consiste en un conjunto de arenas. El tamaño de memoria inicial destinado al almacenamiento dinámico corresponde a una arena, es decir, 64 MB (esto es relevante para Go 1.11.5).


Tamaño actual de arena en varios sistemas

Como resultado, la memoria necesaria para las necesidades actuales del programa se asigna en pequeñas porciones. Este proceso comienza con una arena de 64 MB.

Esos indicadores numéricos de los que estamos hablando aquí no deben tomarse para algunos valores absolutos y sin cambios. Pueden cambiar. Anteriormente, por ejemplo, Go reservó un espacio virtual continuo por adelantado, en sistemas de 64 bits el tamaño de la arena era 512 GB (es interesante pensar en lo que sucede si la demanda de memoria real es tan grande que mmap rechazará la solicitud correspondiente).

De hecho, llamamos a un montón de arenas un montón. En Go, las arenas se perciben como fragmentos de memoria, divididos en bloques de 8192 bytes (8 Kb) de tamaño.


Una arena de 64 MB

Go tiene un par de sabores más de bloques: span y mapa de bits. La memoria para ellos se asigna fuera del montón, almacenan metadatos de arena. Se utilizan principalmente en la recolección de basura.
Aquí hay un resumen general de cómo funcionan los mecanismos de asignación de memoria en Go.


Esquema general de los mecanismos de asignación de memoria en Go

Resumen


En general, se puede observar que en este material describimos los subsistemas para trabajar con la memoria Go en términos muy generales. La idea principal del subsistema de memoria en Go es asignar memoria usando varias estructuras y cachés de diferentes niveles. Esto tiene en cuenta el tamaño de los objetos para los que se asigna memoria.

La representación de un solo bloque de direcciones de memoria continua recibidas del sistema operativo en forma de una estructura multinivel aumenta la eficiencia del mecanismo de asignación de memoria debido al hecho de que este enfoque evita el bloqueo. La asignación de recursos, teniendo en cuenta el tamaño de los objetos que deben almacenarse en la memoria, reduce la fragmentación y, después de liberar memoria, le permite acelerar la recolección de basura.

Estimados lectores! ¿Ha encontrado problemas causados ​​por un mal funcionamiento de la memoria en los programas escritos en Go?

Source: https://habr.com/ru/post/442648/


All Articles