Programación Concurrente
Fork:
fork es una primitiva de Linux, no de C, que permite crear procesos. Estos procesos
comparten código, en particular las variables. La sentencia fork() permitirá crear dos
procesos, con una estructura jerárquica: hay un proceso padre y otro hijo. De este
modo, y hasta la finalización de uno de los dos procesos, padre e hijo se ejecutarán
concurrentemente, a la vez.
Una vez invocada la sentencia fork(), está retorna tres posibles valores:
● -1 en caso de existir algún error.
● 0 si el proceso es el hijo.
● Valor positivo en el caso del proceso padre.
Estructura básica de un programa concurrente:
#include <stdio.h>
#include <stdlib.h> // Para usar exit()
int main() {
int i = fork();
if (i < 0) { // Controla la ocurrencia de un error
exit(0);
}
if (i > 0) { // Este es el proceso padre
printf("Buenas, soy el padre. %i\n", i);
} else { // Este es el proceso hijo
printf("Hola, soy el hijo. %i\n", i);
}
return 0;
}
Vamos a ver ahora qué pasa con las variables.
#include <stdio.h>
#include <stdlib.h>
main() {
int i=fork();
int a=0;
if (i==-1){
exit(0);
}
if (i>0) {
for (int j=0; j<100; j++){
printf(“%i”,a);
}
a=1;
}else{
for (int s=0; s<100; s++){
printf(“%i”,a);
}
}
}
Puede haber pasado que primero se haya ejecutado el proceso padre (i>0), el cual ha
repetido el valor de a, cero (0). Este proceso modifica el valor de a, asignándole uno
(1).
Luego se ejecutará el proceso hijo, el cual mostrará... cero (0). ¿Por qué?... porque los
procesos no comparten memoria, solo su nombre. Las modificaciones que realiza un
proceso quedan restringidas a dicho proceso. Son necesarias otras formas de trabajo
para asegurarnos poder compartir datos entre los procesos.
Aclaración: es posible que se invierta el orden de ejecución de los procesos, eso lo
determina el sistema operativo, solo pruebe varias veces. Puede eliminar los for, no
afecta la ejecución, pues la única función que cumple for es asegurarnos de generar un
tiempo "relativamente largo" para que dicho proceso se ejecute y se vea la
concurrencia.
Memoria Compartida:
El espacio de memoria definido podrá ser usado por todos los procesos que se
ejecutan concurrentemente. La concurrencia puede observarse al compartir espacios
de memoria, pues la modificación que se hace en ellos evidencia los problemas
relacionados con la consistencia de datos.
Pasos para compartir memoria:
1) Crear una clave para compartir el espacio de memoria, la cual será la misma
para todos los procesos que compartan el mismo espacio de memoria.
2) Configurar el espacio de memoria compartido.
3) Definir variables de tipo puntero sobre la cual se compartirán los datos.
#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <sys/msg.h>
#include <umistd.h>
main(){
//1)
key_t clave;
clave = ftok("/home/usuario/datos", 25); //Se crea la clave
//2)
int mem_id;
mem_id = shmget(clave, 1024, 0777 | IPC_CREAT); //Se define el
espacio de memoria
//3)
int * a;
a = (int *)shmat(mem_id; NULL; NULL); //Se asocia la variable al
espacio de memoria
int i = fork();
*a = 0;
if (i==-1){
exit(0);
}
if (i>0){ //Padre
for(int j=0; j<100; j++){
printf("%i", *a);
}
*a = 1;
}else{ //Hijo
for(int s=0; s<100; s++){
printf("%i", *a);
}
}
return 0;
}
1) Se crea la clave, la cual depende de un archivo (datos) y un entero cualquiera.
Si cada programa es un archivo distinto se debe copiar en todos.
2) Se define el espacio de memoria usando la clave, el tamaño en bytes de dicho
espacio (se puede usar sizeof(Variable)), los permisos en octal y crear el
espacio de memoria en caso de que no exista (IPC_CREAT).
3) Se asocia la variable al espacio de memoria, el sistema operativo se encarga de
permisos de lectura y escritura (NULL; NULL).
Una vez ejecutado el programa anterior, la salida dependerá del orden de ejecución de
los procesos y cuándo es modificada la variable a:
● 0000000000 seguido de 1111111111
● 0000000000 seguido de 0000000000
Debemos recordar que dado que la ejecución es muy corta en tiempo, difícilmente se
intercambie la ejecución de los procesos sin que esto implique su finalización. La
inclusión de funciones que aleatoriamente generen un retardo (usando for o
equivalentes) en la ejecución de las operaciones de manipulación de datos, aportará
realismo a las soluciones.
Semáforos:
Los semáforos son mecanismos implementados por el sistema operativo para asegurar
que no haya inconsistencia de datos a la hora de programar concurrentemente. Su
función es bloquear la entrada o salida de la sección crítica.
La sección crítica es una región de datos (memoria) a la que pueden o deben entrar los
procesos para su funcionamiento concurrente. La idea es que todos puedan ver lo que
hay dentro pero solo uno pueda modificarlo a la vez. Esta sección se considera
atómica, es decir, se considera como una sentencia que no tiene la posibilidad de
interrumpirse en el medio de su ejecución.
Las secciones de código dentro de los semáforos deben ser pequeñas porque de lo
contrario la ejecución de los procesos no sería eficiente. Suponiendo que tenga un
proceso padre e hijo, ambos con una sección de código de 100 mil líneas que se itera
varias veces con un for. Al principio y al final de esas 100 mil líneas (dentro del for) se
abren y cierran semáforos. Cuando un proceso entre a la sección protegida por el
semáforo el otro debe esperar. Cuanto más largo sea el código más larga es la espera,
lo que causa que se pierda la sensación de concurrencia. Además, es posible que el
proceso que entró primero al semáforo vuelva a hacerlo por el for, dejando al proceso
que estaba esperando rezagado una vez más. Por eso la ejecución de los procesos
deja de ser eficiente y se pierde la sensación de concurrencia.
#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h> //No nesasaria si no se trabaja con memoria compartida
#include <unistd.h> //No nesasaria si no se trabaja con memoria compartida
#include <semaphore.h>
#include <sys/ipc.h>
#include <fcntl.h>
main(){
//1)
sem_t *semaforo;
semaforo = sem_open("/semaforo", O_CREAT, 0644, 1);
int i=fork();
if(i<0){
exit(0);
}
if(i>0){
for(int j=0; j<10; j++){
//2)
sem_wait(semaforo);
printf("Inicio consumo \n");
printf("Fin consumo \n");
sem_post(semaforo);
}
} else {
for(int j=0; j<10; j++){
sem_wait(semaforo);
printf("Inicio producción \n");
printf("Fin producción \n");
sem_post(semaforo);
}
}
}
1) Se crea un semáforo (sem_t*) compartido entre procesos (/) y con nombre
“semaforo”. O_CREAT crea el semáforo ignorando otros parámetros. 0644 son
los permisos. 1 es el valor inicial booleano.
2) En cada una de las secciones críticas la producción y el consumo requieren más
de una operación. Pero al estar entre semáforos, se considera atómica.
Notas:
Para compilar:
// gcc -o Ejecutable Ejecutable.c -lpthread -lrt
sem_unlink(semaforo) se usa para borrar un semáforo,
wait() hace que el proceso padre espere a que los hijos finalicen y luego finaliza.
Por tanto podemos poner un wait() al final de cada padre (recordar que podemos tener
varios procesos que son padres unos de otros) y luego de borrar el semáforo con
sem_unlink(…).
Mensajería:
Es la forma básica de comunicación entre procesos. Funciona como un correo
electrónico, envío y recibo información, mientras existe un lugar donde almacenar la
misma. Aunque, a diferencia de los correos electrónicos, el acceso a los mensajes es
en función del orden de llegada.
Es necesario definir una id para los mensajes: #define ID_SMS 1
El mensaje es un tipo de dato estructurado. Se utiliza el mismo struct para enviar y para
recibir. La primer componente del mensaje siempre es del tipo long y corresponde a la
id del mensaje.
typedef struct tipo_mensaje{
long id_mensaje;
char mensaje[10];
} tipo_mensaje;
tipo_mensaje un_mensaje;
un_mensaje.id_mensaje = ID_SMS;
También hay que definir una clave (igual que para compartir memoria), y una variable
de tipo entero. Estas dos, nos permiten definir la cola (el lugar donde se almacenan los
mensajes).
key_t clave;
int id_cola;
clave = ftok("/home/ale/pepito", 25);
id_cola = msgget(clave,0600|IPC_CREAT);
Ahora en el padre, le asignamos el contenido del mensaje:
strcpy(un_mensaje.mensaje,"Hola");
(Utilizamos strcpy porque en C no tenemos el tipo de dato String)
Podemos imprimir esto en pantalla para comprobar que quedó cargado:
printf("Emisor: código %i mensaje %s \n", un_mensaje.id_mensaje,
un_mensaje.mensaje);
Luego enviamos el mensaje e imprimimos por pantalla la confirmación de que se envió:
msgsnd(id_cola, &un_mensaje, sizeof(un_mensaje.mensaje), IPC_NOWAIT);
printf(" Termine de enviar \n");
Utilizamos msgsnd y los parámetros que le pasamos son: la cola, la dirección donde
está el mensaje, el tamaño del mensaje (sin el campo long), y por último, IPC_NOWAIT
significa que es no bloqueante.
No bloqueante quiere decir que envía el mensaje y no se queda esperando una
confirmación del receptor, de forma tal de que sigue con la ejecución, en este caso,
imprimiendo en pantalla que terminó el envío.
En el proceso hijo recibimos el mensaje:
msgrcv(id_cola, &un_mensaje, sizeof(un_mensaje.mensaje), 0, 0);
Utilizamos msgrcv y los parámetros que le pasamos son: la cola, la dirección donde
está el mensaje, el tamaño del mensaje (sin el long). Hasta ahora es igual que para
envíar, pero luego sigue: el primer cero (0) indica que es el primer mensaje de la cola,
el segundo cero (0) indica que es bloqueante.
Bloqueante quiere decir que se queda esperando hasta que llegue el mensaje. Por
ejemplo, si tenemos las siguientes líneas de código:
printf("Receptor: código %s mensaje %i \n", un_mensaje.mensaje,
un_mensaje.id_mensaje);
printf(" Terminé de recibir \n");
Antes de pasar a los printf es necesario que llegue el mensaje. Si en lugar del cero (0)
ponemos que sea no bloqueante, podría seguir la ejecución sin recibir ningún mensaje.
Lo que imprime por pantalla en ese caso es la basura que haya en esa dirección de
memoria, ya que no hay ningún mensaje.
Es importante que el envío sea no bloqueante y que la recepción sea bloqueante.
Por último, al final del proceso hijo escribimos:
msgctl(id_cola, IPC_RMID, 0);
Que lo que hace es borrar la cola de mensajes y asegurar que no quede basura.
Sobre la cola de mensajes, es importante aclarar que tiene una política FIFO, es decir,
el primer mensaje que se envía es el primero que se recibe.
Con respecto al id de los mensajes, este sirve para identificar a una familia de
procesos. En una misma cola puede haber mensajes de varios tipos de id. El que
reciba cada proceso depende del valor que le pasemos al msgrcv en el cuarto
parámetro. Por ejemplo, si tenemos la siguiente cola:
1→2→1→1→2→2→3→2→1→3→4→4→5→1→2
Si el parámetro es 0: devuelve siempre el primer mensaje que llegó.
Si el parámetro es n: devuelve el primer mensaje de id=n.
Si el parámetro es -n: devuelve el primer mensaje de id<=n.