PRÁCTICAS DE LABORATORIO DE SISTEMAS OPERATIVOS

QUINTA SESIÓN


  1. Sucesos asíncronos.

    Dejamos la sesión anterior con un problema que resolver. Hablamos de lo nefasta que era la espera ocupada y de lo poco que contribuía al buen comportamiento de los sistemas compartidos. Por eso, debemos siempre usar las llamadas al sistema en su versión bloqueante. En ellas, el proceso pasa al estado de bloqueado y no consume CPU mientras espera a que le den el recurso que ha solicitado.

    Sin embargo, el proceso no sabe cuándo va a disponer del recurso. ¿Quiere esto decir que nuestro programa no podrá hacer hada mientras esté esperando por el recurso? Una solución sería que el proceso tuviea un hijo con fork y el hijo hiciera algo de trabajo mientras él espera por el recurso. ¿Pero cuánto trabajo le manda si no sabe cuánto tiempo esperará? Además, la creación de nuevos hijos penaliza el rendimiento debido a los cambios de proceso.

    La solución que da UNIX a este tipo de problemas pasan por la multiplexión de E/S asíncrona, que veremos más adelante y por las señales. Imaginad la situación: en vez de realizar una llamada bloqueante, el programa sigue adelante y, cuando el recurso está disponible, es avisado por el Sistema Operativo mediante una señal e, inmediatamente obligado por el propio Sistema Operativo a ejecutar una rutina para atender a esa señal.

  2. ¿Qué son las señales?

    Las señales son sucesos asíncronos (que no se sabe cuándo se van a producir) y que afectan al comportamiento de un proceso. Las causas de la producción de una señal son varias: Las consecuencias de la recepción de una señal pueden ser también variadas: Existe siempre una consecuencia (comportamiento) por defecto de una señal. La lista de señales y su comportamiento por defecto en el servidor la tenéis en la página de manual signal(3HEAD). Para acceder a esta página de manual, teclead man -s 3HEAD signal. También tenéis un listado en el resumen.

  3. Algunas señales famosas.

    Algunas de las señales más comunes en un sistema UNIX son:



  4. Envío de señales.

    Desde la shell podemos enviar una señal a un proceso mediante la orden kill. Por ejemplo, para mandar la señal SIGTERM al proceso cuyo PID es 7803. haríamos:
        kill -SIGTERM 7803
    o
         kill -15 7803
    La orden kill de HPUX admite el nombre de la señal como parámetro, mientras que en Solaris hay que poner el número de la señal obligatoriamente. En este caso, a SIGTERM le corresponde el número 15. Podéis ver la correspondencia entre señales y números en la página de manual de Solaris man -s 3head signal.

    Para que la señal tenga efecto, el proceso tiene que ser nuestro o ser nosotros el superusuario (root). Imaginaos si cualquiera pudiera mandar señales a otro. Simplemente con mandar SIGSTOP a la shell de otro usuario, paralizaríamos su terminal o mandando SIGKILL haríamos que se desconectara...

    Para ver un ejemplo de envío de señales, ejecutad la siguiente orden desde la shell:
    sleep 60 &
    Con ayuda de ps, ved al proceso en ejecución. Paradlo a continuación enviándole la señal SIGSTOP. Comprobad que se para. Haced que continúe mandándole la señal SIGCONT.

    Existe una llamada al sistema (kill) para enviar señales desde nuestro programa en C. Miradla en el resumen. También existe otra que manda la señal al propio proceso (raise).

    Existe otra llamada al sistema muy interesante que hace que un proceso se quede bloqueado hasta que se reciba cualquier señal. Se denomina pause y, con ella, se evita la espera ocupada por la recepción de una señal. Sin embargo, hay que ser cautelosos en el uso de pause(). Siempre existe la posibilidad de que la señal por la que estamos esperando se reciba antes de caer en el pause(). En tal caso, dicha señal sería atendida en la posible manejadora y, al volver, caemos en el pause y, como la señal ya se ha recibido, no salimos de él jamás. Sería necesario, pues, bloquear la señal antes y desbloquearla justo la instrucción previa al pause(). Pero ni siquiera así evitaríamos el error. Se nos puede colar la señal justo entre las dos instrucciones. La manera correcta de hacerlo es usando la llamada al sistema sigsuspend que desbloquea la señal y se queda esperando de un modo atómico. De dicha llamada hablaremos un poco más adelante en la sesión. pause queda reservado casi exclusivamente para bucles infinitos de pauses.

  5. Llamadas al sistema bloqueantes y señales.

    Normalmente, cuando una llamada al sistema es bloqueante y el proceso está parado en la llamada al sistema esperando a que se disponga del recurso solicitado, si se recibe una señal, la llamada al sistema vuelve inmediatamente, devolviendo condición de error y asignando a la variable errno el valor EINTR. Tanto errno como EINTR están definidas en el fichero de cabeceras errno.h.

    Esto es así para permitir a nuestro programa cambiar su comportamiento en caso de que la recepción de la señal indicara algo.

    Vamos a verlo con un ejemplo. En el siguiente código, un padre tiene un hijo. El padre se queda bloqueado con una llamada al sistema wait esperando a que el hijo muera. El padre también ha registrado la señal SIGUSR1 para que, si se recibe, salte a una manejadora donde se imprime un mensaje. El hijo está en un bucle infinito de pauses sin consumir CPU. En principio, el padre espera en vano, pues el hijo no va a morir nunca:
    #include <errno.h>
    #include <unistd.h>
    #include <signal.h>
    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    
    void nonada(int s)
       {
        printf("PADRE: Estoy en la manejadora.\n");
        }
    
    int main(void)
       {
        char lInea[40];
        int valor_devuelto;
        struct sigaction ss;
    
        switch (fork())
           {
            case -1: perror("fork"); return 1;
            case 0: /* HIJO */
                for (;;) pause();
            default: /* PADRE */
                ss.sa_handler=nonada;
                sigemptyset(&ss.sa_mask);
                ss.sa_flags=0;
                if (-1==sigaction(SIGUSR1,&ss,NULL))
                   {perror("PADRE: sigaction");
                    return 1;}
    
                printf("PADRE: SIGUSR1(%d) registrada.\n",SIGUSR1);
    
                if (wait(&valor_devuelto)==-1)
                   {perror("PADRE: wait");
                    return 1;}
            }
    
        return 0;
        }
    Compilamos y ejecutamos el programa en segundo plano, para no perder el control del terminal. El padre nos indica que ha registrado la señal. Vemos que hay corriendo dos procesos, el padre y el hijo y que uno es hijo del otro:
    <gyermo@ENCINA>/home/gyermo/PRIVADO/BLOQSIG$ c89 bloqsig.c -o bloqsig
    <gyermo@ENCINA>/home/gyermo/PRIVADO/BLOQSIG$ bloqsig &
    [1]     8829
    PADRE: SIGUSR1(16) registrada.
    
    <gyermo@ENCINA>/home/gyermo/PRIVADO/BLOQSIG$ ps -f        
         UID   PID  PPID  C    STIME TTY      TIME CMD
      gyermo  8830  8829  0 16:25:00 pts/17   0:00 bloqsig
      gyermo  8829  8685  0 16:25:00 pts/17   0:00 bloqsig
      gyermo  8685  8683  0 16:12:55 pts/17   0:00 -ksh
    Mandamos la señal SIGUSR1 al proceso padre ( PID=8829 en nuestro ejemplo):
     
    <gyermo@ENCINA>/home/gyermo/PRIVADO/BLOQSIG$ kill -16 8829
    PADRE: Estoy en la manejadora.
    PADRE: wait: Interrupted system call
    
    [1] +  Terminado(1)            bloqsig &
    Como se indicaba más arriba, al recibir la señal, el padre salta a la manejadora nonada. Cuando vuelve, en lugar de seguir bloqueado y continuar esperando en el wait, por el mero hecho de haber recibido la señal, wait falla, devuelve -1 y nos da el error "Llamada al sistema interrumpida". Pero no se trata de un error estrictamente hablando, como estáis viendo. De querer evitarlo, se puede configurar el sigaction en el campo flags para que las llamadas al sistema interrumpidas no den error y continúen o, se puede discriminar esta situación mediante el propio programa:
    for (;;)
       {int retorno;
        retorno=wait(&valor_devuelto);
        if (retorno==-1 && errno!=EINTR)
            {perror("PADRE: wait");
             return 1;}
        if (retorno!=-1) break; /* Un hijo requiere atención */
        /* Se ha producido una interrupción, a volver a esperar */ }
    No os olvidéis de matar al hijo de la prueba anterior que, como el padre murió, fue adoptado por init:
    <gyermo@ENCINA>/home/gyermo/PRIVADO/BLOQSIG$ ps -f
         UID   PID  PPID  C    STIME TTY      TIME CMD
      gyermo  8830     1  0 16:25:00 pts/17   0:00 bloqsig
      gyermo  8685  8683  0 16:12:55 pts/17   0:00 -ksh
    <gyermo@ENCINA>/home/gyermo/PRIVADO/BLOQSIG$ kill 8830


  6. Máscara de bloqueo de señales.

    Un proceso puede decidir bloquear la recepción de una o varias señales. Las señales bloqueadas constituyen la máscara de señales bloqueadas del proceso y se dice de ellas que están enmascaradas.

    Si se recibe una señal y la señal está enmascarada, la señal se ignora, aunque permanece "pendiente de atención". Si en un momento futuro, se desbloquea (desenmascara) esa señal, se atenderá inmediatamente (como si se acabara de recibir). Si, mientras permanece bloqueada, una señal se recibe varias veces, a todos los efectos es como si se hubiera recibido sólo una vez (las señales "no se apilan").

    Para modificar la máscara de señales bloqueadas de un proceso, se puede usar la llamada al sistema:
    #include <signal.h>
    int sigprocmask(int quE_cosa, const sigset_t *nuevo, sigset_t *viejo); 
    Los parámetros nuevo y viejo funcionan como el campo sa_mask de sigaction. En viejo, se devuelve el valor de la máscara antes de la llamada a la función, por si se quiere guardar para luego restaurarla. El parámetro quE_cosa debe valer SIG_SETMASK si lo que queremos es establecer de un golpe las señales que queremos que estén bloqueadas y las que no. Si sólo queremos bloquear unas señales, dejando las demás como estén, usaremos SIG_BLOCK. Si lo que queremos es desbloquear un conjunto de señales dejando el resto inalteradas usaremos SIG_UNBLOCK.

    El conjunto de posibilidades que se nos ofrecen se completa con dos funciones:
    #include <signal.h>
    int sigpending(sigset_t *set);
    int sigsuspend(const sigset_t *mask); 
    La primera simplemente devuelve el valor de la máscara de señales bloqueadas de un proceso. La segunda es más útil. Reemplaza temporalmente la máscara actual por aquella que le pasamos como parámetro y bloquea al proceso (lo deja sin consumir CPU) hasta que se produzca una de las señales que hemos permitido temporalmente. Por tanto, debéis incluir en el conjunto de señales mask todas aquellas señales que queráis prohibir mientras dure la ejecución de sigsuspend. Una vez recibida la señal, la llamada al sistema vuelve, se deja la máscara de señales que había antes automáticamente y el programa continúa su ejecución normalmente. Es habitual que la señal que deba desbloquear sigsuspend esté bloqueada antes de llegar a ella, para tener bien localizado el lugar donde se recibe la señal.

  7. Señal de alarma.

    Hay una llamada al sistema, alarm que hace que el proceso que la ejecuta reciba la señal SIGALRM pasados un numero de segundos que se le especifica como parámetro. Con esta llamada es con la que se construye la función de biblioteca sleep.

    Un error bastante frecuente consiste en pensar que la llamada al sistema alarm hace que un proceso se pare durante el tiempo que se especifica. No, lo que hace es avisar al sistema operativo para que tome nota de mandar la señal SIGALRM pasados los segundos que se especifican.

  8. Funciones de rellamada (callback).

    En condiciones normales, cuando queremos que se ejecute una función en C dentro de un programa nuestro, lo que hacemos es poner el nombre de la función con sus parámetros dentro del código fuente del programa. Cuando el flujo de ejecución del programa pase por ese punto, se realizará una llamada a la función.

    Las funciones de rellamada o callback funcionan de manera diferente. En este caso, la función con los parámetros que le correspondan es llamada en cualquier momento de la ejecución del programa, interrumpiendo el flujo normal de instrucciones del programa. Es decir, el programa salta momentáneamente a la función de rellamada para luego seguir con la ejecución normal del programa.

    Normalmente, hay que registrar las funciones de rellamada del programa. Esto sirve para indicar al Sistema Operativo qué función de las codificadas en nuestro programa queremos que se comporte como de rellamada y en qué condiciones queremos que sea llamada. Para poder especificar qué función queremos que sea llamada se usan los punteros a funciones de C. Un puntero a función es un puntero que apunta a la dirección de memoria donde comienza el código de una función. Para especificar en qué condiciones queremos que sea llamada la función, se usan funciones de registro específicas.

    Por ejemplo, la función de biblioteca atexit permite registrar una función de rellamada para que sea invocada cuando finalice el programa. Es muy útil si queremos realizar alguna operación siempre que el programa finalice. Tened en cuenta, no obstante, que si el proceso es matado con SIGKILL, la función no se ejecutará. Fijémonos en el prototipo de la función atexit:
    #include <stdlib.h>
    int atexit(void (*func)(void)); 
    El puntero a función es ese tipo tan extraño que aparece como parámetro de la función (void (*func)(void)). El tipo puntero a función es void (*)(void). El tipo es algo complicado porque no sólo se ha de especificar que se trata de un puntero a función (con el asterisco entre paréntesis) sino también qué prototipo tiene que tener la función para que sea aceptable registrarla con esa función. En este caso, la función ha de tener un prototipo: void f(void). Así, si queremos que la función salida se ejecute cuando el programa acabe, debemos poner:
    #include <stdlib.h>
    [...]
    void salida(void)
       {/* Lo que queramos que haga la función. */
        }
    [...]
    void main(void)
       {
        if (atexit(salida)==-1)
            {perror("atexit"); exit(1);}
        [...]
        }       
    Observad cómo el nombre de la función sin parámetros es considerado por el compilador como un puntero a dicha función. Es algo parecido al caso de los arrays, en los que el nombre del array sin nada detrás es considerado por el compilador como un puntero al primer elemento del array.

    Tened en cuenta también que hasta que no se llame a la función de registro, no se entera el Sistema Operativo que queremos que la función de rellamada sea invocada cuando ocurra la condición. La función de registro sirve para que el Sistema Operativo tome nota para luego llamarla. Por eso es conveniente llamar a la funciónd de registro cuanto antes.

  9. Cambiando el comportamiento por defecto de las señales.

    Lo que hace el mecanismo de señales de UNIX realmente potente es que es posible variar el comportamiento por defecto que produce en un proceso la recepción de determinadas señales.

    Se puede avisar al Sistema Operativo que, cuando el proceso reciba una determinada señal, se invoque una función que le especifiquemos. Esto, como podéis imaginar, no es más que especificar una función de rellamada mediante una función de registro. El prototipo de la función de registro es:
    int sigaction(int sig, const struct sigaction *acciOn_nueva,
                                 struct sigaction *acciOn_vieja); 
    Nada ver el prototipo el avispado lector podrá deducir muchas cosas:

    Este alarde de clarividencia se ve confirmado cuando vemos la definición del tipo struct sigaction:
    struct sigaction
       {void (*)(int) sa_handler;
        sigset_t      sa_mask;
        int           sa_flags;}; 
    ¡Helo ahí! El primer campo de la estructura es un puntero a función que nos indicará la función de rellamada que se invocará cuando se reciba la señal. La función tiene que recibir un parámetro de tipo entero y no devolver nada. Lo que ya no podemos adivinar es qué valor contiene ese parámetro cuando el Sistema Operativo llama a esa función. Pues bien, es el número de la señal que ha provocado la llamada. Gracias a este parámetro podemos usar una única función de rellamada para atender múltiples señales. También, en lugar de una función, podemos especificar como primer parámetro las macros SIG_DFL, para que se ejecute la acción por defecto de la señal o SIG_IGN para que el proceso ignore la recepción de la señal.

    El segundo parámetro especifica las señales que queremos que se bloqueen mientras se está ejecutando la función de rellamada. Por defecto, sólo se impide la recepción de la misma señal que provocó la llamada a la manejadora. Se han de usar unas funciones de biblioteca para especificar este segundo parámetro, que es de tipo especial. Mirad el resumen para ver cuáles son estas funciones.

    El tercer parámetro es un campo de bits que especifica algunas opciones:

  10. Señal SIGCHLD.

    Esta señal la recibe un padre cuando uno de sus hijos muere o necesita atención, es decir, cuando puede hacer una llamada al sistema wait sabiendo que no se va a bloquear. La señal carece de poca utilidad en su comportamiento por defecto, pues no hace nada. Podemos registrar una función de rellamada para que antienda a los hijos que vayan muriendo o podemos simplemente indicar que se ignore (con SIG_IGN en sigaction) y de este modo ninguno de los hijos del proceso se quedará zombie.

  11. Apilamiento de señales.

    Las señales no se apilan para los procesos. Quiere esto decir que si se recibe una señal y, sin haberse atendido, se recibe otra del mismo tipo, cuentan como una única señal.

  12. Aviso de compatibilidad con algunos Linux.

    Puede ocurrir que las funciones de señales, como sigaction, den errores de compilación en algún Linux, por ejemplo, en los del aula de prácticas. Si os dan y para que no os den, incluid al inicio de vuestro código:
    #define __USE_POSIX
    
    Notad que delante de "USE" hay dos caracteres de subrayado seguidos. También puede ser que tengáis que definir "a mano" la union sembuf para manejar semáforos. Consultad la página de manual de semop.

    Finalmente, usad en Linux siempre el compilador de GNU, gcc.

  13. Práctica.

    Codificad una función que se llame nuevosleep a la que se le pase el número de segundos que tiene el programa que esperar e imprima una cuenta atrás de esos segundos por la salida estándar. Así:
    nuevosleep(3);
    Y sale:
    3, [[un segundo...]] 2, [[un segundo...]], 1, [[un segundo...]] 0.
    No se puede usar la función de biblioteca sleep. Podéis suponer que existe en el programa una función ya definida llamada void nonada(int), que no hace nada.

    Soluciones y comentarios.

  14. Órdenes de la shell relacionadas.

    kill
    manda una señal a un proceso


  15. Funciones de biblioteca relacionadas.

    atexit
    registra una función de rellamada para que sea llamada cuando el programa acabe


  16. LPEs.


© 2000 Guillermo González Talaván.