GNU/Linux >> Linux Esercitazione >  >> Linux

Comunicazione tra processi in Linux:socket e segnali

Questo è il terzo e ultimo articolo di una serie sulla comunicazione interprocesso (IPC) in Linux. Il primo articolo è incentrato sull'IPC tramite l'archiviazione condivisa (file e segmenti di memoria) e il secondo articolo fa lo stesso per i canali di base:pipe (con nome e senza nome) e code di messaggi. Questo articolo passa da IPC di fascia alta (prese) a IPC di fascia bassa (segnali). Gli esempi di codice arricchiscono i dettagli.

Prese

Proprio come le pipe sono disponibili in due versioni (con nome e senza nome), così anche le prese. I socket IPC (noti anche come socket di dominio Unix) consentono la comunicazione basata sul canale per i processi sullo stesso dispositivo fisico (host ), mentre i socket di rete abilitano questo tipo di IPC per processi che possono essere eseguiti su host diversi, mettendo così in gioco la rete. I socket di rete necessitano del supporto di un protocollo sottostante come TCP (Transmission Control Protocol) o UDP (User Datagram Protocol) di livello inferiore.

Al contrario, i socket IPC si basano sul kernel del sistema locale per supportare la comunicazione; in particolare, i socket IPC comunicano utilizzando un file locale come indirizzo socket. Nonostante queste differenze di implementazione, le API del socket IPC e del socket di rete sono le stesse nell'essenziale. Il prossimo esempio copre i socket di rete, ma il server di esempio e i programmi client possono essere eseguiti sulla stessa macchina poiché il server utilizza l'indirizzo di rete localhost (127.0.0.1), l'indirizzo della macchina locale sulla macchina locale.

I socket configurati come flussi (discussi di seguito) sono bidirezionali e il controllo segue uno schema client/server:il client avvia la conversazione tentando di connettersi a un server, che tenta di accettare la connessione. Se tutto funziona, le richieste dal client e le risposte dal server possono quindi fluire attraverso il canale fino a quando questo non viene chiuso su entrambe le estremità, interrompendo così la connessione.

[Scarica la guida completa alla comunicazione tra processi in Linux]

Un iterativo il server, adatto solo allo sviluppo, gestisce i client connessi uno alla volta fino al completamento:il primo client viene gestito dall'inizio alla fine, poi il secondo e così via. Lo svantaggio è che la gestione di un particolare cliente potrebbe bloccarsi, il che fa morire di fame tutti i clienti che aspettano dietro. Un server di livello produttivo sarebbe simultaneo , in genere utilizzando un mix di multi-elaborazione e multi-threading. Ad esempio, il server Web Nginx sulla mia macchina desktop ha un pool di quattro processi di lavoro in grado di gestire le richieste dei client contemporaneamente. L'esempio di codice seguente riduce al minimo il disordine usando un server iterativo; l'attenzione rimane quindi sull'API di base, non sulla concorrenza.

Infine, l'API socket si è evoluta in modo significativo nel tempo poiché sono emersi vari perfezionamenti POSIX. L'attuale codice di esempio per server e client è volutamente semplice ma sottolinea l'aspetto bidirezionale di una connessione socket basata su flusso. Ecco un riepilogo del flusso di controllo, con il server avviato in un terminale e poi il client in un terminale separato:

  • Il server attende le connessioni client e, data una connessione riuscita, legge i byte dal client.
  • Per sottolineare la conversazione bidirezionale, il server restituisce al client i byte ricevuti dal client. Questi byte sono codici di caratteri ASCII, che costituiscono i titoli dei libri.
  • Il client scrive i titoli dei libri nel processo del server e quindi legge gli stessi titoli ripresi dal server. Sia il server che il client stampano i titoli sullo schermo. Ecco l'output del server, essenzialmente lo stesso del client:
    Listening on port 9876 for clients...
    War and Peace
    Pride and Prejudice
    The Sound and the Fury

Esempio 1. Il server 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;
}

Il programma server di cui sopra esegue i classici quattro passaggi per prepararsi alle richieste dei client e quindi per accettare le singole richieste. Ogni passaggio prende il nome da una funzione di sistema che il server chiama:

  1. presa(...) :ottieni un descrittore di file per la connessione socket
  2. lega(...) :associa il socket a un indirizzo sull'host del server
  3. ascolta(...) :ascolta le richieste dei clienti
  4. accetta(...) :accetta una particolare richiesta del cliente

La presa la chiamata per intero è:

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

Il primo argomento specifica un socket di rete anziché un socket IPC. Esistono diverse opzioni per il secondo argomento, ma SOCK_STREAM e SOCK_DGRAM (datagramma) sono probabilmente i più utilizzati. Un basato sullo streaming socket supporta un canale affidabile in cui vengono riportati i messaggi persi o alterati; il canale è bidirezionale e i carichi utili da un lato all'altro possono essere di dimensioni arbitrarie. Al contrario, un socket basato su datagram non è affidabile (best try ), unidirezionale e richiede carichi utili di dimensioni fisse. Il terzo argomento per socket specifica il protocollo. Per il socket basato sul flusso in gioco qui, c'è un'unica scelta, che rappresenta lo zero:TCP. Perché una chiamata riuscita a socket restituisce il noto descrittore di file, un socket viene scritto e letto con la stessa sintassi, ad esempio, di un file locale.

Il vincolo call è la più complicata, poiché riflette vari perfezionamenti nell'API socket. Il punto di interesse è che questa chiamata associa il socket a un indirizzo di memoria sulla macchina server. Tuttavia, ascolta la chiamata è semplice:

if (listen(fd, MaxConnects) < 0)

Il primo argomento è il descrittore di file del socket e il secondo specifica quante connessioni client possono essere gestite prima che il server emetta una connessione rifiutata errore su un tentativo di connessione. (Collegamenti massimi è impostato su 8 nel file di intestazione sock.h .)

L'accetta l'impostazione predefinita della chiamata è un attesa di blocco :il server non fa nulla finché un client non tenta di connettersi e poi procede. L'accetta la funzione restituisce -1 per indicare un errore. Se la chiamata riesce, restituisce un altro descrittore di file, per una lettura/scrittura presa in contrasto con accettare socket a cui fa riferimento il primo argomento in accetta chiamata. Il server utilizza il socket di lettura/scrittura per leggere le richieste dal client e per riscrivere le risposte. Il socket di accettazione viene utilizzato solo per accettare connessioni client.

In base alla progettazione, un server funziona a tempo indeterminato. Di conseguenza, il server può essere terminato con Ctrl+C dalla riga di comando.

Esempio 2. Il 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;
}

Il codice di installazione del programma client è simile a quello del server. La principale differenza tra i due è che il client non ascolta né accetta, ma invece si connette:

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

La connessione la chiamata potrebbe non riuscire per diversi motivi; ad esempio, il client ha l'indirizzo del server sbagliato o troppi client sono già connessi al server. Se connetti operazione riesce, il client scrive le richieste e quindi legge le risposte ripetute in un for ciclo continuo. Dopo la conversazione, sia il server che il client chiudono il socket di lettura/scrittura, sebbene un'operazione di chiusura su entrambi i lati sia sufficiente per chiudere la connessione. Il client esce in seguito ma, come notato in precedenza, il server rimane aperto per le attività.

L'esempio del socket, con i messaggi di richiesta restituiti al client, suggerisce le possibilità di conversazioni arbitrariamente ricche tra il server e il client. Forse questo è il principale fascino delle prese. È comune sui sistemi moderni che le applicazioni client (ad esempio, un client di database) comunichino con un server tramite un socket. Come notato in precedenza, i socket IPC locali e i socket di rete differiscono solo per alcuni dettagli di implementazione; in generale, i socket IPC hanno un sovraccarico inferiore e prestazioni migliori. L'API di comunicazione è essenzialmente la stessa per entrambi.

Segnali

Un segnale interrompe un programma in esecuzione e, in questo senso, comunica con esso. La maggior parte dei segnali può essere ignorata (bloccata) o gestita (tramite codice designato), con SIGSTOP (pausa) e SIGKILL (cessare immediatamente) come le due eccezioni degne di nota. Costanti simboliche come SIGKILL hanno valori interi, in questo caso 9.

I segnali possono sorgere nell'interazione dell'utente. Ad esempio, un utente preme Ctrl+C dalla riga di comando per terminare un programma avviato dalla riga di comando; Ctrl+C genera un SIGTERM segnale. SIGTERM per terminare , a differenza di SIGKILL , può essere bloccato o gestito. Un processo può anche segnalarne un altro, trasformando così i segnali in un meccanismo IPC.

Considera come un'applicazione multi-elaborazione come il server Web Nginx potrebbe essere chiusa con grazia da un altro processo. L'uccisione funzione:

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

può essere utilizzato da un processo per terminare un altro processo o gruppo di processi. Se il primo argomento a funzionare kill è maggiore di zero, questo argomento viene trattato come pid (ID processo) del processo mirato; se l'argomento è zero, l'argomento identifica il gruppo di processi a cui appartiene il mittente del segnale.

Il secondo argomento per uccidere è un numero di segnale standard (ad es. SIGTERM o SIGKILL ) o 0, che effettua la chiamata al segnale una domanda se il pid nel primo argomento è effettivamente valido. L'arresto regolare di un'applicazione multi-elaborazione potrebbe quindi essere ottenuto inviando un termina segnale:una chiamata all'uccisione funzione con SIGTERM come secondo argomento, al gruppo di processi che compongono l'applicazione. (Il processo master Nginx potrebbe terminare i processi di lavoro con una chiamata a kill e poi esci da solo.) L'uccisione funzione, come tante funzioni di libreria, racchiude potenza e flessibilità in una semplice sintassi di invocazione.

Esempio 3. Lo spegnimento regolare di un sistema multiprocessing

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

Lo arresto il programma sopra simula l'arresto regolare di un sistema multi-elaborazione, in questo caso, uno semplice costituito da un processo padre e un processo figlio singolo. La simulazione funziona come segue:

  • Il processo padre tenta di eseguire il fork di un figlio. Se il fork riesce, ogni processo esegue il proprio codice:il figlio esegue la funzione child_code e il genitore esegue la funzione parent_code .
  • Il processo figlio entra in un ciclo potenzialmente infinito in cui il bambino dorme per un secondo, stampa un messaggio, torna a dormire e così via. È precisamente un SIGTERM segnale del genitore che fa sì che il bambino esegua la funzione di callback di gestione del segnale aggraziata . Il segnale interrompe così il processo figlio dal suo ciclo e imposta la terminazione aggraziata sia del bambino che del genitore. Il bambino stampa un messaggio prima di terminare.
  • Il processo genitore, dopo aver biforcato il bambino, dorme per cinque secondi in modo che il bambino possa essere eseguito per un po'; ovviamente, il bambino dorme principalmente in questa simulazione. Il genitore quindi chiama l'uccisione funzione con SIGTERM come secondo argomento, attende che il figlio termini, quindi esce.

Ecco l'output di un'analisi di esempio:

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

Per la gestione del segnale, l'esempio utilizza la sigazione funzione di libreria (consigliato POSIX) anziché il segnale legacy funzione, che presenta problemi di portabilità. Ecco i segmenti di codice di maggiore interesse:

  • Se la chiamata a fork riesce, il genitore esegue il codice_parent funzione e il bambino esegue il codice_figlio funzione. Il genitore attende cinque secondi prima di segnalare al bambino:
    puts("Parent sleeping for a time...");
    sleep(5);
    if (-1 == kill(cpid, SIGTERM)) {
    ...

    Se l'uccisione la chiamata riesce, il genitore fa un attesa sulla cessazione del bambino per evitare che il bambino diventi uno zombi permanente; dopo l'attesa, il genitore esce.

  • Il codice_figlio la funzione prima chiama set_handler e poi entra nel suo ciclo del sonno potenzialmente infinito. Ecco il set_handler funzione per la revisione:
    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 */
    }

    Le prime tre righe sono la preparazione. La quarta istruzione imposta il gestore sulla funzione aggraziato , che stampa alcuni messaggi prima di chiamare _exit terminare. La quinta e ultima istruzione registra quindi il gestore con il sistema tramite la chiamata a sigaction . Il primo argomento per sigazione è SIGTERM per terminare , il secondo è l'attuale sigazione setup e l'ultimo argomento (NULL in questo caso) può essere utilizzato per salvare una precedente sigazione configurazione, forse per un uso successivo.

L'uso dei segnali per IPC è davvero un approccio minimalista, ma per giunta collaudato. L'IPC attraverso i segnali appartiene chiaramente alla cassetta degli attrezzi dell'IPC.

Concludendo questa serie

Questi tre articoli su IPC hanno trattato i seguenti meccanismi attraverso esempi di codice:

  • File condivisi
  • Memoria condivisa (con semafori)
  • Tubi (con nome e senza nome)
  • Code di messaggi
  • Prese
  • Segnali

Anche oggi, quando i linguaggi incentrati sui thread come Java, C# e Go sono diventati così popolari, IPC rimane interessante perché la concorrenza attraverso il multi-processing ha un ovvio vantaggio rispetto al multi-threading:ogni processo, per impostazione predefinita, ha il proprio spazio di indirizzi , che esclude race condition basate sulla memoria nel multi-processing a meno che non venga attivato il meccanismo IPC della memoria condivisa. (La memoria condivisa deve essere bloccata sia in multi-elaborazione che in multi-threading per una concorrenza sicura.) Chiunque abbia scritto anche un semplice programma multi-threading con comunicazione tramite variabili condivise sa quanto può essere difficile scrivere thread-safe ma chiaro, codice efficiente. L'elaborazione multipla con processi a thread singolo rimane un modo praticabile, anzi piuttosto interessante, per sfruttare le macchine multiprocessore di oggi senza il rischio intrinseco delle condizioni di competizione basate sulla memoria.

Non esiste una risposta semplice, ovviamente, alla domanda su quale tra i meccanismi IPC sia il migliore. Ciascuno comporta un compromesso tipico nella programmazione:semplicità e funzionalità. I segnali, ad esempio, sono un meccanismo IPC relativamente semplice ma non supportano conversazioni complesse tra processi. Se è necessaria una tale conversione, allora una delle altre scelte è più appropriata. I file condivisi con blocco sono ragionevolmente semplici, ma i file condivisi potrebbero non funzionare abbastanza bene se i processi devono condividere enormi flussi di dati; pipe o anche socket, con API più complicate, potrebbero essere una scelta migliore. Lascia che il problema in questione guidi la scelta.

Sebbene il codice di esempio (disponibile sul mio sito Web) sia tutto in C, altri linguaggi di programmazione spesso forniscono sottili wrapper attorno a questi meccanismi IPC. Gli esempi di codice sono abbastanza brevi e semplici, spero, per incoraggiarti a sperimentare.


Linux
  1. Presentazione della guida alla comunicazione tra processi in Linux

  2. Comunicazione tra processi in Linux:utilizzo di pipe e code di messaggi

  3. Comunicazione tra processi in Linux:archiviazione condivisa

  4. Monitora il server Linux con Prometheus e Grafana

  5. Copia di utenti e password Linux su un nuovo server

ReaR:esegui il backup e il ripristino del tuo server Linux in tutta sicurezza

Come installare e configurare un server NFS su un sistema Linux

Jenkins Server su Linux:un server di automazione gratuito e open source

4 semplici passaggi per installare e configurare VMware Server 2 su Linux

Come installare e configurare il server DNS in Linux

I 20 migliori software e soluzioni per server di posta Linux