¿Puede explicar por qué varios subprocesos necesitan bloqueos en una CPU de un solo núcleo?

18

Suponga que estos subprocesos se ejecutan en CPU de un solo núcleo. Como CPU solo ejecuta una instrucción en un ciclo. Eso se dice, incluso pensando que comparten el recurso de la CPU. pero la computadora asegura que una vez una instrucción. Entonces, ¿es innecesario el bloqueo para subprocesos múltiples?

pitón
fuente
Porque la memoria transaccional de software aún no es convencional.
dan_waterworth
@dan_waterworth Debido a que la memoria transaccional de software falla gravemente en niveles de complejidad no triviales, ¿quiere decir? ;)
Mason Wheeler
Apuesto a que Rich Hickey no está de acuerdo con eso.
Robert Harvey
@MasonWheeler, mientras que el bloqueo no trivial funciona increíblemente bien y nunca ha sido una fuente de errores sutiles que son difíciles de rastrear. STM funciona bien con niveles de complejidad no triviales, pero es problemático cuando hay contención. En esos casos, algo como esto , que es una forma más restrictiva de STM, es mejor. Por cierto, con el cambio de título, me llevó un tiempo averiguar por qué comenté como lo hice.
dan_waterworth

Respuestas:

32

Esto se ilustra mejor con un ejemplo.

Supongamos que tenemos una tarea simple que queremos realizar varias veces en paralelo, y queremos realizar un seguimiento global de la cantidad de veces que se ha realizado la tarea, por ejemplo, contar los hits en una página web.

Cuando cada hilo llega al punto en el que está incrementando el conteo, su ejecución se verá así:

  1. Leer el número de visitas de la memoria a un registro del procesador
  2. Incrementa ese número.
  3. Escribe ese número en la memoria

Recuerde que cada hilo puede suspenderse en cualquier punto de este proceso. Entonces, si el subproceso A realiza el paso 1, y luego se suspende, luego el subproceso B realiza los tres pasos, cuando se reanuda el subproceso A, sus registros tendrán el número incorrecto de aciertos: sus registros se restaurarán, felizmente incrementará el número anterior de visitas y almacenar ese número incrementado.

Además, cualquier otro número de subprocesos podría haberse ejecutado durante el tiempo en que se suspendió el subproceso A, por lo que el recuento que el hilo A escribe al final podría estar muy por debajo del recuento correcto.

Por esa razón, es necesario asegurarse de que si un subproceso realiza el paso 1, debe realizar el paso 3 antes de que cualquier otro subproceso pueda realizar el paso 1, lo que puede realizar todos los subprocesos que esperan obtener un solo bloqueo antes de comenzar este proceso. , y liberando el bloqueo solo después de que se complete el proceso, de modo que esta "sección crítica" del código no se pueda entrelazar incorrectamente, lo que da como resultado un recuento incorrecto.

Pero, ¿y si la operación fuera atómica?

Sí, en la tierra de unicornios mágicos y arcoíris, donde la operación de incremento es atómica, entonces el bloqueo no sería necesario para el ejemplo anterior.

Sin embargo, es importante darse cuenta de que pasamos muy poco tiempo en el mundo de los unicornios mágicos y el arco iris. En casi todos los lenguajes de programación, la operación de incremento se divide en los tres pasos anteriores. Esto se debe a que, incluso si el procesador admite una operación de incremento atómico, esa operación es significativamente más costosa: tiene que leer de la memoria, modificar el número y volver a escribirla en la memoria ... y generalmente la operación de incremento atómico es una operación que puede fallar, lo que significa que la secuencia simple anterior debe reemplazarse con un bucle (como veremos a continuación).

Dado que, incluso en el código multiproceso, muchas variables se mantienen locales en un solo subproceso, los programas son mucho más eficientes si asumen que cada variable es local en un solo subproceso y permiten que los programadores se encarguen de proteger el estado compartido entre subprocesos. Especialmente dado que las operaciones atómicas no suelen ser suficientes para resolver problemas de subprocesos, como veremos más adelante.

Variables volátiles

Si deseamos evitar bloqueos para este problema en particular, primero tenemos que darnos cuenta de que los pasos descritos en nuestro primer ejemplo no son realmente lo que sucede en el código compilado moderno. Debido a que los compiladores suponen que solo un subproceso está modificando la variable, cada subproceso mantendrá su propia copia en caché de la variable, hasta que se necesite el registro del procesador para otra cosa. Siempre que tenga la copia en caché, se supone que no necesita volver a la memoria y volver a leerla (lo que sería costoso). Tampoco volverán a escribir la variable en la memoria siempre que se mantenga en un registro.

Podemos volver a la situación que dimos en el primer ejemplo (con los mismos problemas de subprocesos que identificamos anteriormente) marcando la variable como volátil , lo que le dice al compilador que esta variable está siendo modificada por otros, por lo que debe leerse o escrito en la memoria cada vez que se accede o se modifica.

Entonces, una variable marcada como volátil no nos llevará a la tierra de las operaciones de incremento atómico, solo nos acerca tanto como pensábamos que ya estábamos.

Hacer el incremento atómico

Una vez que estamos usando una variable volátil, podemos hacer que nuestra operación incremental sea atómica mediante el uso de una operación de conjunto condicional de bajo nivel que la mayoría de las CPU modernas admiten (a menudo denominadas comparar y establecer o comparar e intercambiar ). Este enfoque se toma, por ejemplo, en la clase AtomicInteger de Java :

197       /**
198        * Atomically increments by one the current value.
199        *
200        * @return the updated value
201        */
202       public final int incrementAndGet() {
203           for (;;) {
204               int current = get();
205               int next = current + 1;
206               if (compareAndSet(current, next))
207                   return next;
208           }
209       }

El bucle anterior realiza repetidamente los siguientes pasos, hasta que el paso 3 tiene éxito:

  1. Lea el valor de una variable volátil directamente de la memoria.
  2. Incrementa ese valor.
  3. Cambie el valor (en la memoria principal) si y solo si su valor actual en la memoria principal es el mismo que el valor que leímos inicialmente, utilizando una operación atómica especial.

Si el paso 3 falla (debido a que el valor fue cambiado por un subproceso diferente después del paso 1), nuevamente lee la variable directamente de la memoria principal y lo intenta nuevamente.

Si bien la operación de comparar e intercambiar es costosa, es un poco mejor que usar el bloqueo en este caso, porque si un subproceso se suspende después del paso 1, otros subprocesos que alcanzan el paso 1 no tienen que bloquear y esperar el primer subproceso, que puede evitar el costoso cambio de contexto. Cuando se reanuda el primer subproceso, fallará en su primer intento de escribir la variable, pero podrá continuar releyendo la variable, que de nuevo es probablemente menos costosa que el cambio de contexto que hubiera sido necesario con el bloqueo.

Entonces, podemos llegar a la tierra de los incrementos atómicos (u otras operaciones en una sola variable) sin usar bloqueos reales, a través de comparar e intercambiar.

Entonces, ¿cuándo es estrictamente necesario el bloqueo?

Si necesita modificar más de una variable en una operación atómica, entonces será necesario el bloqueo, no encontrará una instrucción de procesador especial para eso.

Sin embargo, siempre que esté trabajando en una sola variable y esté preparado para cualquier trabajo que haya fallado y tenga que leer la variable y comenzar de nuevo, comparar y cambiar será suficiente.

Consideremos un ejemplo en el que cada subproceso primero agrega 2 a la variable X, y luego multiplica X por dos.

Si X es inicialmente uno y se ejecutan dos subprocesos, esperamos que el resultado sea (((1 + 2) * 2) + 2) * 2 = 16.

Sin embargo, si los hilos se intercalan, podríamos, incluso con todas las operaciones siendo atómicas, en lugar de que ambas adiciones ocurran primero, y las multiplicaciones vengan después, resultando en (1 + 2 + 2) * 2 * 2 = 20.

Esto sucede porque la multiplicación y la suma no son operaciones conmutativas.

Por lo tanto, las operaciones en sí mismas siendo atómicas no son suficientes, debemos hacer que la combinación de operaciones sea atómica.

Podemos hacerlo mediante el bloqueo para serializar el proceso, o podríamos usar una variable local para almacenar el valor de X cuando comenzamos nuestro cálculo, una segunda variable local para los pasos intermedios, y luego usar compare-and-swap para establezca un nuevo valor solo si el valor actual de X es el mismo que el valor original de X. Si fallamos, tendríamos que comenzar de nuevo leyendo X y realizando los cálculos nuevamente.

Hay varias compensaciones involucradas: a medida que los cálculos se hacen más largos, es mucho más probable que el hilo en ejecución se suspenda, y el valor será modificado por otro hilo antes de reanudar, lo que significa que las fallas se vuelven mucho más probables, lo que lleva a un desperdicio tiempo de procesador En el caso extremo de un gran número de subprocesos con cálculos de ejecución muy larga, podríamos tener 100 subprocesos que lean la variable y se involucren en los cálculos, en cuyo caso solo el primero en terminar tendrá éxito al escribir el nuevo valor, los otros 99 aún complete sus cálculos, pero descubra al finalizar que no pueden actualizar el valor ... en ese momento cada uno leerá el valor y comenzará el cálculo nuevamente. Es probable que los 99 subprocesos restantes repitan el mismo problema, desperdiciando grandes cantidades de tiempo de procesador.

La serialización completa de la sección crítica a través de bloqueos sería mucho mejor en esa situación: 99 hilos se suspenderían cuando no obtuvieran el bloqueo, y correríamos cada hilo en orden de llegada al punto de bloqueo.

Si la serialización no es crítica (como en nuestro caso de incremento), y los cálculos que se perderían si la actualización del número falla son mínimos, puede haber una ventaja significativa al usar la operación de comparar e intercambiar, porque esa operación Es menos costoso que el bloqueo.

Theodore Murdock
fuente
pero, ¿y si el contraataque es atómico, era necesaria la cerradura?
pitón
@pythonee: si el incremento del contador es atómico, entonces posiblemente no. Pero en cualquier programa multiproceso de tamaño razonable, tendrá que realizar tareas no atómicas en un recurso compartido.
Doc Brown
1
A menos que esté utilizando un compilador intrínseco para hacer que el incremento sea atómico, probablemente no lo sea.
Mike Larsen
Sí, si la lectura / modificación (incremento) / escritura es atómica, el bloqueo es innecesario para esa operación. La instrucción DEC-10 AOSE (agregue una y omita si el resultado == 0) se hizo específicamente atómica para que pudiera usarse como un semáforo de prueba y ajuste. El manual menciona que era lo suficientemente bueno porque la máquina tardaría varios días en contar continuamente para rodar un registro de 36 bits por completo. AHORA, sin embargo, no todo lo que hagas será "agregar uno a la memoria".
John R. Strohm
He actualizado mi respuesta para abordar algunas de estas inquietudes: sí, puede hacer que la operación sea atómica, pero no, incluso en las arquitecturas que lo admiten, no será atómica por defecto, y hay situaciones en las que la atomicidad no lo es. suficiente y se necesita una serialización completa. El bloqueo es el único mecanismo que conozco para lograr la serialización completa.
Theodore Murdock
4

Considere esta cita:

Algunas personas, cuando se enfrentan a un problema, piensan: "Lo sé, usaré hilos", y luego dos tienen problemas.

usted ve, incluso si 1 instrucción se ejecuta en una CPU en un momento dado, los programas de computadora comprenden mucho más que simples instrucciones de ensamblaje atómico. Entonces, por ejemplo, escribir en la consola (o en un archivo) significa que tiene que bloquear para asegurarse de que funcione como desea.

gbjbaanb
fuente
¿Pensé que la cita era expresiones regulares, no hilos?
user16764
3
La cita me parece mucho más aplicable para hilos (con las palabras / caracteres que se imprimen fuera de servicio debido a problemas de hilos). Pero actualmente hay una "s" adicional en la salida, lo que sugiere que el código tiene tres problemas.
Theodore Murdock
1
Es un efecto secundario. Muy ocasionalmente podría agregar 1 más 1 y obtener 4294967295 :)
gbjbaanb
3

Parece que muchas respuestas intentaron explicar el bloqueo, pero creo que lo que OP necesita es una explicación de lo que realmente es la multitarea.

Cuando tiene más de un subproceso ejecutándose en un sistema, incluso con una CPU, hay dos metodologías principales que dictan cómo se programarán estos subprocesos (es decir, se colocarán para ejecutarse en su CPU de un solo núcleo):

  • Multitarea cooperativa : se utiliza en Win9x para que cada aplicación renuncie explícitamente al control. En este caso, no tendrá que preocuparse por el bloqueo, ya que mientras el hilo A esté ejecutando algún algoritmo, se le garantizará que nunca se interrumpirá.
  • Multitarea preventiva : se utiliza en la mayoría de los sistemas operativos modernos (Win2k y versiones posteriores). Esto usa intervalos de tiempo e interrumpirá los subprocesos incluso si todavía están trabajando. Esto es mucho más robusto porque un solo hilo nunca puede colgar toda su máquina, lo cual era una posibilidad real con la multitarea cooperativa. Por otro lado, ahora debe preocuparse por los bloqueos porque en cualquier momento dado, uno de sus hilos podría interrumpirse (es decir, evitarse) y el sistema operativo podría programar un hilo diferente para ejecutarse. Al codificar aplicaciones multiproceso con este comportamiento, DEBE considerar que entre cada línea de código (o incluso cada instrucción) podría ejecutarse un subproceso diferente. Ahora, incluso con un solo núcleo, el bloqueo se vuelve muy importante para garantizar un estado consistente de sus datos.
DXM
fuente
0

El problema no radica en las operaciones individuales, sino en las tareas más grandes que realizan las operaciones.

Muchos algoritmos se escriben asumiendo que tienen el control total del estado en el que operan. Con un modelo de ejecución ordenada intercalada como el que usted describe, las operaciones se pueden intercalar arbitrariamente entre sí, y si comparten el estado, existe el riesgo de que el estado esté en una forma inconsistente.

Puede compararlo con funciones que pueden romper temporalmente una invariante para hacer lo que hacen. Mientras el estado intermediario no sea observable desde el exterior, pueden hacer lo que quieran para lograr su tarea.

Cuando escribe código concurrente, debe asegurarse de que el estado en cuestión se considere inseguro a menos que tenga acceso exclusivo a él. La forma común de lograr acceso exclusivo es sincronizar en una primitiva de sincronización, como mantener un bloqueo.

Otra cosa que las primitivas de sincronización tienden a provocar en algunas plataformas es que emiten barreras de memoria, lo que garantiza la coherencia de la memoria entre CPU.

Lars Viklund
fuente
0

Excepto para establecer 'bool' no hay garantía (al menos en c) de que leer o escribir una variable requiere solo una instrucción, o más bien no puede ser interrumpida en medio de la lectura / escritura

Martin Beckett
fuente
¿Cuántas instrucciones tomaría establecer un número entero de 32 bits?
DXM
1
¿Puedes ampliar un poco tu primer enunciado? Usted implica que solo un bool puede leerse / escribirse atómicamente, pero eso no tiene sentido. Un "bool" en realidad no existe en el hardware. Por lo general, se implementa como un byte o una palabra, por lo tanto, ¿cómo podría booltener esta propiedad? ¿Y estás hablando de cargar de memoria, alterar y volver a la memoria, o estás hablando en un nivel de registro? Todas las lecturas / escrituras en los registros son ininterrumpidas, pero la carga de memoria y la memoria de memoria no lo son (ya que solo hay 2 instrucciones, luego al menos 1 más para cambiar el valor).
Corbin
1
El concepto de una sola instrucción en una CPU hiperhreaded / multinúcleo / predicción de ramificación / multi-caché es un poco complicado, pero el estándar dice que solo 'bool' necesita estar seguro contra un cambio de contexto en medio de una lectura / escritura de una sola variable. Hay un impulso :: Atomic que envuelve mutex alrededor de otros tipos y creo que el c ++ 11 agrega algunas garantías más de subprocesamiento
Martin Beckett
La explicación the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variablerealmente debería agregarse a la respuesta.
Wolf
0

Memoria compartida.

Es la definición de ... hilos : un montón de procesos concurrentes, con memoria compartida.

Si no hay memoria compartida, generalmente se denominan procesos UNIX de la vieja escuela .
Sin embargo, es posible que necesiten un bloqueo de vez en cuando al acceder a un archivo compartido.

(la memoria compartida en núcleos similares a UNIX se implementó generalmente usando un descriptor de archivo falso que representa la dirección de memoria compartida)

ZJR
fuente
0

Una CPU ejecuta una instrucción a la vez, pero ¿qué pasa si tiene dos o más CPU?

Tiene razón en que no se necesitan bloqueos, si puede escribir el programa de manera que aproveche las instrucciones atómicas: instrucciones cuya ejecución no es interrumpible en el procesador dado, y libre de interferencias de otros procesadores.

Se requieren bloqueos cuando varias instrucciones necesitan ser protegidas de interferencia, y no hay una instrucción atómica equivalente.

Por ejemplo, insertar un nodo en una lista doblemente vinculada requiere la actualización de varias ubicaciones de memoria. Antes de la inserción, y después de la inserción, ciertos invariantes sostienen sobre la estructura de la lista. Sin embargo, durante la inserción, esos invariantes se rompen temporalmente: la lista está en un estado "en construcción".

Si otro hilo avanza por la lista mientras los invariantes, o también intenta modificarlo cuando se encuentra en ese estado, la estructura de datos probablemente se corromperá y el comportamiento será impredecible: tal vez el software se bloquee o continúe con resultados incorrectos. Por lo tanto, es necesario que los subprocesos de alguna manera acuerden mantenerse fuera del camino de los demás cuando se actualiza la lista.

Las listas diseñadas adecuadamente pueden manipularse con instrucciones atómicas, de modo que no se necesitan bloqueos. Los algoritmos para esto se llaman "sin bloqueo". Sin embargo, tenga en cuenta que las instrucciones atómicas son en realidad una forma de bloqueo. Se implementan especialmente en hardware y funcionan mediante comunicación entre procesadores. Son más caros que instrucciones similares que no son atómicas.

En los multiprocesadores que carecen del lujo de las instrucciones atómicas, las primitivas para la exclusión mutua deben construirse con simples accesos de memoria y bucles de sondeo. Tales problemas han sido trabajados por personas como Edsger Dijkstra y Leslie Lamport.

Kaz
fuente
Para su información, he leído sobre algoritmos sin bloqueo para procesar actualizaciones de listas doblemente vinculadas usando solo un solo comparar e intercambiar. Además, leí un documento técnico sobre una instalación que parecería que sería mucho más barata en hardware que una comparación doble y un intercambio (que se implementó en el 68040 pero no se realizó en otros procesadores 68xxx): extender la carga -linked / store-conditional para permitir dos cargas vinculadas y almacenes condicionales, pero con la condición de que un acceso que ocurra entre los dos almacenes no retroceda el primero. Eso es mucho más fácil de implementar que una doble comparación y tienda ...
supercat
... pero ofrecerá beneficios similares al tratar de administrar actualizaciones de listas de doble enlace. Hasta donde puedo decir, la carga de doble enlace no se ha dado cuenta, pero el costo del hardware parecería bastante barato si hubiera alguna demanda.
supercat