Lab3: Programando con Hilos
Objetivos
Escribir multiprogramas que intercambien información
y se sincronicen utilizando POSIX Threads (pthreads), contemplando
los problemas típicos de la concurrencia como condiciones de
carrera, abrazos mortales, inhanición, etc.
Introducción
En el Laboratorio anterior utilizamos las facilidades de concurrencia
de gruesa granularidad de UNIX. Creabamos y esperábamos
la finalización de procesos que se ejecutaban en espacios de
memoria disjuntos, y donde sólo se compartían los descriptores
de archivos.
Esta concurrencia hacía uso del concepto de proceso como
unidad básica de ejecución, el cual brindaba la posibilidad
de ejecutar una componente de un multiprograma en un espacio de memoria
disjunto y a la vez protegido. Al no compartir memoria, los procesos
efectuan su comunicación utilizando alguna de las estructuras
que sí se comparten, por ejemplo el sistema de archivos, y vimos
como a través de pipes podíamos comunicar y sincronizar
procesos.
Un hilo o proceso liviano (ligthweight process - LWP)
es otro de los posibles modelos de ejecución de un multiprograma
que proveen los sistemas operativos en general y *NIX en particular,
y brinda otra posibilidad para realizar multiprogramación.
Esta segunda opción existe para que el programador
tenga la posibilidad de elegir una solución de compromiso entre
2 cualidades deseables protección y eficiencia
.
Normalmente estos los hilos tienen vida dentro de los procesos,
y podemos pensar en los procesos simples que utilizamos en el Lab2 como
que sólo contienen un hilo de ejecución interno.
El problema reside en que un proceso maneja un estado relativamente
grande, que involucra los registros del microprocesador, la pila de
ejecución, el mapa de memoria, los descriptores de archivos y
tabla de manejadores de señal, con lo cual el nacimiento, la vida
(cambios de contexto) y la muerte de los procesos, son operaciones que
consumen lo suficiente en cuanto a recursos de tiempo y espacio (memoria)
como para que no sea viable la creación de decenas de miles de éstos
procesos.
Los hilos en cambio, lo único que no comparten
son los registros del microprocesador y el espacio de la pila, con
lo cual las opearciones de creación, cambio de contexto y muerte,
resultan mucho más económicas.
La comunicación entre las componentes del multiprograma
es otro factor que diferencia los procesos de los hilos. Mientras que en
los hilos es posible transferir información entre componentes simplemente
escribiendo en el espacio de memoria compartido, los procesos tienen
que efectuar un secuencia de llamadas al núcleo, para poder completar
la transferencia de información: espacio_de_usuario1, kernel,
espacio_de_usuario_2, incurriendo no sólo en sobrecarga por
copia, sino también por cambios de niveles de protección
.
El sistema operativo Linux, incorporta procesos livianos en un modelo
conocido uno-a-uno utilizando clone, la generalización
del syscall fork. A cada hilo se le asocia un proceso,
donde se comparte la memoria y los descriptores de archivos.
Lamentablemente esta implementación de procesos livianos,
pierde algunas de las características esperables (
LinuxThreads Frequently Asked Questions
,
Programación utilizando C threads
,
FAQ de comp.programming.threads
).
Como crear hilos
Veamos a través un ejemplo, como crear 2 hilos que impriman N veces
"Hola Mundo" y M veces"Mundo Hola" respectivamente por la salida
estandar.
Necesitamos incluir la cabecera de la biblioteca pthreads
, que forma parte de la
glibc
de Linux, que nos brinda acceso a todas las llamadas pthreads_*
. Nosotros sólo utilizaremos en este ejemplo pthread_create
y pthread_join, donde esta última tiene una semántica
parecida al wait de los procesos. Observar la declaración
y uso de arreglos constantes.
#include <stdio.h>
#include <pthread.h>
#define N 8
#define M 16
const char *mensaje[2] = {"Hola Mundo", "Mundo Hola"};
const int cantidad[2] = {N, M};
void imprime_mensaje(void *ptr) {
int i;
int id;
id = *(int *) ptr;
for(i=0; i<cantidad[id];
i++)
printf("%s\n",mensaje[id]);
return;
}
int main(int argc, char *argv[])
{
pthread_t hilo0,
hilo1;
int id0=0, id1=1;
pthread_create(&hilo0,
NULL, (void *) &imprime_mensaje, (void *) &id0);
pthread_create(&hilo1,
NULL, (void *) &imprime_mensaje, (void *) &id1);
pthread_join(hilo0,
NULL);
pthread_join(hilo1,
NULL);
return 0;
}
|
Para compilar este ejemplo debemos incluir la biblioteca pthread, de
la siguiente manera:
[juan@hal juan]$ gcc -Wall -lpthread -o holaMundo
holaMundo.c
|
Notamos que la llamada a pthread_create esta pasando
una función como argumento y que se necesitan varios
typecasts para poder compatibilizar los tipos actuales con
los requeridos por la función (ver man pthread_create
).
Puede parecer extraño que definamos las variables
id0 , id1 con dos constantes, pero esto también
es necesario cuando tenemos que pasarle la dirección del
argumento y no el valor en sí mismo.
Las funciones pthread_join, evitan que al terminar el main,
acabe con la vida de sus hilos. La terminación del hilo se
hace un simple return, que resulta equivalente a pthread_exit(NULL)
.
Lamentablemente en máquinas con procesadores rápidos,
no podemos observar ni el interleaving entre los mensajes,
ni el no determinismo, esto se debe a que toma tan poco tiempo
imprimir los N o M mensajes definidos, que el planificador casi no actua
y el orden es puramente secuencial .
¿De qué nos sirve entonces un entorno de multiprogramación
donde las planificaciones de procesos cortos resultan seriales? Realmente
de poco, pues toda la problemática de interferencia entre componentes,
regiones críticas, abrazos mortales se reducen a la nada. Sin
embargo podemos simular interleavings más ricos
, indicando explícitamente al planificador que queremos entregar
el control a otro proceso (recordemos que en Linux, el planificador no
distingue entre proceso e hilo), mediante la función sched_yield
.
Estos sched_yield nos permitirán estar más
confiados en que nuestros multiprogramas funcionarán de manera
correcta independientemente del hardware o situación de carga
concreta en donde se esté ejecutando.
Ejercicios
- Predecir y comprobar los comportamientos posibles quitando
alguno o ambos pthread_join.
- Comprobar los interleavings que se producen dependen
de la velocidad y factor de carga de la CPU. Deberían observar
diferencias de ejecución entre las HP nuevas y las viejas.
- Forzar otros interleavings utilizando sched_yield
.
- Si ponemos M y N de manera tal que los hilos tarden un
tiempo considerable y corremos nuestro programa, mientras que en otra
terminal observamos los procesos que se están ejecutando. ¿Cuántos
son? ¿Por qué?
Observar que ahora si se producen interleavings entre los 2 mensajes.
Memoria compartida entre hilos y las primeras interferencias
El ejemplo anterior sólo mostraba como crear hilos y
que ambos leyeran desde la memoria compartida, pero al tener sólo
accesos de lectura a la memoria, no es posible diferenciar entre memoria
compartida y memoria disjunta con copias idénticas, como ocurría
en el caso del fork.
El siguiente ejemplo implementa uno de los ejercicios del primer
parcial donde se tenía un multiprograma con 2 componentes donde
cada una de estas, de manera cíclica incrementaba y decrementaba
una variable compartida.
P0:
do true ->
x:=x+1;
x:=x-1
od
|
P1:
do true ->
x:=x+1;
x:=x-1
od
|
Es posible demostrar de manera mas o menos convincente que
0<=x<=2 si la atomicidad es línea a línea.
El siguiente programa es una implementación en pthreads
de este multiprograma escrito en el Lenguaje de los Comandos Guardados
de Dijkstra (LCGD).
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
//compilacion condicional
#define __DEBUG
void *IncrDecr(void *);
//la variable global y compartida
int x=0;
int main(int argc, char *argv[]) {
pthread_t t0id,t1id;
pthread_create(&t0id,
NULL, IncrDecr, NULL);
pthread_create(&t1id,
NULL, IncrDecr, NULL);
#ifdef __DEBUG
printf("Hilos Creados\n");
printf("Inicio de
sonda de testeo\n");
#endif
//sonda que comprueba el Invariante
while(1) {
assert(0<=x && x<=2); // Inv: 0<=x<=2
printf("%2d", x);
}
//aqui nunca llega
}
//componente del multiprograma
void *IncrDecr(void *arg)
{
#ifdef __DEBUG
printf("Hilo IncrDecr
iniciado\n");
#endif
for (;;) {
x = x+1;
x = x-1;
}
}
|
Remarcamos 2 aspectos de este programa: la compilación
condicional a través del uso de las directivas del preprocesador
#define , #undef, #ifdef
y #endif, y las variables globales.
La compilación condicional, nos permite en este
caso generar 2 versiones diferentes de nuestro código una con
información de debug y la otra sin ella, cambiando #define
__DEBUG por #undef __DEBUG. Luego veremos
como es posible obtener un mecanismo similar mejorando la legibilidad del
código.
El uso de variables globales es una condición necesaria
para el uso de memoria compartida entre los hilos, a menos que pasemos
a las funciones que ejecutan los hilos referencias a espacios de memoria
pedidos en el montículo o heap.
Si compilamos y ejecutamos nuestro ejemplo que podemos llamar
programTopology, veremos que aunque la sonda
muestra pocos cambios en x, esta siempre se mantiene en el rango [0,2].
La falta de cambios se debe nuevamente a la poca cantidad de interleavings
que se producen, donde el efecto se nota más en procesadores
rápidos y poco utilizados por el SO.
Para generar más interleavings podemos inundar de
sched_yield() nuestro programa. Sin embargo nunca se producen
los interleavings que muestran que x:=x+1 no es atómico,
pues en este caso el invariante del multiprograma 0<=x<=2 ya no
vale. Notar que jamás le declaramos al compilador C que esta instrucción
fuese atómica, asi que puede ser compilada a una sola o a un
conjunto (interrumpible) de intrucciones.
A fin de simular el caso del incremento no atómico
suele ser necesario dividir explicitamente los incrementos y decrementos
en 3 instrucciones.
Ejercicios
- ¿Por qué no es posible compartir entre los
hilos una variable declarada dentro del main()? ¡Comprobarlo!
- Hay dos posibles causas para que el planificador no interrumpa
x:=x+1, una puede ser simplemente que este hecho resulte
altamente improbable y la segunda es que la compilación genere
una instrucción atómica a nivel del microprocesador. Investigue
sobre este hecho en diversas arquitecturas. (Ayuda: con gcc -S
generamos el código ensamblador intermedio donde podemos corroborar
este último hecho, quienes estén interesados, se pueden perparar
más ejercicios).
- Modificar programTopology.c para que los incrementos
no sean atómicos y comprobar que la aserción puede no cumplirse
para ciertos interleavings.
- ¿Por qué al activar las opciones de optimización
de código -O, el programa anterior podría dejar
de producir los interleavings requeridos para que no valga el invariante,
o para peor imprime constantemente 0. Intente reproducir estos resultados.
Sincronizando con Mutexes
La biblioteca de hilos en Linux incorpora Locks o Mutexes, para
proteger regiones críticas del código, y las llamadas
básicas son las siguientes: pthread_mutex_init,
pthread_mutex_lock , pthread_mutex_unlock y
pthread_mutex_destroy .
Notamos que tenemos llamadas para destruir el objeto Mutex a
fin de liberar los recursos que este pueda consumir.
En este caso nuestro ejemplo sincroniza los accesos a un buffer
compartido por parte de dos componentes, donde una establece un
valor i en todo el buffer y la otra un valor j
distinto. La idea es que queremos que se mantenga el invariante que
todo hilo externo siempre vea el buffer con un contenido uniforme de valores,
ya sea i o j.
Claramente si no protegemos con regiones críticas, es posible
que a la mitad de una actualizacion, que no es atómica, se produzca
un cambio de contexto, ya sea para inspeccionar los valores o para establecerlos
en otro valor.
Lo que sigue es el programa setBuffer que implementa
esta idea y protege a las secciones críticas utilizando Locks.
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <pthread.h>
#define CAPACIDAD 16
void *set_buffer_to(void *);
void yield();
//las variables globales y compartidas
int buffer[CAPACIDAD];
pthread_mutex_t mutex;
int main(int argc, char *argv[]) {
pthread_t hilo0,hilo1;
int val[2]={4,8};
int i;
for(i=0; i<CAPACIDAD;
i++)
buffer[i] = 0;
pthread_mutex_init(&mutex,
NULL);
pthread_create(&hilo0,
NULL, set_buffer_to, (void *)&val[0]);
pthread_create(&hilo1,
NULL, set_buffer_to, (void *)&val[1]);
//sonda que comprueba que el Inv siempre se cumpla
while(1) {
yield();
pthread_mutex_lock(&mutex); //Inicio SC
//Inv: { \forall i : buffer[i]=buffer[0] }
for (i=0; i<CAPACIDAD; i++) {
assert(buffer[i]==buffer[0]);
yield();
}
pthread_mutex_unlock(&mutex); //Fin SC
}
//aqui nunca llega
}
//pone todo el buffer en un valor
void *set_buffer_to(void *arg) {
int val, i;
val = *(int *)arg;
while(1) {
yield();
pthread_mutex_lock(&mutex); //Inicio SC
for (i=0; i<CAPACIDAD; i++) {
buffer[i] = val;
yield();
}
pthread_mutex_unlock(&mutex); //Fin SC
yield();
}
}
//se entrega al planificador de manera no deterministica
void yield(void) {
if (rand()%2)
sched_yield();
}
|
Notar el uso de la función yield(), la cual agrega
un poco de no-determinismo al sistema, entregando o no el hilo actual
de ejecución con probabilidad ½. Luego veremos sistemas
un poco más sofisticados, todos tendientes a producir interleavings
siempre distintos en cada ejecución del código.
Ejercicios
- Utilizar esta forma no-determinística de entregar
el control al planificador en los 2 ejemplos anteriores y comprobar que
efectivamente hay mayor cantidad de interleavings.
- Comprobar que sin la exclusión mutua, podría
suceder el buffer se encuentre inconsistente.
- Se vio en los ejercicios anteriores que dividiendo el incremento
en instrucciones más simples e introduciendo algunos sched_yield
, el programa abortaba pues se producían ciertos interleavings
que invalidaban el invariante. Solucionar los problemas de concurrencia
de este programa utilizando locks para reestablecer el grado de atomicidad.
Sincronizando con Semáforos
Utilizaremos los semáforos que brinda pthreads
incluyendo su cabecera semaphore.h, a fin de implementar el
problema del productor-consumidor sobre un
buffer circular fifo.
Asi como en los mutexes, los semáforos pueden ser inicializados
y destruidos con sem_init y sem_destroy, y sus operaciones
básicas P y V están implementadas en
las funciones sem_wait y sem_post respectivamente.
El código está hecho de forma tal que todas las
variables compartidas que mantienen datos y sincronización están
dentro de una estructura de datos denominada buffer síncrono
. Esta forma de empaquetar estructuras de datos y su sincronización,
es muy común, y los ejemplos más usuales son los monitores
y las clases sincronizadas de Java.
Notar que el buffer circular es una implementación de una
secuencia finita xs de N elementos, en un arreglo
a de N+1 elementos y dos naturales
pr y pw. La relación entre la secuencia y la representación
está dada por los siguientes invariantes:
- #xs = | pw-pr |
- 0<=pr,pw<N+1
- \forall i : 0<=i<#xs : xs.i = a.((i+pr)mod (N+1))
Dado este buffer síncrono, creamos 2 hilos, donde uno es
el productor y inserta números naturales crecientes, mientras
que el otro es el consumidor y los obtiene del buffer.
Una correcta ejecución de los 2 hilos debería mostrar
para cada i, exactamente 1 valor encolado y 1 valor decolado,
además los
valores decolados deberían estar en orden creciente (los
encolados también lo están, pero por construcción
del programa).
Algunas pruebas más débiles que estas (o sea que la
validez es implicada) pueden ser contar la cantidad total de elementos
producidos y consumidos, y divididos por tipo.
[juan@hal juan]$ ./prodCons | wc -l
2048
[juan@hal juan]$ ./prodCons | tee 1 | grep ">"
| wc -l ; grep "<" 1 | wc -l
1024
1024
|
Pero la mejor forma es mirar directamente la salida redirigida a
un archivo, o bien generar un programa que compruebe estas propiedades
a partir del archivo de salida. Mostramos a continuación una salida
que contiene errores de sincronización.
[juan@hal juan]$ ./prodCons | head
> 0
< 0
> 1
< 1
> 0
< 2
> 0
< 3
> 0
< 4
|
El listado del programa se puede ver a continuación:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <pthread.h>
#include <semaphore.h>
#define NOTSHARED 0
#define N 16
#define NUM_ITEMS 1024
void *productor(void *);
void *consumidor(void *);
void yield();
//la estructura compartida
struct buffer_sync_s {
int buffer[N+1];
int pr,pw;
pthread_mutex_t mutex;
sem_t vacios;
sem_t llenos;
} buffer_sync;
int main(int argc, char *argv[]) {
pthread_t hilo_p, hilo_c
//init de la estructura
buffer_sync.pr = buffer_sync.pw
= 0;
pthread_mutex_init(&buffer_sync.mutex,
NULL);
sem_init(&buffer_sync.vacios,
NOTSHARED, N);
sem_init(&buffer_sync.llenos,
NOTSHARED, 0);
//creo los hilos
pthread_create(&hilo_p,
NULL, productor, NULL);
pthread_create(&hilo_c,
NULL, consumidor, NULL);
//espero que terminen
pthread_join(hilo_p,
NULL);
pthread_join(hilo_c,
NULL);
//destruyo la estructura
sem_destroy(&buffer_sync.llenos);
sem_destroy(&buffer_sync.vacios);
pthread_mutex_destroy(&buffer_sync.mutex);
return 0;
}
//se entrega al planificador de manera no deterministica
void yield(void) {
if (rand()%2)
sched_yield();
}
|
//productor
void *productor(void *arg) {
int i;
for(i=0; i<NUM_ITEMS;
i++) {
yield();
sem_wait(&buffer_sync.vacios);
yield();
pthread_mutex_lock(&buffer_sync.mutex);
yield();
//el buffer circular, encolar(i)
buffer_sync.buffer[buffer_sync.pw] = i;
yield();
buffer_sync.pw = (buffer_sync.pw+1) % (N+1);
yield();
printf("< %5d\n",i);
yield();
pthread_mutex_unlock(&buffer_sync.mutex);
yield();
sem_post(&buffer_sync.llenos);
yield();
}
pthread_exit(NULL);
}
//consumidor, totalmente simetrico
void *consumidor(void *arg) {
int i;
int a;
for(i=0; i<NUM_ITEMS;
i++) {
yield();
sem_wait(&buffer_sync.llenos);
yield();
pthread_mutex_lock(&buffer_sync.mutex);
yield();
//el buffer circular, decolar(i)
a = buffer_sync.buffer[buffer_sync.pr];
yield();
buffer_sync.pr = (buffer_sync.pr+1) % (N+1);
yield();
printf("> %5d\n",a);
yield();
pthread_mutex_unlock(&buffer_sync.mutex);
yield();
sem_post(&buffer_sync.vacios);
yield();
}
pthread_exit(NULL);
}
|
Ejercicios
- Comprobar y explicar los problemas que surgen si quitamos
la sincronización con los semáforos.
- (*) Si quitamos mutex del productor y consumidor, el programa
parece funcionar correctamente. Explique porque esto es así, y
cuando se vuelven imprescindibles los mutex para que el buffer síncrono
funcione correctamente.
- (*) Modificar el ejemplo para que sean N productores y M consumidores.
Establecer criterios claros respecto a los valores que producen los productores,
asi es posible decidir a partir de la salida si la sincronización
es correcta.
- Encontrar alguna forma para insertar los yield()
después de cada línea, de manera que no dificulte la legibilidad
del código.
- Transforme el programa para que el TAD buffer_sync
incorpore todas las funciones necesarias: buffer_sync_crear
, buffer_sync_destruir , buffer_sync_encolar
, buffer_sync_decolar , buffer_sync_tamanio ,
buffer_sync_utilizado, buffer_sync_mostrar , etc. Donde
todas las operaciones del TAD estén sincronizadas, a fin de mantener
la consistencia de representación del TAD.
- Utilize la función srand() tomando valores
de /dev/random para mejorar la generación
de interleavings (man srand).
Variables de Condición
Podemos pensar en los Semaforos y los Mutex (caso particular del
anterior) como primitivas que esperan bajo ciertas condiciones (P(s)
espera si s=0) y otras que señalizan la continuación
( V(s) ), sólo que las condiciones de espera son predicados
específicos a fin de mantener el invariante 0<=s
.
Las variables de condición son el tercer y último
mecanismo de sincronización que veremos y nos permite permite
esperar y señalizar por condiciones más generales que las
impuestas por los semáforos.
Hay 2 primitivas básicas:
- esperar en una variable de condición.
- señalizar una variable de condición para
que algún proceso esperando sobre ella pueda continuar.
También se suele incorporar una transmisión masiva
( broadcast) de señalizaciones a fin de despertar a todos los
procesos esperando por la variable de condición.
La primitiva de esperar es pthread_cond_wait y ademá
s de tomar una variable de condición, toma un mutex. Esto es a
fin de establecer exclusión mutua entre todos los hilos que pueden
modificar los datos que componen la condición sobre la que se espera
y se señaliza. El pseudocódigo de pthread_cond_wait
se muestra a continuación:
pthread_cond_wait (cond, mutex)
begin
pthread_mutex_unlock
(mutex);
block_on_cond (cond);
pthread_mutex_lock
(mutex);
end
Normalmente la secuencia de instrucciones de espera sería
la siguente:
pthread_mutex_lock(mutex);
"se modifican los valores de las variables compartidas
y se decide esperar"
pthread_cond_wait(cond, mutex);
"debido al mutex, estoy seguro que las condiciones
sobre las variables compartidas
que produjeron la señalización se siguen cumpliendo"
pthread_mutex_unlock(mutex);
Si pthread_cond_wait, no liberara y readquiriera la exclusión
mútua de manera atómica con las acciones de dormir y despertar,
otros hilos podrían cambiar las condiciones sobre las variables
compartidas que invaliden la condición que se asocia a cond
. Exactamente esto es lo que se puntualiza en la página 269 respecto
a la implementación de semáforos en "An Operating Systems
Vade Mecum" de R.Finkel.
El ejemplo que se muestra a continuación se denomina sincronización
de barrera y nos permite sincronizar fases de una computación
que se realiza de forma paralela por un conjunto de hilos.
Supongamos que tenemos una máquina con 2 procesadores y necesitamos
realizar un computo, pero para que este computo sea correcto, tenemos
que poner a 0 un gran vector A de valores que están en
memoria compartida. Podríamos decidir que sería bueno que
existan 2 hilos de ejecución, donde uno ponga a 0 los lugares pares
del vector y el otro los impares. Si tenemos en cuenta que es muy probable
que cada hilo se ejecute en un procesador por separado, entonces esta etapa
ocuparía la mitad del tiempo.
Lamentablemente no queremos que se comience a ejecutar el cálculo
(segunda fase) hasta que se haya terminado con el proceso de reseto
del vector (primera fase), y por supuesto no podemos confiar
en que ambos hilos terminarán exactamente al mismo tiempo. Es por
ello que introducimos una variable de condición llegaron
y una de exclusión mútua mutex. La primera
se asocia a la condición 2<=n, donde n representa
la cantidad de componentes (en este caso a lo más 2) que finalizaron
la primera etapa, mientras que mutex , asegura que los accesos
a n sean en exclusión mutua para evitar condiciones
de carrera, invalidaciones o interferencias sobre esta
variable compartida.
El procedimiento es simple, cada componente pone a 0 sus elementos
del vector y cuando termina incrementa n indicando que "una más
llegó". Luego si n<2, entonces tenemos que esperar por la condición
llegaron, y en cualquier caso que detectemos que 2<=n despertamos a
todos los otros hilos que puedan estar esperando por la condición.
Veamos el código:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N 2
#define TAMANIO 1024
#define _Y yield()
void *trabajador(void *);
void yield();
void randomize()
//los datos compartidos
int vector[TAMANIO];
struct barrera_s {
int n;
pthread_mutex_t mutex;
pthread_cond_t llegaron;
} barrera;
int main(int argc, char *argv[]) {
pthread_t hilo_p, hilo_i;
int par,impar;
//cambio la semilla de
los numeros aleatorios
randomize();
//init de los datos compartidos
barrera.n = 0;
pthread_mutex_init(&barrera.mutex,
NULL);
pthread_cond_init(&barrera.llegaron,
NULL);
//creo los hilos
par=0; impar=1;
pthread_create(&hilo_p,
NULL, trabajador, (void *)&par);
pthread_create(&hilo_i,
NULL, trabajador, (void *)&impar);
//espero que terminen
pthread_join(hilo_p, NULL);
pthread_join(hilo_i, NULL);
//destruyo la estructura
pthread_cond_destroy(&barrera.llegaron);
pthread_mutex_destroy(&barrera.mutex);
return 0;
}
|
//trabajador
void *trabajador(void *arg) {
int paridad
int i;
paridad = *(int *)arg;
_Y;
for(i=paridad; i<TAMANIO;
i+=2) {
vector[i] = i; _Y;
}
printf("Termino %d\n",paridad);
_Y;
pthread_mutex_lock(&barrera.mutex);
_Y;
barrera.n++; _Y;
if (N<=barrera.n) {
//todas las componentes llegaron
pthread_cond_broadcast(&barrera.llegaron); _Y;
} else {
//faltan de llegar
pthread_cond_wait(&barrera.llegaron, &barrera.mutex); _Y;
}
pthread_mutex_unlock(&barrera.mutex);
_Y;
printf("Paso %d\n",paridad);
_Y;
return 0;
}
void yield(void) {
if (rand()%2)
sched_yield();
}
void randomize(void) {
FILE *fd;
unsigned int read;
unsigned int buffer;
fd = fopen("/dev/random","r");
read = fread((void *)&buffer,
sizeof(unsigned int), 1, fd);
srand(buffer);
}
|
Notar que mejoramos la legibilidad del código definiendo la
macro _Y. También aumentamos el no-determinismo en la
planificación cambiando la semilla de producción
de números aleatorios (man srand), mediante el dispositivo
/dev/random, el
cual esta produciendo constantemente valores al azar extraídos del
entorno (interrupciones, contadores, memoria, etc.).
Ejercicios
- El efecto de espera casi no se nota porque la velocidad de
ejecución de los 2 trabajadores es más o menos igual, ya
que el planificador es bastante justo (variante del RR). Definir para
cada trabajador un retardo específico que se ejecutará
como busy wait luego de cada modificación a vector, de manera
que se pueda ver que uno termina antes que el otro.
- Generalizar y probar el código para N trabajadores.
- ¿Qué sucedería si no se incluye la mutex
previo al incremento de n?
- ¿Tiene acceso a una placa madre dual? ¡Compruebe
que este código tarda la mitad que su versión secuencial!
Todos los códigos de los ejemplos
En el archivo (Lab3Ejemplos.tar.gz
) incluye todos los códigos de ejemplo.
Tareas
Se deberán resolver tres problemas antropomorfos de
sincronización:
- la lavandería
- producción de agua
- el puente colgante de una vía
Todos estos problemas tienen su correlato en problemas de sincronización
de Sistemas Operativos y se puntualizarán oportunamente.
La Lavandería
"El lavadero de la esquina incorporó
la computadora para mejorar la productividad y eficiencia de sus lavarropas.
Para esto incluyó en su entrada una serie de terminales tontas
donde cada persona que desea una máquina, introduce una moneda,
y en caso de que existan máquinas libres, recibe el número
de la maquina que le corresponde. Cuando termina de lavar, la persona se
retira indicando en la terminal que ya liberó la máquina,
para que pueda ser utilizado por otro cliente.
Del programador prolijo y estructurado, pero que poco sabe de concurrencia
y sincronización, obtuvimos el siguiente tarball
LaLavanderia.tar.gz
, cedido gentilmente.
La tarea es sencilla, se deberá modificar el código
de forma tal que se solucionen dos problemas más o menos graves
que desde hace ya un mes el programador no es capaz de resolver:
- Cuando no hay máquinas, las personas simplemente se
van, cuando se les debería decir que esperen en los bancos.
- De vez en cuando el sistema asigna la misma máquina
a dos personas distintas, causando confusión y malestar entre
los clientes.
La versión "seria" de este problema se conoce como la Tabla
Multihilo , y básicamente consiste en una tabla de objetos
no tipados Tabla[0,tam), que soporta accesos concurrentes (
thread-safe ), con las siguientes operaciones:
- tabla_mh tabla_mh_crear(int t): crea una tabla vacía
de tamaño tam.
- int tabla_mh_alocar(void *o): inserta en algúna
ranura vacía el objeto o, y devuelve en número de
ranura.
- void *tabla_mh_obtener(int i): obtiene el objeto de
la ranura i-ésima.
- void tabla_mh_liberar(int i): libera la ranura
i -ésima.
- void tabla_mh_destruir(void): destruye la tabla en
memoria.
y se utiliza para implementar tablas de procesos, hilos, páginas
de memoria, archivos abiertos, etc.
Producción de Agua
Usted ha sido contratado por la Madre Naturaleza para ayudarla con
la reacción química para formar agua. Ella tiene dificultades
para generarla debido a problemas de sincronización. El truco consiste
en tener 2 átomos de H y 1 átomo de O, todos al
mismo tiempo. Los átomos son threads y puede haber varios O
y otros tantos H. Cada átomo llama al procedimiento naturaleza_o_listo
y naturaleza_h_listo , cuando cada uno esta listo para reaccionar.
Se supone que eventualmente todo átomo estará listo para reaccionar.
Los procesos que ejecutan los átomos-hilos serían
void abstraccion_o(void)
{
//solo soy un O calmo
naturaleza_o_listo(n);
}
|
void abstraccion_h(void)
{
//solo soy un O calmo
naturaleza_h_listo(n); _Y;
} |
Los procedimientos naturaleza_o_listo y naturaleza_h_listo
, deberían incrementar la cantidad de átomos listos para reaccionar
de cada tipo en la naturaleza y cuando se junte la cantidad necesaria, uno
de ellos debería llamar a la función naturaleza_hacer_agua
, que simplemente imprime un mensaje y deja que estos átomos terminen,
pues ya forman parte de una molécula y no son capaces de reaccionar
de ahora en más.
Al finalizar se deberían tener todos las posibles moléculas
de agua armadas y los átomos restantes esperando y listos para reaccionar.
Se deberán crear el tipo de dato abstracto naturaleza que
será una abstracción de ella, donde además de llevarse
la cuenta de la cantidad de átomos de H y O listos para
reaccionar se incluyan las variables de sincronización que se consideren
necesarias (locks, semáforos y variables de condición).
La versión "seria" del problema se conoce como encuentro entre
procesos o rendezvous, y es algo parecido al problema
de sincronización de fase o barrera visto en los ejemplos.
El Puente Colgante de una Vía
Ahora su contrato es con Caminos de la Nada, una subcontratista que
provee los sistemas informáticos a una de las tantas empresas que concesionan
caminos. A Caminos ... se le pidió que brinde un sistema para sincronizar
el tráfico sobre un angosto y de liviano tránsito puente en
un camino público. El tráfico sólo puede cruzar el puente
en una sola dirección a la vez, y si hay más de 3 vehículos
sobre el puente, este decididamente se cae.
En este sistema cada automóvil se representa con un hilo que ejecuta
el proceso abstraccion_de_auto que se presenta a continuación.
void abstraccion_de_auto(direccion
d) {
puente_entrar(p,d);
puente_cruzar(p,d);
puente_salir(p,d);
}
|
La direccion puede ser Oeste-Este o Este-Oeste, e indica que dirección
de paso tiene el vehículo.
El objetivo es crear un TAD sincronizado puente que en sus métodos
puente_entrar y puente_salir
, tenga la sincronización suficiente para que la expresión
siguiente resulte invariante, donde los naturales oe y eo
representan la cantidad de vehículos que están sobre el puente
en cada una de las direcciones.
(oe=0 | eo=0) & oe<=3
& eo<=3
La versión seria de este problema se conoce como la exclusión
mutua entre rojos y azules.
Para este problema se dividen los procesos entre rojos y azules, donde ambos
quieren acceder a una sección crítica. Puede haber cuantos rojos
y azules se quiera dentro de la SC, mientras no haya dos procesos de diferente
color en ella. El invariante que se debe mantener es
azules!=0 => rojos=0
o equivalentemente
azules=0 | rojos=0
El problema del one-lane-bridge es similar sólo que
pone restricciones respecto a la cantidad máxima de cada color que
pueden estar en la SC.
Problemas como este o el de lectores-escritores (Finkel p.279) pueden ser
resueltos de manera sistemática mediante la técnica del
Semáforo Binario Dividido. La técnica
es originalmente de Hoare y en 1979 Dijkstra escribió
EWD703
, un tutorial sobre esta técnica.
Cómo atacar los problemas
Es prerequisito fundamental haber probado y comprendido los códigos
de ejemplo de la sección de introducción. También resulta
útil como precalentamiento la resolución de los ejercicios
que allí se plantean.
La Lavandería
Casi toda la cáscara del problema está lista, solo
se deberá incluir suficiente sincronización como para que
no ocurran estos errores, pero no demasiada como para que el comportamiento
sea puramente serial (pocos interleavings posibles).
Se pueden utilizar cualquiera de los 3 mecanismos de sincronización
vistos en pthreads (locks, semáforos y variables de condición),
pero siempre adentro del TAD Lavadero definido en lavadero.{c,h}
.
Antes de comenzar a resolver los problemas sería bueno mejorar
la producción de interleavings, utilizando los sched_yield
como en los ejemplos anteriores, a fin de que estos problemas surjan,
o si no en la mayoría de los casos se obtiene una ejecución
serial.
Producción de Agua
En este problema no damos ningún esqueleto sin sincronizar del problema,
pero se puede seguir la línea del problema anterior. Una posibilidad
es crear un TAD naturaleza que inicialmente tenga 2 componentes, más
todas las de sincronización que se pudieran agregar, junto a las
funciones que operan sobre ese TAD.
struct naturaleza_s {
nat h_listo,o_listo;
};
typedef struct naturaleza_s* naturaleza;
naturaleza naturaleza_crear(void);
void naturaleza_destruir(naturaleza n);
void naturaleza_o_listo(naturaleza n);
void naturaleza_h_listo(naturaleza n);
void naturaleza_hacer_agua(naturaleza n);
void naturaleza_a_cadena(naturaleza n, char* buffer);
|
Las funciones naturaleza_o_listo y naturaleza_h_listo
simplemente incrementan los escalares en el TAD y deberían sincronizarse
de manera que tan pronto como haya H2O, se llame a naturaleza_hacer_agua
, que tiene como precondición
2<=n->h_listo &
1<=n->o_listo
para que luego de los decrementos que implican la formación del agua,
los escalares sigan siendo de tipo Nat.
Claramente si la sincronización resulta correcta siempre se dará
la precondición de naturaleza_hacer_agua.
El main es sencillo, una cantidad NUM_O de hilos que hacen
abstracción_o y una cantidad NUM_H que hacen lo mismo
pero para el hidrógeno.
Hay que imprimir todos los mensajes de debug necesarios dentro del TAD para
que se pueda comprobar la corrección de una ejecución inspeccionando
la salida y viendo como se suceden las creaciones, reacciones y esperas para
la sincronización y luego la muerte de la representación en
hilos de los átomos. Puede resular útil engordar la información
de debug con la función pthread_self(), la cual devuelve el
identificador único del proceso, a fin de identificar que proceso realiza
cada acción.
El Puente Colgante de una Vía
Los tipos y las signaturas de los métodos del TAD inicial, sin sincronización
podrían ser como sigue:
struct puente_s {
nat oe,eo;
};
typedef struct puente_s* puente;
puente puente_crear(void);
void puente_destruir(puente p);
void puente_entrar(puente p, direccion d);
void puente_salir(puente p, direccion d);
void puente_cruzar(puente p, direccion d);
void puente_a_cadena(puente p, char *buffer);
|
Los cuales tienen el mismo patrón que los anteriores.
Las funciones puente_entrar y puente_salir incrementan
y decrementan respectivamente p->oe o p->eo dependiendo
de la dirección d dada. Esta dirección puede ser codificada
con un nuevo tipo de datos enumerado como se muestra a continuación
:
typedef enum {
DIR_OE, DIR_EO
} direccion;
|
Es conveniente poner assert en todos los lugares donde el invariante
pueda ser destruido. Por ejemplo para puente_entrar(p,DIR_EO) deberíamos
poder aseverar que es válido p->oe==0 y también
que p->eo<3 antes de proceder al incremento de la variable
global p->eo.
Clarament cuando completemos el TAD con la sincronización necesaria,
estos assert deberán ser siempre válidos.
Este problema tiene muchos aspectos respecto a la justicia de la política
de sincronización, por ejemplo sería interesante poder indicar
como se comporta su solución si tenemos un auto cruzando en
DIR_EO, aparece otro por cruzar en la misma
dirección, pero ya hay uno en DIR_OE esperando que el que
está sobre el puente pase.
Qué se debe Entregar
Deberá crearse un informe que incluya cada uno de los
problemas, indicando todo lo que se crea necesario, para que, a partir de
la lectura del informe, una persona con conocimientos de Sistemas Operativos
y Concurrencia entienda cuales eran los problemas y como se planteó
la solución.
En el informe tambíen se deben copiar y pegar secuencias de ejecución
correctas e incorrectas que se produzcan.
Que TAD, primitivas de sincronización, invariantes, modularización,
etc, se utilizaron son ejemplos típicos de lo que debería
consignarse en el informe.
En general se los códigos deberán ser sencillos, modulares,
utilizar TAD y programación defensiva, proveer información
de debugging condicional, entre otras cosas.
Toda la sincronización deberá estar dentro del
TAD, asi como la información de debug, a fin de que el programa
principal sea lo más sencillo posible. No se permite utilizar busy
wait.
La Lavandería
Se pide que el código compile en 4 versiones, utilizando
compilación condicional con dos
definiciones NO_PROB_1 y NO_PROB_2 de manera tal que,
cada definición controle la aparición del problema 1 (esperar)
y el problema 2 (condiciones de carrera). Entonces si compilo con
DEFINES=-DNO_PROB_1 -DNO_PROB_2 en el Makefile, el programa
funciona a la perfección ante cualquier interleaving posible, mientras
que si compilo con DEFINES= , produce los dos errores de concurrencia
mencionados.
El informe deberá indicar cuales eran los problemas, y como la sincronización
agregada los soluciona, dando argumentos convincentes al respecto.
No es necesario que el programa que entreguen sea una modificación
de éste, pero si deberán mantener al idea de TAD sincronizados
.
Producción de Agua
Se deberá entregar además del programa final que incluya toda
la sincronización necesaria para la producción de agua.
No es necesario generar compilación condicional a fin de generar
diferentes versiones de código.
El informe deberá mostrar como se sincronizó el problema,
asi como una explicación convincente de porqué resulta correcta
dicha sincronización.
El Puente Colgante de una Vía
Idem al anterior, el programa, y la documentación detallada de como
se sincronizó el problema y argumentos convincentes que demuestren
que esta resulta correcta.
Cómo se debe Entregar
Cada problema y se coloca en un directorio distinto, debajo
del directorio Lab3Gxx, donde xx es el número
de grupo, siguendo el árbol que se muestra a continuación.
El informe que incluye los 3 problemas deberá estar en el directorio
Lab3Gxx/ .
- Lab3Gxx/
- LaLavanderia/
- ProduccionDeAgua/
- ElPuenteColgante/
A partir de todo esto generar un archivo tar.gz que incluya
todo recursivamente desde el directorio Lab3Gxx/ con el siguiente
comando de línea:
[juan@hal juan]$ tar -zcvf Lab3Gxx.tar.gz Lab3Gxx/
|
Finalmente enviar el archivo como adjunto a un email a la dirección
so2002@famaf.unc.edu.ar
.
Tips
Resulta fundamental no confiar jamás en un par de ejecuciones
exitosas, probablemente con otra forma de sched_yield() nuestro
multiprograma ya no funcione correctamente. Recuerden que el planificador
es nuestro peor enemigo y cosas que aquí y ahora funcionan correctísimamente,
en dos días y en otra máquina pueden fallar siempre. Prueben
sus códigos terminados en diferentes máquinas, con distintas
versiones del SO y bajo distintas condiciones de carga.
Como hacer debugging en programas multihilos es casi magia negra, resulta
absolutamente indispensable utilizar programación defensiva
con asserts y manejo de errores de syscalls, además
de estar más que seguros que los TAD funcionan correctamente en un
entorno monoprogramación.
Utilizen todos los artilugios para generar interleavings ricos
(srand, /dev/random), a fin de asegurar que cada ejecución
producirá un interleaving distinto con alta probabilidad.
Imprimir información de debugging resulta también
fundamental. Es posible generar un esquema de debugging con salida controlada,
definiendo una función void debug(char *mensaje), que
internamente controle si los mensajes se eliminarán o se escribirán
y a donde. Probablemente una función void debug(int nivel, char
*mensaje) , sea un poco más sofisticada, pero también
más útil, y básicamente imprime todos los mensajes
que tienen un nivel de debugging por debajo de una constante DEBUG
, con lo cual los mensajes con nivel bajo son generales y a medida que subimos
de nivel aumenta la especificidad.
Como la escritura de la información de debugging es manejada
por el kernel del SO, y esté suele hacerlo de manera asíncrona
(puede escribir un poco después de que se llamó a la syscall),
es posible forzar la sincronicidad, por ejemplo con un syscall
fflush(stdout) , a fin de obtener un temporizado más preciso
de los mensajes.
Una buena función de impresión del TAD, también
puede ayudar mucho, asi como funciones de autotesteo del estilo
lavadero_autotesteo que comprueben
cosas tan básicas como que pedir una máquina y después
preguntar si está disponible, sea verdadero.
Tareas Adicionales
Si les sobra tiempo pueden hacer las siguientes mejoras:
Nicolás Wolovick, 23 de Octubre de
2002