PRÁCTICAS DE LABORATORIO DE SISTEMAS OPERATIVOS

OCTAVA SESIÓN


  1. Sincronización de procesos.

    En la sesión de hoy veremos un aspecto importante en la gestión de procesos, la sincronización. En el momento en que los procesos tienen que interactuar, hay que arbitrar mecanismos mediante los cuales sus acciones se coordinen. Por ejemplo, si estamos tratando el problema de la barbería visto en teoría, y tenemos un barbero y varios clientes, un cliente no se puede sentar en la silla del barbero ANTES DE que el cliente anterior se haya levantado. Además, es interesante que si el cliente quiere sentarse y la silla está ocupada, el cliente se quede bloqueado sin consumir CPU. Como habréis adivinado, una manera de conseguir esto es mediante semáforos. También se puede hacer por paso de mensajes y, con cuidado, mediante señales.

    En teoría veíamos distintos tipos de sincronización, refiriéndonos al paso de mensajes:

    Ejemplos de sincronización:

      • Envío bloqueante, recepción bloqueante (rendezvous): fuerte sincronización.
      • Envío no bloqueante, recepción bloqueante: esquemas cliente-servidor.
    En general, el esquema que siempre se produce es el siguiente:
        Proceso 1            Proceso 2
        =========            =========
            A                    C
        punto de             punto de
      sincronización       sincronización
            B                    D
    Todos los procesos que queramos sincronizar tendrán un punto de sincronización. En el segundo esquema que vimos en teoría (cliente-servidor), el proceso 2 no puede ejecutar D sin que el proceso 1 haya ejecutado completamente A. Si el proceso 2 acaba antes de hacer C, se debe quedar bloqueado esperando.

    Si consideramos el rendezvous, tenemos el caso simétrico. Ni el proceso 1 puede ejecutar B antes de que el proceso 2 haya ejecutado completamente C, ni el proceso 2 puede ejecutar D sin que el proceso 1 haya ejecutado completamente A. Una sincronización entre dos procesos de tipo rendezvous se reduce, por lo tanto, a dos sincronizaciones del primer tipo. También se conoce a las sincronizaciones de tipo rendezvous como barreras de sincronización, pues ningún proceso puede atravesar la barrera sin que los otros hayan llegado a ella.

  2. Herramientas para hacer sincronización.

    Podemos realizar la sincronización mediante:
    1. Semáforos: por ejemplo el primer tipo de sincronización que veíamos antes
          Proceso 1            Proceso 2
          =========            =========
              A                    C
          punto de             punto de
        sincronización       sincronización
              B                    D
      se puede solucionar haciendo que A y D formen una zona de exclusión mutua accesible por un solo proceso y regulada por un semáforo.
    2. Paso de mensajes: en este caso, el contenido del mensaje es irrelevante. Se basa en el hecho de que un mensaje no se puede recibir sin antes haberlo mandado. En el ejemplo, el proceso 1 realiza un envío no bloqueante (lo podemos hacer en UNIX) y el proceso 2 realiza una recepción bloqueante (también se puede hacer en UNIX).
    3. Señales: el proceso 1 puede enviar una señal al proceso dos cuando alcance el punto de sincronización. El proceso 2 puede entonces interceptar la señal y cambiar el valor de una variable para indicar que el proceso 1 ha llegado al punto de sincronización. El único problema es que no es fácil hacer que el proceso 2 se bloquee si llega antes al punto de sincronización. Podríamos hacer:
          Proceso 1            Proceso 2
          =========            =========
              A                    C
          punto de               pause();
        sincronización
              B                    D
      pero entonces puede ocurrir que el proceso 1 llegue al punto de sincronización mientras el proceso 2 esté ejecutando C y el proceso 2 luego se quedaría bloqueado para siempre, pues la señal ya fue enviada. Es, por lo tanto, un método desaconsejable.
    4. Mediante tuberías: si se intenta leer de una tubería que no contiene nada, el proceso se queda bloqueado. Por lo tanto, pueden el proceso 1 y el proceso 2 compartir una tubería y el proceso 1 escribir un carácter en ella cuando alcance el punto de sincronización y el proceso 2 leer un carácter de la tubería en su punto de sincronización.


  3. Sincronización en la apertura de tuberías con nombre.

    Cuando ejecutamos desde la shell una orden del tipo:
    ls -l | more
    para listar el contenido de un directorio página a página, a estas alturas del Laboratorio de Sistemas Operativos ya sabemos que se producen muchas acciones antes de que la operación se lleve a cabo:
    1. La shell (ksh) interpreta los caracteres que hemos tecleado. Deduce que hay una tubería (|).
    2. Crea una tubería sin nombre. Hace un par de forks.
    3. El primer proceso conecta su salida estándar con la entrada de la tubería mediante la llamada al sistema dup2.
    4. El segundo proceso conecta su entrada estándar con la salida de la tubería.
    5. Ambos procesos ejecutan un exec para ejecutar un ls y un more, respectivamente.
    6. La shell espera el código de retorno de sus dos hijos.


    Supongamos ahora el mismo esquema pero con tuberías con nombre. Si el segundo proceso muere por cualquier circunstancia, puede que el primer proceso no se enterara y siguiera escribiendo en la tubería hasta que se llenase y se quedara bloqueado para siempre. Para evitar eso, cuando un proceso intenta escribir en una tubería que no tiene a ningún otro proceso "escuchando" de ella, el proceso recibe una señal SIGPIPE que, por defecto, mata el proceso. Pero entonces se produce un problema: ¿qué pasa cuando abrimos la tubería con nombre?
        Proceso 1                    Proceso 2
        =========                    =========
    fd=open("tubo",O_WRONLY);    fd=open("tubo",O_RDONLY);
    write(fd,"Hola",5);          read(fd,buffer,5);
    [...]                        [...]
    Si el primer proceso llega, por la circunstancia del reparto de la CPU a ejecutar el write antes de que al segundo proceso le haya dado tiempo a abrir la tubería, el primer proceso recibe una señal SIGPIPE fulminante. Es por ello, que los opens de tuberías con nombre están sincronizados. Ningún proceso continúa hasta que haya un proceso para leer de la tubería y otro para escribir en ella.

    Considerad que si un proceso abre la tubería para lectura y escritura (O_RDWR) no se bloqueará nunca.

  4. Primera práctica.

    Hay que crear dos procesos, padre e hijo. El proceso padre imprime cien unos (1) en la pantalla. El proceso hijo imprime cien ceros (0) por la pantalla. Sincronizar mediante semáforo(s) los dos procesos para que en la pantalla aparezca:
    101010101010101010101010101010101010...
    NOTA: Tened precaución en hacer una llamada a la función de biblioteca fflush depués de hacer los printfs porque si no, los caracteres no salen inmediatamente por pantalla sino que quedan almacenados en un búfer intermedio. Alternativamente, podéis usar write.

  5. Memoria compartida.

    En numerosas ocasiones desearíamos que alguna variable de un proceso pudiera ser "vista" y "modificada" por otro proceso. Todos sabéis que al ejecutar el código siguiente:
    int variable;
    [...]
    variable=7;
    swich (fork())
       {case -1: /* Error */
            [...]
        case 0:  /* Hijo  */
            variable=8;
            exit(0);}
    /* Aquí sigue el padre */
    printf("El valor de la variable es %d.\n", variable);
    [...]       
    jamás aparecerá por la pantalla un valor de 8 para la variable, siempre 7, incluso si al hijo le diera tiempo a cambiar su valor antes de que el padre ejecutara el printf. Las zonas de memoria del padre y el hijo se separaron después del fork (aunque esto no es completamente cierto si se usa copy on write) y cada uno tiene sus propias variables. Si deseamos que el padre y el hijo compartan una variable, tenemos que usar memoria compartida.

    Por tanto, para que el programa anterior llegara a poner alguna vez un 8 en la pantalla habría que hacer algo así (no se hace así, ya veremos exactamente cómo se hace):
    int *pvariable;
    [...]
    pvariable=compartir_malloc(sizeof(int));
    [...]
    *pvariable=7;
    swich (fork())
       {case -1: /* Error */
            [...]
        case 0:  /* Hijo  */
            *pvariable=8;
            exit(0);}
    /* Aquí sigue el padre */
    printf("El valor de la variable es %d.\n", *pvariable);
    [...]       


  6. Memoria compartida en UNIX.

    Afortunadamente, la memoria compartida en UNIX es un mecanismo IPC. Como consecuencia, todo los que habéis aprendido para semáforos y buzones, os sirve también para memoria compartida. Para crear/acceder a una zona de memoria compartida, se usa la llamada al sistema:
          int shmget(key_t clave, size_t tamaNo, int shmflg);
    Los parámetros y valor devuelto son como en semget. La excepción es el parámetro tamaNo, donde se especifica el tamaño de la zona de memoria que queremos reservar. Sería el equivalente al parámetro de la función de biblioteca malloc para memoria dinámica, sólo que ahora se trata de memoria compartida.

    Con shmctl podemos realizar operaciones sobre el segmento de memoria compartida que hemos definido:
          int shmctl(int shmid, int cmd, struct shmid_ds *buf); 
    Las operaciones que podemos realizar van desde consultar/modificar las propiedades del segmento hasta eliminarlo. Además de estas, se puede bloquear un segmento de memoria compartida (impedir que se realice intercambio con memoria secundaria sobre el) y luego desbloquearlo con los cmds SHM_LOCK y SHM_UNLOCK. Ver llamada parecida: semctl.

    Podéis ver los segmentos compartidos que hay en el sistema y borrar alguno con las órdenes de la shell: ipcs -m e ipcrm -m número.



  7. Cómo usar un segmento de memoria compartida en el programa.

    Para usar un segmento de memoria compartida en el programa previamente definido, sólo necesitamos un puntero a la zona de la memoria donde se encuentre la memoria compartida. A partir de ahí, se usa igual que la memoria dinámica. La llamada al sistema que nos proporciona el puntero es:
          void *shmat(int shmid, void *shmaddr, int shmflg); 
    Hay que pasarle el identificador del segmento de memoria compartida como primer parámetro (nos lo dio shmget). En el segundo parámetro basta con poner un cero. En el tercer parámetro puede especificarse la macro SHM_RDONLY si queremos que el segmento sea de sólo lectura.

    Cuando hemos acabado de usar el segmento de memoria compartida, hay que liberarlo. Lo hacemos con:
          int shmdt(void *shmaddr); 
    En el parámetro se le pasa el puntero que nos devolvió shmat.

    En el caso de que no sepáis manejar la memoria dinámica, os recomiendo que primero repaséis y entendáis bien cómo funciona antes de manejar memoria compartida.

  8. Segunda práctica.

    Haremos un programa del estilo al del productor-consumidor que vimos en clase, con memoria compartida. Habrá dos procesos, padre e hijo, el padre será el proceso productor. El hijo el proceso consumidor. Para comunicarse, usarán una zona de memoria compartida de un carácter. El padre producirá los caracteres: "En un lugar de la Mancha de cuyo nombre no quiero acordarme, no ha mucho que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor.". Producirá los caracteres a un ritmo de 10 caracteres por segundo (usad la llamada al sistema nanosleep para controlar esto (mirad la página de manual)). El hijo, por su parte consumirá los caracteres según le vayan llegando, con un gasto de CPU mínimo. Para consumir un carácter, lo que hace es leerlo. A los caracteres que le llegan en una posición par (segundo, cuarto, etc.) los imprime por la pantalla tal cual. A los que le llegan en una posición impar, los transforma en mayúsculas y los imprime por pantalla. Para trasformar en mayúsculas usad la función de biblioteca toupper. Haced un único programa fuente (.c).
    En Solaris, existe una llamada más sencilla que nanosleep para dormir durante una fracción de segundo. Se llama usleep. Podéis usarla en esta práctica.

  9. Órdenes de la shell relacionadas.

    ipcs
    muestra los mecanismos ipc en uso
    ipcrm
    borra un mecanismo ipc en uso


  10. Funciones de biblioteca relacionadas.

    fflush
    vacía los búferes intermedios de un flujo (stream)
    toupper
    devuelve un carácter en mayúsculas


  11. LPEs.


© 2000 Guillermo González Talaván.