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í:
- Leer el número de visitas de la memoria a un registro del procesador
- Incrementa ese número.
- 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:
- Lea el valor de una variable volátil directamente de la memoria.
- Incrementa ese valor.
- 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.