10. Comunicación mediante paso de mensajes

Tiempo de lectura: 34 minutos

El paso de mensajes es un mecanismo que permite a los procesos compartir información y sincronizar sus acciones sin necesidad de compartir recursos —compartir memoria, archivos, etc.—

Esto lo hace especialmente útil en entornos distribuidos, donde los procesos a comunicar residen en ordenadores diferentes conectados a una red, por lo que tiene muy difícil —o incluso imposible— compartir memoria u otros recursos para comunicarse. En este caso, el sistema operativo es el encargado de codificar los mensajes y enviarlos a través de la red para hacerlos llegar a su destinatario. La web —donde un navegador se conecta a un servidor web para obtener contenido— y el resto de servicios de Internet son ejemplos de sistemas de paso de mensajes.

El sistema de paso de mensajes debe ser proporcionado por el sistema operativo que, a diferencia de cuando se usa memoria compartida, se encarga de la sincronización —ya que no existen riesgos en el envío y recepción de mensajes al mismo tiempo— y de establecer el formato que deben tener los datos del mensaje.

En algunas fuentes se sigue haciendo referencia al término IPC (Interprocess Communication) —o comunicación entre procesos— para identificarlo exclusivamente con sistemas de paso de mensajes. Sin embargo, la memoria compartida y otras técnicas también sirven para «comunicar procesos», por lo que es más adecuado usar el término IPC para englobar todas las técnicas conocidas de comunicación entre procesos.

Los sistemas de paso de mensaje de cualquier sistema operativo debe proporcionar al menos dos llamadas al sistema similares a:

  • send( message ) para mandar mensajes a otro proceso.

  • receive( &message ) para recibir mensajes de otro proceso y copiarlo en message.

Vamos a hablar de funciones de un sistema de paso de mensajes hipotético. Meros ejemplos para ilustrar las diferentes alternativas. Esto no significa que en los sistemas operativos reales las funciones se llamen así y tengan esos mismos argumentos.

Para que estas llamadas puedan enviar y recibir mensajes entre dos procesos es necesario que haya un enlace de comunicaciones entre ambos. No trataremos aquí la implementación física del enlace —que por ejemplo puede ser mediante memoria compartida, un bus hardware o una red de ordenadores— sino de su implementación lógica, es decir, las características de la interfaz que usan las aplicaciones para comunicarse con sus correspondientes operaciones de envío y recepción.

10.1. Tamaño del mensaje

Los diseñadores del sistema operativo deben escoger entre implementar un sistema de paso de mensajes con mensajes de tamaño fijo o mensajes de tamaño variable:

  • Mensajes de tamaño fijo. La implementación del sistema operativo es muy sencilla, pero el uso de la interfaz por parte de las aplicaciones es mucho más compleja.

    Por ejemplo, para comunicar procesos en un mismo ordenador cada enlace puede tener un búfer de tamaño fijo donde se copia el mensaje enviado y de donde se extrae el mensaje al recibirlo. Esto es muy sencillo de implementar en el sistema operativo. Sin embargo, si el desarrollador de la aplicación quiere enviar algo de mayor tamaño que el tamaño del mensaje, debe trocearlo en varios mensajes para enviarlo y reconstruirlo al recibirlo.

  • Mensajes de tamaño variable. La implementación del sistema operativo es más compleja, ya que ahora tiene que gestionar la memoria para almacenar mensajes de tamaño variable hasta que son recibidos. Sin embargo, la programación de aplicaciones es más simple, puesto que el programador puede mandar mensajes de cualquier tamaño sin ninguna preocupación

10.1.1. Comunicación orientada a flujos

En algunos sistemas con mensajes de tamaño variable no se preserva la separación entre mensajes al recibirlos. Es decir, que los procesos leen un número arbitrario de bytes, donde puede haber parte de un mensaje o varios mensajes al mismo tiempo. Por ejemplo, en esos sistemas el transmisor puede mandar tres mensajes de 16000, 3200 y 100 bytes, pero el receptor leer la secuencia de bytes en bloques de 512 bytes.

A esto se lo denomina comunicación orientada a flujos o (streams). Si usamos este tipo de sistema es importante conservar la separación entre los mensajes recibidos, será nuestra responsabilidad escoger un formato de mensaje adecuado que permita al receptor recuperar dónde comienza y termina un mensaje dentro de la secuencia de bytes.

10.2. Referenciación

Los procesos que se quieran comunicar deben tener una forma de señalarse el uno al otro. Para ello el diseñador del sistema puede elegir que el sistema de paso de mensajes sea con comunicación directa o indirecta.

10.2.1. Comunicación directa

En la comunicación directa cada proceso debe nombrar explícitamente al proceso destinatario o receptor de la información. Por ejemplo, ahora las llamadas al sistema básicas podrían ser así:

  • send( A, message ) para mandar un mensaje al proceso identificado como «A».

  • receive( A, &message ) para recibir un mensaje del proceso identificado como «A», copiándolo en «message».

De hecho el ejemplo anterior corresponde a un caso de comunicación directa con direccionamiento simétrico, pero existe una variante de ese mismo esquema denominado direccionamiento asimétrico donde el receptor puede recibir mensajes de cualquier proceso, de forma que al volver de la llamada recibe el mensaje y la identidad del remitente.

  • send( A, message ) para mandar un mensaje al proceso identificado como «A»

  • receive( &pid, &message ) para recibir un mensaje de cualquier proceso, recibiendo en «message» una copia del message y en «pid» la identidad del remitente.

En resumen:

  • En el direccionamiento simétrico tanto el proceso que envía como el que recibe tienen que identificar al otro para comunicarse.

  • En el direccionamiento asimétrico solo el proceso que envía identifica a que recibe, mientras que este último no tiene que nombrar al remitente. Es el sistema operativo el que informa de quién es el remitente del mensaje que se ha recibido.

Un enlace de comunicaciones según este esquema tiene las siguientes características:

  • Un enlace se establece automáticamente entre cada par de procesos que quieren comunicarse. Por tanto, los procesos solo necesitan conocer la identidad de los otros para comunicarse.

  • Cada enlace se asocia exactamente a dos procesos.

  • Entre cada par de procesos solo hay un enlace.

enlace comunicación directa
Figura 32. Comunicación directa.

La principal desventaja de este tipo de comunicación es que si cambia el identificador de un proceso hay que actualizar todas las referencias en todos los procesos que se comunican con él. En general cualquier técnica que requiera que los identificadores de los procesos sean establecidos explícitamente en el código de los programas no es deseable, puesto que en muchos sistemas los identificadores de los procesos cambian de una ejecución a otra. Por lo tanto, lo mejor sería disponer de una solución con un nivel adicional de indirección que evite que los procesos usen sus identificadores para comunicarse.

Colas de mensajes en Windows API

En Windows API un hilo puede enviar mensajes a otro hilo usando PostThreadMessage(). Como aún no hemos visto el concepto de hilo, podemos asumir que es equivalente al de proceso.

BOOL PostThreadMessage(
    DWORD  idThread, (1)
    UINT   Msg, (2)
    WPARAM wParam, (3)
    LPARAM lParam (3)
);
1 Identificador del hilo.
2 Un número entero que identifica el mensaje.
3 Parámetros del mensaje de tipo entero.

Como se puede observar, en las colas de mensajes de Windows API el tamaño del mensaje es fijo y con una estructura muy bien definida: un identificador del mensaje y dos enteros que sirven de parámetros opcionales del mensaje.

Para recibir el mensaje el proceso llama a GetMessage():

BOOL GetMessage(
    LPMSG lpMsg, (1)
    HWND  hWnd,
    UINT  wMsgFilterMin,
    UINT  wMsgFilterMax
);
1 Puntero a una estructura MSG que a la vuelta contendrá el identificador y los parámetros del mensaje recibido, entre otra información.

Como se puede observar, no se indica de qué hilo o proceso se quiere recibir el mensaje, por lo que se trata de un caso de comunicación directa asimétrica. De hecho, si se quiere conocer la identidad del remitente, este tendría que poner su identificador en alguno de los parámetros del mensaje.

El sistema de colas de mensajes de Windows API es una pieza fundamental del entorno gráfico de Microsoft Windows. Con ese fin, el sistema trae un conjunto de mensajes predefinidos, pero podemos definir nuestros propios mensajes para comunicar unos hilos o procesos con otros.

10.2.2. Comunicación indirecta

En la comunicación indirecta los mensajes son enviados a buzones, maillox o puertos que son objetos donde los procesos pueden dejar y recoger mensajes.

  • send( P, message ) para mandar un mensaje al puerto «P»

  • receive( P, &message ) para recibir un mensaje del puerto «P».

Un enlace de comunicaciones según este esquema tiene las siguientes características:

  • Un enlace se establece entre un par de procesos solo si ambos comparten un mismo puerto, dado que cada enlace corresponde con un puerto.

  • Un enlace puede estar asociado a más de dos procesos, puesto que múltiples procesos pueden compartir el mismo puerto.

  • Entre cada par de procesos en comunicación puede haber varios enlaces, cada uno de los cuales corresponde a un puerto.

enlace comunicación indirecta
Figura 33. Comunicación indirecta.
Colas de mensajes en sistemas POSIX

El estándar POSIX también define un sistema de colas de mensajes, pero es bastante diferente a la solución en Windows API (véase «mq_overview(7) — Linux Manual»).

Para usarlo, lo primero es abrir o crear —si aún no existe— la cola de mensajes llamando a mq_open():

mqd_t mqueue = mq_open(
    "/foo-mqueue",      (1)
    O_CREAT | O_RDWR,   (2)
    0644,               (3)
    NULL
);
1 Nombre que identifica la cola de mensajes. Como con los archivos, para que varios procesos puedan acceder a la misma cola, deben indicar el mismo nombre.
2 Valores que indican diferentes opciones a la hora de abrir la cola de mensajes. Por ejemplo, usando O_RDWR se indica abrir para enviar o recibir y con O_CREAT se indica que la cola debe crearse si no existe previamente.
3 Indica los permisos de la cola de mensajes al crearla nueva, de forma similar a los permisos que se aplican a los archivos.

El valor devuelto por mq_open() es el descriptor de la cola de mensajes. Como otros descriptores, se hereda de padres a hijos al usar fork().

Este descriptor se utiliza como primer argumento en funciones posteriores para indicar sobre qué cola queremos realizar la correspondiente operación. Por ejemplo, para enviar un mensaje se utiliza mq_send() así:

int mq_send(
    mqueue,                 (1)
    (const char *)&message, (2)
    sizeof(message),        (3)
    0                       (4)
);
1 Descriptor de la cola a la que enviar el mensaje.
2 Puntero a la dirección de memoria donde está el mensaje.
3 Tamaño del mensaje en bytes.
4 Prioridad del mensaje. Los mensajes con mayor prioridad se entregarán antes.

Mientras que para recibir un mensaje se utiliza mq_receive()

unsigned int msg_prio;

int mq_receive(
    mqueue,                 (1)
    (const char *)&message, (2)
    sizeof(message),        (3)
    &msg_prio               (4)
);
1 Descriptor de la cola de la que recibir el mensaje.
2 Puntero a la dirección de memoria donde guardar el mensaje al recibirlo.
3 Tamaño máximo de espacio reservado en message para guardar el mensaje.
4 Puntero a variable entera donde devolver la prioridad del mensaje recibido.

Como los mensajes no se dirigen directamente a los procesos, sino a estas entidades llamadas colas de mensajes, se trata de un caso de comunicación indirecta. Además, el tamaño de los mensajes es variable, aunque limitado por defecto a 8 KiB si no se configura de otra manera.

Si varios procesos intentan recibir de una misma cola de mensajes al mismo tiempo, queda en manos del sistema operativo decidir cuál recibirá el siguiente mensaje que llegue. Por lo general es el primero en ser escogido por el planificador de la CPU para seguir ejecutándose.

Recepción concurrente

Este tipo de comunicación da lugar a algunas situaciones que deben ser resueltas durante el diseño. Por ejemplo, ¿qué ocurre, por ejemplo, si los procesos A, B y C comparten el puerto P1; A manda un mensaje y B y C invocan receive() en el puerto P al mismo tiempo?.

recepción concurrente
Figura 34. Problema de la recepción concurrente.

La respuesta correcta dependerá de la elección de los diseñadores del sistema:

  • No permitir que cada enlace de comunicación —y por tanto cada puerto— esté asociado a más de dos procesos.

  • No permitir que más de un proceso puedan ejecutar receive() al mismo tiempo. Por ejemplo, en algunos sistemas solo el proceso que crea el puerto tiene permisos para recibir de él. Los sistemas que optan por esta solución suelen disponer de algún mecanismo para que un proceso pueda transferir el permiso de recibir a otros procesos.

  • Permitir que el sistema operativo escoja arbitrariamente quién recibe el mensaje si dos o más procesos ejecutan receive() al mismo tiempo. La elección puede ser aleatoria, mediante algún algoritmo, por ejemplo, por turnos o el siguiente proceso en obtener la CPU, a criterio del planificador de la CPU.

10.3. Buffering

Los mensajes intercambiados por enlace de comunicación se almacenan en una cola temporal, a la espera de ser enviados o, tras recibirlos, a la espera de que los reclame el proceso.

Básicamente hay tres formas de implementar dicha cola:

  • Con capacidad cero o sin buffering la cola tiene una capacidad máxima de 0 mensajes, por lo que no puede haber ningún mensaje esperando en el enlace. En este caso el proceso transmisor se bloquea en espera hasta que el receptor recibe el mensaje.

  • Con buffering automático, donde existe dos opciones:

    • Con capacidad limitada la cola tiene una capacidad máxima de N mensajes, por lo que si la cola se llena el proceso transmisor se bloquea a la espera de que haya espacio en la cola. Obviamente, mientras la cola no se llene en transmisor puede seguir metiendo mensajes sin bloquearse.

    • Con capacidad ilimitada la cola es de longitud potencialmente infinita, lo que permite que el transmisor nunca espere.

Las colas de longitud infinita son imposibles, puesto que los recursos son limitados. En realidad este término hace referencia a colas de longitud variable cuyo máximo viene determinado por la memoria principal disponible, que suele ser lo suficientemente grande como para que podamos considerar que las colas son infinitas.

Buffering en las colas de mensajes POSIX

Las colas de mensajes en sistemas POSIX tienen capacidad limitada. Los límites se configuran al crear la cola, a través del último argumento de mq_open():

struct mq_attr attr = {  (1)
    .mq_maxmsg = 5,      (2)
    .mq_msgsize = 2049   (3)
};

mqd_t mqueue = mq_open(
    "/foo-queue",
    O_CREAT | O_RDWR,
    0644,
    &attr (1)
);
1 Estructura con propiedades para la cola cuando esta se crea nueva.
2 Una de las propiedades es el número máximo de mensajes en la cola al mismo tiempo.
3 Otra de las propiedades es el tamaño máximo de cada mensaje.

Estos límites tienen unos valores por defecto por si en el lugar de attr en mq_open() se indica NULL. El estándar POSIX indica que esos valores por defecto dependen de cada sistema operativo, por lo que es necesario ir a la documentación para desarrolladores de cada sistema para conocer los detalles en cada caso concreto.

Por ejemplo, en Linux los valores por defecto son 10 mensajes y 8 KiB por mensaje, siendo estos, además, los valores máximos que admiten esas propiedades. Estos valores máximos y por defecto se pueden cambiar de forma global para todo el sistema, por si tuviéramos interés en valores más altos.

10.4. Operaciones síncronas y asíncronas

La comunicación entre dos procesos tiene lugar por medio de las llamadas send() y receive(); de tal forma que generalmente la primera se bloquea cuando la cola de transmisión se llena —en función del tipo de buffering— mientras que la segunda lo hace cuando la cola de recepción está vacía.

Sin embargo, en lugar de bloquearse, puede que aun proceso le interese ejecutar otras tareas en la CPU. A fin de cuentas las comunicaciones son bastante lentas, por lo que en caso de bloquearse podría estar dejando de aprovechar bastante tiempo de CPU. Incluso puede darse el caso que tengan conexión con otros procesos y que quiera aprovechar para intentar comunicarse con alguno de ellos.

Por eso existen diferentes opciones de diseño a la hora de implementar las llamadas anteriores en función de si se pueden bloquear o no. Concretamente, el paso de mensajes puede ser síncrono —con bloqueo— o asíncrono —sin bloqueo—.

  • Cuando el envío es asíncrono, el proceso transmisor nunca se bloquea. Si se llama a send() cuando la cola de mensajes esté llena, lo más común es que retorne con un código de retorno que indique que el proceso debe volver a intentar el envío más tarde.

  • Cuando el envío es síncrono, el proceso transmisor se bloquea cuando no queda espacio en la cola de mensajes y hasta que pueda depositar el mensaje en la misma.

  • Cuando la recepción es asíncrona, el receptor nunca se bloquea. En caso de que la cola de mensajes esté vacía, el sistema operativo puede indicar al proceso que lo intente más tarde a través de un código de retorno o devolviendo un mensaje vacío.

  • Cuando la recepción es con bloqueo, el receptor se bloquea cuando no hay mensajes en la cola y hasta que llegue alguno.

Algunos sistemas de paso de mensajes son claramente síncronos o asíncronos. Mientras que otros permiten activar un modo u otro según las necesidades de la aplicación. E incluso los hay que soportan que la transmisión y recepción sean síncronas o asíncronas de manera totalmente independiente.

Comunicaciones asíncronas con colas de mensajes POSIX

Por defecto las colas de mensajes son síncronas, tanto en envío como en recepción. Es decir, si al enviar un mensaje la cola está llena, el proceso transmisor quedará bloqueado en estado esperando hasta que haya un hueco libre para depositar el nuevo mensaje. Si al recibir un mensaje la cola está vacía, el receptor quedará bloqueado hasta que otro proceso deposite un mensaje.

Sin embargo, si en el argumento oflag de mq_open() un proceso indica la opción O_NONBLOCK estas operaciones para ese proceso en esa cola serán asíncronas:

mqd_t mqueue = mq_open(
    "foo-mqueue",
    O_RDONLY | O_NONBLOCK, (1)
    0644,
    &attr
);
1 Abrir la cola de mensajes para solo lectura —con O_RDONLY— y para comunicaciones asíncronas —con O_NONBLOCK—.

Eso quiere decir que las funciones mq_send() y mq_receive(), en lugar de bloquear el proceso en estado de esperando, devolverán -1 y el valor de errno será EAGAIN. Así el proceso puede aprovechar el tiempo de CPU del que dispone para realizar otras tareas mientras tanto y volver a intentarlo más tarde.

int return_code = mq_receive(mqueue, &message, sizeof(message), &msg_prio);
if ( return_code > 0 ) (1)
{
    // Aquí va código para usar el mensaje recibido...
}
else if( return_code < 0 && errno != EAGAIN) (2)
{
    // Aquí va código para manejar el error de mq_receive()...
}
1 Si todo va bien, mq_receive() devuelve el tamaño en bytes del mensaje.
2 Si devuelve -1, es que ha ocurrido un error. Pero solo será un error real si el código de error en errno no es EAGAIN. Si es EAGAIN, se pueden ejecutar otras partes del programa y volver a intentar la recepción más adelante.

Si un proceso debe comunicarse mediante varias colas de mensajes, la comunicación asíncrona también sirve para intentar recibir y enviar de varias colas sin bloquearse en ninguna. Para este caso algunos sistemas ofrecen una alternativa más sencilla y eficiente, que veremos en el Apartado 10.5.1.

10.5. Ejemplos de sistemas de paso de mensajes

10.5.1. Colas de mensajes POSIX

Como hemos comentado a lo largo de capítulo, las colas de mensajes POSIX son un caso de comunicación indirecta, con tamaño de mensaje variable, buffering con capacidad limitad y que soporta operaciones asíncronas.

Las colas de mensajes son útiles para enviar mensajes de pequeño tamaño entre procesos que se ejecutan en el mismo sistema. Además tienen la posibilidad de asociar a cada mensaje una prioridad, de tal forma que se reciban primero los mensajes de prioridad más alta. Su uso es relativamente común en sistemas de tiempo real, aunque lo más frecuente en los sistemas de propósito general es usar sockets.

En message_queue.hpp se puede ver un ejemplo de una clase desarrollada en C++ para utilizar colas de mensajes POSIX. En los distintos métodos se puede ver cómo se utilizan las funciones de la librería del sistema para crear la cola y enviar y recibir mensajes.

En mqueue-server.cpp y mqueue-client.cpp se puede ver un ejemplo de cómo se utiliza la clase en message_queue.hpp. El primero es un programa que muestra la hora del sistema periódicamente. El segundo se puede comunicar con el primero a través de una cola de mensajes para controlarlo. En ejemplo es muy sencillo, así que, por el momento, lo único que puede hacer mqueue-client.cpp es pedirle a mqueue-server.cpp que termine. Aunque no costaría nada añadir otras órdenes, como pedir que cambie la hora del sistema o la periodicidad con la que la muestra.

En Linux los descriptores de colas de mensajes son descriptores de archivo —como también lo son los descriptores de sockets, tuberías y los de archivos abiertos con open(), entre otros—. Esta particularidad implica que en Linux, mediante las funciones select(), poll() o epoll(), se pueden monitorizar al mismo tiempo varios descriptores de colas de mensajes, para así saber cuándo se puede enviar o recibir por ellas sin que el proceso se bloquee.

Este comportamiento es específico de Linux. No está contemplado en el estándar POSIX, por lo que otros sistemas POSIX no tienen por qué soportarlo. Así que no es portable.

A continuación se puede ver un ejemplo específico con poll(), aunque las tres funciones se utilizan empleando un patrón similar:

mqd_t mqueue1 = mq_open( "/foo-queue", /* ... */ ); (1)
mqd_t mqueue2 = mq_open( "/bar-queue", /* ... */ );

struct pollfd fds[] =
{
    {
        .fd = mqueue1, (2)
        .events = POLLIN, (3)
        .revents = 0
    }, {
        .fd = mqueue2, (2)
        .events = POLLIN | POLLOUT, (3)
        .revents = 0
    }
};

while ( !quit_app )
{
    int return_code = poll( fds, 2, -1 ); (4)
    if (return_code > 0) (5)
    {
        if (fds[0].revents & POLLIN) (7)
        {
            mq_receive( fds[0].fd, /* ... */ );

            // Aquí va código para usar el mensaje recibido...
        }

        if (fds[1].revents & POLLIN) (7)
        {
            mq_receive( fds[1].fd, /* ... */ );

            // Aquí va código para usar el mensaje recibido...
        }

        if (fds[1].revents & POLLOUT) (7)
        {
            // Aquí va código para preparar el mensaje a enviar...

            mq_send( fds[1].fd, /* ... */ );
        }
    }
    else if (return_code < 0) (6)
    {
        // Error en poll().
        // Aquí va código para leer errno y manejar el error...

        quit_app = true;
    }
}
1 Abrimos o creamos las colas que vamos a utilizar.
2 Creamos un array de la estructura pollfd con un elemento por cola que vamos a monitorizar con poll(). En cada estructura, en el campo fd, se indica el descriptor de cada una de las colas de mensajes.
3 Para cada cola hay que utilizar el campo events para indicar qué queremos que monitorice poll(). events es una máscara de bit donde a cada evento monitorizable le corresponde un bit. Si queremos monitorizar un evento, debemos poner su bit a 1.

Para eso nos podemos ayudar de macros como POLLIN y POLLOUT. Por ejemplo, para mqueue1 se quiere monitorizar cuándo hay mensajes para recibir, por lo que se activa POLLIN. Mientras que para mqueue2 se quiere saber tanto cuándo hay mensajes para recibir como cuándo hay un hueco en la cola para enviar sin bloqueos, por lo que se activan POLLIN y POLLOUT.

4 Iterativamente se llama a poll() —mientras no queramos que termine la aplicación— que pondrá el proceso en estado esperando hasta que ocurra alguno de los eventos que nos interesan. poll() necesita fds —el array de la estructura pollfd que hemos inicializado previamente— el número de elementos en el array y el tiempo máximo que debe mantener bloqueado el proceso esperando a que ocurra alguno de los eventos. Con un número negativo en este último campo, se indica que queremos que espere indefinidamente.
5 Si poll() tiene éxito, devuelve un número positivo que indica en cuántos descriptores se ha detectado un evento.
6 Si poll() devuelve un valor negativo, es que ha ocurrido algún error. El motivo del error se puede conocer comprobando el valor de la variable global errno.
7 El campo revents es una máscara de bits similar a events, pero al retornar de poll() indica qué eventos se han detectado, para cada cola de mensajes en fds.

Por ejemplo, en ambas colas se comprueba si POLLIN está activo. En caso afirmativo, sabemos que podemos leer un mensaje sin que mq_receive() se bloquee. Igualmente, sabemos si mqueue2 tiene hueco para enviar un mensaje comprobando si POLLOUT está activo. En caso afirmativo, podemos enviar un mensaje con mq_send() sabiendo que no se bloqueará.

10.5.2. Señales en sistemas operativos POSIX

En los sistemas POSIX, una forma más sencilla de comunicar dos procesos del mismo sistema es mediante el envío de una señal de uno al otro.

Los procesos pueden mandar señales utilizando la llamada al sistema kill(), que solo requiere el identificador del proceso de destino y el número que identifica la señal.

kill(pid, SIGTERM);

Como se usa el identificador del proceso, estamos hablando de un mecanismo de comunicación directa.

El tamaño y formato del mensaje es fijo. Las señales solo pueden portar la información de que ha ocurrido un evento, indicado qué evento es a través del número que identifica la señal.

Cada señal tiene un efecto particular por defecto —que por lo general es matar al proceso— en el proceso que las recibe. Sin embargo, cada proceso puede declarar un manejador de señal. Una función del programa que será invocada por el sistema operativo para tratar una señal determinada, interrumpiendo lo que esté haciendo el proceso en ese momento. En ese sentido las señales en POSIX puede interpretarse como una forma de interrupción por software.

Como la ejecución del programa se interrumpe para saltar al manejador de señal y luego continuar, hay que tener cuidado con el código de los manejadores de señal. Puede causar problemas que, por ejemplo, modifiquen el valor de una variable en un punto donde el resto del código no espera que puedan ocurrir cambios. Por eso es recomendable que el código de los manejadores de señal sea reentrante (véase el Apartado 13.7.1) y que solo invoquen otras funciones reentrantes o funciones de la librería del sistema marcadas como seguras en señales.

El manejador de señal se puede configurar usando la llamada al sistema signal():

signal(
    SIGTERM, (1)
    &mi_manejador_de_sigterm (2)
);
1 Identificador de la señal a recibir.
2 Puntero al manejador de señal. Es decir, la del programa que será llamada por el sistema operativo cuando llegue la señal SIGTERM.

El problema de signal() es que el estándar POSIX permite diferencias que hacen que se pueda comportar de forma distinta en diferentes sistemas operativos. Por ejemplo, qué ocurre al retornar del manejador de señal si cuando llegó la señal el proceso estaba ocupado en una llamada al sistema. Como el estándar no especifica nada al respecto, los sistemas BSD optaron porque las llamadas al sistema continuaran como si nada, mientras que en otros sistemas POSIX las llamadas al sistema son interrumpidas como si hubiera ocurrido un error.

Para resolverlo, el estándar recomienda usar sigaction() en su lugar, ya que está descrita de forma más precisa —evitando este tipo de divergencias— y permite que el programador escoja de entre varias opciones el comportamiento que más le convenga:

struct sigaction act = {
    .sa_handler = &mi_manejador_de_sigterm, (4)
    .sa_sigaction = NULL,   (5)
    .sa_mask = 0,           (6)
    .sa_flags = SA_RESTART, (7)
}

sigaction(
    SIGTERM, (1)
    &act,    (2)
    NULL     (3)
);
1 Identificador de la señal a recibir.
2 Puntero a una estructura de tipo sigaction que describe los detalles de como tratar la señal cuando llega al proceso.
3 Puntero a una estructura de tipo sigaction donde sigaction() guarda la configuración anterior sobre como tratar la señal indicada.
4 Puntero al manejador de señal para la señal indicada.
5 Puntero a un manejador de señal alternativo al de sa_handler. Este manejador recibe más información sobre la señal cuando es llamado. Para activar es necesario indicar SA_SIGINFO en el campo sa_flags.
6 Máscara de bits de señales a bloquear durante el manejo de la señal. Cada bit de la máscara identifica a una señal. Deben ponerse a 1 aquellas señales que queremos que estén bloqueadas —es decir, que no se puedan recibir— mientras se ejecuta el manejador de señal porque ha llegado una. Es especialmente útil si se va a usar el mismo manejador para varias señales.
7 Opciones de configuración. En el ejemplo se usa SA_RESTART, que indica que si la señal llega durante una llamada al sistema, la llamada debe continuar una vez se haya salido del manejador de señal. Como comentamos anteriormente, este es el comportamiento de signal() en los sistemas BSD. El comportamiento por defecto, sin esta opción, es que la llamada al sistema interrumpida falle con el error EINTR en errno.
orígenes de las señales
Figura 35. Orígenes más comunes de las señales.

Las señales fueron diseñadas originalmente como un mecanismo para que el sistema operativo notificase a los programas ciertos errores y sucesos críticos. Por ejemplo:

  • La señal HUP o SIGHUP es enviada a cada proceso iniciado desde una sesión de terminal cuando dicha sesión termina —o cuando se usa la combinación de teclas CTRL+D, que tiene el mismo efecto—.

    En el caso de los servicios del sistema —que, como no son interactivos, no están conectados a ninguna terminal— esta señal suele usarse para indicarles que deben reiniciarse, volviendo a leer sus archivos de configuración, o para que guarden su estado interno en algún sitio conocido del almacenamiento.

  • La señal INT o SIGINT es enviada al proceso que está enganchado a la consola cuando el usuario pulsa el carácter de interrupción —frecuentemente la combinación de teclas CTRL+C—.

  • La señal TERM o SIGTERM es enviada al proceso cuando debe terminar. Por ejemplo, el sistema operativo envía esta señal a todos los procesos cuando se está apagando el sistema.

  • La señal SEGV o SIGSEGV es enviada a un proceso cuando intenta acceder a una zona de memoria a la que no tiene permiso. Si no se maneja esta señal, el programa termina con el conocido mensaje de violación de segmento.

Obviamente hay muchas más señales. Entre todas, el estándar POSIX incluye dos señales —USR1 y USR2— especialmente indicadas para usarlas con el significado que nosotros queramos.

Se puede consultar una lista de las señales del estándar POSIX en «Señales (informática) — Wikipedia». Mientras que la lista completa de señales soportadas en Linux se puede consultar en «signal(7) — Linux Manual».

El ejemplo en mqueue-server.cpp y en otros ejemplos de este capítulo, utiliza señales para manejar SIGINT, SIGTERM y para mostrar la hora periódicamente. El código dedicado a eso está en timeserver.c y se comparte entre todos los ejemplos. En todos los casos se evita usar SA_RESTART porque interesa que el proceso interrumpa lo que esté haciendo cuando llegue una señal para terminar.

En signals.c hay un programa de ejemplo que muestra cómo manejar las señales del sistema y que sirve para ver cómo funcionan. Solo hay que ejecutarlo y luego enviarle señales con el comando kill desde otra terminal. En este caso si se usa SA_RESTART, porque el programa debe estar esperando la pulsación de una tecla con getc() y no nos interesa que deje de hacerlo cuando llegue una señal.

10.5.3. Tuberías

Las tuberías son un mecanismo de paso de mensajes de comunicación indirecta, orientada a flujos, capacidad limitada y, generalmente, comunicación síncrona —aunque en algunos sistemas operativos también puede soportar asíncrona—.

Conceptualmente, cada tubería tiene dos extremos en los que opera utilizando la misma interfaz que generalmente empleamos para manipular archivos. Es decir, usando funciones como read(), write() y close(), entre otras. Un extremo permite a los procesos en ese extremo escribir en la tubería, mientras el otro extremo permite a los procesos leer de la tubería los datos escritos desde el otro extremo.

tuberías
Figura 36. Esquema del concepto de tubería.

El que cada extremo imite ser un archivo, facilita que se puedan usar en muchas de las llamadas al sistema que aceptan un archivo como argumento. Los procesos pueden leer o escribir en un archivo sin saber realmente si están accediendo a una archivo real o se están comunicando con otro proceso mediante una tubería.

Existen dos tipos de tuberías:

  • Las tuberías anónimas que solo existen en el espacio de direcciones del proceso que las crea, de tal forma que debe heredarse de padres a hijos para que otros procesos puedan tener acceso.

  • Las tuberías con nombre son públicas al resto del sistema, por lo que teóricamente cualquier proceso con permisos puede abrir una para comunicarse con otros procesos. Por eso se suele utilizar en aplicaciones cliente-servidor, donde un proceso servidor ofrece algún servicio a otros procesos cliente a través de la tubería.

En los sistemas POSIX las tuberías con nombre se denominan FIFO y tienen presencia en el sistema de archivos como archivos especiales.

Tabla 3. Funciones de la API para manipular tuberías.
POSIX API Windows API

Crear tubería anónima

pipe()

CreatePipe()

Crear tubería con nombre

mkfifo()

CreateNamedPipe()

Abrir tubería con nombre

open()

CreateFile()

Leer

read()

ReadFile()

Escribir

write()

WriteFile()

Cerrar

close()

CloseHandle()

Destruir tubería con nombre

unlink()

[Automático]

Con fork() es muy sencillo lanzar otros procesos para que ejecuten tareas en paralelo. El proceso hijo tiene acceso a los datos del padre por la forma en la que funciona fork() y gracias a las tuberías anónimas puede comunicar los resultados al padre. En fork-pipe.cpp se puede observar un ejemplo de esto.

Además, el hecho de que cada extremo se comporte como un archivo —uno en modo solo lectura y el otro en modo solo escritura— hace posible redirigir la E/S estándar del proceso hijo. Es decir, conectar la entrada, la salida estándar o la salida de error a una tubería, desde la que leer lo que el proceso intenta imprimir por la pantalla de la terminal o proporcionarle lo que debe leer, como si fuera desde el teclado. En fork-redir.c se puede ver un ejemplo de cómo ejecutar el comando ls y redirigir su salida al proceso padre para contar el número de líneas en lo que el comando quería mostrar por pantalla.

Por otro lado, las tuberías con nombre permiten que un proceso se comunique con cualquier otro, solo con conocer la ruta de la tubería. En fifo-server.c tenemos un ejemplo de un programa que muestra la hora del sistema de forma periódica, mientras espera órdenes de una tubería que sirve de canal de control remoto. Los programas en fifo-client.c y fifo-client.cpp pueden conectarse a esa tubería y mandar el comando que hace terminar fifo-server.c.

10.5.4. Sockets

Mientras que las tuberías son conceptualmente un enlace de comunicación unidireccional que tiene dos extremos, un socket representa un solo extremo en un enlace de comunicación bidireccional. Para que una pareja de procesos se pueda comunicar son necesarios dos sockets —uno en cada proceso— de manera que cada uno de ellos es el medio por el que el proceso accede al enlace de comunicación.

La API de sockets fue creada por la Universidad de Berkeley para abstraer el acceso a la familia de protocolos de Internet en el UNIX desarrollado por esa misma universidad. Sin embargo, rápidamente se convirtió en el estándar de facto para la comunicación en red, por lo que todos los sistemas operativos modernos —incluidos los sistemas POSIX y Microsoft Windows— tienen una implementación de la misma.

Pese a sus orígenes en Internet, los sockets se diseñaron para ser independientes de la tecnología de red subyacente con la que se implementa el enlace de comunicación. En Linux, por ejemplo, se puede utilizar como interfaz de programación para utilizar dos decenas de familias de protocolos y tecnologías diferentes.

Para crear un socket se utiliza la llamada al sistema socket():

int sockfd = socket( (1)
    AF_UNIX,         (2)
    SOCK_DGRAM,      (3)
    0
)
1 En sistemas POSIX la función devuelve un int con el descriptor del socket mientras que en Microsoft Windows devuelve un HANDLE.
2 En el primer argumento se especifica la familia de protocolos. AF_UNIX son un tipo de socket que solo sirve para comunicar procesos en el mismo sistema, denominado socket de dominio UNIX. Otras familias muy comunes son AF_INET, que corresponde a la familia de protocolos TCP/IP y AF_INET6 para los protocolos IPv6.
3 En el segundo argumento se especifica el tipo del socket. Cada tipo suele corresponde con un protocolo concreto de la familia elegida. Por ejemplo, los sockets SOCK_DGRAM son «no orientados a conexión», no fiables y de longitud máxima fija, así que en la familia AF_INET estos sockets utilizan UDP. Mientras que los sockets SOCK_STREAM son orientados a conexión, fiables, bidireccionales y orientados a flujo, por lo que en la familia AF_INET utilizan TCP.
Los sockets de dominio UNIX solo entregan los mensajes dentro del mismo sistema, así que siempre son confiables, independientemente de si son SOCK_DGRAM o SOCK_STREAM.

Un socket recién creado no tiene un nombre que otro proceso pueda usar para identificarlo y comunicarse con él. Si se va a usar este socket solo para enviar mensajes a otro socket, esto es no es un problema. Pero para recibir mensajes, el remitente tiene que poder identificar al destinatario.

Para asignar un nombre o dirección a un socket se utiliza bind(). La dificultad es que cada familia de protocolos tiene un formato de direcciones diferente, así que hay que tener cuidado de usar el adecuado:

struct sockaddr_un addr = {
    .sun_family = AF_UNIX,        (5)
    .sun_path = "/tmp/foo-socket" (6)
};

int result_code = bind( (1)
    sockfd, (2)
    (struct sockaddr*) &addr, (3)
    sizeof(addr) (4)
)
1 Como en el resto de llamadas al sistema, en caso de error se devuelve un número negativo y errno contendrá el código del error.
2 El descriptor del socket al que se le quiere cambiar la dirección.
3 La nueva dirección del socket especificada como una estructura adecuada para la familia del socket. En socket de tipo AF_UNIX la estructura debe ser de tipo sockaddr_un mientras que en los de tipo AF_INET es del tipo sockaddr_in.
4 El tamaño en bytes de la estructura con la nueva dirección.
5 En la estructura con la dirección, el primer campo siempre es para indicar la familia.
6 En los sockets de dominio UNIX la dirección es una ruta en el sistema de archivos. Para otras familias, las direcciones se indican de otra manera, por lo que es necesario consultar la documentación.

La API de sockets incluye muchas otras funciones:

  • listen(), para poner sockets tipo SOCK_STREAM a la espera de conexiones.

  • connect(), para conectar un socket tipo SOCK_STREAM con otro que esté a la espera de conexiones.

  • accept() para que un socket tipo SOCK_STREAM a la espera de conexiones acepte una solicitud de conexión.

  • shutdown() para cerrar uno de los sentidos de una conexión.

  • close() para destruir un socket.

  • send(), sendto() y sendmsg() para enviar mensajes. send() solo se puede utilizar con sockets conectados. Mientras que sendto() permiten indicar la dirección del socket de destino, por lo que es útil en sockets no orientados a conexión SOCK_DGRAM.

  • recv(), recvfrom() y recvmsg() para recibir mensajes. recvfrom() permite obtener la dirección del socket del que llegó el mensaje. Por eso es útil en sockets no orientados a conexión SOCK_DGRAM.

struct sockaddr_un addr;
socklen_t addrlen;

int result_code = recvfrom( (1)
    sockfd,          (2)
    &message,        (3)
    sizeof(message), (4)
    0,               (5)
    (struct sockaddr*) &addr, (6)
    &addrlen         (7)
)
1 En caso de éxito devuelve el número de bytes del mensaje recibido. En caso de error, un -1 y errno contiene el código del error.
2 El descriptor del socket al que se le quiere cambiar la dirección.
3 Puntero a la dirección de memoria donde está el mensaje.
4 Tamaño del mensaje en bytes.
5 Opciones adicionales de configuración.
6 Estructura de dirección vacía donde se copiará la dirección del socket que remite el mensaje.
7 Puntero donde la llamada al sistema copiará el tamaño de la estructura copiada en addr.

Las operaciones con sockets son síncronas por defecto. Sin embargo, es posible configurarlos en modo asíncrono, para que así cualquiera de estas funciones falle, retornando -1 y código de error EAGAIN o EWOULDBLOCK, antes de poner el proceso en estado esperando.

También se pueden utilizar las funciones select() y poll() para monitorizar varios sockets al mismo tiempo, de forma similar a como se hace para colas de mensajes POSIX (véase el Apartado 10.5.1).

En socket-server.cpp y socket-client.cpp se puede observar un ejemplo similar al que usamos con las tuberías y las colas de mensajes, pero empleando sockets de dominio UNIX. Ambos programas utilizan la cabecera socket.hpp que incluye un ejemplo de clase en C++ para comunicaciones mediante sockets. En los distintos métodos se puede ver cómo se utilizan las funciones de la librería del sistema para crear sockets, asignarles dirección y usarlos para enviar y recibir mensajes.

En resumen, los sockets son un mecanismo de paso de mensajes de comunicación indirecta, que admite tanto comunicación orientada a flujos como mensajes de tamaño variable, buffering de capacidad limitada y tanto comunicación síncrona como asíncrona, aunque el comportamiento real final de la interfaz depende de la tecnología de red utilizada.