Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

lunedì 11 luglio 2022

ZeroMQ Festival
come si usa ZeroMQ in C - pt.2

Sue: Hai sentito che il film di Philippe ha vinto il primo premio al Festival di Colonia, oggi?.
Mort: Molto eccitante. Ma Eichmann non era di lì?

E va bene, questa volta ci siamo, è il turno della seconda parte di Rifkin's Festival (oops: ZeroMQ Festival), come promesso nella prima parte dell'articolo. Qui sopra vi ho proposto un commento secco e lapidario di Mort (Wallace Shawn), il protagonista alter-ego di Woody Allen in questo bel film, un commento che ben introduce questo articolo: dopo aver parlato dell'interfaccia a ZeroMQ di "basso livello", e cioè la libreria libzmq, è il momento di parlare dell'interfaccia ad "alto livello", la CZMQ (libczmq). Grazie a CZMQ è possibile scrivere codice di rete sintetico e compatto (anzi, secco e lapidario) anche per problemi complessi, problemi che richiederebbero la scrittura di codice lungo e complicato anche aiutandosi con ZeroMQ low-level (e lunghissimo e complicatissimo usando, ad esempio, i classici BSD Socket).

...crepi l'avarizia, oggi ti offrirò un menu high-level...

Allora: CZMQ è una interfaccia C ma è strutturata come se fosse una libreria di classi (nel manuale il termine "class" viene usato frequentemente). Comunque niente paura per chi non è avvezzo al C++: è una interfaccia C al 100%. E questa è la descrizione che danno gli autori stessi della libreria nel manuale:

CZMQ ha questi obiettivi:

  • "wrappare" l'API core di ØMQ in una semantica che sia naturale e porti ad applicazioni più brevi e leggibili.
  • Nascondere le differenze tra le versioni di ØMQ.
  • Fornire uno spazio per lo sviluppo di semantiche API più sofisticate.

E quali sono, in pratica, i vantaggi dell'uso di CZMQ? Facciamo un caso reale: se, per esempio, volessimo risolvere un problema complicato come scrivere un Load Balancing Broker  che segua lo schema di funzionamento seguente (copio una descrizione di questo oggetto da 0MQ - The Guide):

lbbroker

grazie a CZMQ potremmo farlo in maniera abbastanza sintetica e leggibile. Mentre se provassimo a farlo con altri strumenti ci renderemmo subito conto della grande differenza a livello di lunghezza e complicazione. Per quanto riguarda la realizzazione pratica di questo LBB (visto che non amo fare il copia-incolla di cose non mie) vi passo direttamente due link dove viene descritto il codice corrispondente, un doppio codice scritto usando le due librerie (low e high-level): se gli date un occhiata noterete che con CZMQ è decisamente più semplificato e leggibile. Ah, e vi consiglio un utile esercizio: provate a riscrivere (o, perlomeno, a immaginare) lo stesso codice scritto con i BSD Socket... tremo solo a pensarci. Ecco i link: versione con lzmq e versione con CZMQ.

E adesso? Ma non posso chiudere un articolo senza proporre nessun codice mio! Quindi ho pensato che per ben descrivere la differenza di approccio tra low-level e high-level è meglio partire dalle basi, e allora vi propongo un esempio semplicissimo di codice Client/Server scritto in tre versioni, BSD Socket, ZeroMQ low-level e CZMQ.

Ho scritto tutti gli esempi a mo' di codice di test (e non di produzione), quindi sono (ahimè) completamente assenti le importantissime istruzioni di trattamento degli errori (praticamente sono gli scheletri funzionali da completare per entrare in produzione): questo con l'obiettivo di non sviare l'attenzione dal flusso base del codice, quello che ci mostra come si lavora con una libreria rispetto a un'altra. Tutti gli esempi sono composti da due file, client.c e server.c, e sono perfettamente compilabili e funzionanti. Ok, cominciamo con i BSD Socket, vai col codice!

// client.c - un semplice client con BSD Socket
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

// main() - funzione main
int main(void)
{
// creo il socket
printf ("Connessione al server...\n");
int cli_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

// preparo la struttura sockaddr_in per il server remoto
struct sockaddr_in server;
memset(&server, 0, sizeof(struct sockaddr_in));
server.sin_family = AF_INET; // set famiglia di indirizzi
server.sin_addr.s_addr = inet_addr("127.0.0.1");// set indirizzo del server
server.sin_port = htons(5555); // set port del server

// connetto il socket al server remoto
connect(cli_sock, (struct sockaddr *)&server, sizeof(server));

// loop di invio
int cnt = 0;
while (cnt++ < 10) {
// invio la richiesta
send(cli_sock, "Ping", 5, 0);

// ricevo la risposta
char string[10];
recv(cli_sock, string, sizeof(string), 0);
printf("risposta ricevuta: %s\n", string);
}

// disconnetto
close(cli_sock);
return 0;
}
// server.c - un semplice server con BSD Socket
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

// main() - funzione main
int main(void)
{
// creo il socket TCP
printf ("Avvio server...\n");
int srv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

// preparo la struttura sockaddr_in per questo server
struct sockaddr_in server;
memset(&server, 0, sizeof(struct sockaddr_in));
server.sin_family = AF_INET; // set famiglia di indirizzi
server.sin_addr.s_addr = INADDR_ANY; // set indirizzo del server
server.sin_port = htons(5555); // set port del server

// associo l'indirizzo del server al socket e start ascolto
bind(srv_sock, (struct sockaddr *)&server, sizeof(server));
listen(srv_sock, 10);

// accetto connessioni da un client entrante
socklen_t socksize = sizeof(struct sockaddr_in);
struct sockaddr_in client; // struttura sockaddr_in per il client remoto
int cli_sock = accept(srv_sock, (struct sockaddr *)&client, &socksize);
close(srv_sock);

// loop di ricezione
char string[10];
while (recv(cli_sock, string, sizeof(string), 0) != -1) {
printf("richiesta ricevuta: %s\n", string);

// invio la risposta
send(cli_sock, "Pong", 5, 0);
}

// disconnetto
close(cli_sock);
return 0;
}

E passiamo ora al codice in versione libzmq (ZeroMQ low-level):

// client.c - un semplice client ZeroMQ con libzmq
#include <stdio.h>
#include <zmq.h>

// main() - funzione main
int main(void)
{
// creo il context e il requester
printf ("Connessione al server...\n");
void *context = zmq_ctx_new();
void *requester = zmq_socket(context, ZMQ_REQ);

// connetto il requester al responder
zmq_connect(requester, "tcp://localhost:5555");

// loop di invio
int cnt = 0;
while (cnt++ < 10) {
// invio la richiesta
zmq_send(requester, "Ping", 5, 0);

// ricevo la risposta
char string[10];
zmq_recv(requester, string, sizeof(string), 0);
printf("risposta ricevuta: %s\n", string);
}

// disconnetto
zmq_close (requester);
zmq_ctx_destroy (context);
return 0;
}
// server.c - un semplice server ZeroMQ con libzmq
#include <stdio.h>
#include <zmq.h>

// main() - funzione main
int main(void)
{
// creo il context e il responder
printf ("Avvio server...\n");
void *context = zmq_ctx_new();
void *responder = zmq_socket(context, ZMQ_REP);

// associo l'indirizzo del responder al contesto
zmq_bind(responder, "tcp://*:5555");

// loop di ricezione
char string[10];
while (zmq_recv(responder, string, 10, 0) != -1) {
printf("richiesta ricevuta: %s\n", string);

// invio la risposta
zmq_send(responder, "Pong", 5, 0);
}

// disconnetto
zmq_close(responder);
zmq_ctx_destroy(context);
return 0;
}

E già questo codice dimostra che ZeroMQ è una libreria che permette di scrivere codice di rete molto semplificato. Ma con CZMQ possiamo snellirlo ancora di più (ma senza aspettarci miracoli: la libzmq già di per sé un passo avanti rispetto al vero low-level, che è rappresentato dai BSD Socket). Vai di nuovo col codice!

// client.c - un semplice client ZeroMQ con CZMQ
#include <stdio.h>
#include <czmq.h>

// main() - funzione main
int main(void)
{
// create requester
printf ("Connessione al server...\n");
zsock_t *requester = zsock_new_req("tcp://localhost:5555");

// loop di invio
int cnt = 0;
while (cnt++ < 10) {
// invio la richiesta
zstr_send(requester, "Ping");

// ricevo la risposta
char *string;
string = zstr_recv(requester);
printf("risposta ricevuta: %s\n", string);
zstr_free(&string);
}

// disconnetto
zsock_destroy(&requester);
return 0;
}
// server.c - un semplice server ZeroMQ con CZMQ
#include <stdio.h>
#include <czmq.h>

// main() - funzione main
int main(void)
{
// creo il responder
printf ("Avvio server...\n");
zsock_t *responder = zsock_new_rep("tcp://*:5555");

// loop di ricezione
char *string;
while ((string = zstr_recv(responder)) != NULL) {
printf("richiesta ricevuta: %s\n", string);
zstr_free(&string);

// invio la risposta
zstr_send(responder, "Pong");
}

// disconnetto
zsock_destroy(&responder);
return 0;
}

Che ne dite? La differenza tra le tre versioni, pur non essendo eclatante, è evidente, e si nota una filosofia di programmazione abbastanza diversa. E, a parte le considerazioni filosofiche, alla fin fine si può dire che nel percorso da BSD Socket a libzmq a CZMQ, le linee di codice vanno in diminuzione, e questo è il fattore molto importante che stavamo ricercando.

Ok, per oggi può bastare: ZeroMQ passa, a pieni voti, l'esame di "ottima libreria per Software di rete". Lo passa bene già con la libreria low-level e "più meglio" (ah ah ah) con CZMQ, la libreria high-level. Ma, nonostante questo, vi invito a non dimenticare che, sotto sotto (anche sotto ZeroMQ!), ci sono i mitici e insostituibili BSD Socket che possono essere ancora usati direttamente per scrivere ottimo e soddisfacente codice di rete, magari solo un po' più complicato (da scrivere e da leggere). De gustibus...

Ciao, e al prossimo post!

martedì 28 giugno 2022

System? No, grazie!
considerazioni sul perchè non usare la system(3) in C (e C++)

[Frase sulla t-shirt regalata a N.Van Orton/M.Douglas]: Sono stato drogato e lasciato per morto in Messico, e tutto quello che ho ottenuto è questa stupida maglietta.

Ebbene si, l'ho fatto un altra volta: nell'ultimo articolo avevo promesso una imminente seconda parte, e invece oggi parleremo d'altro (ma niente paura, la seconda parte su ZeroMQ arriverà presto! Fidatevi!). Succede che, come al solito, ottengo uno spunto da fatti reali (di solito per cose che faccio sul lavoro) e dico "Ma qui c'è materiale per un articolo!", per cui oggi parleremo di system(3). Uhm, ma abbiamo un film collegato? Mah, visto che appartiene alla serie dei "No Grazie!" (ah: ne ho scritti altri e vi invito a leggerli o rileggerli, qui, qui, qui e qui) non farò giochi di parole con il titolo, e quindi ne sceglierò uno a caso tra quelli che ho visto/rivisto ultimamente... si, The Game, va benissimo: è un bel film del grande David Fincher, e non può mancare nella lista di un Cinefilo. Anzi, una attinenza film/articolo c'è: system(3) è una funzione anti-pattern, quindi chi la usa troppo può ridursi come il protagonista della foto qui sotto...

...guardate come mi sono ridotto usando la system(3)...

system(3), è una funzione della libc usata e abusata: è molto comoda e semplice da usare e permette di fare cose, anche complicate, sfruttando applicazioni esterne: proprio questo è, allo stesso tempo, il suo maggior pregio e difetto. Chiariamolo subito: se avete bisogno di scrivere rapidamente una applicazione, diciamo, "sperimentale", o un programma di test (o testing) ad uso interno, o volete semplicemente verificare (rapidamente) se una idea è realizzabile, nessuno vi può criticare se usate la system(3). Ma per una applicazione reale che andrà in produzione è corretto usarla? E vabbè, la risposta è una sola:

NO, ASSOLUTAMENTE NO!

Ok, è ora di entrare nel dettaglio. Cosa è la system(3) e cosa permette di fare? Il manuale della glibc titola semplicemente questo:

system - execute a shell command

quindi ci permette di chiamare, dal nostro programma, un comando esterno del sistema operativo (o meglio: un comando nostro o del sistema operativo chiamabile tramite la Shell).

(...apro una parentesi: come al solito l'articolo è focalizzato sui sistemi POSIX, ma le note che seguono relative all'uso, pregi e difetti della system(3) sono perfettamente adattabili anche a Windows, dove la stessa funzione è disponibile, con l'unica differenza che usa CMD.EXE invece della UNIX Shell o della Linux Bash. Chiudo la parentesi...)

Per cui, ad esempio, se vogliamo che il nostro programma mostri che file sono contenuti in una directory  che si chiama "test" possiamo, molto semplicemente, usare system(3) per chiamare il comando "ls ./test" usando ls(1) di Linux. Facilissimo, no? Vediamo un piccolo esempio di codice che vi farà gioire (spoiler: e subito dopo ve lo smonterò!):

#include <stdlib.h>

// main() - funzione main
int main(int argc, char *argv[])
{
// uso system(3) per leggere il contenuto della directory ./test
system("ls ./test");

exit(EXIT_SUCCESS);
}

Ok, system(3) è indubbiamente comoda da usare, ma si porta dietro una lista di problemi così lunga che si potrebbe scriverne in un libro. Vediamone alcuni:

  1. Non è per nulla portabile: non è possibile garantire al 100% che su tutti i sistemi dove verrà installata la mia applicazione i comandi da eseguire con system(3) siano disponibili.
  2. Apre, come minimo, due processi supplementari: su Linux invoca bash(1) che, a sua volta, invoca il comando da eseguire (alla faccia dell'efficienza).
  3.  Quando si chiama la system(3) il programma principale viene sospeso fino al termine del comando invocato, e non c'è nessuna maniera di controllare efficacemente quello che sta succedendo.
  4. Questo, poi, lo dice direttamente il manuale della system(3): "During execution of the command, SIGCHLD will be blocked, and SIGINT and SIGQUIT will be ignored, in the process that calls system().", e questo non è una semplice descrizione di funzionamento, è la descrizione di un possibile problema.
  5. Anche questo, poi, lo dice direttamente il manuale della system(3): "Do not use system() from a privileged program (a set-user-ID or set-group-ID program, or a program with capabilities)". La sicurezza del sistema può essere molto compromessa se la stringa di comando viene ad una fonte esterna e può essere manipolata: il grande pericolo si chiama Shell Command Injection.
  6. Un altro buco di sicurezza è possibile se il sistema non è completamente sicuro (cosa frequente...) e un utente malintenzionato può, in qualche maniera, alterare il path di ricerca degli eseguibili o sostituire un eseguibile con un altro con lo stesso nome.
  7. Non c'è alcun modo di leggere direttamente l'output del comando eseguito nel programma che invoca la system(3).
  8. Problemi vari ed eventuali: la system(3) in un loop non controllato, la system(3) e la thread-safety problematica, la system(3) per eseguire un comando in background, ecc. Una lunga lista che vi risparmio.

Insomma, è un vero disastro. Come dite? Ah si, sento alcuni che dicono "A me i problemi della system(3) non interessano, tanto io uso la popen(3)!". Ok, ho delle cattive notizie: almeno la metà dei problemi elencati li ha anche la popen(3), che forse non è un disastro ma, direi, è un mezzo disastro, sai che consolazione...

Ah, solo come curiosità: nella lista dei problemi qui sopra quello che mi molesta di più è il numero 3: una applicazione che usa system(3) non ha maniera di controllare quello che sta succedendo con i programmi esterni invocati, e deve sospendersi per aspettare che finiscano l'esecuzione: ma questa vi sembra la descrizione di una buona applicazione da mandare in produzione? E vabbè, continuiamo cosi, facciamoci del male...

Quindi: che bisogna fare se abbiamo una attività buona per la system(3) ma non vogliamo usarla visto che siamo programmatori consapevoli? Abbiamo due soluzioni:

  1. La soluzione semplice ma accettabile: usare fork(2) + execv(3) + waitpid(2): è un po' una emulazione, un po' laboriosa, della system(3), che però evita i problemi elencati sopra: non usa la Shell, ci permette di mantenere il controllo sull'esecuzione, ci permette di evitare i problemi di shell-iniection, ecc. ecc.
  2. La soluzione complicata ma impeccabile: scrivere nel nostro programma il codice che vorremmo far eseguire esternamente con la system(3): in alcuni casi potrebbe risultare abbastanza laborioso, ma avremo tutto dentro il nostro codice, quindi avremo il controllo totale sia dell'esecuzione che della implementazione.

Che dite? Volete un esempio dei due casi? Magari con lo stesso tema di eseguire/emulare un comando "ls ./test"? Eccoli!

Caso 1: fork(2) + execv(3) + waitpid(2)

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

// funzione main()
int main(int argc, char* argv[])
{
pid_t pid = fork();
if (pid == 0) {
// sono il figlio
char *pathname = "/usr/bin/ls";
char *newargv[] = { "ls", "./test", NULL };
execv(pathname, newargv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pid > 0) {
// sono il padre
int status;
waitpid(pid, &status, 0);
exit(EXIT_SUCCESS);
}
else {
// errore fork()
printf("error: %s\n", strerror(errno));
}
}

Caso 2: implementazione interna

#include <stdio.h>
#include <stdlib.h>
#include <dirent.h>

// main() - funzione main
int main(int argc, char *argv[])
{
// apro la directory
DIR *dir = opendir("./test");
if (dir) {
// leggo una entry alla volta
struct dirent *dir_ent;
while ((dir_ent = readdir(dir)) != NULL) {
// mostro solo se è un file
if (dir_ent->d_type == DT_REG)
printf("%s ", dir_ent->d_name);
}

// chiudo la directory e la linea
printf("\n");
closedir(dir);
}

exit(EXIT_SUCCESS);
}

Che ne dite? Entrambi i casi sono interessanti, funzionali e non usano la system(3)! Se volete testarli basta creare (nella directory dove avete compilato il programma) una directory test con dentro qualche file (io ci ho copiato i sorgenti mostrati in questo articolo). Poi eseguite i programmi, et voilà!

E, già che ci sono, posso mostrarvi dei risultati reali: ho eseguito direttamente (con bash) il comando "ls ./test"  e poi ho eseguito e i tre programmi di test (quello con system(3) e i due senza). Il risultato è stato il seguente:

aldo@Linux $ ls ./test/
forkexecwait.c readdir.c system.c
aldo@Linux $ ./system
forkexecwait.c readdir.c system.c
aldo@Linux $ ./forkexecwait
forkexecwait.c readdir.c system.c
aldo@Linux $ ./readdir
readdir.c forkexecwait.c system.c

che era esattamente quello aspettato. Provare per credere!

Ok, per oggi può bastare: spero di avervi convinto che system(3) è una funzione anti-pattern, e di più non posso aggiungere. Nella seconda parte dell'articolo penso che tratteremo usi di ZeroMQ più sofisticati e usando un approccio high-level... oops! Ma questo è quello che avevo scritto alla fine dello scorso articolo! Speriamo, almeno questa volta, di riuscire a mantenere la promessa...

Ciao, e al prossimo post!  

lunedì 30 maggio 2022

ZeroMQ Festival
come si usa ZeroMQ in C - pt.1

Sue: Lo hai sentito parlare del suo prossimo film? Vuole tentare di far riconciliare Arabi e Israeliani.
Mort: Sono contento che si dedichi alla fantascienza.

Rifkin's Festival è un bel film del Maestro Woody Allen appartenente alla serie di quelli diretti da lui e interpretati da un suo alter-ego (in questo caso il bravissimo Wallace Shawn). È un ode al Cinema (con la C maiuscola), è pieno di citazioni dotte, è brillante. Ed è brillante come l'argomento di questo articolo, la libreria ZeroMQ, che è un festival di sorprese, di ecletticitá e di prestazioni, una libreria "Super" che si distingue nel mondo della messaggistica (e no: non sono un azionista di ZeroMQ, che, tra l'altro, è open-source e gratuita. Ne parlo bene solo perché merita).

ZeroMQ Festival
...e nella prossima scena useremo ZeroMQ...

Veniamo al dunque: cos'è esattamente ZeroMQ? Visto che non amo reinventare la ruota lo faccio dire ai suoi stessi creatori/curatori, che nella loro pagina Web la descrivono così:

ZeroMQ (scritto anche ØMQ, 0MQ o ZMQ) è una libreria di messaggistica asincrona ad alte prestazioni, destinata all'uso in applicazioni distribuite o concorrenti. Fornisce una coda di messaggi, ma a differenza dei middleware orientati ai messaggi, un sistema ZeroMQ può funzionare senza un broker di messaggi dedicato.

ZeroMQ supporta gli schemi di messaggistica più comuni (pub/sub, request/reply, client/server e altri) su una varietà di transports (TCP, in-process, inter-process, multicast, WebSocket e altri), rendendo la messaggistica inter-process semplice come quella inter-thread. In questo modo il codice rimane chiaro, modulare ed estremamente facile da scalare.

Ecco, è evidente che la descrizione qui sopra è di parte ("Ogne scarrafone è bell' a mamma soja") e quindi, per definizione, poco attendibile. Però io non sono di parte e vi assicuro che è tutto vero! ZeroMQ è un unico pacchetto che permette di scrivere codice di messaggistica di tutti i tipi fondamentali: inter-process, inter-thread e inter-network, e senza scomodare le librerie specializzate (come le POSIX IPC o i BSD Socket). E, oltretutto, ZeroMQ è leggera, ed è (relativamente) semplice da usare. E ha altissime prestazioni (veramente molto alte, grazie all'architettura brokerless e a un codice interno genialmente progettato). ZeroMQ non è un prodotto di nicchia semisconosciuto: è usato in vari progetti da una impressionante lista di aziende (come Samsung, AT&T, Spotify, Microsoft e molte altre).

ZeroMQ è multi-piattaforma ed è veramente multi-linguaggio, nel senso che sono disponibili un sacco (ma veramente un sacco!) di interfacce; ad esempio abbiamo: C, C++, C#, Java, Python, Ruby, Dart, Go, NodeJS e molti altri. Ovviamente in questo articolo ci soffermeremo sul suo uso in C, dove abbiamo a disposizione due librerie: libzmq e CZMQ. E perché due librerie? Perché ZeroMQ è un progetto molto versatile e con due personalità: con libzmq si può scrivere codice "classico" usando lo stile low-level  che si usa per programmare, ad esempio, con i BSD Sockets; invece con CZMQ si può scrivere codice di tipo high-level usando funzioni semplici che eseguono attività molto complesse (e che sarebbero complicate da scrivere in modo low-level).

(...apro una doverosa parentesi: per chi non l'avesse ancora capito, io sono un super-fan del C, e quindi, anche se mi costa un po' dirlo, lo dico: ZeroMQ è stato scritto usando il lato oscuro della forza, il C++ (che uso moltissimo anch'io, e anche abbastanza bene, nonostante non sia esattamente il mio linguaggio preferito...). E qui viene il bello: nonostante gli ottimi risultati ottenuti, il creatore del progetto (il bravissimo Martin Sústrik) si è pentito della scelta, affermando che sarebbe venuto meglio in C (l'ha detto lui! Io non centro nulla!). I motivi li ha spiegati qui (leggere con attenzione, è molto interessante). Chiudo la doverosa parentesi...).

Ok, e a questo punto come si procede? Nella pagina Web di ZeroMQ ci sono un sacco di esempi base, e sarebbe troppo facile fare il copia-incolla di qualcuno. Piuttosto mi interessa l'idea di fare un bel benchmark (che, indirettamente, fornisce già un esempio base). Poi, nel prossimo articolo (spoiler: ci sarà una seconda parte) potremo vedere un esempio d'uso un po' più interessante. Per il benchmark ho deciso di seguire, pari pari, il mio vecchio ciclo di articoli sulla POSIX IPC, sia perché l'argomento è (quasi) lo stesso, sia per dimostrare che ZeroMQ è perfettamente integrabile nello stesso codice che usai a suo tempo (e, come detto sopra, ZeroMQ funziona anche in modo inter-process). Ovviamente userò la libreria low-level per ottenere dei sorgenti quasi sovrapponibili a quelli del ciclo POSIX IPC .

E allora, sulla falsariga dei vecchi articoli (che potete trovare qui, qui e qui), useremo i seguenti listati:

  1. Il main di un processo padre (processes.c) che crea ed segue due processi figli con fork + exec. I due processi figli si chiameranno writer e reader.
  2. Il main del processo writer (writer.c).
  3. Il main del processo reader (reader.c).
  4. Un header file (data.h).

Ok, cominciamo da processes.c: vai col codice!

// processes.c - main processo padre
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/wait.h>
#include "data.h"

// funzione main()
int main(int argc, char* argv[])
{
// crea i processi figli
pid_t pid1, pid2;
(pid1 = fork()) && (pid2 = fork());

// test pid processi
if (pid1 == 0) {
// sono il figlio 1
printf("sono il figlio 1 (%d): eseguo il nuovo processo\n", getpid());
char *pathname = "reader";
char *newargv[] = { pathname, NULL };
execv(pathname, newargv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pid2 == 0) {
// sono il figlio 2
printf("sono il figlio 2 (%d): eseguo il nuovo processo\n", getpid());
char *pathname = "writer";
char *newargv[] = { pathname, NULL };
execv(pathname, newargv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pid1 > 0 && pid2 > 0) {
// sono il padre
printf("sono il padre (%d): attendo la terminazione dei figli\n", getpid());
int status;
pid_t wpid;
while ((wpid = wait(&status)) > 0)
printf("sono il padre (%d): figlio %d terminato (%d)\n", getpid(),
(int)wpid, status);

// esco
printf("%s: processi terminati\n", argv[0]);
exit(EXIT_SUCCESS);
}
else {
// errore nella fork(): esco
printf("%s: fork error (%s)\n", argv[0], strerror(errno));
exit(EXIT_FAILURE);
}
}

Come avrete notato è identico (come era da sperare) a quello della POSIX IPC Socket, dei vecchi articoli, quindi è il più semplice di tutti i vari processes.c mostrati a suo tempo.

E ora vediamo l’header file data.h che ci mostra il tipo di dati scambiati e definisce il path usato da ZeroMQ per il modo IPC (è un file temporaneo che è consigliabile creare in /tmp):

#ifndef DATA_H
#define DATA_H

// path del file temporaneo per IPC
#define ZEROMQ_PATH "ipc:///tmp/0mq.ipc"

// numero di messaggi da scambiare per il benchmark
#define N_MESSAGES 2000000

// struttura Data per i messaggi
typedef struct {
unsigned long index; // indice dei dati
char text[1024]; // testo dei dati
} Data;

#endif /* DATA_H */

E adesso siamo pronti per vedere il codice di writer.c:

// writer.c - main processo figlio (il Writer (è un Client))
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <zmq.h>
#include "data.h"

// funzione main()
int main(int argc, char *argv[])
{
// creo il context, il socket e la connessione
printf("processo %d partito\n", getpid());
void *context = zmq_ctx_new();
void *writer = zmq_socket(context, ZMQ_PUSH);
zmq_connect(writer, ZEROMQ_PATH);

// loop di scrittura messaggi per il reader
Data my_data;
my_data.index = 0;
do {
// test index per forzare l'uscita
if (my_data.index == N_MESSAGES) {
// il processo chiude il socket ed esce per indice raggiunto
printf("processo %d terminato (text=%s index=%ld)\n",
getpid(), my_data.text, my_data.index);
zmq_close (writer);
zmq_ctx_destroy (context);
exit(EXIT_SUCCESS);
}

// compongo il messaggio e lo invio
my_data.index++;
snprintf(my_data.text, sizeof(my_data.text), "un-messaggio-di-test:%ld",
my_data.index);
} while (zmq_send(writer, &my_data, sizeof(Data), 0) != -1);

// il processo chiude il socket ed esce per altro motivo (errore)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
zmq_close(writer);
zmq_ctx_destroy(context);
exit(EXIT_FAILURE);
}

Anche questo codice è semplicissimo e auto-esplicativo (specialmente se avete letto i vecchi articoli): dopo aver aperto il context , il socket  ZeroMQ ed eseguito il bind, esegue un loop di 2000000 (due milioni! Chi offre di più?) di send del messaggio definito in data.h, dopodiché esce. Per ridurre all'osso l’attività della CPU e non falsare i risultati, si potrebbe omettere anche la snprintf() di ogni messaggio e aggiornare solo il campo index: ho fatto dei test e, come era prevedibile, la snprintf() locale è molto più` veloce della scrittura IPC, e quindi, praticamente, i risultati non vengono alterati. Notare che il loop pseudo-infinito prevede una uscita forzata del processo al raggiungimento del numero di scritture prestabilite. In caso di send error  si ferma il loop anticipatamente e si va all'uscita generica di errore. La fase di apertura iniziale dell'ambiente ZeroMQ (context, socket, bind) l'ho eseguita "secca" senza testare gli errori: è un programma di test, non andrà in produzione.

Ma si, dai, passiamo al reader!

// reader.c - main processo figlio (il Reader (è un Server))
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <zmq.h>
#include <time.h>
#include <sys/time.h>
#include "data.h"

// funzione main()
int main(int argc, char *argv[])
{
// creo il context, il socket ed eseguo il bind
printf("processo %d partito\n", getpid());
void *context = zmq_ctx_new();
void *reader = zmq_socket(context, ZMQ_PULL);
zmq_bind(reader, ZEROMQ_PATH);

// set clock e time per calcolare il tempo di CPU e il tempo di sistema
clock_t t_start = clock();
struct timeval tv_start;
gettimeofday(&tv_start, NULL);

// loop di lettura messaggi dal writer
Data my_data;
while (zmq_recv(reader, &my_data, sizeof(Data), 0) != -1) {
// test index per forzare l'uscita
if (my_data.index == N_MESSAGES) {
// get clock e time per calcolare il tempo di CPU e il tempo di sistema
clock_t t_end = clock();
double t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
struct timeval tv_end, tv_elapsed;
gettimeofday(&tv_end, NULL);
timersub(&tv_end, &tv_start, &tv_elapsed);

// il processo chiude il socket ed esce per indice raggiunto
printf("reader: ultimo messaggio ricevuto: %s\n", my_data.text);
printf("processo %d terminato (index=%ld CPU time elapsed: "
"%.3f - total time elapsed:%ld.%ld)\n",
getpid(), my_data.index, t_passed, tv_elapsed.tv_sec,
tv_elapsed.tv_usec / 1000);
zmq_close(reader);
zmq_ctx_destroy(context);
exit(EXIT_SUCCESS);
}
}

// il processo chiude il socket ed esce per altro motivo (errore)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
zmq_close(reader);
zmq_ctx_destroy(context);
exit(EXIT_FAILURE);
}

Il reader è strutturalmente identico (e speculare) al writer, quindi il loop di lettura ha due possibili uscite (forzata e per errore), ed esegue simmetricamente le stesse operazioni, solo che legge invece di scrivere. Ed ha in più la gestione del calcolo dei tempi di esecuzione, che ci servono per effettuare il benchmark che ci eravamo proposti.

In definitiva si può` affermare che la IPC attraverso ZeroMQ è veramente semplice da implementare, perché è pura attività di read/write su un file. Tutto Ok, quindi… e i risultati? Il test l’ho effettuato su una macchina Linux (Kernel 5.4.0) con un i7 (4 core/8 thread) con 8GB di RAM. Ok, vediamo come è andata (con la stessa forma del vecchio ciclo di articoli):

ZeroMQ (modo IPC)
-----------------
sono il padre (1896): attendo la terminazione dei figli
sono il figlio 1 (1897): eseguo il nuovo processo
sono il figlio 2 (1898): eseguo il nuovo processo
processo 1897 partito
processo 1898 partito
processo 1898 terminato (text=un-messaggio-di-test:2000000 index=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 1897 terminato (index=2000000 CPU time elapsed: 2.935 - total time elapsed:1.759)
sono il padre (1896): figlio 1897 terminato (0)
sono il padre (1896): figlio 1898 terminato (0)
./processes: processi terminati

Allora: il nostro test mostra che, usando ZeroMQ in modo IPC, abbiamo scambiato due milioni di messaggi tra i due processi in 1.759 secondi (quindi un messaggio ogni 0,89 us). ZeroMQ è velocissimo! A questo punto forse qualcuno noterà la stranezza del CPU time che è molto più alto del total time: non è un errore, è che su una macchina multi-core come quella di test, ZeroMQ distribuisce opportunamente il carico su thread diversi della CPU e il tempo calcolato nel codice è la somma dei tempi "reali".

Già che c'ero ho pensato che sarebbe stato interessante fare una comparazione diretta con un prodotto similare, quindi ho rieseguito i test con POSIX Message Queue. Ecco i risultati:

POSIX Message Queue
-------------------
sono il figlio 1 (8824): eseguo il nuovo processo
sono il padre (8823): attendo la terminazione dei figli
sono il figlio 2 (8825): eseguo il nuovo processo
processo 8824 partito
processo 8825 partito
processo 8825 terminato (text=un-messaggio-di-test:2000000 index=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 8824 terminato (index=2000000 CPU time elapsed: 1.881 - total time elapsed:1.923)
sono il padre (8823): figlio 8825 terminato (0)
sono il padre (8823): figlio 8824 terminato (0)
./processes: processi terminati

I risultati assoluti sono simili (come c'era da aspettarsi) con una leggera superiorità di ZeroMQ. Il multithreading sembra usato differentemente (pare che la Message Queue non lo usa o lo usa in maniera diversa).

A questo punto, per chiudere in bellezza, ho deciso di sfruttare la versatilità di ZeroMQ che permette di cambiare il modo d'uso (con la libreria low-level) con una facilità disarmante, semplicemente giocando con i parametri di zmq_connect(). Vediamo come: writer e reader  IPC usano questo:

writer:
zmq_connect(writer, "ipc:///tmp/0mq.ipc");

reader:
zmq_bind(reader, "ipc:///tmp/0mq.ipc");
Ebbene, per passare al modo TCP è sufficiente fare questo:
writer:
zmq_connect(writer, "tcp://localhost:5555");

reader:
zmq_bind(reader, "tcp://*:5555");
Semplicissimo, no? Così semplice che, in quattro e quattr’otto, ho rifatto i test in modo TCP e ho anche rispolverato il codice del POSIX IPC Socket per fare un confronto di prestazioni. Vediamo::
ZeroMQ (modo TCP)
-----------------
sono il padre (4249): attendo la terminazione dei figli
sono il figlio 1 (4250): eseguo il nuovo processo
sono il figlio 2 (4251): eseguo il nuovo processo
processo 4250 partito
processo 4251 partito
processo 4251 terminato (text=un-messaggio-di-test:2000000 index=2000000)
sono il padre (4249): figlio 4251 terminato (0)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 4250 terminato (index=2000000 CPU time elapsed: 3.251 - total time elapsed:2.389)
sono il padre (4249): figlio 4250 terminato (0)
./processes: processi terminati

POSIX IPC Socket (modo UDP)
---------------------------
sono il padre (2068): attendo la terminazione dei figli
sono il figlio 1 (2069): eseguo il nuovo processo
sono il figlio 2 (2070): eseguo il nuovo processo
processo 2069 partito
processo 2070 partito
processo 2070 terminato (text=un-messaggio-di-test:2000000 index=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 2069 terminato (index=2000000 CPU time elapsed: 4.004 - total time elapsed:5.197)
sono il padre (2068): figlio 2070 terminato (0)
sono il padre (2068): figlio 2069 terminato (0)
./processes: processi terminati

Allora, pare che ZeroMQ in modo TCP è veloce quasi quanto in modo IPC (notevole), e batte nettamente POSIX IPC Socket in modo UDP (un modo più veloce ma meno affidabile di TCP)... che dire? Veramente sorprendente.

E, prima di chiudere questa prima parte, approfitto per far notare che il parametro "type" di zmq_socket() ha un uso importante: ad esempio nel nostro semplice benchmark con un writer che scrive (e basta) e un reader che legge (e basta), si usa il modo push-pull (PUSH sul writer e PULL sul reader, attraverso il parametro type). Invece con un più classico sistema client/server, con un client che scrive (e aspetta una risposta) e un server che legge (e invia una risposta), si dovrebbe usare il modo request-reply (ZMQ_REQ sul client e ZMQ_REP sul server, attraverso il parametro type). Quindi il codice del modo TCP diventa così:

client:
void *requester = zmq_socket(context, ZMQ_REQ);
zmq_connect(requester, "tcp://localhost:5555");

server:
void *responder = zmq_socket(context, ZMQ_REP);
int rc = zmq_bind(responder, "tcp://*:5555");

Semplice ed efficace, direi.

Ok, per oggi può bastare. Nella seconda parte dell'articolo penso che tratteremo usi di ZeroMQ più sofisticati e usando un approccio high-level... ma non trattenete il respiro nell'attesa: non sarebbe certo la prima volta (ahimè) che rimando la seconda parte "a più avanti" perché ho qualche argomento che mi preme trattare prima: cose che succedono...

Ciao, e al prossimo post!