GNU/Linux >> Tutoriels Linux >  >> Linux

Communication inter-processus sous Linux :Sockets et signaux

Ceci est le troisième et dernier article d'une série sur la communication interprocessus (IPC) sous Linux. Le premier article s'est concentré sur l'IPC via le stockage partagé (fichiers et segments de mémoire), et le deuxième article fait de même pour les canaux de base :les canaux (nommés et non nommés) et les files d'attente de messages. Cet article passe de l'IPC haut de gamme (sockets) à l'IPC bas de gamme (signaux). Des exemples de code étoffent les détails.

Sockets

Tout comme les tuyaux existent en deux versions (nommées et sans nom), il en va de même pour les sockets. Les sockets IPC (alias sockets de domaine Unix) permettent une communication basée sur les canaux pour les processus sur le même périphérique physique (hôte ), tandis que les sockets réseau permettent ce type d'IPC pour les processus qui peuvent s'exécuter sur différents hôtes, mettant ainsi en jeu la mise en réseau. Les sockets réseau doivent être pris en charge par un protocole sous-jacent tel que TCP (Transmission Control Protocol) ou UDP (User Datagram Protocol) de niveau inférieur.

En revanche, les sockets IPC s'appuient sur le noyau du système local pour prendre en charge la communication; en particulier, les sockets IPC communiquent en utilisant un fichier local comme adresse de socket. Malgré ces différences d'implémentation, les API de socket IPC et de socket réseau sont les mêmes dans l'essentiel. L'exemple à venir couvre les sockets réseau, mais les exemples de programmes serveur et client peuvent s'exécuter sur la même machine car le serveur utilise l'adresse réseau localhost (127.0.0.1), l'adresse de la machine locale sur la machine locale.

Les sockets configurés en tant que flux (décrits ci-dessous) sont bidirectionnels et le contrôle suit un modèle client/serveur :le client initie la conversation en essayant de se connecter à un serveur, qui essaie d'accepter la connexion. Si tout fonctionne, les demandes du client et les réponses du serveur peuvent alors transiter par le canal jusqu'à ce qu'il soit fermé à chaque extrémité, rompant ainsi la connexion.

[Télécharger le guide complet de la communication inter-processus sous Linux]

Un itératif Le serveur, adapté au développement uniquement, gère un par un les clients connectés jusqu'à la fin :le premier client est géré du début à la fin, puis le second, et ainsi de suite. L'inconvénient est que la gestion d'un client particulier peut se bloquer, ce qui affame alors tous les clients qui attendent derrière. Un serveur de niveau production serait concurrent , utilisant généralement une combinaison de multitraitement et de multithreading. Par exemple, le serveur Web Nginx sur mon ordinateur de bureau dispose d'un pool de quatre processus de travail qui peuvent gérer simultanément les demandes des clients. L'exemple de code suivant réduit l'encombrement au minimum en utilisant un serveur itératif; l'accent reste donc sur l'API de base, pas sur la concurrence.

Enfin, l'API socket a considérablement évolué au fil du temps à mesure que divers raffinements POSIX sont apparus. L'exemple de code actuel pour le serveur et le client est délibérément simple mais souligne l'aspect bidirectionnel d'une connexion socket basée sur le flux. Voici un résumé du flux de contrôle, avec le serveur démarré dans un terminal puis le client démarré dans un terminal séparé :

  • Le serveur attend les connexions client et, en cas de connexion réussie, lit les octets du client.
  • Pour souligner la conversation bidirectionnelle, le serveur renvoie au client les octets reçus du client. Ces octets sont des codes de caractères ASCII, qui composent les titres de livres.
  • Le client écrit les titres des livres dans le processus serveur, puis lit les mêmes titres renvoyés par le serveur. Le serveur et le client impriment les titres à l'écran. Voici la sortie du serveur, essentiellement la même que celle du client :
    Listening on port 9876 for clients...
    War and Peace
    Pride and Prejudice
    The Sound and the Fury

Exemple 1. Le serveur 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;
}

Le programme serveur ci-dessus effectue les quatre étapes classiques pour se préparer aux demandes des clients, puis pour accepter les demandes individuelles. Chaque étape porte le nom d'une fonction système que le serveur appelle :

  1. prise(…) :obtenir un descripteur de fichier pour la connexion socket
  2. lier(…) :lier le socket à une adresse sur l'hôte du serveur
  3. écouter(…)  :écouter les demandes des clients
  4. accepter(…) :accepter une demande client particulière

La prise l'appel complet est :

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

Le premier argument spécifie un socket réseau par opposition à un socket IPC. Il existe plusieurs options pour le deuxième argument, mais SOCK_STREAM et SOCK_DGRAM (datagramme) sont probablement les plus utilisés. Un basé sur le flux socket prend en charge un canal fiable dans lequel les messages perdus ou modifiés sont signalés ; le canal est bidirectionnel et les charges utiles d'un côté à l'autre peuvent être de taille arbitraire. En revanche, un socket basé sur un datagramme n'est pas fiable (meilleur essai ), unidirectionnel et nécessite des charges utiles de taille fixe. Le troisième argument de socket précise le protocole. Pour le socket basé sur le flux en jeu ici, il y a un seul choix, que le zéro représente :TCP. Parce qu'un appel réussi à socket renvoie le descripteur de fichier familier, une socket est écrite et lue avec la même syntaxe que, par exemple, un fichier local.

Le lier call est le plus compliqué, car il reflète divers raffinements dans l'API socket. Le point intéressant est que cet appel lie le socket à une adresse mémoire sur la machine serveur. Cependant, l'écoute l'appel est simple :

if (listen(fd, MaxConnects) < 0)

Le premier argument est le descripteur de fichier du socket et le second spécifie combien de connexions client peuvent être acceptées avant que le serveur n'émette une connexion refusée erreur lors d'une tentative de connexion. (MaxConnects est défini sur 8 dans le fichier d'en-tête sock.h .)

Le accepter l'appel est par défaut une attente bloquante :le serveur ne fait rien jusqu'à ce qu'un client tente de se connecter, puis continue. Le accepter la fonction renvoie -1 pour signaler une erreur. Si l'appel réussit, il renvoie un autre descripteur de fichier—pour une lecture/écriture socket contrairement à accepter socket référencé par le premier argument dans le accept appel. Le serveur utilise le socket de lecture/écriture pour lire les requêtes du client et réécrire les réponses. Le socket d'acceptation est utilisé uniquement pour accepter les connexions client.

De par sa conception, un serveur fonctionne indéfiniment. En conséquence, le serveur peut être terminé avec un Ctrl+C depuis la ligne de commande.

Exemple 2. Le client 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;
}

Le code d'installation du programme client est similaire à celui du serveur. La principale différence entre les deux est que le client n'écoute ni n'accepte, mais se connecte :

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

La connexion l'appel peut échouer pour plusieurs raisons ; par exemple, le client a la mauvaise adresse de serveur ou trop de clients sont déjà connectés au serveur. Si le connect l'opération réussit, le client écrit des requêtes puis lit les réponses en écho dans un for boucle. Après la conversation, le serveur et le client ferment le socket de lecture/écriture, bien qu'une opération de fermeture de chaque côté soit suffisante pour fermer la connexion. Le client se ferme ensuite mais, comme indiqué précédemment, le serveur reste ouvert.

L'exemple de socket, avec des messages de requête renvoyés en écho au client, fait allusion aux possibilités de conversations arbitrairement riches entre le serveur et le client. C'est peut-être le principal attrait des prises. Il est courant sur les systèmes modernes pour les applications clientes (par exemple, un client de base de données) de communiquer avec un serveur via un socket. Comme indiqué précédemment, les sockets IPC locaux et les sockets réseau ne diffèrent que par quelques détails de mise en œuvre ; en général, les sockets IPC ont une surcharge inférieure et de meilleures performances. L'API de communication est essentiellement la même pour les deux.

Signaux

Un signal interrompt un programme en cours d'exécution et, dans ce sens, communique avec lui. La plupart des signaux peuvent être ignorés (bloqués) ou gérés (via un code désigné), avec SIGSTOP (pause) et SIGKILL (mettre fin immédiatement) comme les deux exceptions notables. Constantes symboliques telles que SIGKILL ont des valeurs entières, dans ce cas, 9.

Des signaux peuvent survenir lors de l'interaction de l'utilisateur. Par exemple, un utilisateur appuie sur Ctrl+C à partir de la ligne de commande pour terminer un programme démarré à partir de la ligne de commande ; Ctrl+C génère un SIGTERM signal. SIGTERM pour terminer , contrairement à SIGKILL , peuvent être bloqués ou manipulés. Un processus peut également en signaler un autre, faisant ainsi des signaux un mécanisme IPC.

Considérez comment une application multi-traitement telle que le serveur Web Nginx peut être arrêtée correctement à partir d'un autre processus. Le tuer fonction :

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

peut être utilisé par un processus pour terminer un autre processus ou groupe de processus. Si le premier argument de la fonction kill est supérieur à zéro, cet argument est traité comme le pid (process ID) du processus ciblé ; si l'argument est zéro, l'argument identifie le groupe de processus auquel appartient l'émetteur du signal.

Le deuxième argument pour tuer est soit un numéro de signal standard (par exemple, SIGTERM ou SIGKILL ) ou 0, ce qui rend l'appel au signal une requête pour savoir si le pid dans le premier argument est bien valide. L'arrêt progressif d'une application multi-traitement pourrait donc être accompli en envoyant un terminate signal—un appel au tuer fonction avec SIGTERM comme deuxième argument - au groupe de processus qui composent l'application. (Le processus maître Nginx pourrait mettre fin aux processus de travail avec un appel à kill puis quittez-le.) Le tuer fonction, comme tant de fonctions de bibliothèque, abrite puissance et flexibilité dans une syntaxe d'invocation simple.

Exemple 3. L'arrêt progressif d'un système multitraitement

#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 */
}

La fermeture Le programme ci-dessus simule l'arrêt progressif d'un système multi-traitement, dans ce cas, un simple composé d'un processus parent et d'un processus enfant unique. La simulation fonctionne comme suit :

  • Le processus parent essaie de forker un enfant. Si le fork réussit, chaque processus exécute son propre code :le fils exécute la fonction child_code , et le parent exécute la fonction parent_code .
  • Le processus enfant entre dans une boucle potentiellement infinie dans laquelle l'enfant dort pendant une seconde, imprime un message, se rendort, etc. C'est précisément un SIGTERM signal du parent qui amène l'enfant à exécuter la fonction de rappel de gestion du signal graceful . Le signal brise ainsi le processus enfant hors de sa boucle et met en place la terminaison gracieuse à la fois de l'enfant et du parent. L'enfant imprime un message avant de terminer.
  • Le processus parent, après avoir forké l'enfant, dort pendant cinq secondes afin que l'enfant puisse s'exécuter pendant un certain temps ; bien sûr, l'enfant dort la plupart du temps dans cette simulation. Le parent appelle alors le kill fonction avec SIGTERM comme deuxième argument, attend que l'enfant se termine, puis quitte.

Voici le résultat d'un exemple d'exécution :

% ./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...

Pour la gestion du signal, l'exemple utilise la sigaction fonction de bibliothèque (POSIX recommandée) plutôt que l'ancien signal fonction, qui a des problèmes de portabilité. Voici les segments de code les plus intéressants :

  • Si l'appel à fourcher réussit, le parent exécute le parent_code fonction et l'enfant exécute le child_code une fonction. Le parent attend cinq secondes avant de signaler à l'enfant :
    puts("Parent sleeping for a time...");
    sleep(5);
    if (-1 == kill(cpid, SIGTERM)) {
    ...

    Si le tuer l'appel réussit, le parent fait une attente à la résiliation de l'enfant pour éviter que l'enfant ne devienne un zombie permanent ; après l'attente, le parent quitte.

  • Le code_enfant la fonction appelle d'abord set_handler puis entre dans sa boucle de sommeil potentiellement infinie. Voici le set_handler fonction à revoir :
    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 */
    }

    Les trois premières lignes sont la préparation. La quatrième instruction définit le gestionnaire sur la fonction graceful , qui imprime certains messages avant d'appeler _exit Terminer. La cinquième et dernière instruction enregistre ensuite le gestionnaire auprès du système via l'appel à sigaction . Le premier argument de sigaction est SIGTERM pour terminer , la seconde est la sigaction actuelle setup, et le dernier argument (NULL dans ce cas) peut être utilisé pour enregistrer une sigaction précédente configuration, peut-être pour une utilisation ultérieure.

L'utilisation de signaux pour l'IPC est en effet une approche minimaliste, mais qui a fait ses preuves. L'IPC via les signaux appartient clairement à la boîte à outils IPC.

Conclusion de cette série

Ces trois articles sur IPC ont couvert les mécanismes suivants à travers des exemples de code :

  • Fichiers partagés
  • Mémoire partagée (avec sémaphores)
  • Tuyaux (nommés et sans nom)
  • Files d'attente de messages
  • Prises
  • Signaux

Même aujourd'hui, alors que les langages centrés sur les threads tels que Java, C # et Go sont devenus si populaires, IPC reste attrayant car la concurrence via le multi-traitement a un avantage évident sur le multi-threading :chaque processus, par défaut, a son propre espace d'adressage. , qui exclut les conditions de concurrence basées sur la mémoire dans le multitraitement à moins que le mécanisme IPC de la mémoire partagée ne soit mis en jeu. (La mémoire partagée doit être verrouillée à la fois en multi-traitement et en multi-threading pour une concurrence sûre.) Quiconque a écrit même un programme multi-threading élémentaire avec communication via des variables partagées sait à quel point il peut être difficile d'écrire thread-safe mais clair, code efficace. Le multitraitement avec des processus à un seul thread reste un moyen viable, voire assez attrayant, de tirer parti des machines multiprocesseurs d'aujourd'hui sans le risque inhérent de conditions de concurrence basées sur la mémoire.

Il n'y a pas de réponse simple, bien sûr, à la question de savoir lequel des mécanismes de la CIB est le meilleur. Chacun implique un compromis typique en programmation :simplicité contre fonctionnalité. Les signaux, par exemple, sont un mécanisme IPC relativement simple mais ne prennent pas en charge les conversations riches entre les processus. Si une telle conversion est nécessaire, alors l'un des autres choix est plus approprié. Les fichiers partagés avec verrouillage sont relativement simples, mais les fichiers partagés peuvent ne pas fonctionner suffisamment bien si les processus doivent partager des flux de données massifs ; des pipes ou même des sockets, avec des API plus compliquées, pourraient être un meilleur choix. Laissez le problème à résoudre guider le choix.

Bien que l'exemple de code (disponible sur mon site Web) soit entièrement en C, d'autres langages de programmation fournissent souvent des wrappers légers autour de ces mécanismes IPC. Les exemples de code sont suffisamment courts et simples, je l'espère, pour vous encourager à expérimenter.


Linux
  1. Présentation du guide de communication inter-processus sous Linux

  2. Communication inter-processus sous Linux :utilisation de canaux et de files d'attente de messages

  3. Communication inter-processus sous Linux :stockage partagé

  4. Surveiller le serveur Linux en utilisant Prometheus et Grafana

  5. Comment installer et configurer le serveur et le client Linux NTP

ReaR :sauvegardez et restaurez votre serveur Linux en toute confiance

Comment installer et configurer un serveur NFS sur un système Linux

Jenkins Server sous Linux :un serveur d'automatisation gratuit et open source

4 étapes faciles pour installer et configurer VMware Server 2 sur Linux

Comment installer et configurer le serveur DNS sous Linux

Top 20 des meilleurs logiciels et solutions de serveur de messagerie Linux