Nel primo articolo abbiamo analizzato il funzionamento delle socket e di un server con rispettivo client. In questo vedremo un modo di programmare un tipico server concorrente utilizzando la funzione fork().

Prima di addentrarci nel codice del server concorrente, vediamo un po’ come funziona la tecnica di forking.

Forking

Schema del forking di un'applicazione

Il forking consente di effettuare delle copie di un processo. Il processo che genera copie di se stesso si chiama processo padre, mentre la copia viene chiamata processo figlio. Fork in inglese significa “forchetta”, il termine non rispecchia altro che il comportamento di questa operazione. Infatti si può associare al manico di una forchetta il processo padre e ad ogni rebbio si può associare un processo figlio.

Bisogna fare attenzione ad usare il forking. Il processo quando genera un figlio, le variabili ed altre risorse non vengono condivise.

Qui a lato è mostrato uno schema che riassume sinteticamente il funzionamento di questa tecnica. In un certo punto del programma si effettua una chiamata alla funzione fork che divide il programma in due, tale funzione:

  • se ha esito negativo restituisce -1, in questo caso non viene generato alcun processo figlio ed errno viene settata;
  • se ha esito positivo restituisce 0 al processo figlio, mentre al processo padre viene restituito il PID (Process ID) del processo figlio.

Dato che non è scopo dell’articolo illustrare nel dettaglio la tecnica di forking, si rimanda al seguente link [in inglese] per eventuali approfondimenti.

Esempio di forking del processo

Di seguito è presente un piccolo codice che metterà in pratica il concetto di forking. Nel codice c’è una variabile che apparentemente sembra essere condivisa.

#include < stdio.h >
#include < stdlib.h >
#include < unistd.h >    /* fork */
#include < sys/types.h >    /* pid_t */
int main(void) {
	pid_t pid; /* ID del processo figlio*/
	int i;

	pid = fork();

	if(pid == 0) {	/* Processo figlio */
		for(i = 2; i <= 20; i+=2) {
 			printf("Processo figlio:\t%d\n", i);
 			sleep(1);
 		}
 	} else if(pid > 0) {	/* Processo padre */
		for(i = 1; i <= 10; i++) {
			printf("Processo padre: %d\n", i);
			sleep(1);
		}
	} else {	/* Errore */
		fprintf(stderr, "Errore nel fork\n");
		exit(-1);
	}

	return 0;
}

Il codice crea un processo figlio che calcola la tabellina del due, mentre il padre conta fino a 10. I due processi sono eseguiti in parallelo, per capire meglio che sono due processi distinti, nel codice, la variabile i è usata sia dal padre che dal figlio senza entrare in conflitto. Infatti nel momento che si effettua la chiamata fork i due processi sono allocati in due zone di memoria diverse, quindi ogni processo avrà la sua variabile i.

Di seguito c’è l’output del programma eseguito sul mio computer.

alessandro@debian-vm:~/Desktop$ gcc -std=c89 fork.c -o fork
alessandro@debian-vm:~/Desktop$ ./fork
Processo figlio:        2
Processo padre: 1
Processo padre: 2
Processo figlio:        4
Processo figlio:        6
Processo padre: 3
Processo figlio:        8
Processo padre: 4
Processo padre: 5
Processo figlio:        10
Processo figlio:        12
Processo padre: 6
Processo figlio:        14
Processo padre: 7
Processo padre: 8
Processo figlio:        16
Processo figlio:        18
Processo padre: 9
Processo figlio:        20
Processo padre: 10
alessandro@debian-vm:~/Desktop$

L’ordine nel quale vengono eseguiti i processi viene stabilito dallo scheduler.

Server concorrente

Un server concorrente è un server che ha la possibilità di accettare più connessioni contemporanee da parte dei client. La base del funzionamento è la stessa del server sequenziale (per approfondimenti si veda l’articolo precedente), ma con una lieve modifica:

  1. Creare una socket
  2. Effettuare il binding della socket
  3. Mettere in ascolto la socket
  4. Accettare la connessione da parte del client
  5. Copia del processo tramite fork()
  6. Effettuare la comunicazione tramite le funzioni recv() e send

Il passo scritto in neretto è quello che si andrà ad analizzare più avanti in questo articolo. Il server concorrente appena accetta una connessione da parte del client si sdoppia.

Il codice

Il codice cambia rispetto a quello dell’articolo precedente in due parti:

  1. Il server è ovviamente gestito diversamente perché genera i processi figlio.
  2. Ogni client può dialogare con il server inviando più messaggi. Per disconnettere il client dal server è possibile farlo inviando la stringa EXIT.

Se si vuole mettere il server in ascolto per più client, si deve creare quindi un loop. Si può fare in questo modo:

while(1) {
	sd_client = accept(sd_server, (struct sockaddr *)&client_addr, &address_size);
	if(sd_client < 0) {
		printf("Errore nella chiamata accept\n");
		exit(-1);
	}
}

Ora c’è bisogno di dividere il processo principale, ogni processo figlio gestirà la comunicazione con il rispettivo client:

pid = fork();

if(pid == 0) { /* figlio */
} else if(pid > 0) { /* padre */
	close(sd_client);
} else {
	printf("Errore nella creazione del figlio\n");
	exit(-1);
}

Il server accetta la chiamata da parte del client e si divide. Al processo padre ora non serve più la socket del client, perché verrà gestita dal processo figlio. Non può mancare anche la visualizzazione di un messaggio di errore se fallisce il forking del processo. Il codice a questo punto è il seguente:

while(1) {
	sd_client = accept(sd_server, (struct sockaddr *)&client_addr, &address_size);
	if(sd_client < 0) { 		 		printf("Errore nella chiamata accept\n");  		exit(-1);  	}  	pid = fork();  	if(pid == 0) { /* figlio */  	} else if(pid > 0) { /* padre */
		close(sd_client);
	} else {
		printf("Errore nella creazione del figlio\n");
		exit(-1);
	}
}

Si passa ora alla gestione del client, all’interno del blocco if si inseriscono le seguenti righe:

if(pid == 0) { /* figlio */
	close(sd_server);
	recv(sd_client, buff, sizeof(buff), 0);
	printf("Dati ricevuti: %s", buff);
	strcpy(buff, "Tutto OK!\n");
	send(sd_client, buff, strlen(buff), 0);
	close(sd_client);
	exit(0);
}

La socket del server è possibile chiuderla anche dopo (si ricorda che non è la stessa socket del processo padre, ma semplicemente una copia). Effettuate le operazioni di ricezione ed invio si chiude la socket del client ed il processo figlio, utilizzando exit(0).

Il server è finito, per completarlo si potrebbe rendere ciclico il dialogo con il client:

if(pid == 0) { /* figlio */
	close(sd_server);

	while(1) {
		memset(buff, '\0', MAX);	/* si cancellano i dati ricevuti in precedenza */
		recv(sd_client, buff, sizeof(buff), 0);
		printf("Dati ricevuti: %s", buff);
		strcpy(buff, "Tutto OK!\n");
		send(sd_client, buff, strlen(buff), 0);
	}			

	close(sd_client); /* disconnessione */
	exit(0); /* chiude il processo */
}

Bene, si hanno un client ed un server che interagiscono ciclicamente, l’unico problema è che andranno avanti fino all’infinito finché non cade la connessione o arrestando forzatamente uno dei due processi. Per rimediare a questo, basta inserire un controllo nel while:

if(pid == 0) { /* figlio */
	close(sd_server);
	char tmp_string[MAX]; /* servirà per capire se chiudere o meno la connessione con il client */

	while(strcmp(tmp_string, "EXIT\r\n") != 0) { /* se si riceve il comando EXIT, termina il ciclo */
		memset(buff, '\0', MAX);	/* si cancellano i dati ricevuti in precedenza */
		memset(tmp_string, '\0', MAX);
		recv(sd_client, buff, sizeof(buff), 0);
		strcpy(tmp_string, buff);
		printf("Dati ricevuti: %s", buff);
		strcpy(buff, "Tutto OK!\n");
		send(sd_client, buff, strlen(buff), 0);
	}

	close(sd_client); /* disconnessione */
	exit(0); /* chiude il processo */
}

Quando al server arriva la stringa EXIT, il client viene disconnesso ed il server si mette in ascolto per un’altra connessione. Avrete notato che manca il codice del client, infatti per collegarsi al server basta eseguire da terminale il seguente comando:

telnet 127.0.0.1 1745

Provate ad aprire più connessioni con telnet e vedere come le gestisce il server. Questo è tutto, alla prossima!

Scarica il codice completo.

6 commenti
  1. LtWorf
    LtWorf dice:

    C’è un problema: non vengono menzionati i wait, e quindi un uso prolungato di un server che fa fork senza mai fare wait tenderà a riempire la tabella dei processi di zombie, con conseguente impossibilità di avviare ulteriori processi e nel caso estremo il blocco del sistema.

    Si deve ascoltare il segnale SIGCHILD che viene inviato quando un figlio termina ed eseguire una wait durante la sua gestione, per eliminare il processo zombie dalla tabella dei processi.

    Rispondi
  2. Alessandro Iezzi
    Alessandro Iezzi dice:

    @LtWorf
    Ciao, so benissimo che genererà zombie fino a quando non verrà fermato il server.
    Purtroppo, non è un corso di sistemi operativi e l’articolo vuole far capire a grandi linee come funziona un server concorrente.
    Comunque può essere lo spunto per un prossimo articolo… 😀

    Rispondi
    • Antonio Barba
      Antonio Barba dice:

      LtWorf hai assolutamente ragione 🙂

      Per utilizzi reali questo codice non va bene. Diciamo che è un proof of concept, per illustrare i fondamenti. Il target di queste guide comunque non è il programmatore avanzato, ma l’amministratore di sistema che vuole approfondire un po’ la propria conoscenza sul funzionamento dei programmi che utilizza ogni giorno 🙂

      Rispondi

Lascia un Commento

Vuoi partecipare alla discussione?
Sentitevi liberi di contribuire!

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *