La corrupción de memoria es un problema real en concurrencia que puede afectar a la forma en la que nuestro programa se ejecuta. Por ello, se requiere de cuidado a la hora de diseñar la forma en la que los hilos de un programa acceden a recursos compartidos tales como áreas de memoria visibles desde dos o más hilos.
Si los hilos sólo acceden para leer una zona de memoria que no cambia, por lo general la operación debería ser segura; pero si los hilos van a modificar los valores de memoria, o si puede que el valor que los hilos están leyendo cambie, habrá que tener cuidado para asegurarse de que los hilos no leen o escriben información desactualizada.
Un ejemplo de cómo puede ocurrir corrupción de memoria
Uno de los ejemplos más claros de fallos de acceso a memoria en un modelo concurrente es tratar de modificar una misma variable desde varios hilos de una manera no atómica.
Atómico querrá decir, como estudiaremos en el futuro en esta lista, que la operación de leer y actualizar el valor de una variable se hace en un único paso, sin posibilidad de que, por ejemplo, otro hilo empiece a acceder a una zona de memoria mientras otro hilo todavía está actualizando el valor de la variable.
Pensemos por ejemplo en incrementar una variable. El siguiente pseudocódigo toma el valor de un contador almacenado en un recurso compartido y lo incrementa en uno:
int valor = recurso.getTotal();
recurso.setTotal(valor + 1);
En este caso, el incremento está ocurriendo de manera no atómica. Primero obtenemos el valor, y luego lo volvemos a colocar en su sitio una vez actualizado.
Si existe la mínima posibilidad de que varios hilos puedan ejecutar esta zona de código a la vez, entonces hay que prepararse para la situación en la que dos hilos puedan ejecutar exactamente a la vez estas instrucciones, o bien para la situación en la que el scheduler de nuestra CPU decida dejar de ejecutar un hilo justo cuando ese hilo está entre ambas instrucciones.
En ambas situaciones, lo que puede ocurrir es lo siguiente: vamos a decir que inicialmente el total es 0 y que pretendemos que cada hilo use ese código para incrementar en 1 el valor de la variable. En total, el valor final del contador será 2 porque tenemos dos hilos. Si tuviésemos 10 hilos, el valor final debería ser 10, en tanto que lo que pretendemos es que cada hilo incremente una vez el valor de la variable total.
Esto es algo que podría ocurrir bien cuando el hilo A consigue ejecutar las dos instrucciones de forma simultánea antes de que el hilo B haga lo mismo. El orden de las instrucciones sería el siguiente:
- Hilo A ejecuta
getTotal(), por lo que hilo A ahora tiene una variable local que vale 0. - Hilo A ejecuta
setTotal(), por lo que hilo A ahora pone el total a 0 + 1 = 1. - Hilo B ejecuta
getTotal(), por lo que hilo B ahora tiene una variable local que vale 1. - Hilo A ejecuta
setTotal(), por lo que hilo B ahora pone el total a 1 + 1 = 2.
El problema es que no hay garantías de que este sea el orden en el que se ejecuten las instrucciones. Por lo tanto, en alguna ocasión puede ocurrir lo siguiente en su lugar:
- Hilo A ejecuta
getTotal(), por lo que hilo A ahora tiene una variable local que vale 0. - Hilo B ejecuta
getTotal(), por lo que hilo B ahora tiene una variable local que también vale 0. - Hilo A ejecuta
setTotal(), por lo que hilo A ahora pone el total a 0 + 1 = 1. - Hilo B ejecuta
setTotal(), pero como la variable local que había sacado B también vale 0, vuelve a poner el total como 0 + 1 = 1.
En este caso, debido a que uno de los hilos ha trabajado con una variable local desactualizada, lo que va a ocurrir es que se va a corromper la memoria, porque se va a hacer algo importante a partir de datos desactualizados.
¿Cómo impedir que pase esto?
Existen medios para impedir que varios hilos corrompan memoria o un recurso compartido. La concurrencia no solamente requiere estudiar las estructuras que nos permiten organizar el código de un programa (hilo, tarea, futuro...), sino que también exige estudiar las estructuras que nos permiten asegurar que esas otras estructuras acceden de forma segura a recursos compartidos.
Aquí englobamos cosas como los locks, los semáforos, las memory barriers. Estas cosas son muy relevantes a la hora de hacer programas concurrentes, y en este curso también tendremos que estudiar en qué consisten estas estructuras para que cuando debamos decidir cómo organizar el código de un programa concurrente podamos tomar en consideración todo lo que sabemos de concurrencia para decidir qué es lo mejor que podemos hacer para garantizar un acceso ordenado a un recurso.
En la siguiente lección, por ejemplo, empezaremos usando monitores para tratar de arreglar este ejemplo.