GNU/Linux >> Tutoriales Linux >  >> Linux

Comunicación entre procesos en Linux:sockets y señales

Este es el tercer y último artículo de una serie sobre comunicación entre procesos (IPC) en Linux. El primer artículo se centró en IPC a través del almacenamiento compartido (archivos y segmentos de memoria), y el segundo artículo hace lo mismo para los canales básicos:conductos (con nombre y sin nombre) y colas de mensajes. Este artículo pasa de IPC en el extremo superior (enchufes) a IPC en el extremo inferior (señales). Los ejemplos de código desarrollan los detalles.

Enchufes

Así como las pipas vienen en dos sabores (con y sin nombre), también lo hacen los enchufes. Los sockets IPC (también conocidos como sockets de dominio Unix) permiten la comunicación basada en canales para procesos en el mismo dispositivo físico (host ), mientras que los sockets de red permiten este tipo de IPC para procesos que pueden ejecutarse en diferentes hosts, lo que hace que las redes entren en juego. Los sockets de red necesitan soporte de un protocolo subyacente como TCP (Protocolo de control de transmisión) o el UDP (Protocolo de datagramas de usuario) de nivel inferior.

Por el contrario, los sockets IPC se basan en el kernel del sistema local para admitir la comunicación; en particular, los sockets IPC se comunican utilizando un archivo local como dirección de socket. A pesar de estas diferencias de implementación, las API del socket IPC y del socket de red son las mismas en lo esencial. El próximo ejemplo cubre los sockets de red, pero el servidor de muestra y los programas cliente pueden ejecutarse en la misma máquina porque el servidor usa la dirección de red localhost (127.0.0.1), la dirección de la máquina local en la máquina local.

Los sockets configurados como flujos (discutidos a continuación) son bidireccionales y el control sigue un patrón cliente/servidor:el cliente inicia la conversación intentando conectarse a un servidor, que intenta aceptar la conexión. Si todo funciona, las solicitudes del cliente y las respuestas del servidor pueden fluir a través del canal hasta que se cierre en cualquiera de los extremos, rompiendo así la conexión.

[Descargar la guía completa de comunicación entre procesos en Linux]

Un iterativo El servidor, que solo es adecuado para el desarrollo, maneja los clientes conectados de uno en uno hasta su finalización:el primer cliente se maneja de principio a fin, luego el segundo, y así sucesivamente. La desventaja es que el manejo de un cliente en particular puede bloquearse, lo que luego priva a todos los clientes que esperan detrás. Un servidor de nivel de producción sería concurrente , normalmente usando una combinación de multiprocesamiento y subprocesos múltiples. Por ejemplo, el servidor web Nginx en mi máquina de escritorio tiene un grupo de cuatro procesos de trabajo que pueden manejar las solicitudes de los clientes al mismo tiempo. El siguiente ejemplo de código mantiene el desorden al mínimo mediante el uso de un servidor iterativo; por lo tanto, el enfoque permanece en la API básica, no en la concurrencia.

Finalmente, la API de socket ha evolucionado significativamente con el tiempo a medida que surgieron varios refinamientos de POSIX. El código de muestra actual para el servidor y el cliente es deliberadamente simple pero subraya el aspecto bidireccional de una conexión de socket basada en flujo. Aquí hay un resumen del flujo de control, con el servidor iniciado en una terminal y luego el cliente iniciado en una terminal separada:

  • El servidor espera las conexiones del cliente y, en caso de una conexión exitosa, lee los bytes del cliente.
  • Para subrayar la conversación bidireccional, el servidor devuelve al cliente los bytes recibidos del cliente. Estos bytes son códigos de caracteres ASCII, que componen los títulos de los libros.
  • El cliente escribe títulos de libros en el proceso del servidor y luego lee los mismos títulos repetidos desde el servidor. Tanto el servidor como el cliente imprimen los títulos en pantalla. Aquí está la salida del servidor, esencialmente la misma que la del cliente:
    Listening on port 9876 for clients...
    War and Peace
    Pride and Prejudice
    The Sound and the Fury

Ejemplo 1. El servidor de socket

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include "sock.h"

void report(const char* msg, int terminate) {
  perror(msg);
  if (terminate) exit(-1); /* failure */
}

int main() {
  int fd = socket(AF_INET,     /* network versus AF_LOCAL */
                  SOCK_STREAM, /* reliable, bidirectional, arbitrary payload size */
                  0);          /* system picks underlying protocol (TCP) */
  if (fd < 0) report("socket", 1); /* terminate */

  /* bind the server's local address in memory */
  struct sockaddr_in saddr;
  memset(&saddr, 0, sizeof(saddr));          /* clear the bytes */
  saddr.sin_family = AF_INET;                /* versus AF_LOCAL */
  saddr.sin_addr.s_addr = htonl(INADDR_ANY); /* host-to-network endian */
  saddr.sin_port = htons(PortNumber);        /* for listening */

  if (bind(fd, (struct sockaddr *) &saddr, sizeof(saddr)) < 0)
    report("bind", 1); /* terminate */

  /* listen to the socket */
  if (listen(fd, MaxConnects) < 0) /* listen for clients, up to MaxConnects */
    report("listen", 1); /* terminate */

  fprintf(stderr, "Listening on port %i for clients...\n", PortNumber);
  /* a server traditionally listens indefinitely */
  while (1) {
    struct sockaddr_in caddr; /* client address */
    int len = sizeof(caddr);  /* address length could change */

    int client_fd = accept(fd, (struct sockaddr*) &caddr, &len);  /* accept blocks */
    if (client_fd < 0) {
      report("accept", 0); /* don't terminate, though there's a problem */
      continue;
    }

    /* read from client */
    int i;
    for (i = 0; i < ConversationLen; i++) {
      char buffer[BuffSize + 1];
      memset(buffer, '\0', sizeof(buffer));
      int count = read(client_fd, buffer, sizeof(buffer));
      if (count > 0) {
        puts(buffer);
        write(client_fd, buffer, sizeof(buffer)); /* echo as confirmation */
      }
    }
    close(client_fd); /* break connection */
  }  /* while(1) */
  return 0;
}

El programa de servidor anterior realiza los cuatro pasos clásicos para prepararse para las solicitudes de los clientes y luego para aceptar solicitudes individuales. Cada paso lleva el nombre de una función del sistema que llama el servidor:

  1. enchufe (...) :obtenga un descriptor de archivo para la conexión de socket
  2. vincular(…) :vincular el socket a una dirección en el host del servidor
  3. escuchar (…) :escuche las solicitudes de los clientes
  4. aceptar(…) :aceptar una solicitud de cliente en particular

El enchufe la llamada completa es:

int sockfd = socket(AF_INET,      /* versus AF_LOCAL */
                    SOCK_STREAM,  /* reliable, bidirectional */
                    0);           /* system picks protocol (TCP) */

El primer argumento especifica un socket de red en lugar de un socket IPC. Hay varias opciones para el segundo argumento, pero SOCK_STREAM y SOCK_DGRAM (datagrama) son probablemente los más utilizados. Un basado en flujo socket admite un canal confiable en el que se informan los mensajes perdidos o alterados; el canal es bidireccional y las cargas útiles de un lado al otro pueden tener un tamaño arbitrario. Por el contrario, un socket basado en datagramas no es confiable (mejor intento ), unidireccional y requiere cargas útiles de tamaño fijo. El tercer argumento para socket especifica el protocolo. Para el socket basado en flujo en juego aquí, hay una sola opción, que representa el cero:TCP. Porque una llamada exitosa a socket devuelve el descriptor de archivo familiar, un socket se escribe y lee con la misma sintaxis que, por ejemplo, un archivo local.

El vínculo call es la más complicada, ya que refleja varios refinamientos en la API de socket. El punto de interés es que esta llamada vincula el socket a una dirección de memoria en la máquina del servidor. Sin embargo, la escucha la llamada es directa:

if (listen(fd, MaxConnects) < 0)

El primer argumento es el descriptor de archivo del socket y el segundo especifica cuántas conexiones de clientes se pueden acomodar antes de que el servidor emita una conexión rechazada. error en un intento de conexión. (MaxConnects se establece en 8 en el archivo de encabezado sock.h .)

El aceptar la llamada se establece de forma predeterminada en una espera de bloqueo :el servidor no hace nada hasta que un cliente intenta conectarse y luego continúa. El aceptar la función devuelve -1 para indicar un error. Si la llamada tiene éxito, devuelve otro descriptor de archivo, para una lectura/escritura socket en contraste con el aceptar socket al que hace referencia el primer argumento en aceptar llamar. El servidor utiliza el socket de lectura/escritura para leer las solicitudes del cliente y escribir las respuestas. El socket de aceptación se usa solo para aceptar conexiones de clientes.

Por diseño, un servidor se ejecuta indefinidamente. En consecuencia, el servidor se puede terminar con Ctrl+C desde la línea de comando.

Ejemplo 2. El cliente de socket

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>
#include "sock.h"

const char* books[] = {"War and Peace",
                       "Pride and Prejudice",
                       "The Sound and the Fury"};

void report(const char* msg, int terminate) {
  perror(msg);
  if (terminate) exit(-1); /* failure */
}

int main() {
  /* fd for the socket */
  int sockfd = socket(AF_INET,      /* versus AF_LOCAL */
                      SOCK_STREAM,  /* reliable, bidirectional */
                      0);           /* system picks protocol (TCP) */
  if (sockfd < 0) report("socket", 1); /* terminate */

  /* get the address of the host */
  struct hostent* hptr = gethostbyname(Host); /* localhost: 127.0.0.1 */
  if (!hptr) report("gethostbyname", 1); /* is hptr NULL? */
  if (hptr->h_addrtype != AF_INET)       /* versus AF_LOCAL */
    report("bad address family", 1);

  /* connect to the server: configure server's address 1st */
  struct sockaddr_in saddr;
  memset(&saddr, 0, sizeof(saddr));
  saddr.sin_family = AF_INET;
  saddr.sin_addr.s_addr =
     ((struct in_addr*) hptr->h_addr_list[0])->s_addr;
  saddr.sin_port = htons(PortNumber); /* port number in big-endian */

  if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)
    report("connect", 1);

  /* Write some stuff and read the echoes. */
  puts("Connect to server, about to write some stuff...");
  int i;
  for (i = 0; i < ConversationLen; i++) {
    if (write(sockfd, books[i], strlen(books[i])) > 0) {
      /* get confirmation echoed from server and print */
      char buffer[BuffSize + 1];
      memset(buffer, '\0', sizeof(buffer));
      if (read(sockfd, buffer, sizeof(buffer)) > 0)
        puts(buffer);
    }
  }
  puts("Client done, about to exit...");
  close(sockfd); /* close the connection */
  return 0;
}

El código de instalación del programa cliente es similar al del servidor. La principal diferencia entre ambos es que el cliente ni escucha ni acepta, sino que conecta:

if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)

El conectar la llamada puede fallar por varias razones; por ejemplo, el cliente tiene una dirección de servidor incorrecta o ya hay demasiados clientes conectados al servidor. Si el conectar la operación tiene éxito, el cliente escribe solicitudes y luego lee las respuestas repetidas en un for círculo. Después de la conversación, tanto el servidor como el cliente cierran el zócalo de lectura/escritura, aunque una operación de cierre en cualquier lado es suficiente para cerrar la conexión. El cliente sale a partir de entonces pero, como se señaló anteriormente, el servidor permanece abierto para los negocios.

El ejemplo del socket, con mensajes de solicitud devueltos al cliente, sugiere las posibilidades de conversaciones arbitrariamente ricas entre el servidor y el cliente. Quizás este sea el principal atractivo de los enchufes. Es común en los sistemas modernos que las aplicaciones cliente (por ejemplo, un cliente de base de datos) se comuniquen con un servidor a través de un socket. Como se señaló anteriormente, los sockets IPC locales y los sockets de red difieren solo en algunos detalles de implementación; en general, los zócalos IPC tienen una sobrecarga más baja y un mejor rendimiento. La API de comunicación es esencialmente la misma para ambos.

Señales

Una señal interrumpe un programa en ejecución y, en este sentido, se comunica con él. La mayoría de las señales se pueden ignorar (bloquear) o manejar (a través de un código designado), con SIGSTOP (pausa) y SIGKILL (terminar inmediatamente) como las dos excepciones notables. Constantes simbólicas como SIGKILL tienen valores enteros, en este caso, 9.

Las señales pueden surgir en la interacción del usuario. Por ejemplo, un usuario pulsa Ctrl+C desde la línea de comandos para finalizar un programa iniciado desde la línea de comandos; Ctrl+C genera un SIGTERM señal. SIGTERM para terminar , a diferencia de SIGKILL , se puede bloquear o gestionar. Un proceso también puede señalar a otro, lo que convierte a las señales en un mecanismo IPC.

Considere cómo una aplicación de procesamiento múltiple, como el servidor web Nginx, podría cerrarse correctamente desde otro proceso. El matar función:

int kill(pid_t pid, int signum); /* declaration */

puede ser utilizado por un proceso para terminar otro proceso o grupo de procesos. Si el primer argumento de la función matar es mayor que cero, este argumento se trata como el pid (ID de proceso) del proceso de destino; si el argumento es cero, el argumento identifica el grupo de procesos al que pertenece el emisor de la señal.

El segundo argumento para matar es un número de señal estándar (por ejemplo, SIGTERM o SIGKILL ) o 0, que hace la llamada a señalizar una consulta sobre si el pid en el primer argumento es de hecho válido. El apagado correcto de una aplicación de procesamiento múltiple podría lograrse enviando un terminar señal:una llamada a matar funciona con SIGTERM como segundo argumento—al grupo de procesos que componen la aplicación. (El proceso maestro de Nginx podría finalizar los procesos de trabajo con una llamada a matar y luego salir.) El matar función, como tantas funciones de biblioteca, alberga potencia y flexibilidad en una sintaxis de invocación simple.

Ejemplo 3. El elegante cierre de un sistema multiprocesamiento

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

void graceful(int signum) {
  printf("\tChild confirming received signal: %i\n", signum);
  puts("\tChild about to terminate gracefully...");
  sleep(1);
  puts("\tChild terminating now...");
  _exit(0); /* fast-track notification of parent */
}

void set_handler() {
  struct sigaction current;
  sigemptyset(&current.sa_mask);         /* clear the signal set */
  current.sa_flags = 0;                  /* enables setting sa_handler, not sa_action */
  current.sa_handler = graceful;         /* specify a handler */
  sigaction(SIGTERM, &current, NULL);    /* register the handler */
}

void child_code() {
  set_handler();

  while (1) {   /** loop until interrupted **/
    sleep(1);
    puts("\tChild just woke up, but going back to sleep.");
  }
}

void parent_code(pid_t cpid) {
  puts("Parent sleeping for a time...");
  sleep(5);

  /* Try to terminate child. */
  if (-1 == kill(cpid, SIGTERM)) {
    perror("kill");
    exit(-1);
  }
  wait(NULL); /** wait for child to terminate **/
  puts("My child terminated, about to exit myself...");
}

int main() {
  pid_t pid = fork();
  if (pid < 0) {
    perror("fork");
    return -1; /* error */
  }
  if (0 == pid)
    child_code();
  else
    parent_code(pid);
  return 0;  /* normal */
}

El apagado El programa anterior simula el apagado correcto de un sistema de procesamiento múltiple, en este caso, uno simple que consta de un proceso principal y un único proceso secundario. La simulación funciona de la siguiente manera:

  • El proceso padre intenta bifurcar a un hijo. Si la bifurcación tiene éxito, cada proceso ejecuta su propio código:el hijo ejecuta la función child_code , y el padre ejecuta la función parent_code .
  • El proceso hijo entra en un ciclo potencialmente infinito en el que el hijo duerme por un segundo, imprime un mensaje, vuelve a dormir, y así sucesivamente. Es precisamente un SIGTERM señal del padre que hace que el hijo ejecute la función de devolución de llamada de manejo de señales elegante . La señal, por lo tanto, rompe el proceso hijo fuera de su bucle y establece la terminación elegante tanto del hijo como del padre. El niño imprime un mensaje antes de terminar.
  • El proceso principal, después de bifurcar al secundario, duerme durante cinco segundos para que el secundario pueda ejecutarse durante un tiempo; por supuesto, el niño duerme principalmente en esta simulación. Luego, el padre llama al matar funciona con SIGTERM como segundo argumento, espera a que el hijo termine y luego sale.

Este es el resultado de una ejecución de muestra:

% ./shutdown
Parent sleeping for a time...
        Child just woke up, but going back to sleep.
        Child just woke up, but going back to sleep.
        Child just woke up, but going back to sleep.
        Child just woke up, but going back to sleep.
        Child confirming received signal: 15  ## SIGTERM is 15
        Child about to terminate gracefully...
        Child terminating now...
My child terminated, about to exit myself...

Para el manejo de la señal, el ejemplo usa la sigaction función de biblioteca (se recomienda POSIX) en lugar de la señal heredada función, que tiene problemas de portabilidad. Estos son los segmentos de código de mayor interés:

  • Si la llamada a bifurcación tiene éxito, el padre ejecuta el parent_code función y el niño ejecuta el child_code función. El padre espera cinco segundos antes de señalar al niño:
    puts("Parent sleeping for a time...");
    sleep(5);
    if (-1 == kill(cpid, SIGTERM)) {
    ...

    Si el matar la llamada tiene éxito, el padre hace una espera en la terminación del niño para evitar que el niño se convierta en un zombi permanente; después de la espera, el padre sale.

  • El child_code la función primero llama a set_handler y luego entra en su ciclo de sueño potencialmente infinito. Aquí está el set_handler función para revisión:
    void set_handler() {
      struct sigaction current;            /* current setup */
      sigemptyset(&current.sa_mask);       /* clear the signal set */
      current.sa_flags = 0;                /* for setting sa_handler, not sa_action */
      current.sa_handler = graceful;       /* specify a handler */
      sigaction(SIGTERM, &current, NULL);  /* register the handler */
    }

    Las primeras tres líneas son preparación. La cuarta declaración establece el controlador para la función elegante , que imprime algunos mensajes antes de llamar a _exit para terminar. La quinta y última instrucción registra al manejador con el sistema a través de la llamada a sigaction . El primer argumento para sigaction es SIGTERM para terminar , el segundo es la sigaction actual configuración y el último argumento (NULL en este caso) se puede utilizar para guardar una sigaction anterior configuración, tal vez para su uso posterior.

El uso de señales para IPC es de hecho un enfoque minimalista, pero probado y verdadero. IPC a través de señales claramente pertenece a la caja de herramientas de IPC.

Terminando esta serie

Estos tres artículos sobre IPC han cubierto los siguientes mecanismos a través de ejemplos de código:

  • Archivos compartidos
  • Memoria compartida (con semáforos)
  • Tubos (con y sin nombre)
  • Colas de mensajes
  • Enchufes
  • Señales

Incluso hoy en día, cuando los lenguajes centrados en subprocesos como Java, C# y Go se han vuelto tan populares, IPC sigue siendo atractivo porque la concurrencia a través del procesamiento múltiple tiene una ventaja obvia sobre los subprocesos múltiples:cada proceso, de manera predeterminada, tiene su propio espacio de direcciones. , que descarta las condiciones de carrera basadas en la memoria en el multiprocesamiento a menos que se ponga en juego el mecanismo IPC de memoria compartida. (La memoria compartida debe estar bloqueada tanto en multiprocesamiento como en subprocesos múltiples para una concurrencia segura). Cualquiera que haya escrito incluso un programa elemental de subprocesos múltiples con comunicación a través de variables compartidas sabe lo difícil que puede ser escribir de forma segura para subprocesos pero claro, código eficiente. El multiprocesamiento con procesos de subproceso único sigue siendo una forma viable (de hecho, bastante atractiva) de aprovechar las máquinas multiprocesador de hoy en día sin el riesgo inherente de las condiciones de carrera basadas en la memoria.

Por supuesto, no hay una respuesta simple a la pregunta de cuál de los mecanismos de IPC es el mejor. Cada uno implica una compensación típica en la programación:simplicidad frente a funcionalidad. Las señales, por ejemplo, son un mecanismo IPC relativamente simple pero no admiten conversaciones ricas entre procesos. Si se necesita tal conversión, entonces una de las otras opciones es más apropiada. Los archivos compartidos con bloqueo son razonablemente sencillos, pero es posible que los archivos compartidos no funcionen lo suficientemente bien si los procesos necesitan compartir flujos de datos masivos; las tuberías o incluso los zócalos, con API más complicadas, podrían ser una mejor opción. Deje que el problema en cuestión guíe la elección.

Aunque el código de muestra (disponible en mi sitio web) está todo en C, otros lenguajes de programación a menudo proporcionan envolturas delgadas alrededor de estos mecanismos IPC. Los ejemplos de código son lo suficientemente breves y simples, espero, para alentarlo a experimentar.


Linux
  1. Presentamos la guía para la comunicación entre procesos en Linux

  2. Comunicación entre procesos en Linux:uso de conductos y colas de mensajes

  3. Comunicación entre procesos en Linux:Almacenamiento compartido

  4. Cómo instalar y configurar el servidor y el cliente NTP de Linux

  5. Copiar usuarios y contraseñas de Linux a un nuevo servidor

ReaR:Respalde y recupere su servidor Linux con confianza

Cómo instalar y configurar un servidor NFS en un sistema Linux

Jenkins Server en Linux:un servidor de automatización gratuito y de código abierto

4 sencillos pasos para instalar y configurar VMware Server 2 en Linux

Cómo instalar y configurar el servidor DNS en Linux

Los 20 mejores software y soluciones de servidor de correo Linux