C ++ 11 introdujo un modelo de memoria estandarizado, pero ¿qué significa eso exactamente? ¿Y cómo va a afectar la programación en C ++?
Este artículo (de Gavin Clarke, quien cita 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 hizo el compilador y en qué plataforma se está ejecutando. Hay una forma estándar de controlar cómo los diferentes hilos se comunican con la memoria del procesador.
"Cuando se habla de dividir [el código] en diferentes núcleos que están en el estándar, estamos hablando del modelo de memoria. Vamos a optimizarlo sin romper los siguientes supuestos que la gente hará en el código", dijo Sutter .
Bueno, puedo memorizar este y otros párrafos similares disponibles en línea (ya que tengo mi propio modelo de memoria desde mi nacimiento: P) e incluso puedo publicar como respuesta a las preguntas formuladas por otros, pero para ser sincero, no entiendo exactamente esta.
Los programadores de C ++ solían desarrollar aplicaciones de subprocesos múltiples incluso antes, entonces, ¿qué importancia tiene si son hilos POSIX, Windows o C ++ 11? ¿Cuales son los beneficios? Quiero entender los detalles de bajo nivel.
También tengo la sensación de que el modelo de memoria C ++ 11 está relacionado de alguna manera con el soporte de subprocesos múltiples de C ++ 11, ya que a menudo los veo juntos. Si es así, ¿cómo exactamente? ¿Por qué deberían estar relacionados?
Como no sé cómo funcionan las partes internas de subprocesos múltiples y qué significa el modelo de memoria en general, por favor, ayúdenme a comprender estos conceptos. :-)
Respuestas:
Primero, debes aprender a pensar como un abogado de idiomas.
La especificación C ++ no hace referencia a ningún compilador, sistema operativo o CPU en particular. Hace referencia a una máquina abstracta que es una generalización de sistemas reales. En el mundo de Law Lawyer, el trabajo del programador es escribir código para la máquina abstracta; El trabajo del compilador es actualizar ese código en una máquina concreta. Al codificar rígidamente la especificación, puede estar seguro de que su código se compilará y ejecutará sin modificaciones en cualquier sistema con un compilador C ++ compatible, ya sea hoy o dentro de 50 años.
La máquina abstracta en la especificación C ++ 98 / C ++ 03 es fundamentalmente de un solo subproceso. Por lo tanto, no es posible escribir código C ++ multiproceso que sea "totalmente portátil" con respecto a la especificación. La especificación ni siquiera dice nada sobre la atomicidad de las cargas y las tiendas de memoria o el orden en que pueden ocurrir las cargas y las tiendas, no importa cosas como mutexes.
Por supuesto, puede escribir código multiproceso en la práctica para sistemas concretos concretos, como pthreads o Windows. Pero no hay una forma estándar de escribir código multiproceso para C ++ 98 / C ++ 03.
La máquina abstracta en C ++ 11 es multiproceso por diseño. También tiene un modelo de memoria bien definido ; es decir, dice lo que el compilador puede y no puede hacer cuando se trata de acceder a la memoria.
Considere el siguiente ejemplo, donde dos hilos acceden simultáneamente a un par de variables globales:
¿Cuál podría ser la salida del hilo 2?
Bajo C ++ 98 / C ++ 03, esto ni siquiera es un comportamiento indefinido; la pregunta en sí misma no tiene sentido porque el estándar no contempla nada llamado "hilo".
Bajo C ++ 11, el resultado es Comportamiento indefinido, porque las cargas y las tiendas no necesitan ser atómicas en general. Lo que puede no parecer una gran mejora ... Y por sí solo, no lo es.
Pero con C ++ 11, puedes escribir esto:
Ahora las cosas se ponen mucho más interesantes. En primer lugar, el comportamiento aquí está definido . El subproceso 2 ahora podría imprimirse
0 0
(si se ejecuta antes del subproceso 1),37 17
(si se ejecuta después del subproceso 1) o0 17
(si se ejecuta después del subproceso 1 se asigna a x pero antes de que se asigne a y).Lo que no puede imprimir es
37 0
porque el modo predeterminado para las cargas / almacenes atómicos en C ++ 11 es imponer una coherencia secuencial . Esto solo significa que todas las cargas y almacenes deben ser "como si" ocurrieran en el orden en que las escribió dentro de cada hilo, mientras que las operaciones entre hilos se pueden intercalar como quiera el sistema. Por lo tanto, el comportamiento predeterminado de los atómicos proporciona atomicidad y pedidos para cargas y tiendas.Ahora, en una CPU moderna, garantizar la coherencia secuencial puede ser costoso. En particular, es probable que el compilador emita barreras de memoria entre todos los accesos aquí. Pero si su algoritmo puede tolerar cargas y tiendas fuera de servicio; es decir, si requiere atomicidad pero no ordenar; es decir, si puede tolerar
37 0
como salida de este programa, entonces puede escribir esto:Cuanto más moderna sea la CPU, más probable será que sea más rápida que en el ejemplo anterior.
Finalmente, si solo necesita mantener en orden determinadas cargas y tiendas, puede escribir:
Esto nos lleva de vuelta a las cargas y tiendas ordenadas, por
37 0
lo que ya no es una salida posible, pero lo hace con una sobrecarga mínima. (En este ejemplo trivial, el resultado es el mismo que la consistencia secuencial completa; en un programa más amplio, no lo sería).Por supuesto, si las únicas salidas que desea ver son
0 0
o37 17
, puede envolver un mutex alrededor del código original. Pero si has leído hasta aquí, apuesto a que ya sabes cómo funciona, y esta respuesta ya es más larga de lo que pretendía :-).Entonces, el resultado final. Los mutexes son geniales y C ++ 11 los estandariza. Pero a veces, por razones de rendimiento, desea primitivas de nivel inferior (p. Ej., El clásico patrón de bloqueo de doble verificación ). El nuevo estándar proporciona dispositivos de alto nivel como mutexes y variables de condición, y también proporciona dispositivos de bajo nivel como tipos atómicos y los diversos sabores de la barrera de la memoria. Entonces, ahora puede escribir rutinas concurrentes sofisticadas y de alto rendimiento completamente dentro del lenguaje especificado por el estándar, y puede estar seguro de que su código se compilará y se ejecutará sin cambios tanto en los sistemas actuales como en los de mañana.
Aunque, para ser sincero, a menos que sea un experto y trabaje en algún código serio de bajo nivel, probablemente deba atenerse a mutexes y variables de condición. Eso es lo que pretendo hacer.
Para más información sobre estas cosas, vea esta publicación de blog .
fuente
i = i++
. El viejo concepto de puntos de secuencia ha sido descartado; El nuevo estándar especifica lo mismo usando una relación secuenciada antes , que es solo un caso especial del concepto más general entre hilos que sucede antes .Solo daré la analogía con la que entiendo los modelos de consistencia de memoria (o modelos de memoria, para abreviar). Está inspirado en el artículo seminal de Leslie Lamport "Time, Clocks, and the Ordering of Events in a Distributed System" . La analogía es adecuada y tiene un significado fundamental, pero puede ser exagerada para muchas personas. Sin embargo, espero que proporcione una imagen mental (una representación pictórica) que facilite el razonamiento sobre los modelos de consistencia de la memoria.
Veamos los historiales de todas las ubicaciones de memoria en un diagrama de espacio-tiempo en el que el eje horizontal representa el espacio de direcciones (es decir, cada ubicación de memoria está representada por un punto en ese eje) y el eje vertical representa el tiempo (veremos que, en general, no existe una noción universal del tiempo). El historial de valores que posee cada ubicación de memoria está, por lo tanto, representado por una columna vertical en esa dirección de memoria. Cada cambio de valor se debe a que uno de los hilos escribe un nuevo valor en esa ubicación. Por una imagen de memoria , nos referiremos al agregado / combinación de valores de todas las ubicaciones de memoria observables en un momento particular por un hilo particular .
Cita de "Una cartilla sobre consistencia de memoria y coherencia de caché"
Ese orden de memoria global puede variar de una ejecución del programa a otra y puede no conocerse de antemano. El rasgo característico de SC es el conjunto de cortes horizontales en el diagrama de dirección espacio-tiempo que representan planos de simultaneidad (es decir, imágenes de memoria). En un plano dado, todos sus eventos (o valores de memoria) son simultáneos. Existe una noción de tiempo absoluto , en la que todos los hilos acuerdan qué valores de memoria son simultáneos. En SC, en cada instante, solo hay una imagen de memoria compartida por todos los hilos. Es decir, en cada instante de tiempo, todos los procesadores acuerdan la imagen de la memoria (es decir, el contenido agregado de la memoria). Esto no solo implica que todos los subprocesos ven la misma secuencia de valores para todas las ubicaciones de memoria, sino que todos los procesadores observan lo mismocombinaciones de valores de todas las variables. Esto es lo mismo que decir que todas las operaciones de memoria (en todas las ubicaciones de memoria) se observan en el mismo orden total por todos los subprocesos.
En los modelos de memoria relajada, cada subproceso dividirá la dirección-espacio-tiempo a su manera, la única restricción es que los segmentos de cada subproceso no se cruzarán entre sí porque todos los subprocesos deben coincidir en el historial de cada ubicación de memoria individual (por supuesto , las rebanadas de diferentes hilos pueden cruzarse y se cruzarán entre sí). No hay una forma universal de dividirlo (no hay una foliación privilegiada de dirección-espacio-tiempo). Los cortes no tienen que ser planos (o lineales). Pueden ser curvos y esto es lo que puede hacer que un hilo lea los valores escritos por otro hilo fuera del orden en que fueron escritos. Los historiales de diferentes ubicaciones de memoria pueden deslizarse (o estirarse) arbitrariamente entre sí cuando son vistos por cualquier hilo particular.. Cada hilo tendrá un sentido diferente de qué eventos (o, equivalentemente, valores de memoria) son simultáneos. El conjunto de eventos (o valores de memoria) que son simultáneos a un hilo no son simultáneos a otro. Por lo tanto, en un modelo de memoria relajada, todos los hilos siguen observando el mismo historial (es decir, secuencia de valores) para cada ubicación de memoria. Pero pueden observar diferentes imágenes de memoria (es decir, combinaciones de valores de todas las ubicaciones de memoria). Incluso si dos ubicaciones de memoria diferentes están escritas por el mismo hilo en secuencia, los otros dos valores pueden observar los dos valores recién escritos en un orden diferente.
[Imagen de Wikipedia]
Los lectores familiarizados con la Teoría especial de la relatividad de Einstein notarán a qué me estoy refiriendo. Traduciendo las palabras de Minkowski en el reino de los modelos de memoria: el espacio de direcciones y el tiempo son sombras del espacio de direcciones-tiempo. En este caso, cada observador (es decir, hilo) proyectará sombras de eventos (es decir, almacenes / cargas de memoria) en su propia línea mundial (es decir, su eje de tiempo) y su propio plano de simultaneidad (su eje de espacio de direcciones) . Los subprocesos en el modelo de memoria C ++ 11 corresponden a observadores que se mueven entre sí en una relatividad especial. La coherencia secuencial corresponde al espacio-tiempo galileano (es decir, todos los observadores están de acuerdo en un orden absoluto de eventos y un sentido global de simultaneidad).
La semejanza entre los modelos de memoria y la relatividad especial se deriva del hecho de que ambos definen un conjunto de eventos parcialmente ordenados, a menudo llamado un conjunto causal. Algunos eventos (es decir, almacenes de memoria) pueden afectar (pero no verse afectados por) otros eventos. Un hilo C ++ 11 (u 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 direcciones posiblemente diferentes).
En la relatividad, se restablece cierto orden en la imagen aparentemente caótica de eventos parcialmente ordenados, ya que el único ordenamiento temporal en el que todos los observadores acuerdan es el ordenamiento entre eventos "temporales" (es decir, aquellos eventos que en principio son conectables por cualquier partícula que se vuelve más lenta) que la velocidad de la luz en el vacío). Solo los eventos relacionados con el tiempo se ordenan invariablemente. Tiempo en física, Craig Callender .
En el modelo de memoria C ++ 11, se utiliza un mecanismo similar (el modelo de coherencia de adquisición-liberación) para establecer estas relaciones de causalidad local .
Para proporcionar una definición de la coherencia de la memoria y una motivación para abandonar el SC, citaré "Una introducción a la coherencia de la memoria y la coherencia de la memoria caché"
Debido a que la coherencia de la memoria caché y la coherencia de la memoria a veces se confunden, es instructivo tener también esta cita:
Continuando con nuestra imagen mental, el SWMR invariante corresponde al requisito físico de que haya como máximo una partícula ubicada en cualquier ubicación, pero puede haber un número ilimitado de observadores de cualquier ubicación.
fuente
Esta es ahora una pregunta de varios años, pero siendo muy popular, vale la pena mencionar un recurso fantástico para aprender sobre el modelo de memoria C ++ 11. No veo ningún punto en resumir su charla para hacer esta otra respuesta completa, pero dado que este es el tipo que realmente escribió el estándar, creo que vale la pena ver la charla.
Herb Sutter tiene una charla de tres horas sobre el modelo de memoria C ++ 11 titulado "Armas atómicas <>", disponible en el sitio Channel9 - parte 1 y parte 2 . La charla es bastante técnica y cubre los siguientes temas:
La charla no profundiza en la API, sino más bien en el razonamiento, los antecedentes, bajo el capó y detrás de escena (¿Sabía que se agregó una semántica relajada al estándar solo porque POWER y ARM no admiten la carga sincronizada de manera eficiente?).
fuente
Significa que el estándar ahora define subprocesos múltiples y define lo que sucede en el contexto de subprocesos múltiples. Por supuesto, las personas usaban diferentes implementaciones, pero eso es como preguntar por qué deberíamos tener un
std::string
momento en el que todos podríamos estar usando unastring
clase casera .Cuando habla de hilos POSIX o hilos de Windows, esto es una ilusión, ya que en realidad habla de hilos x86, ya que es una función de hardware que se ejecuta simultáneamente. El modelo de memoria C ++ 0x ofrece garantías, ya sea que esté en x86, o ARM, o MIPS , o cualquier otra cosa que se le ocurra.
fuente
Para idiomas que no especifican un modelo de memoria, está escribiendo código para el idioma y el modelo de memoria especificado por la arquitectura del procesador. El procesador puede optar por reordenar los accesos de memoria para el rendimiento. Entonces, si su programa tiene carreras de datos (una carrera de datos es cuando es posible que múltiples núcleos / hiperprocesos accedan simultáneamente a la misma memoria), entonces su programa no es multiplataforma debido a su dependencia del modelo de memoria del procesador. Puede consultar los manuales del software Intel o AMD para averiguar cómo los procesadores pueden reordenar los accesos a la memoria.
Muy importante, los bloqueos (y la semántica de concurrencia con bloqueo) generalmente se implementan de forma multiplataforma ... Entonces, si está utilizando bloqueos estándar en un programa multiproceso sin carreras de datos, entonces no tiene que preocuparse por los modelos de memoria multiplataforma .
Curiosamente, los compiladores de Microsoft para C ++ tienen una semántica de adquisición / lanzamiento de volátil, que es una extensión de C ++ para tratar la falta de un modelo de memoria en C ++ http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs .80) .aspx . Sin embargo, dado que Windows solo se ejecuta en x86 / x64, eso no dice mucho (los modelos de memoria Intel y AMD hacen que sea fácil y eficiente implementar la semántica de adquisición / lanzamiento en un idioma).
fuente
Si usa mutexes para proteger todos sus datos, realmente no debería preocuparse. Mutexes siempre ha proporcionado suficientes garantías de orden y visibilidad.
Ahora, si utilizó algoritmos atómicos o sin bloqueo, debe pensar en el modelo de memoria. El modelo de memoria describe con precisión cuándo los atómicos brindan garantías de orden y visibilidad, y proporciona cercas portátiles para garantías codificadas a mano.
Anteriormente, los atómicos se harían usando intrínsecos del compilador, o alguna biblioteca de nivel superior. Las cercas se habrían hecho usando instrucciones específicas de la CPU (barreras de memoria).
fuente
Las respuestas anteriores llegan a los aspectos más fundamentales del modelo de memoria C ++. En la práctica, la mayoría de los usos de
std::atomic<>
"solo trabajo", al menos hasta que el programador optimiza en exceso (por ejemplo, tratando de relajar demasiadas cosas).Hay un lugar donde los errores siguen siendo comunes: bloqueos de secuencia . Hay una discusión excelente y fácil de leer sobre los desafíos en https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf . Los bloqueos de secuencia son atractivos porque el lector evita escribir en la palabra de bloqueo. El siguiente código se basa en la Figura 1 del informe técnico anterior y destaca los desafíos al implementar bloqueos de secuencia en C ++:
Tan poco intuitivo como parece al principio,
data1
ydata2
debe serloatomic<>
. Si no son atómicos, entonces podrían leerse (inreader()
) al mismo tiempo que se escriben (inwriter()
). Según el modelo de memoria de C ++, esta es una carrera, incluso sireader()
nunca utiliza los datos . Además, si no son atómicos, el compilador puede almacenar en caché la primera lectura de cada valor en un registro. Obviamente no querrás eso ... quieres volver a leer en cada iteración delwhile
buclereader()
.Tampoco es suficiente hacerlos
atomic<>
y acceder a ellos conmemory_order_relaxed
. La razón de esto es que las lecturas de seq (inreader()
) solo tienen semántica adquirida . En términos simples, si X e Y son accesos de memoria, X precede a Y, X no es una adquisición o liberación, e Y es una adquisición, entonces el compilador puede reordenar Y antes de X. Si Y fue la segunda lectura de seq, y X era una lectura de datos, tal reordenamiento rompería la implementación del bloqueo.El artículo da algunas soluciones. El que tiene el mejor rendimiento hoy es probablemente el que usa un
atomic_thread_fence
conmemory_order_relaxed
antes de la segunda lectura del seqlock. En el documento, es la Figura 6. No estoy reproduciendo el código aquí, porque cualquiera que haya leído hasta aquí realmente debería leer el documento. Es más preciso y completo que este post.El último problema es que podría no ser natural hacer que las
data
variables sean atómicas. Si no puede en su código, entonces debe tener mucho cuidado, porque la conversión de no atómico a atómico solo es legal para los tipos primitivos. Se supone que C ++ 20 agregaatomic_ref<>
, lo que hará que este problema sea más fácil de resolver.Para resumir: incluso si cree que comprende el modelo de memoria C ++, debe tener mucho cuidado antes de rodar sus propios bloqueos de secuencia.
fuente
C y C ++ solían definirse mediante un seguimiento de ejecución de un programa bien formado.
Ahora están medio definidos por un seguimiento de ejecución de un programa, y medio a posteriori por muchos ordenamientos en objetos de sincronización.
Lo que significa que estas definiciones de lenguaje no tienen ningún sentido ya que no hay un método lógico para mezclar estos dos enfoques. En particular, la destrucción de un mutex o una variable atómica no está bien definida.
fuente