C ++ 11 introdujo un modelo de memoria estandarizado. ¿Qué significa esto? ¿Y cómo afectará esto a la programación en C ++?

C ++ 11 introdujo un modelo de memoria estandarizado, pero ¿qué significa esto exactamente? ¿Y cómo afectará esto a la programación en C ++?

Este artículo ( Gavin Clark citando a Herb Sutter ) dice que

El modelo de memoria significa que el código C ++ ahora tiene una biblioteca estandarizada para llamar, independientemente de quién creó el compilador y en qué plataforma se ejecuta. Hay una forma estándar de controlar cómo los distintos hilos se comunican con la memoria del procesador.

"Cuando se habla de separar [código] en diferentes núcleos que están en el estándar, estamos hablando del modelo de memoria. Lo vamos a optimizar sin violar las siguientes suposiciones que las personas van a hacer en el código", dijo Satter .

Bueno, puedo recordar este y otros párrafos similares que están disponibles en Internet (ya que tuve mi propio modelo de memoria desde el momento del nacimiento: P), e incluso puedo escribir una respuesta a las preguntas formuladas por otros, pero sinceramente, no estoy exactamente Entiendo esto

Los programadores de C ++ solían desarrollar aplicaciones de subprocesos múltiples incluso antes, así que, ¿qué importancia tiene si se trata de subprocesos POSIX o subprocesos de Windows o subprocesos de C ++ 11? ¿Cuáles son los beneficios? Quiero entender los detalles del bajo nivel.

También siento que el modelo de memoria C ++ 11 está relacionado de alguna manera con el soporte de subprocesos múltiples para C ++ 11, ya que a menudo los veo juntos. Si es así, ¿cómo? ¿Por qué deberían estar relacionados?

Como no sé cómo funciona el trabajo con varios subprocesos y qué modelo de memoria en su conjunto, ayúdeme a entender estos conceptos. :-)

1625
12 июня '11 в 2:30 2011-06-12 02:30 Nawaz se establece el 12 de junio de 2011 a las 2:30 a.m. 2011-06-12 02:30
@ 6 respuestas

Primero, debes aprender a pensar como un abogado por idioma.

La especificación de C ++ no se refiere a ningún compilador, sistema operativo o procesador específico. Se refiere a una máquina abstracta, que es una generalización de sistemas reales. En el mundo de los abogados, el trabajo de un programador es escribir código para una máquina abstracta; El trabajo del compilador es implementar este código en una máquina específica. Si codifica estrictamente la especificación, puede estar seguro de que su código se compilará y se ejecutará sin cambios en cualquier sistema con un compilador de C ++ compatible, ya sea hoy o después de 50 años.

La máquina abstracta en la especificación C ++ 98 / C ++ 03 es esencialmente de un solo hilo. Por lo tanto, es imposible escribir código C ++ de múltiples subprocesos, que se transfiere completamente de acuerdo con la especificación. La especificación ni siquiera dice nada acerca de la atomicidad de las cargas y el almacenamiento de la memoria o el orden de carga y almacenamiento de datos, por no mencionar cosas tales como mutexes.

Por supuesto, puede escribir código multiproceso en la práctica para sistemas específicos, por ejemplo, pthreads o Windows. Pero no hay una forma estándar de escribir código de subprocesos múltiples para C ++ 98 / C ++ 03.

Máquina abstracta en diseño multihilo C ++ 11. También tiene un modelo de memoria bien definido; es decir, dice que el compilador puede y no puede hacer cuando se trata de acceso a la memoria.

Considere el siguiente ejemplo en el que dos subprocesos acceden simultáneamente a un par de variables globales:

  Global int x, y; Thread 1 Thread 2 x = 17; cout << y << " "; y = 37; cout << x << endl; 

¿Qué podría sacar el hilo 2?

En C ++ 98 / C ++ 03, esto no es ni siquiera un comportamiento indefinido; La pregunta en sí no tiene sentido, ya que la norma no considera nada llamado "hilo".

En C ++ 11, el resultado es un comportamiento indefinido, porque las cargas y las tiendas no tienen que ser atómicas en absoluto. Puede que no parezca una mejora muy buena ... Y en sí misma no lo es.

Pero con C ++ 11, puedes escribir lo siguiente:

  Global atomic<int> x, y; Thread 1 Thread 2 x.store(17); cout << y.load() << " "; y.store(37); cout << x.load() << endl; 

Ahora todo se vuelve mucho más interesante. En primer lugar, el comportamiento se define aquí. El subproceso 2 ahora puede imprimir 0 0 (si funciona antes del subproceso 1), 37 17 (si se ejecuta después del subproceso 1) o 0 17 (si comienza después de que el subproceso 1 asigna x, pero antes de que asigne y ).

Lo que no puede imprimir es 37 0 porque el modo predeterminado para cargas atómicas / almacenamiento en C ++ 11 es garantizar una consistencia consistente. Esto significa que todas las cargas y almacenamientos deben ser "como si", ocurrieron en el orden en que los grabó en cada flujo, mientras que las operaciones entre los flujos pueden alternarse, pero el sistema es agradable. Por lo tanto, el comportamiento predeterminado de Atomics proporciona tanto la atomicidad como el orden de carga y almacenamiento.

Ahora, en un procesador moderno, asegurar una consistencia consistente puede ser costoso. En particular, el compilador probablemente emita barreras de memoria a gran escala entre cada acceso aquí. Pero si su algoritmo puede tolerar cargas y almacenes no administrados; es decir, si requiere atomicidad, pero no ordena; es decir, si puede sacar 37 0 como una salida de este programa, entonces puede escribir esto:

  Global atomic<int> x, y; Thread 1 Thread 2 x.store(17,memory_order_relaxed); cout << y.load(memory_order_relaxed) << " "; y.store(37,memory_order_relaxed); cout << x.load(memory_order_relaxed) << endl; 

Cuanto más moderno sea el procesador, más probable será que sea más rápido que el ejemplo anterior.

Finalmente, si solo necesita mantener ciertas cargas y almacenes en orden, puede escribir:

  Global atomic<int> x, y; Thread 1 Thread 2 x.store(17,memory_order_release); cout << y.load(memory_order_acquire) << " "; y.store(37,memory_order_release); cout << x.load(memory_order_acquire) << endl; 

Esto nos devuelve a las cargas y almacenes ordenados, por lo que 37 0 ya no es una salida posible, pero lo hace con una sobrecarga mínima. (En este ejemplo trivial, el resultado es el mismo que una consistencia consistente a gran escala; en un programa más grande esto no ocurrirá).

Por supuesto, si solo las salidas que desea ver, 0 0 o 37 17 , simplemente puede envolver un mutex alrededor del código fuente. Pero si lo lees lejos, estoy seguro de que ya sabes cómo funciona, y esta respuesta es más larga de lo que pensé :-).

Así que la línea de fondo. Los Mutexes son geniales, y C ++ 11 los estandariza. Pero a veces, por razones de rendimiento, necesita primitivos de nivel inferior (por ejemplo, un patrón clásico con comprobación de doble bloqueo ). El nuevo estándar proporciona gadgets de alto nivel, como mutexes y variables de estado, y también proporciona gadgets de bajo nivel, como tipos atómicos y varias opciones de protección de memoria. Por lo tanto, ahora puede escribir rutinas paralelas complejas de alto rendimiento completamente en el idioma especificado por el estándar, y puede estar seguro de que su código se compilará y funcionará sin cambios tanto en el presente como en el futuro.

Si bien, para ser sincero, si no es un experto y no está trabajando en un código serio de bajo nivel, probablemente deba atenerse a las exclusiones mutuas y las condiciones variables. Esto es lo que pretendo hacer.

Vea esta publicación del blog para más detalles.

1877
12 июня '11 в 3:23 2011-06-12 03:23 La respuesta la da Nemo el 12 de junio de 2011 a las 3:23 2011-06-12 03:23

Solo daré una analogía con la que entiendo el modelo de consistencia de la memoria (o modelo de memoria, para abreviar). Está inspirado en el artículo semántico de Lesley Lamport "El tiempo, las horas y el orden de los eventos en un sistema distribuido" . La analogía es relevante y tiene una importancia fundamental, pero puede ser redundante para muchas personas. Sin embargo, espero que esto proporcione una imagen mental (representación gráfica), lo que hace que sea más fácil razonar acerca de los modelos de consistencia de la memoria.

Le permite ver el historial de todas las ubicaciones de memoria en el diagrama espacio-tiempo, en el que el eje horizontal representa el espacio de direcciones (es decir, cada celda de memoria está representada por un punto en este eje), y el eje vertical representa el tiempo (veremos que en general , no hay concepto universal del tiempo). Por lo tanto, el historial de los valores almacenados en cada celda de memoria se representa mediante una columna vertical en esta dirección de memoria. Cada cambio en el valor se debe al hecho de que uno de los subprocesos escribe un nuevo valor en este lugar. Bajo la imagen de la memoria, entenderemos la totalidad / combinación de valores de todas las ubicaciones de memoria que se observan en un momento determinado , utilizando un flujo específico .

Cita de "El fundador de la coherencia y la consistencia de caché"

El modelo de memoria intuitivo (y el más restrictivo) es la consistencia secuencial (SC), en la que la ejecución de múltiples subprocesos debería verse como una alternancia de ejecuciones consecutivas de cada subproceso compuesto, como si los subprocesos estuvieran multiplexados en el tiempo en un procesador de un solo núcleo.

Este orden de memoria global puede variar de un programa a otro y puede no ser conocido de antemano. Un rasgo característico de SC es un conjunto de cortes horizontales en el diagrama de dirección-espacio-tiempo que representa los planos de simultaneidad (es decir, imágenes en memoria). En este plano, todos sus eventos (o valores de memoria) son simultáneos. Existe un concepto de tiempo absoluto en el que todos los subprocesos coinciden con los valores de memoria que son simultáneos. En SC, solo hay una imagen de memoria a la vez, común a todos los subprocesos. Es decir, en cada momento, todos los procesadores son coherentes con la imagen de la memoria (es decir, los contenidos de la memoria agregada). Esto no solo significa que todos los subprocesos ven la misma secuencia de valores para todas las ubicaciones de memoria, sino que también todos los procesadores ejecutan las mismas combinaciones de valores para todas las variables. Esto es lo mismo que decir que todas las hebras observan todas las operaciones de memoria (en todas las celdas de memoria) en el mismo orden completo.

En los modelos con memoria debilitada, cada hilo separará la dirección-espacio-tiempo a su manera, la única limitación es que los cortes de cada flujo no se intersectan entre sí, porque todos los hilos deben coincidir con el historial de cada celda de memoria individual (por supuesto, partes de diferentes hilos). pueden y se cruzarán entre sí). No hay una manera universal de cortarlo (sin la foliación privilegiada de la dirección espacio-tiempo). Las rebanadas no deben ser planas (o lineales). Pueden ser curvas, y esto es lo que pueden hacer los valores de flujo de lectura escritos por otro flujo, desde el que fueron escritos. Las historias de diferentes lugares de la memoria pueden deslizarse (o estirarse) de manera arbitraria entre sí cuando se ve una secuencia en particular . Cada hilo tendrá una idea diferente de qué eventos (o, equivalentemente, valores de memoria) son simultáneos. El conjunto de eventos (o valores de memoria) que se asocian simultáneamente con un flujo no son simultáneos con los otros. Por lo tanto, en un modelo de memoria debilitada, todos los subprocesos aún mantienen el mismo historial (es decir, una secuencia de valores) para cada ubicación de memoria. Pero pueden observar diferentes imágenes de memoria (es decir, combinaciones de los valores de todas las ubicaciones de memoria). Incluso si dos ubicaciones de memoria diferentes se graban por el mismo flujo en una secuencia, los otros flujos pueden observar dos valores recién registrados en un orden diferente.

[Ilustración de Wikipedia]

Los lectores familiarizados con la Relatividad Especial de Einsteins se darán cuenta de lo que estoy hablando. Traducción de las palabras de Minkowski al campo de los modelos de memoria: el espacio y el tiempo de direcciones son sombras del espacio-tiempo de direcciones. En este caso, cada observador (es decir, Flujo) proyectará las sombras del evento (es decir, Memoriza memoria / cargas) en su propia línea del mundo (es decir, su eje de tiempo) y su propio plano de simultaneidad (su eje del espacio de direcciones). ) Los temas en el modelo de memoria C ++ 11 corresponden a observadores que se mueven unos con otros en la teoría especial de la relatividad. La consistencia consistente corresponde al espacio-tiempo galileo (es decir, todos los observadores están de acuerdo en un orden absoluto de eventos y un sentido global de simultaneidad).

border=0

La similitud entre los modelos de memoria y la teoría especial de la relatividad se deriva del hecho de que ambos definen un conjunto de eventos parcialmente ordenados, a menudo llamado conjunto causal. Algunos eventos (es decir, el almacenamiento) pueden afectar (pero no afectar) a otros eventos. El flujo de C ++ 11 (o el observador en física) no es más que una cadena (es decir, un conjunto totalmente ordenado) de eventos (por ejemplo, la memoria se carga y almacena en posibles direcciones diferentes).

En la teoría de la relatividad, se restablece cierto orden a la imagen aparentemente caótica de eventos parcialmente ordenados, ya que el único orden temporal con el que todos los observadores están de acuerdo es el ordenamiento entre "eventos temporales" (es decir, aquellos eventos que en principio pueden estar relacionados por cualquier partícula). más lento que la velocidad de la luz en el vacío). Solo los eventos programados son ordenados invariablemente. Tiempo en Física, Craig Callender .

En el modelo de memoria C ++ 11, se utiliza un mecanismo similar (el modelo de lanzamiento de liberación de coherencia) para establecer estas relaciones causales locales .

Para garantizar la determinación de la secuencia de la memoria y la motivación de rechazo de SC, daré de Primer información sobre la consistencia de la memoria y la consistencia de la memoria caché.

Para una computadora con memoria compartida, el modelo de consistencia de la memoria determina el comportamiento arquitectónicamente visible de su sistema de memoria. El criterio para la corrección de un solo núcleo de procesador divide el comportamiento entre "un resultado correcto" y "muchas alternativas irregulares". Esto se debe al hecho de que la arquitectura del procesador proporciona que la ejecución del subproceso convierte el estado de entrada especificado en un estado de salida bien definido, incluso en el núcleo fuera de orden. Sin embargo, los modelos de consistencia con memoria compartida están relacionados con cargas y almacenamientos de múltiples subprocesos y generalmente permiten muchas ejecuciones correctas, evitando muchas (más) incorrectas. La posibilidad de múltiples ejecuciones correctas se debe al hecho de que ISA permite la ejecución simultánea de múltiples subprocesos, a menudo con muchas posibles intercepciones legítimas de comandos de diferentes subprocesos.

Los modelos de consistencia de memoria relajada o débil están motivados por el hecho de que la mayoría de los ordenamientos de memoria en modelos fuertes no son necesarios. Si el flujo actualiza diez elementos de datos y luego el indicador de sincronización, por lo general no es importante para los programadores si los elementos de datos se actualizan en orden entre sí, y solo se actualizan todos los elementos de datos antes de que se actualice el indicador (generalmente se implementan utilizando la instrucción FENCE). Los modelos relajados tienden a capturar esta mayor flexibilidad de pedidos y retienen solo los pedidos que "requieren" los programadores para obtener un mejor rendimiento y precisión de SC. Por ejemplo, en algunas arquitecturas, cada núcleo utiliza los buffers de escritura FIFO para almacenar los resultados de almacenamientos fijos (remotos) antes de escribir los resultados en cachés. Esta optimización mejora el rendimiento, pero viola SC. El búfer de escritura oculta el retraso en el servicio de los pases de almacenamiento. Dado que las tiendas son comunes, evitar la detención de la mayoría de ellas es una ventaja importante. Para un procesador de un solo núcleo, el búfer de escritura se puede hacer arquitectónicamente invisible, asegurando que la carga de la dirección A devuelva el almacenamiento más reciente a A, incluso si uno o más del almacenamiento para A está en el búfer de escritura. Esto se suele hacer pasando el valor del último almacenamiento en A a la carga desde A, donde el "último" se determina por el orden del programa o deteniendo la carga A, si el almacenamiento A está en el búfer de escritura. . Sin los buffers de escritura, el hardware es SC, pero con los buffers de escritura, este no es el caso, lo que hace que los buffers de escritura sean arquitectónicamente visibles en un procesador multi-core.

La reordenación del almacenamiento basado en la tienda puede ocurrir si el kernel tiene un búfer de escritura distinto al FIFO que permite que las tiendas salgan en un orden diferente al orden en que fueron ingresadas. Esto puede suceder si la primera tienda pierde la memoria caché y la segunda, o si la segunda tienda puede fusionarse con la tienda anterior (es decir, antes de la primera tienda). La reorganización de la carga también puede ocurrir en núcleos programados dinámicamente que ejecutan instrucciones desde el programa. Esto se puede comportar de la misma manera que reordenar las tiendas en un núcleo diferente (¿puedes pensar en un ejemplo de alternancia entre dos flujos?). Reordenar la carga temprana seguida del almacenamiento (reordenar almacenamiento-carga) puede llevar a muchas acciones incorrectas, como cargar un valor después de liberar un bloqueo, que lo protege (si el almacenamiento es una operación de desbloqueo). Tenga en cuenta que la reordenación en el repositorio también puede ocurrir debido a un rastreo local en un búfer de escritura FIFO comúnmente implementado, incluso con el núcleo, que ejecuta todos los comandos en el orden de ejecución del programa.

Dado que la consistencia de la memoria caché y la consistencia de la memoria a veces se confunden, también es instructivo tener esta cita:

A diferencia de la coherencia, la coherencia de caché no se muestra ni en el software ni en la consulta. Когерентность направлена ​​на то, чтобы кэши системы с разделяемой памятью были функционально невидимы как кеши в одноядерной системе. Правильная согласованность гарантирует, что программист не может определить, имеет ли и где система кэширует, анализируя результаты нагрузок и хранилищ. Это связано с тем, что правильная когерентность гарантирует, что кэши никогда не будут включать новое или другое поведение функционировать (программисты могут все еще иметь возможность вывести вероятную структуру кэша, используя информацию время ). Основная цель протоколов когерентности кеша - поддерживать инвариант одиночного писателя-множественного считывателя (SWMR) для каждой ячейки памяти. Важным различием между согласованностью и согласованностью является то, что согласованность указана в на основе расположения памяти , тогда как согласованность указана в отношении местоположений памяти all .