En la serie de subprocesos de Linux, discutimos las formas en que un subproceso puede terminar y cómo el estado de retorno se transmite del subproceso de terminación a su subproceso principal. En este artículo arrojaremos algo de luz sobre un aspecto importante conocido como sincronización de subprocesos.
Serie de subprocesos de Linux:parte 1, parte 2, parte 3, parte 4 (este artículo).
Problemas de sincronización de subprocesos
Tomemos un código de ejemplo para estudiar los problemas de sincronización:
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; void* doSomeThing(void *arg) { unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); return NULL; } int main(void) { int i = 0; int err; while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); return 0; }
El código anterior es simple en el que se crean dos subprocesos (trabajos) y en la función de inicio de estos subprocesos, se mantiene un contador a través del cual el usuario obtiene los registros sobre el número de trabajo que se inicia y cuándo se completa. El código y el flujo se ven bien, pero cuando vemos el resultado:
$ ./tgsthreads Job 1 started Job 2 started Job 2 finished Job 2 finished
Si se enfoca en los dos últimos registros, verá que el registro "Trabajo 2 terminado" se repite dos veces mientras que no se ve ningún registro para "Trabajo 1 terminado".
Ahora, si regresa al código y trata de encontrar algún defecto lógico, probablemente no lo encontrará fácilmente. Pero si observa más de cerca y visualiza la ejecución del código, encontrará que:
- El registro "Trabajo 2 iniciado" se imprime justo después de "Trabajo 1 iniciado", por lo que se puede concluir fácilmente que mientras el subproceso 1 estaba procesando, el programador programó el subproceso 2.
- Si la suposición anterior era cierta, entonces el valor de la variable 'contador' se incrementó nuevamente antes de que terminara el trabajo 1.
- Entonces, cuando el trabajo 1 realmente terminó, el valor incorrecto del contador produjo el registro "Trabajo 2 terminado" seguido de "Trabajo 2 terminado" para el trabajo real 2 o viceversa, ya que depende del programador.
- Entonces vemos que el problema no es el registro repetitivo sino el valor incorrecto de la variable 'contador'.
El problema real fue el uso de la variable 'contador' por parte del segundo subproceso cuando el primer subproceso la estaba usando o estaba a punto de usarla. En otras palabras, podemos decir que la falta de sincronización entre los subprocesos al usar el "contador" de recursos compartidos causó los problemas o, en una palabra, podemos decir que este problema ocurrió debido a un "problema de sincronización" entre dos subprocesos.
mutexes
Ahora que hemos entendido el problema base, discutamos la solución. La forma más popular de lograr la sincronización de subprocesos es mediante Mutexes.
Un Mutex es un bloqueo que establecemos antes de usar un recurso compartido y lo liberamos después de usarlo. Cuando se establece el bloqueo, ningún otro subproceso puede acceder a la región de código bloqueada. Entonces vemos que incluso si el subproceso 2 está programado mientras que el subproceso 1 no terminó de acceder al recurso compartido y el código está bloqueado por el subproceso 1 usando mutexes, entonces el subproceso 2 ni siquiera puede acceder a esa región de código. Esto asegura un acceso sincronizado de los recursos compartidos en el código.
Internamente funciona de la siguiente manera:
- Supongamos que un subproceso ha bloqueado una región de código usando mutex y está ejecutando ese fragmento de código.
- Ahora, si el planificador decide hacer un cambio de contexto, todos los demás subprocesos que están listos para ejecutarse en la misma región se desbloquean.
- Solo uno de todos los subprocesos llegaría a la ejecución, pero si este subproceso intenta ejecutar la misma región de código que ya está bloqueada, volverá a dormirse.
- El cambio de contexto tendrá lugar una y otra vez, pero ningún subproceso podrá ejecutar la región bloqueada del código hasta que se libere el bloqueo mutex.
- El bloqueo de exclusión mutua solo lo liberará el subproceso que lo bloqueó.
- Así que esto asegura que una vez que un subproceso haya bloqueado un fragmento de código, ningún otro subproceso podrá ejecutar la misma región hasta que sea desbloqueado por el subproceso que lo bloqueó.
- Por lo tanto, este sistema asegura la sincronización entre los subprocesos mientras se trabaja en recursos compartidos.
Se inicializa un mutex y luego se logra un bloqueo llamando a las siguientes dos funciones:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); int pthread_mutex_lock(pthread_mutex_t *mutex);
La primera función inicializa un mutex y, a través de la segunda función, se puede bloquear cualquier región crítica del código.
El mutex se puede desbloquear y destruir llamando a las siguientes funciones:
int pthread_mutex_unlock(pthread_mutex_t *mutex); int pthread_mutex_destroy(pthread_mutex_t *mutex);
La primera función anterior libera el bloqueo y la segunda función destruye el bloqueo para que no se pueda usar en ningún lugar en el futuro.
Un ejemplo práctico
Veamos un fragmento de código donde se utilizan mutexes para la sincronización de subprocesos
#include<stdio.h> #include<string.h> #include<pthread.h> #include<stdlib.h> #include<unistd.h> pthread_t tid[2]; int counter; pthread_mutex_t lock; void* doSomeThing(void *arg) { pthread_mutex_lock(&lock); unsigned long i = 0; counter += 1; printf("\n Job %d started\n", counter); for(i=0; i<(0xFFFFFFFF);i++); printf("\n Job %d finished\n", counter); pthread_mutex_unlock(&lock); return NULL; } int main(void) { int i = 0; int err; if (pthread_mutex_init(&lock, NULL) != 0) { printf("\n mutex init failed\n"); return 1; } while(i < 2) { err = pthread_create(&(tid[i]), NULL, &doSomeThing, NULL); if (err != 0) printf("\ncan't create thread :[%s]", strerror(err)); i++; } pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); pthread_mutex_destroy(&lock); return 0; }
En el código de arriba:
- Un mutex se inicializa al comienzo de la función principal.
- El mismo mutex está bloqueado en la función 'doSomeThing()' mientras se usa el 'contador' de recursos compartidos
- Al final de la función 'doSomeThing()', se desbloquea el mismo mutex.
- Al final de la función principal, cuando ambos subprocesos están terminados, el mutex se destruye.
Ahora, si miramos la salida, encontramos:
$ ./threads Job 1 started Job 1 finished Job 2 started Job 2 finished
Entonces vemos que esta vez estaban presentes los registros de inicio y finalización de ambos trabajos. Entonces, la sincronización de subprocesos se llevó a cabo mediante el uso de Mutex.