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:
Global
int x, y;
Thread 1 Thread 2
x = 17; cout << y << " ";
y = 37; cout << x << endl;
¿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:
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, 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) o 0 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:
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ápida que en el ejemplo anterior.
Finalmente, si solo necesita mantener en orden determinadas cargas y tiendas, 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 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
o 37 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 .