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?
multithreading
pitón
fuente
fuente
Respuestas:
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í:
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 :
El bucle anterior realiza repetidamente los siguientes pasos, hasta que el paso 3 tiene éxito:
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.
fuente
Considere esta cita:
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.
fuente
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):
fuente
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.
fuente
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
fuente
bool
tener 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).the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variable
realmente debería agregarse a la respuesta.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)
fuente
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.
fuente