multithreading 14 - C++11 introdujo un modelo de memoria estandarizado.Qué significa eso? ¿Y cómo va a afectar la programación en C++?




que 17 (6)

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 "El tiempo, los relojes y el orden de los eventos en un sistema distribuido" . La analogía es adecuada y tiene un significado fundamental, pero puede ser una exageración 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 las historias de todas las ubicaciones de memoria en un diagrama espacio-tiempo en el que el eje horizontal representa el espacio de direcciones (es decir, cada ubicación de la memoria está representada por un punto en ese eje) y el eje vertical representa el tiempo (veremos que en general, no hay una noción universal del tiempo). El historial de valores que tiene 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 subprocesos escribe un nuevo valor en esa ubicación. Por una imagen de memoria , significaremos el agregado / combinación de valores de todas las ubicaciones de memoria observables en un momento determinado por un hilo en particular .

Cita de "Un manual sobre coherencia de memoria y coherencia de caché"

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

Ese orden de memoria global puede variar de una ejecución del programa a otra y puede no ser conocido de antemano. El rasgo característico de SC es el conjunto de cortes horizontales en el diagrama de dirección-espacio-tiempo que representa 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 subprocesos coinciden en qué valores de memoria son simultáneos. En SC, en cada instante, solo hay una imagen de memoria compartida por todos los subprocesos. Eso es, en cada instante de tiempo, todos los procesadores están de acuerdo con 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 también todos los procesadores observan las mismas combinaciones de valores de todas las variables. Esto es lo mismo que decir que todas las hebras observan todas las operaciones de memoria (en todas las ubicaciones de memoria) en el mismo orden total.

En los modelos de memoria relajada, cada hilo dividirá la dirección espacio-tiempo a su manera, la única restricción es que los segmentos de cada hilo no se crucen entre sí porque todos los hilos deben coincidir en el historial de cada ubicación de memoria individual (por supuesto , rebanadas de hilos diferentes pueden, y se cruzarán entre sí). No hay una manera universal de dividirlo (no hay una foliación privilegiada de la dirección-espacio-tiempo). Las rebanadas no tienen que ser planas (o lineales). Pueden ser curvas y esto es lo que puede hacer que un hilo lea valores escritos por otro hilo en el orden en que fueron escritos. Las historias de diferentes ubicaciones de memoria pueden deslizarse (o estirarse) de manera arbitraria entre sí cuando se ven en cualquier hilo en 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 subprocesos 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 subproceso en secuencia, los otros valores pueden ser observados en diferente orden por otros subprocesos.

[Imagen de Wikipedia]

Los lectores familiarizados con la Teoría de la Relatividad Especial de Einstein notarán a qué me estoy refiriendo. Traducir las palabras de Minkowski al reino de los modelos de memoria: espacio y tiempo de direcciones son sombras del espacio-tiempo-direcciones. En este caso, cada observador (es decir, el hilo) proyectará sombras de eventos (es decir, almacenes / cargas de memoria) en su propia línea del mundo (es decir, su eje de tiempo) y su propio plano de simultaneidad (su eje de dirección-espacio) . Los hilos en el modelo de memoria C ++ 11 corresponden a observadores que se mueven unos respecto a otros en relatividad especial. La consistencia 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 debe al hecho de que ambos definen un conjunto de eventos parcialmente ordenado, a menudo llamado conjunto causal. Algunos eventos (es decir, los almacenes de memoria) pueden afectar (pero no ser 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 relatividad, se restablece cierto orden a la imagen aparentemente caótica de eventos parcialmente ordenados, ya que el único ordenamiento temporal en el que todos los observadores están de acuerdo es el ordenamiento entre eventos "cronológicos" (es decir, aquellos eventos que en principio son conectables por cualquier partícula que va más lento). que la velocidad de la luz en un vacío). Solo los eventos relacionados con el tiempo están ordenados invariablemente. Tiempo en Física, Craig Callender .

En el modelo de memoria C ++ 11, se utiliza un mecanismo similar (el modelo de consistencia de adquisición y liberación) para establecer estas relaciones de causalidad locales .

Para proporcionar una definición de la consistencia de la memoria y una motivación para abandonar el SC, citaré "Una introducción a la consistencia de la memoria y la coherencia de la memoria caché".

Para una máquina de memoria compartida, el modelo de consistencia de memoria define el comportamiento arquitectónicamente visible de su sistema de memoria. El criterio de corrección para el comportamiento de las particiones centrales de un solo procesador entre " un resultado correcto " y " muchas alternativas incorrectas ". Esto se debe a que la arquitectura del procesador exige que la ejecución de un subproceso transforme un estado de entrada dado en un solo estado de salida bien definido, incluso en un núcleo fuera de orden. Sin embargo, los modelos de consistencia de memoria compartida se refieren a las cargas y almacenes de múltiples subprocesos y, por lo general, permiten muchas ejecuciones correctas y no permiten muchas (más) incorrectas. La posibilidad de múltiples ejecuciones correctas se debe a que la ISA permite que múltiples subprocesos se ejecuten simultáneamente, a menudo con muchas interrelaciones legales posibles de instrucciones de diferentes subprocesos.

Los modelos de consistencia de memoria relajados o débiles están motivados por el hecho de que la mayoría de los pedidos de memoria en modelos fuertes no son necesarios. Si un hilo actualiza diez elementos de datos y luego un indicador de sincronización, a los programadores generalmente no les importa si los elementos de datos se actualizan en orden entre sí, pero solo que todos los elementos de datos se actualizan antes de que se actualice el indicador (generalmente implementado usando las instrucciones FENCE). ). Los modelos relajados buscan capturar esta mayor flexibilidad de pedidos y preservan solo los pedidos que los programadores " requieren " para obtener un mayor rendimiento y corrección de SC. Por ejemplo, en ciertas arquitecturas, los búferes de escritura FIFO son utilizados por cada núcleo para mantener los resultados de las tiendas comprometidas (retiradas) antes de escribir los resultados en las memorias caché. Esta optimización mejora el rendimiento pero viola SC. El búfer de escritura oculta la latencia de mantenimiento de una tienda. Debido a que las tiendas son comunes, el hecho de poder evitar el estancamiento en la mayoría de ellas es un beneficio importante. Para un procesador de un solo núcleo, un búfer de escritura se puede hacer arquitectónicamente invisible al garantizar que una carga en la dirección A devuelve el valor de la tienda más reciente a A, incluso si una o más tiendas en A están en el búfer de escritura. Esto se suele hacer ya sea pasando el valor del almacén más reciente a A a la carga desde A, donde "más reciente" se determina por orden del programa, o deteniendo una carga de A si un almacén a A está en el búfer de escritura . Cuando se utilizan varios núcleos, cada uno tendrá su propio búfer de escritura de omisión. Sin buffers de escritura, el hardware es SC, pero con buffers de escritura, no lo es, haciendo que los buffers de escritura sean arquitectónicamente visibles en un procesador multinúcleo.

El reordenamiento de la tienda puede ocurrir si un núcleo tiene un búfer de escritura no FIFO que permite a las tiendas salir en un orden diferente al orden en el que ingresaron. Esto puede ocurrir si la primera tienda se pierde en el caché mientras que la segunda visita o si la segunda tienda puede unirse con una tienda anterior (es decir, antes de la primera tienda). El reordenamiento de carga y carga también puede ocurrir en núcleos programados dinámicamente que ejecutan instrucciones fuera de orden del programa. Eso puede comportarse de la misma manera que reordenar las tiendas en otro núcleo (¿Se puede dar un ejemplo de intercalación entre dos hilos?). Reordenar una carga anterior con una tienda posterior (una reordenación de la tienda de carga) puede causar muchos comportamientos incorrectos, como cargar un valor después de liberar el bloqueo que lo protege (si la tienda es la operación de desbloqueo). Tenga en cuenta que las reordenaciones de carga de la tienda también pueden surgir debido a la omisión local en el búfer de escritura FIFO comúnmente implementado, incluso con un núcleo que ejecuta todas las instrucciones en el orden del programa.

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

A diferencia de la coherencia, la coherencia de caché no es visible para el software ni es necesaria. Coherence busca hacer que los cachés de un sistema de memoria compartida sean tan invisibles funcionalmente como los cachés en un sistema de un solo núcleo. La coherencia correcta garantiza que un programador no pueda determinar si un sistema tiene cachés y dónde los almacena, analizando los resultados de las cargas y los almacenes. Esto se debe a que la coherencia correcta garantiza que las memorias caché nunca habiliten un comportamiento funcional nuevo o diferente (los programadores aún pueden ser capaces de inferir la estructura de la memoria caché usando la información de tiempo ). El propósito principal de los protocolos de coherencia de caché es mantener invariante el único escritor-escritor-múltiples (SWMR) para cada ubicación de memoria. Una distinción importante entre coherencia y consistencia es que la coherencia se especifica en una base de ubicación por memoria , mientras que la coherencia se especifica con respecto a todas las ubicaciones de memoria.

Continuando con nuestra imagen mental, el invariante SWMR 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.

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 (por Gavin Clarke que 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 distintos subprocesos se comunican con la memoria del procesador.

"Cuando se trata de dividir [código] entre diferentes núcleos que están en el estándar, estamos hablando del modelo de memoria. Vamos a optimizarlo sin romper las siguientes suposiciones que las personas van a hacer 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 preguntas formuladas por otros, pero para ser honesto, no entiendo exactamente. esta.

Entonces, lo que básicamente quiero saber es que los programadores de C ++ solían desarrollar aplicaciones de subprocesos múltiples incluso antes, así que, ¿qué importa si se trata de subprocesos POSIX o subprocesos de Windows o subprocesos 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 de 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 los aspectos internos de los subprocesos múltiples y qué significa el modelo de memoria en general, ayúdeme a entender estos conceptos. :-)


Primero, tienes que aprender a pensar como un abogado de lenguaje.

La especificación de 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 Language 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 de forma rígida según la especificación, puede estar seguro de que su código se compilará y ejecutará sin modificaciones en cualquier sistema con un compilador compatible con C ++, 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 hilo. Por lo tanto, no es posible escribir código C ++ multihebra que sea "completamente portátil" con respecto a la especificación. La especificación ni siquiera dice nada acerca de la atomicidad de las cargas y almacenes de memoria o el orden en el que pueden ocurrir las cargas y los almacenes, sin importar las cosas como las exclusiones mutuas.

Por supuesto, puede escribir código de múltiples subprocesos en la práctica para sistemas concretos particulares, como pthreads o Windows. Pero no hay una forma estándar de escribir código de subprocesos múltiples 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, 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 hacer Thread 2?

Bajo C ++ 98 / C ++ 03, esto no es un comportamiento indefinido; La pregunta en sí no tiene sentido porque la norma no contempla nada llamado "hilo".

Bajo C ++ 11, el resultado es un comportamiento indefinido, porque las cargas y los almacenes no necesitan ser atómicos en general. Lo que puede no parecer una gran mejora ... Y, por sí mismo, no lo es.

Pero con C ++ 11, puedes escribir esto:

           Global
           atomic<int> x, y;

Thread 1                 Thread 2
x.store(17);             cout << y.load() << " ";
y.store(37);             cout << x.load() << endl;

Ahora las cosas se ponen mucho más interesantes. En primer lugar, aquí se define el comportamiento. El subproceso 2 ahora puede imprimir 0 0 (si se ejecuta antes del subproceso 1), 37 17 (si se ejecuta después del subproceso 1) o 0 17 (si se ejecuta después de que el subproceso 1 asigne 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 / tiendas atómicas en C ++ 11 es imponer la coherencia secuencial . Esto solo significa que todas las cargas y almacenes deben ser "como si" ocurrieran en el orden en que los escribiste dentro de cada hilo, mientras que las operaciones entre los hilos pueden intercalarse, sin embargo, el sistema lo desea. Por lo tanto, el comportamiento predeterminado de Atomics proporciona atomicidad y ordenamiento para cargas y almacenes.

Ahora, en una CPU moderna, garantizar la coherencia secuencial puede ser costoso. En particular, es probable que el compilador emita barreras de memoria en toda regla entre cada acceso aquí. Pero si su algoritmo puede tolerar cargas y almacenes fuera de orden; es decir, si requiere atomicidad pero no ordenamiento; es decir, si puede tolerar 37 0 como 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 moderna sea la CPU, más probable será que sea más rápido que el ejemplo anterior.

Finalmente, si solo necesita mantener determinadas 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 lleva de vuelta 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 la consistencia secuencial en toda regla; en un programa más grande, no lo sería).

Por supuesto, si las únicas salidas que desea ver son 0 0 o 37 17 , simplemente puede envolver un mutex alrededor del código original. Pero si has leído hasta aquí, apuesto a que ya sabes cómo funciona eso, y esta respuesta ya es más larga de lo que pretendía :-).

Por lo tanto, la línea de fondo. Los Mutexes son geniales, y C ++ 11 los estandariza. Pero a veces, por razones de rendimiento, desea primitivas de nivel inferior (por ejemplo, el patrón de bloqueo de doble control clásico). El nuevo estándar proporciona gadgets de alto nivel como exclusión mutua y variables de condición, y también proporciona gadgets de bajo nivel como los tipos atómicos y los diversos sabores de la barrera de la memoria. Así que 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 en los sistemas de hoy y de mañana.

Aunque, para ser sincero, a menos que sea un experto y esté trabajando en un código serio de bajo nivel, probablemente debería atenerse a las exclusiones y variables de condición. Eso es lo que pretendo hacer.

Para obtener más información sobre este tema, consulte esta publicación de blog .


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. Por lo tanto, si su programa tiene carreras de datos (una carrera de datos es cuando es posible que múltiples núcleos / hipercrocesos accedan a la misma memoria simultáneamente), 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 el bloqueo) se implementan normalmente 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 semántica de adquisición / lanzamiento para volatile, que es una extensión de C ++ para hacer frente a 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 significa mucho (los modelos de memoria de Intel y AMD hacen que sea fácil y eficiente implementar semántica de adquisición / lanzamiento en un idioma).


Esta es ahora una pregunta de varios años, pero al ser muy popular, vale la pena mencionar un recurso fantástico para aprender sobre el modelo de memoria C ++ 11. No tengo sentido resumir su charla para hacer que esta sea 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 "atomic <> Weapons", disponible en el sitio Channel9, parte 1 y parte 2 . La charla es bastante técnica, y cubre los siguientes temas:

  1. Optimizaciones, razas y el modelo de memoria.
  2. Ordenar - Qué: Adquirir y Liberar
  3. Pedidos: cómo: Mutexes, atómicas y / o cercas
  4. Otras restricciones en compiladores y hardware
  5. Generación de código y rendimiento: x86 / x64, IA64, POWER, ARM
  6. Atomica Relajada

La charla no da más detalles sobre la API, sino sobre el razonamiento, el fondo, bajo el capó y detrás de escena (¿sabía que la semántica relajada se agregó al estándar solo porque POWER y ARM no admiten la carga sincronizada de manera eficiente?).


Significa que el estándar ahora define los subprocesos múltiples, y define lo que sucede en el contexto de varios subprocesos. Por supuesto, la gente usaba diferentes implementaciones, pero eso es como preguntar por qué deberíamos tener una std::string cuando todos podríamos usar una clase de string enrollada en casa.

Cuando se habla de subprocesos POSIX o subprocesos de Windows, entonces esto es un poco de una ilusión, ya que en realidad estamos hablando de subprocesos 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, ARM, MIPS o cualquier otra cosa que pueda encontrar.


¿Cómo es la introducción de operadores de conversión explícitos un cambio de ruptura? La versión anterior seguirá siendo tan "válida" como antes.

Sí, el cambio del operator void*() const al explicit operator bool() const será un cambio importante, pero solo si se usa de forma incorrecta dentro y fuera de sí mismo. El código de conformidad no se romperá.

Ahora, otro cambio importante es la prohibición de reducir las conversiones durante la inicialización agregada :

int a[] = { 1.0 }; // error

Edición : solo recordatorio, std::identity<T> se eliminará en C ++ 0x (vea la nota). Es una estructura de conveniencia para hacer tipos dependientes. Dado que la estructura realmente no hace mucho, esto debería arreglarlo:

template<class T>
struct identity{
  typedef T type;
};




c++ multithreading c++11 language-lawyer memory-model