Documentos de Académico
Documentos de Profesional
Documentos de Cultura
Ingeniería de Telecomunicación
2. Introducción
El manejo de concurrencia exige a su vez la necesidad de mecanismos que hagan posible la
solución de los problemas que surgen de la propia definición de concurrencia, problemas tales
como el acceso a una región crítica (exclusión mutua), el interbloqueo, la sincronización entre
procesos, etc. El S.O. UNIX/Linux al ser un Sistema Operativo Multiusuario y Multitarea, nos
permite la posibilidad de realizar procesos concurrentes, además, posee un conjunto de
recursos para comunicación y sincronización entre procesos, que pueden utilizarse mediante
llamadas a funciones del sistema formando parte de un programa en lenguaje C. Este conjunto
de recursos (semáforos, paso de mensajes, etc.) se encuentran en la librería IPC (InterProcess
Communication).
Un proceso es un programa que está siendo ejecutado por un procesador. Un programa queda
completamente definido por el código que resulta de la compilación y del enlazado del código
fuente del programa. Este código contiene una serie de instrucciones que debe ejecutar un
procesador para realizar una determinada tarea y es una estructura pasiva. Sin embargo el
proceso es una estructura activa que incluye el código del programa y sus datos, también el
estado de aquellos recursos que necesita el programa para ejecutarse.
Un programa será un archivo que reside en el disco de sistema y que se crea con otros
programas, como por ejemplo el compilador de C o el editor vi, mientras que un proceso es una
copia del programa en la memoria que está haciendo algo. Un proceso comprende todo el
entorno de ejecución del programa, es decir, desde las variables, los ficheros abiertos, el
directorio en el que reside, información acerca de usuario que ejecuta el proceso y el terminal
donde lo ejecuta así como el código del programa.
Algunas de las funciones relacionadas con el manejo de procesos son las siguientes:
1
2. wait espera a que el proceso hijo pare o termine.
POSIX
Las diferencias a nivel de sistema entre los diferentes sistemas Unix de diferentes vendedores,
provocó la aparición de diferentes estándares en la especificación de la interfaz de los sistemas
con los programas de aplicación. Nosotros vamos a seguir los siguientes estándares: ANSI C,
POSIX y Spec 1170.
La IEEE desarrolló una serie de estándares denominados POSIX ( Portable Operating System
Interface) que especifican la interfaz entre el sistema operativo y el usuario de modo que los
programas sean transportables a través de diferentes plataformas.
De entre los diferentes miembros del grupo de estándares nosotros nos centraremos en el
POSIX.1 que especifica la API (Lenguaje C).
Credenciales de usuario
Cada usuario del sistema se identifica por un número único denominado ID de usuario o UID, y
pertenece a un grupo de usuarios, que posee su ID de grupo o GID. Estos identificadores
afectan a la propiedad de los archivos, a los permisos de acceso, y a la capacidad de enviar
señales a otros procesos. Estos atributos se denominan globalmente credenciales.
2
ejemplo, mknod para crear un nodo del sistema de archivos. Muchos sistemas UNIX actuales
tales como SVR4.1/ES soportan mecanismos de seguridad incrementada . Estos sistemas
sustituyen la abstracción de superusuario privilegiado único con privilegios para operaciones
diferentes.
Cada proceso tiene dos pares de IDs -real y efectivo. Cuando un usuario entra al sistema, el
pograma de entrada (login) establece ambos pares a los UID y GID especificados en la base
de datos de password (el archivo /etc/passwd, o algún mecanismo distribuido como el
Network Information Service (NIS) de Sun Microsystems). Cuando un proceso invoca a fork,
el hijo hereda las credenciales de su padre.
Durante la creación de un archivo, el kernel establece los atributos de propiedad del archivo a
los UID y GID efectivos del proceso creador. Durante el acceso al archivo, el kernel utiliza los
UID y GID efectivos del proceso para determinar si puede acceder al archivo. Los UID y GID
reales identifican al propietario real del proceso y afectan a los permisos para enviar señales.
Un proceso sin privilegios de superusuario puede enviar señales a otro proceso sólo si el UID
real o efectivo del emisor iguala al UID real del receptor.
Existen tres llamadas al sistema que pueden cambiar las credenciales. Si un proceso llama a
exec para ejecutar un programa instalado en modo suid, el kernel cambia el UID efectivo del
proceso al del propietario del archivo. De la misma forma, si el programa esta instalado en
modo sgid, el kernel cambia el GID efectivo del proceso llamador.
UNIX suministra este mecanismo para otorgar privilegios especiales a usuarios para realizar
tareas concretas. El ejemplo clásico es el programa passwd, que permite a un usuario cambiar
su propio password. Este programa debe escribir en la base de datos de password, en la cual
no esta permitida la escritura directa por parte de los usuarios (para evitar la modificación de
password de otros usuarios). Así, el programa passwd el propiedad del superusuario y tiene
activo el bit SUID. Esto permite al usuario obtener los privilegios de superusuario mientras
ejecuta el programa passwd.
Soporta grupos suplementarios, y mantiene los UID y GID salvados a través de exec. A
continuación se recogen las funciones con las cuales podemos consultar los identificadores de
proceso, del padre del proceso, el identificador de usuario real y efectivo, y el identificador de
grupo real y efectivo, del proceso llamador, respectivamente.
# include <sys/types.h>
# include <unistd.h>
3
pid_t getppid(void) Retorna: ID del padre
fork
El único modo de que el núcleo de unix cree un nuevo proceso es mediante la ejecución de la
llamada fork() realizada por un proceso existente. El nuevo proceso que se crea se llama
“proceso hijo ”. El proceso al que llama fork lo llamaremos padre y al nuevo proceso (la
copia) lo llamará hijo. Esta función devuelve un valor numérico que será distinto para el padre y
para el hijo. El núcleo devuelve un valor cero para el proceso hijo, mientras que al proceso
padre le devuelve el identificador del proceso hijo. El motivo de esto es que un proceso padre
puede tener varios hijos mientras que los hijos tienen un solo padre (existe una llamada al
sistema que puede permitir al hijo obtener el identificador de su proceso padre: getppid()).
Una vez creado el proceso ambos siguen ejecutándose por la instrucción que sigue a fork. En
general, no conoceremos nunca si el hijo se ejecuta antes que el padre o la inversa. Esto
dependerá del algoritmo de planificación.
fork retorna diferentes valores para cada proceso. Para el padre fork retorna el processID, y
para el hijo fork retorna cero. Como ejemplo de uso véase el siguiente programa.
#include <stdio.h>
#include <unistd.h>
main () {
4
Cuando ejecutemos el programa lo que obtendremos será :
Ejemplo de fork.
exit
Esta función pone fin a la ejecución de un proceso. Su sintaxis es:
#include <stdlib.h>
#include <unistd.h>
Ambas funciones devuelven un valor del estado de finalización al proceso padre. El estado de
finalización es indefinido si:
Esta función termina el proceso que la invoca, con un código de status igual al byte más a la
derecha del status. Cierra todos los descriptores del proceso.
5
En el siguiente programa el padre espera a que el hijo salga y luego imprime el valor de status
del hijo.
#include <stdio.h>
#include <stdlib.h>
main () {
exit (status);
wait (status);
exit (0);
El hijo lee el exit status que va a ser introducido por el terminal y entonces devuelve este valor
al padre a través del exit. Entonces el padre imprime el exit status del hijo.
1. ¿Qué ocurre cuando el padre finaliza antes de que el hijo lo haga? En este caso el
proceso init se hacer cargo del proceso, lo hereda. De esta forma garantizamos que
todos los proceso tienen un padre.
Cuando un proceso heredado por init finaliza, init esta diseñado para invocar a la
función wait para obtener el estado de finalización.
2. ¿Qué ocurre si el hijo finaliza antes que el padre, y el padre no puede leer el valor de
retorno del hijo? En la terminología UNIX se denomina zombi al proceso que a
terminado pero su padre no ha esperado su fin. De un proceso zombi solo existe la
estructura proc, para mantener la información de estado y recursos utilizados, los
demás recursos son liberados por el kernel. El truco para no tener que esperar por un
hijo y que este no se convierta en zombi, es llamar a fork dos veces (mecanismo que
es utilizado por todos los demonios).
6
wait y waitpid
Cuando un proceso hijo acaba se debe notificar a su padre el estado de terminación de éste.
De algún modo el proceso padre deberá estar esperando la respuesta del hijo. Esto se realiza
mediante la llamada al sistema wait(). El proceso que ejecuta la llamada al sistema wait()
quedará en espera hasta que se le notifique que cualquiera de sus hijos ya ha acabado y
seguirá ejecutando la línea de código siguiente a wait(). Esta llamada tiene un parámetro
donde recogerá el estado de terminación del primer hijo que haya acabado, además de que
devuelve el número de proceso del hijo que ha acabado. Si no nos interesa el estado de
terminación del proceso hijo se le puede pasar NULL. El estado que recoge la función wait()
se interpreta mediante una serie de macros ya definidas (ver hojas del manual de UNIX).
Existe una variante de esta llamada, es waitpid(). En este caso el proceso que ejecuta
waitpid() esperará a un hijo concreto.
Podemos controlar la ejecución del proceso hijo llamando a wait en el padre. wait fuerza al
padre a detener la ejecución hasta que el proceso hijo haya terminado. wait retorna el
procesáis del proceso hijo que termina y almacena su status en el entero al que apunta el
puntero que debe llevar como argumento ( statusp ) a menos que el argumento sea NULL en
cuyo caso no almacena el status. wait retorna -1 en caso de fallo.
El siguiente programa es exactamente igual al anterior sólo que garantiza que el proceso hijo
termina antes que el padre.
#include <stdio.h>
#include <stdlib.h>
main () {
if ( fork() == 0 )
else {
wait (NULL);
exit (EXIT_SUCCESS);
Este es el hijo
7
Este es el padre
#include <sys/types.h>
#include <sys/wait.h>
· wait puede bloquear al llamador hasta que un hijo termine, mientras que waitpid tiene una
opción que evita el bloqueo.
· waitpid no espera la terminación del primer hijo, tiene una opción que permite controlar a
que proceso esperar. Para ambas funciones, el argumento statloc es un puntero a un
entero. Si este argumento no es un puntero nulo, el estado de terminación del hijo se almacena
en la posición apuntada por el argumento. Si no estamos interesados por el valor, podemos
pasar un puntero nulo. Tradicionalmente, el valor de estado devuelto por estas dos funciones
ha sido definido por la implementación, con determinados bits indicando el estado de
finalización. POSIX.1 especifica para la visualización del estado de finalización varias macros
mutuamente exclusivas definidas en <sys/wait.h>:
· WIFSTOPPED(status) - Devuelve cierto si el valor de status fue devuelto por un hijo que
está actualmente detenido. Podemos ejecutar WSTOPSIG(status) para obtener el número
de la señal que paró al hijo.
· WNHANG - En este caso waitpid no será bloqueante si el hijo especificado por pid no esta
disponible inmediatamente. En este caso devuelve 0.
8
· WUNTRACED - Si la implementación soporta control de trabajos, se devuelve el estado del hijo
especificado por pid que ha sido detenido y cuyo estado no ha sido devuelto desde su
detención. La macro WIFSTOPPED(status) determina si el valor de retorno corresponde a
un hijo parado.
• pid == 0 espera por cualquier hijo cuyo ID grupo es igual al del proceso llamador.
• pid < -1 espera por cualquier hijo cuyo ID grupo es igual al valor absoluto de pid.
Wait da error sólo si el proceso llamador no tiene hijos. waitpid dará error si el proceso o
grupo especificado no existe o no es hijo del llamador. Otro posible error de retorno es la
interrupción de la llamada por una señal. El siguiente programa muestra el uso de wait.
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
main () {
pid_t childpid;
int status;
exit(1);
else if (childpid == 0)
else
9
exit(0);
exec
Uno de los usos de la orden fork() es la de crear otro proceso que ejecutará un programa
diferente al del padre mediante la orden exec(). Cuando un proceso hace una llamada
exec(), su código se reemplaza por el correspondiente al del nuevo programa que se quiere
ejecutar. Concretamente, exec() reemplaza el segmento de código, datos, heap y pila del
proceso que hace la llamada por el correspondiente al programa que se quiere ejecutar. El
identificador del proceso no cambia ya que no se ha creado ningún nuevo proceso (además
hay otras características del proceso invocante que se mantienen como: identificador real de
usuario y de grupo, el identificador de sesión, el identificador de grupo de procesos, directorio
actual de trabajo, directorio root, máscara de creación de ficheros, señales pendientes, ...
#include <unistd.h>
... /* (char *) 0 */ );
... /* (char *) 0 */ );
La primera diferencia entre estas funciones es que las cuatro primeras aceptan un pathname
de archivo como argumento y las dos últimas un nombre de archivo. Cuando especificamos un
nombre de archivo, si este contiene una barra se interpreta como un camino, en cualquier otro
caso se busca un ejecutable en los directorios especificados en la variable del entorno PATH. Si
el archivo especificado no es ejecutable, se asume que es un programa shell e intenta invocar
a /bin/sh como el nombre de archivo como argumento.
10
Otra diferencia afecta al paso de la lista de argumentos ( l de lista y v de vector). Las funciones
execl, execlp y execle, requieren que los argumentos del nuevo programa se especifiquen
por separados y que se marque el fin de los argumentos con un puntero nulo.
Para las otras funciones, construiremos una matriz de punteros a los argumentos, y se pasa
como argumento la dirección de la matriz.
La diferencia final esta en el paso de la lista de variables de entorno al nuevo programa. Las
funciones que acaban con e permiten pasar un puntero a una matriz de punteros a la cadena
de entorno. Las otras funciones usan la variable environ (variable global que contiene la
dirección de la matriz de punteros, extern char **environ ) en el proceso llamador para
copiar la lista de entorno. Las variables de entorno se definen de la forma "nombre=valor". La
Figura 1.2 ilustra un entorno que consta de cinco cadenas.
En la mayoría de las implementaciones sólo una de estas funciones (execve) es una llamada
al sistema. Las otras son funciones de biblioteca que la invocan.
El nuevo programa hereda del proceso invocador las siguientes propiedades: los
identificadores del proceso, de grupo y de sesión, el terminal de control, tiempo restante del
reloj de alarma, directorios actual y root, mascara de creación de archivos, bloqueo de
archivos, mascara de señales y señales pendientes, límites de recursos. El manejo de los
archivos abiertos depende del valor del indicador close-on-exec para cada descriptor (ver
función fcntl).
Programa ejemploexec.c que crea un proceso para ejecutar una orden pasada como
argumento.
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
pid_t childpid;
int status;
11
if ((childpid = fork()) == -1) {
perror("Fork ha fallado");
exit(1);
perror("exec ha fallado");
exit(1);
exit(0);
Las rutinas exec son llamadas para ejecutar un programa. Esto lo hacen sustituyendo el
programa que se está ejecutando por el nuevo programa especificado en exec.
Las rutinas exec no retornan valor cuando terminan adecuadamente y -1 cuando lo hacen mal.
execl() Toma el Path del programa ejecutable como primer argumento. El resto de los
argumentos son una lista de argumentos de la línea de comandos para el nuevo programa
(argv[]).
#include <unistd.h>
...
execle() Igual que execl , excepto que el final de la lista de argumentos esta seguido por un
puntero a una lista de caracteres terminada por un NULL que se pasa como el entorno del
nuevo programa .
#include <unistd.h>
...
12
static char *env[] = {"TERM=ansi","PATH=/bin:/usr/bin",NULL};
...
execle ("/bin/cat","cat","f1","f2",NULL,env);
execv() Toma el nombre del Path del programa ejecutable como su primer argumento. El
segundo argumento es un puntero a una lista de punteros a caracteres que se pasa como
argumentos en la linea de comandos para el nuevo programa.
#include <unistd.h>
...
...
execv ("/bin/cat",args);
execve() Igual que execv excepto que el tercer argumento el un puntero a una lista de
caracteres que pasa el entorno para el nuevo programa.
#include <unistd.h>
...
...
execle ("/bin/cat",args,env);
execlp() Igual que execl con la diferencia de que el nombre del programa no tiene que ser el
nombre del Path y puede ser un programa shell en lugar de un módulo ejecutable.
#include <unistd.h>
...
execvp() Igual que execv, solo que el nombre del programa no tiene que ser el nombre de
Path, y puede ser un programa Shell en lugar de un módulo ejecutable.
#include <unistd.h>
...
13
...
14
4. Ejecución de un ejemplo:
El presente ejemplo incluye la realización de varios programas en C que ilustran el
funcionamiento de las librerías de funciones para comunicar procesos.
Estos programas se pueden realizar utilizando el editor vi, joe o nano. Recordar la llamada al
compilador c de gnu (gcc nombre_programa.c –o nombre_ejecutable)
Este programa sirve para mostrar el funcionamiento de la llamada al sistema fork(). Esta es
la única función en UNIX que nos permite crear nuevos procesos.
PADRE . C
Con el siguiente programa no sólo creamos un nuevo proceso sino que vamos a ver como
ejecutarlo. Aunque fork() es una herramienta muy poderosa, su acción suele combinarse en
la mayoría de las ocasiones con otra llamada al sistema: execl()
Si fork() era la única manera de crear procesos en UNIX, execl() es la única manera de
ejecutarlos.
La llamada execl() deber llevar como argumentos mínimos el PATH (camino absoluto o
relativo) del proceso hijo y el nombre del proceso hijo. A partir de ahí se le pueden dar todos los
argumentos que se deseen, siempre que la lista termine en un NULL. De esta forma, con esta
llamada se sustituye en la copia del proceso primitivo su segmento de datos y de instrucciones
con lo que los procesos dejan de ser iguales y se puede empezar a hablar de creación de un
verdadero proceso hijo.
El programa padre creará y ejecutará un proceso hijo (hijo1.c). Antes de compilar y ejecutar el
proceso padre, hay que compilar el código del hijo y al ejecutable llamarle hijo1, los dos en el
mismo directorio.
/*padre.c. Programa que crea un proceso hijo y ejecuta el proceso ejemplo con execl()*/
#include <stdio.h>
main() {
switch(pid) {
15
exit(0);
default:
EJEMPLO.C
Es ejecutado desde el programa padre. Lo único que hace es obtener una salida por pantalla
/*Ejemplo.c. Programa que es ejecutado desde PADRE. para mostrar la cooperación entre fork()
execl()*/
#include <stdio.h>
main() {
16
5. Señales
Las señales son interrupciones software que nos permiten manejar eventos asíncronos. Se
puede definir una señal como un mensaje enviado desde un proceso a otro, siendo dicho
mensaje un número entero que identifica la señal enviada. En POSIX las señales tienen
nombres. Sus nombres empiezan siempre con las letras SIG. Por ejemplo:
SIGINT: señal de interrupción del proceso. Se genera desde el terminal por Ctrl+C
• mediante una combinación de teclas. Por ejemplo Ctrl+C genera una señal ( SIGINT,
interrupción de teclado) que provoca la terminación del proceso que se está ejecutando
en primer plano, o por ejemplo Ctrl+Z (SIGTSTP) que provoca la parada de todos los
procesos en primer plano.
• por excepciones hardware: por ejemplo una división por 0, hacer referencia a una
dirección de memoria inválida. Normalmente estas condiciones las detecta el hardware
y lo notifica al núcleo. Entonces el núcleo genera la señal apropiada al proceso que se
estaba ejecutando cuando se produjo la excepción. Por ejemplo, un proceso que hace
referencia a una dirección de memoria inválida hará que el núcleo mande la señal
SIGSEGV a ese proceso.
• mediante kill(), esta función nos permite enviar una señal a otro proceso, siempre
que el que genere la señal sea el propietario del proceso o sea el superusuario.
• por condiciones software. Por ejemplo, se genera la señal SIGALRM cuando vence el
tiempo establecido mediante alarm() o SIGPIPE que se genera cuando un proceso
escribe en una tubería después de que el lector de la tubería haya acabado.
Cuando un proceso recibe una señal, este puede optar por tres alternativas posibles:
1.- que ignore la señal. Esto es así para muchas señales pero hay dos que no se
pueden ignorar nunca: SIGSTOP y SIGKILL.
2.- que capture la señal para que se ejecute un trozo de código definido por el usuario.
Por ejemplo un proceso ha creado ficheros temporales, al capturar la señal SIGTERM
se asocia una función que borre estos ficheros antes de terminar el proceso.
3.- permitir que se ejecute la acción por defecto asociada a la señal. Para muchas
señales la acción por defecto de las señales es la finalización del proceso.
Todas las señales tienen la misma prioridad. A diferencia de las interrupciones hardware, las
señales se procesan siguiendo la filosofía FIFO.
Si por la acción del envío de una señal a un proceso se activa el manejador correspondiente,
mientras este no termine su ejecución, ninguna otra señal podrá ser recibida por el proceso
receptor.
17
Asignación de un manejador de señal, signal()
donde
signum: entero que representa una de las señales definidas en el archivo de cabecera
/usr/include/signal.h
El manejador asociado será una función que no devolverá ningún valor y recibirá un argumento
entero que contendrá el valor numérico de la señal recibida. Esta función se ejecutara cada vez
que el proceso reciba la señal asociada (siempre que la señal sea recibida después de haber
realizado la llamada a signal()). La sintaxis de la función es:
No es posible asignar un manejador que no sea el manejador por defecto para las señales
SIGKILL, SIGSTOP y SIGCONT.
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
Esta función espera tres parámetros, el primero es la señal, el segundo define la nueva función
a la que se debe transferir el control al recibir la señal y el tercero devuelve el antiguo
manejador establecido. La estructura struct sigaction tiene dos miembros muy importantes:
• void (*)() sa_handler Puntero a la función que recibe la señal o bien, SIG_DFL para
volver al tratamiento normal o SIG_IGN para deshabilitar su tratamiento.
• sigset_t sa_mask Mascara con las señales adicionales a bloquear cuando se ejecuta la
función que trata la señal. Para inicializar un conjunto de señales se utiliza sigemptyset()
y para añadir una nueva señal a ese conjunto se utiliza sigaddset(). Se obtiene más
información en las páginas correspondientes del manual de Unix.
18
int kill(pid_t pid, int sig)
donde:
Valor devuelto: entero que indica si la llamada se ha ejecutado de forma correcta (0) o erronea
(-1)
2649 p5 R 0:00 ps
donde:
Mediante la orden man consultaremos las distintas opciones de la orden ps. Algunas
interesantes:
19
-f: representa en una especie de árbol el parentesco entre los procesos
La orden top
Nos permite ver de forma dinámica el estado del sistema en general y el estado de los
procesos, el tiempo de CPU consumido, la memoria consumida, la prioridad asignada a los
procesos, etc.
El comando kill
Envía la señal especificada a un determinado proceso desde la línea de comandos. Si no se
especifica la señal, por defecto se envía la señal SIGTERM (terminación de un proceso).
20