PL05 Castellano
PL05 Castellano
PL05 Castellano
Práctica 5
Contenido
1. Objetivos...................................................................................................................................................... 3
2. Creación de hilos ......................................................................................................................................... 3
Ejercicio 1: Trabajando con pthread_join y pthread_exit ............................................................................... 5
3. Variables compartidas entre hilos ............................................................................................................... 5
4. Observando condiciones de carrera ............................................................................................................ 7
Ejercicio2: Creación de hilos “CondCarr.c”...................................................................................................... 7
Ejercicio3: Provocando condiciones de carrera .............................................................................................. 7
5. Soluciones para evitar la condición de carrera ........................................................................................... 8
6. Protegiendo sección crítica ......................................................................................................................... 9
Ejercicio4: Solución de sincronización con “test_and_set”............................................................................. 9
Ejercicio5: Solución de sincronización con semáforos .................................................................................. 10
Ejercicio6: Solución de sincronización con mutex ......................................................................................... 10
7. Actividades Opcionales.............................................................................................................................. 11
8. Anexos ....................................................................................................................................................... 13
Anexo 1: Código fuente de apoyo, “CondCarr.c”. ......................................................................................... 13
Anexo 2: Sincronización por espera activa. Test_and_set ............................................................................ 14
Anexo 3: Sincronización por espera pasiva. Semáforos ............................................................................... 15
Anexo 4: Sincronización por espera pasiva. “Mutex de pthreas” ................................................................ 16
2
1. Objetivos
Adquirir experiencia en el manejo de funciones estándar POSIX para creación y espera de hilos.
Trabajar con un escenario donde se produzcan operaciones concurrentes.
Comprender cuándo se producen condiciones de carrera, así como los mecanismos más básicos para
evitar este problema.
Trabajar soluciones al problema de condición de carrera con espera activa y espera pasiva.
2. Creación de hilos
El código de la figura-1 constituye el esqueleto básico de una función que utiliza hilos en su implementación.
/**
* Programa de ejemplo "Hola mundo" con pthreads.
* Para compilar teclea: gcc hola.c -lpthread -o hola
**/
#include <stdio.h>
#include <pthread.h>
#include <string.h>
//EJERCICIO1.b
write(1,men,strlen(men));
}
int main()
{
pthread_attr_t atrib;
pthread_t hilo1, hilo2;
pthread_attr_init( &atrib );
//EJERCICIO1.a
pthread_join( hilo1, NULL);
pthread_join( hilo2, NULL);
}
Figura-1: Esqueleto básico de un programa con hilos POSIX.
Cree un archivo “hola.c” que contenga dicho código, compílelo y ejecútelo desde la línea de órdenes.
$ gcc hola.c -lpthread -o hola
Como se observa en el código de la figura-1, las novedades que introduce el manejo de hilos van de la mano de
las funciones necesarias para inicializarlos, de las cuales sólo hemos hecho uso de las más básicas o
imprescindibles.
Tipos pthread_t y pthread_attr_t que se acceden desde el archivo de cabecera pthread.h.
3
pthread_attr_t attr;
Función pthread_attr_init encargada de asignar unos valores por defecto a los elementos de la
estructura de atributos de un hilo. ¡AVISO! Si no se inicializan los atributos, el hilo no se puede crear
Parámetros de pthread_create:
thread: Contendrá el identificador del hilo
attr: Especifica los atributos del hilo. Con valor NULL, reciben valores por defecto: “the created thread
is joinable (not detached) and has default (non real-time) scheduling policy”.
start_routine: Función que define el comportamiento del hilo que se está creando.
arg: Argumento que se pasa a la función del hilo (start_routine) y cuyo uso dependerá de cada
función.
Función pthread_join. Su efecto es suspender al hilo que la invoca hasta que el hilo que se le especifica
como parámetro termine. Este comportamiento es necesario ya que cuando el hilo principal “termina”
destruye el proceso y, por lo tanto, obliga a la terminación de todos los hilos que se hayan creado.
Parámetros de pthread_join:
thread: Parámetro que identifica al hilo a esperar.
exit_status: contiene el valor que el hilo terminado comunica al hilo que invoca a pthread_join
4
Ejercicio 1: Trabajando con pthread_join y pthread_exit
Compruebe el comportamiento de la llamada pthread_join() realizando las siguientes modificaciones en el
código del programa “hola.c” mostrado anteriormente.
Cuestiones Ejercicio 1:
Sustituya las llamadas pthread_join por una llamada pthread_exit(0), cerca del punto del programa marcado
como //EJERCICIO1.a)
¿Completa ahora correctamente el programa su ejecución?¿Por qué?
Elimine (o comente) cualquier llamada a pthread_join o pthread_exit (cerca del comentario //EJERCICIO1.a)
e introduzca en ese mismo punto un retraso de 1 segundo (usando la función usleep(...) de la librería
<unistd.d> y cuyo definición se muestra a continuación)")
#include <unistd.h>
void usleep(unsigned long usec); // usec en microsegundos
¿Qué ocurre tras la realización de las modificaciones propuestas?
5
Figura-2: Código de las funciones agrega, resta e inspecciona
El hilo “inspecciona()“ accede a V para leer su valor, pero no escribe sobre ella, por lo que no provoca
condiciones de carrera. Los hilos “agrega()“ y “resta()” acceden a V leyéndola y modificándola repetidamente.
La operación incremento, V=V+1, lee la variable, incrementa su valor y escribe en memoria el nuevo valor. Si
durante la operación de incremento se intercala la de decremento V=V-1 debido a un cambio de contexto o a la
ejecución concurrente de incremento y decremento en núcleos diferentes del procesador, es posible que se
produzca una condición de carrera y la variable V tome valores inesperados. Es decir, que al finalizar ambos
hilos el valor de V no sea el inicial, 100 en nuestro caso.
Los escenarios en los que se puede producir la condición de carrera varían según las características de la
máquina donde se trabaja, como muestra la figura-3. Por ejemplo, en un procesador con múltiples núcleos de
ejecución (multi-core), se podrá observar la condición de carrera fácilmente con valores relativamente
bajos de la constante “REPETICIONES” (figura-2). Si el computador tiene un solo núcleo de ejecución, es menos
probable que se produzca una condición de carrera. En este caso habrá que aumentar el número de
REPETICIONES y modificar las secciones de incremento y decremento, con una variable auxiliar durante las
operaciones, para aumentar la probabilidad de que se produzca un cambio de contexto en medio de la
operación.
Figura-3: Ejecución de los hilos agrega y resta en CPU, en máquinas con uno y dos núcleos.
Cuando sea necesario aumentar el tiempo de cómputo de las secciones incremento y decremento, introduzca
los cambios de la tabla 1. Debe declarar la variable local “aux” en cada hilo del tipo “long int”.
6
Código original Sustituir por…..
aux=V;
agrega() V=V+1; aux=aux+1;
V=aux;
aux=V;
resta() V=V-1 aux=aux-1;
V=aux;
Observe que el hilo inspecciona() consiste en un bucle infinito y si en la función main() hacemos
“pthread_join()” sobre él, el programa nunca terminará. Por lo tanto, preste especial atención y asegúrese de
hacer “pthread_join()” sólo para los hilos agrega() y resta() ya que el programa debe acabar cuando los hilos
agrega() y resta() terminen.
Compile y ejecute el código implementado. Observe el valor de V y determine de forma justificada si se ha
producido una condición de carrera o no.
En principio cabría esperar que el acceso concurrente a la variable V sin ningún tipo de protección provocara
una condición de carrera de forma que el valor final de V fuese distinto del inicial (100). Sin embargo, para
valores bajos de REPETICONES, esto puede no ocurrir observándose que el valor final de V es el inicial (100).
Esto es debido a que, en sistemas con un solo procesador, no da tiempo a que los dos hilos se ejecuten
concurrentemente y haya cambios de contexto. Si se crea el primer hilo, este empieza a ejecutarse, y termina
antes de que empiece a ejecutarse el segundo hilo, ambos hilos no llegan a ejecutar concurrentemente. En
sistemas multi-core es más fácil observar una condición de carrera ya que la concurrencia es real.
7
La orden time muestra el tiempo que tarda en ejecutarse un programa. Ejecute:
$ time ./CondCarr
time muestra el tiempo real (como si cronometrásemos) y los tiempos de CPU (medidos por el planificador)
ejecutando instrucciones de usuario y del sistema operativo.
Nota: Si estamos trabajando con una máquina virual con un único procesador, debemos tener en cuenta
que los los tiempos que obtendremos con la orden time serán coherentes con esta circunstancia. Si
nuestros procesos trabajan sobre un único procesador, aunque se generen diferentes hilos, no
tendremos la concurrencia que nos podrían ofrecer dichos hilos trabajando sobre varios cores. El
planificador de la CPU irá asignando el uso de la CPU a los diferentes hilos, pero no habrá concurrencia
en la ejecución de los mismos. Y, por tanto, los tiempos de CPU medidos por el planificador serán muy
similares al tiempo real de ejecución. No habrá una mejora significativa en el tiempo real de ejecución
por el hecho de utilizar hilos. En cualquier caso, será posible configurar la máquina virtual con más de un
procesador, pero sólo si nuestra máquina tiene recursos suficientes.
Con la ayuda de la orden time averigüe el tiempo de ejecución del programa CondCarr.c con condiciones de
carrera, ya que no se ha protegido la sección crítica. Anote los tiempos mostrados
8
El código del protocolo de entrada y salida dependerá del método de sincronización. En esta práctica
estudiaremos tres métodos de sincronización:
¡AVISO¡: El anexo de este documento incluye una descripción detallada sobre las soluciones, que se trabajan en
esta práctica, para evitar las condiciones de carrera. Es recomendable que el alumno lea detenidamente dicho
anexo antes de desarrollar las actividades.
2. Utilice el comando time para conocer el tiempo de ejecución del programa con la sección crítica
protegida y anótelos en la siguiente tabla
3. Copie CondCarrT.c en CondCarrTB.c. Observe en CondCarrTB.c qué ocurre si reescribe las secciones de
entrada y salida y las sitúa en los lugares indicados en la Figura 5
Protocolo de Entrada
for (cont = 0; cont < REPETICIONES; cont = cont + 1) {
V = V + 1;
}
Protocolo de Salida
printf("-------> Fin AGREGA (V = %ld)\n", V);
pthread_exit(0);
}
Figura 5: Nuevo lugar de colocación de los protocolos de protección
9
4. Anote los resultados de tiempo de ejecución de en la siguiente tabla:
5. Observando los resultados obtenidos identifique qué diferencia hay entre sincronizar las secciones críticas
como se indica en la Figura 4 o hacerlo como indica la Figura 5.
¿Qué ha ocurrido al utilizar el esquema de sincronización de la Figura 5?
¿Qué ventaja tiene sincronizar las secciones críticas como se indica en la Figura 4?
2. Utilizando la orden time ejecute el código para conocer el tiempo de ejecución del programa y anote los
resultados en la siguiente tabla.
En el ejemplo desarrollado en esta práctica ¿qué es más eficiente la espera activa o la pasiva?
10
En general, ¿En qué condiciones cree que es mejor usar espera activa?
En general, ¿En qué condiciones cree que es mejor usar espera pasiva?
7. Actividades Opcionales
Para comprobar qué ocurre cuando la sección crítica es grande y cómo influye esto en el método de
sincronización escogido, podemos aumentar la duración de la sección crítica artificialmente de forma análoga a
como se propuso en la Tabla 1, pero introduciendo un retraso antes de asignar el nuevo valor a la variable
compartida V. Esta es la modificación que se propone en la Tabla 2.
El pequeño retraso introducido de medio milisegundo en el código propuesto en la Tabla 2 hace que aumente
considerablemente la probabilidad de que se produzca una condición de carrera además de aumentar, también
considerablemente, el tiempo de ejecución del programa.
Para que el tiempo de ejecución del programa sea fácilmente observable en todos los casos, hay que disminuir
el valor de la constante REPETICIONES.
11
2. Ejecute con la orden time las cuatro versiones del código y anote los tiempos de ejecución.
3. Según estos resultados revise las respuestas que ha dado a las cuestiones formuladas en el ejercicio 6.
12
8. Anexos
Anexo 1: Código fuente de apoyo, “CondCarr.c”.
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <semaphore.h>
// ****FUNCIONES AUXILIARES
int test_and_set(int *spinlock) {
int ret;
__asm__ __volatile__(
"xchg %0, %1"
: "=r"(ret), "=m"(*spinlock)
: "0"(1), "m"(*spinlock)
: "memory");
return ret;
}
13
Anexo 2: Sincronización por espera activa. Test_and_set
La espera activa es una técnica de sincronización que consiste en establecer una variable global de tipo
booleano (spinlock) que indica si la sección crítica está ocupada. La semántica de esta variable es: valor 0
indica FALSE y significa que la sección crítica no está ocupada; valor 1 indica TRUE y significa que la sección
crítica está ocupada.
Por esta razón, los procesadores modernos incorporan en su juego de instrucciones operaciones específicas que
permiten comprobar y asignar el valor a una variable de forma atómica. Concretamente, en los procesadores
compatibles x86, existe una instrucción “xchg” que intercambia el valor de dos variables. Como la operación
consiste en una sola instrucción máquina, su atomicidad está asegurada. Usando la instrucción “xchg”, se puede
construir una función “test_and_set” que realice de forma atómica las operaciones de comprobación y
asignación comentadas anteriormente. El código que implementa esta operación “test_and_set” es el que
aparece en la Figura 6 y está incluido en el código de apoyo que se proporciona con esta práctica.
Aunque la comprensión del código suministrado para la función “test_and_set” no es el objetivo de esta
práctica, es interesante observar cómo en lenguaje C se puede incluir código escrito en ensamblador.
Con todo esto, para asegurar la exclusión mutua en el acceso a la sección crítica utilizando este método, hay
que modificar el código tal y como se indica en la tabla 3.
//Declarar una variable global, el “spinlock” que usarán todos los hilos
int llave = 0; // inicialmente FALSE sección crítica NO está ocupada.
14
Anexo 3: Sincronización por espera pasiva. Semáforos
La espera pasiva se consigue con la ayuda del Sistema Operativo. Cuando un hilo tiene que esperar para entrar
en la sección crítica (porque otro hilo está ejecutando su sección crítica), se “suspende” eliminándolo de la lista
de hilos en estado “preparado” del planificador. De esta forma los hilos en espera no consumen tiempo de CPU,
en lugar de esperar en un bucle de consulta como en el caso de espera activa.
Para que los programadores puedan utilizar la espera pasiva, el Sistema Operativo ofrece unos objetos
específicos que se llaman “semáforos” (tipo sem_t). Un semáforo, ilustrado en la Figura 6, está compuesto por
un contador, cuyo valor inicial se puede fijar en el momento de su creación, y una cola de hilos suspendidos a la
espera de ser reactivados. Inicialmente el contador debe ser mayor o igual a cero y la cola de procesos
suspendidos está vacía. El semáforo soporta dos operaciones:
Nota: Aunque los semáforos POSIX (sem_t) forman parte del estándar, en MacOSX no funcionan.
MacOSX proporciona otros objetos (semaphore_t) que se comportan de manera análoga y pueden
usarse para ofrecer el mismo interfaz que ofrecen los semáforos POSIX.
Dependiendo del uso que le queramos dar a un semáforo, definiremos su valor inicial. El valor inicial de un
semáforo puede ser mayor o igual a cero y su semántica asociada es la de “número de recursos disponibles
inicialmente”. Esencialmente un semáforo es un contador de recursos que pueden ser solicitados (con la
operación sem_wait) y liberados (con la operación sem_post) de forma que cuando no hay recursos disponibles,
los hilos que solicitan recursos se suspenden a la espera de que algún recurso sea liberado.
Especialmente relevantes son los semáforos con valor inicial igual a uno. Como sólo hay un recurso libre
inicialmente, sólo un hilo podrá ejecutar la sección crítica en exclusión mutua con los demás. Estos semáforos se
suelen llamar “mutex” y son los que nos interesan en esta práctica.
Con todo esto, para asegurar la exclusión mutua en el acceso a la sección crítica utilizando este método, hay
que modificar el código tal y como se indica en la tabla 4.
15
//Incluir las cabeceras de la librería de semáforos.
#include <semaphore.h>
//Declarar una variable global, el “semáforo” que usarán todos los hilos
sem_t sem; // No está inicializado, sólo declarado.
Tabla 4. Descripción del protocolo de entrada y salida a la sección critica con semáforos
Al igual que se ha hecho con los otros métodos de sincronización, se muestra el uso de los “mutex de pthreads”
en la siguiente tabla
//Incluir las cabeceras de la librería de pthreads. Normalmente ya está incluida porque estamos usando hilos.
#include <pthread.h>
//Declarar una variable global, el “mutex” que usarán todos los hilos
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //Esto lo declara e inicializa.
Tabla 5 : Descripción del protocolo de entrada y salida a la sección critica con Mutex.
16