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.

mercoledì 7 aprile 2021

Breaking Monitor
come scrivere un Process Monitor in C

Saul Goodman: Come l'avete portato qui? Noi siamo come i tre moschettieri! "Tutti per uno e uno per tutti" Non ci serve un quarto moschettiere.
Walter White: Saul, Mike conosce il suo lavoro e i distributori. È uno a posto.
Saul Goodman: Uno a posto? Ha promesso di rompermi le gambe! E non mi dite che non parlava sul serio, perché quando fa lo sguardo da pesce morto parla sul serio!
Walter White: Ha minacciato anche me e Jesse e chissà quante altre persone minaccia ogni giorno. È il suo mestiere. Andiamo, è fatto così.

Questa volta invece di citare un film citeremo una serie TV di alta qualità, tanto alta da farne un "film lunghissimo"Breaking Bad è Cinema, e anche di quello buono. E cosa vuol dire Breaking Bad? Vuol dire "allontanarsi dalla retta via", che è proprio quello che fanno, a volte, i processi di un Sistema Operativo: smettono di funzionare e creano problemi. E quale potrebbe essere la soluzione? Ma usare un Process Monitor, Elementare, mio caro Watson!

...senza un monitor non andiamo da nessuna parte...

Allora, chiariamo subito di cosa stiamo parlando: vi sarà capitato sicuramente (a chi non è capitato? sono cose che succedono tutti i giorni!) di scrivere una applicazione che, a sua volta, lancia altre applicazioni, quindi siamo nel caso classico (già visto su questi schermi) di un processo che fa  forkexec (su Linux e sistemi POSIX), magari più volte. Normalmente il processo padre lavora in collegamento con i processi figli, e se qualche processo muore (il padre o i figli) potrebbero crearsi dei (notevoli) problemi di funzionamento. Normalmente la cosa si risolve a livello OS, per esempio usando una applicazione ad-hoc (una buona e molto usata è Monit) oppure ci si aiuta con le utility del sistema operativo, usando systemd o scrivendo degli shell script.

Ma questa è la soluzione che vi proporrebbe un sistemista, mentre noi siamo programmatori! (...beh, per esigenze di lavoro, molti sono entrambe le cose, incluso il sottoscritto, ma questa è un altra storia...). E il discorso è semplice: se abbiamo un mix di applicazioni slegate, che devono partire e lavorare insieme, allora si usa l'approccio sistemistico, ma se il progetto prevede già una applicazione master che avvia, con fork(), dei sotto-processi, la maniera più intelligente di monitorare è di farlo già a livello codice, senza scomodare il Sistema Operativo e/o applicazioni esterne.

E come si può scrivere un semplice ed efficiente Process Monitor in C? Lo vedremo tra poco! Allora, come sempre vi mostrerò del codice il più possibile semplice e chiaro, ma già preparato per essere usato in produzione (previa l'aggiunta di una gestione più completa degli errori). Non sarà un semplice demo ultra-semplificato, sarà una roba ben fatta (scusate la modestia) sviluppata a mo' di libreria e che può essere facilmente integrata in qualsiasi progetto.

Il nostro Process Monitor, quindi, è una piccola libreria formata da due soli file, un header e una implementazione. L'ho chiamata con l'originalissimo nome di ProcMonit. Ma facciamo parlare il codice, partiamo dall'header  ProcMonit.h:

#ifndef PROCMONIT_H_
#define PROCMONIT_H_

#include <stdbool.h>
#include <pthread.h>
#include <sys/types.h>

// struttura dati per ProcMonit
typedef struct {
// dati del processo figlio da monitorare
const char* file; // nome del file da eseguire
char* const* argv; // lista di argomenti del programma da eseguire
pid_t pid; // pid del processo figlio avviato

// dati del thread del monitor
pthread_t t_procmonit; // descrittore del thread
bool stop_t_procmonit; // flag per lo stop del thread
} ProcMonit;

// prototipi globali
void pm_init(ProcMonit *pm, const char* pm_file, char* const pm_argv[]);
void pm_start(ProcMonit *pm);
void pm_stop(ProcMonit *pm);

#endif // PROCMONIT_H_

La semplicità è disarmante e già lascia presagire come sarà l'implementazione: abbiamo una struttura dati che contiene tutti i (pochi) dati necessari al funzionamento (ben descritti nei commenti, come sempre) e solo tre funzioni (con nomi abbastanza espliciti): pm_init(), pm_start() e pm_stop(). Il meccanismo di Monitor che vogliamo implementare è il seguente:

  • avere la possibilità di avviare n processi figli (usando n  volte pm_init() e pm_start()).
  • sorvegliare lo stato dei processi figli: se uno termina (per un errore interno, per un segnale esterno, ecc) bisogna riavviarlo automaticamente.
  • terminare automaticamente i processi figli nel caso di terminazione del processo padre.

Ovviamente si possono prevedere varianti di questo funzionamento, ma vi assicuro che quello che si usa di solito (con Monit, ad esempio) segue lo schema citato. E come si implementa questo? Vediamolo, vai con ProcMonit.c!

#define _GNU_SOURCE
#include "ProcMonit.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <sys/prctl.h>

// prototipi locali
static void *pm_thread(void *arg);

// pm_init - inizializza la struttura del monitor
void pm_init(ProcMonit *pm, const char *pm_file, char* const pm_argv[])
{
// set/reset dati del monitor
pm->file = pm_file; // nome del file da eseguire
pm->argv = pm_argv; // lista di argomenti del programma da eseguire
pm->pid = -1; // pid del processo figlio avviato
pm->t_procmonit = 0; // descrittore del thread
pm->stop_t_procmonit = false; // flag per lo stop del thread
}

// pm_start - avvia il monitor di un processo figlio
void pm_start(ProcMonit *pm)
{
// test se il thread non è già allocato
if (pm->t_procmonit == 0) {
// start del thread di procmonit
pm->stop_t_procmonit = false; // reset flag per stop thread
int error;
if ((error = pthread_create(&pm->t_procmonit, NULL, &pm_thread, pm)) != 0) {
// errore: lo mostro e azzero il descrittore del thread
printf("%s: non posso creare il thread (%s)\n", __func__, strerror(error));
pm->t_procmonit = 0;
}
}
}

// pm_stop - ferma il monitor di un processo figlio
void pm_stop(ProcMonit *pm)
{
// test se il thread è allocato
if (pm->t_procmonit) {
// uccide il processo figlio
kill(pm->pid, SIGKILL);

// stop e join del thread di procmonit
pm->stop_t_procmonit = true; // set flag per lo stop del thread
pthread_join(pm->t_procmonit, NULL);
pm->t_procmonit = 0; // reset del descrittore del thread
}
}

// pm_thread - funzione per il thread di procmonit
static void *pm_thread(void *arg)
{
// estraggo l'argomento con un cast
ProcMonit *pm = (ProcMonit *)arg;

// redirigo stdout e stderr su un file di log (questa parte è opzionale)
char log_fname[64];
snprintf(log_fname, sizeof(log_fname), "%s.log", pm->file);
int fd = open(log_fname, O_WRONLY | O_CREAT | O_TRUNC, DEFFILEMODE);
//dup2(fd, STDOUT_FILENO); // invia stdout al file fd (opzionale)
//dup2(fd, STDERR_FILENO); // invia stderr al file fd (opzionale)
close(fd); // posso chiudere fd visto che è già stato duplicato

// loop del thread
while (!pm->stop_t_procmonit) {
// fork del processo
pid_t parent_pid = getpid();
pm->pid = fork();

// test dei pid dei processi
char errmsg_buf[256]; // buffer per la strerror_r(): 256 è il size raccomandato
if (pm->pid == 0) {
// sono il figlio: con prctl() prenoto lo stop (via signal) se termina il padre
if (prctl(PR_SET_PDEATHSIG, SIGTERM) == -1) {
// errore nella prctl()
fprintf(stderr, "%s: sono il figlio (%d): errore prtctl(): %s\n",
pm->argv[1], getpid(),
strerror_r(errno, errmsg_buf, sizeof(errmsg_buf)));
exit(EXIT_FAILURE);
}

// nel caso che il processo padre esca prima di chiamare la prctl()
if (getppid() != parent_pid) {
// errore nella prctl()
fprintf(stderr, "%s: sono il figlio (%d): errore prtctl()\n",
pm->argv[1], getpid());
exit(EXIT_FAILURE);
}

// continua l'esecuzione del figlio eseguendo un nuovo processo
fprintf(stderr, "%s: sono il figlio (%d): eseguo il nuovo processo: %s\n",
pm->argv[1], getpid(), pm->file);
execv(pm->file, pm->argv);
exit(EXIT_FAILURE); // exec non ritorna mai
}
else if (pm->pid > 0) {
// sono il padre: attendo l'uscita del figlio
fprintf(stderr, "%s: sono il padre (%d): "
"attendo la terminazione del figlio %s (%d)\n",
pm->argv[1], getpid(), pm->file, pm->pid);
int status;
if (waitpid(pm->pid, &status, 0) != pm->pid) {
// errore nella waitpid()
fprintf(stderr, "%s: sono il padre (%d): "
"errore waitpid() figlio %s (%d): %s\n",
pm->argv[1], getpid(), pm->file, pm->pid,
strerror_r(errno, errmsg_buf, sizeof(errmsg_buf)));
}
else {
// processo terminato: mostro lo status
if (WIFEXITED(status))
fprintf(stderr, "%s: sono il padre (%d): "
"figlio %s (%d) uscito ((status=%d))\n",
pm->argv[1], getpid(), pm->file, pm->pid,
WEXITSTATUS(status));
else if (WIFSIGNALED(status))
fprintf(stderr, "%s: sono il padre (%d): "
"figlio %s (%d) ucciso dal segnale %d\n",
pm->argv[1], getpid(), pm->file, pm->pid,
WTERMSIG(status));
else if (WIFSTOPPED(status))
fprintf(stderr, "%s: sono il padre (%d): "
"figlio %s (%d) fermato dal segnale %d\n",
pm->argv[1], getpid(), pm->file, pm->pid,
WSTOPSIG(status));
else
fprintf(stderr, "%s: sono il padre (%d): "
"figlio %s (%d) terminato ((status=%d))\n",
pm->argv[1], getpid(), pm->file, pm->pid, status);
}
}
else {
// errore nella fork(): mostro errore e continuo
fprintf(stderr, "%s: processo %s: errore fork(): %s\n",
pm->argv[1], pm->file,
strerror_r(errno, errmsg_buf, sizeof(errmsg_buf)));
}
}

// il thread esce
pthread_exit(NULL);
}

Che ne dite? Come si nota il funzionamento si basa su un thread (in questo articolo c'è un bel mix esplosivo, processi e thread allo stesso tempo!) che si occupa di monitorare lo stato del processo figlio. Riepiloghiamo il funzionamento centrandoci sulle tre funzioni globali:

  • pm_init(): inizializza la struttura dati del Monitor. Tra i dati possiamo evidenziare il filename che verrà eseguito dal processo figlio (con i relativi argomenti di esecuzione) e le variabili di supporto per gestire il thread di Monitor e il processo figlio.
  • pm_start(): avvia il thread che si occupa di eseguire il processo figlio e di sorvegliarne la attività, per gestire gli eventuali restart. Qualcuno potrebbe (giustamente) commentare: "Ma la pm_init() e la pm_start() non potrebbero essere una unica funzione?" No, e la risposta si chiama pm_stop().
  • pm_stop(): ferma il processo figlio e il thread di controllo. Dopo lo stop si può, se necessario, eseguire un nuovo start... "Ah, allora è per questo che abbiamo init e start in due fasi! La init si chiama una volta sola, mentre la start si può ri-chiamare dopo ogni stop!" Bravo, risposta esatta.

E adesso è il turno del cuore del Process Monitor, la funzione pm_thread() che è quella che viene eseguita dal thread di controllo (e non è esportata globalmente nel header, visto che viene usata solo internamente alla libreria). Il funzionamento è abbastanza semplice e ricalca, in una certa maniera, la struttura del file processes.c di alcuni recenti articoli: esegue fork() e divide il flusso in padre (che sorveglia) e figlio (che esegue con execv() un nuovo processo). E come avviene il monitoring? Il flusso padre resta in attesa dell'uscita del figlio all'interno di un loop pseudo-infinito: in questa maniera, se il figlio muore viene rieseguita la fork() e si ripete il ciclo indefinitamente, fino a quando non viene richiesto lo stop definitivo attraverso la funzione pm_stop(). Non male, no?

Nella funzione pm_thread() ci sono almeno tre trucchetti interessanti, che possiamo descrivere brevemente (per i dettagli leggere bene i commenti nel codice):

  1. Redirezione di stdout e stderr: visto che abbiamo più processi in esecuzione, con (vari ed eventuali) messaggi di errore, è una buona idea redirigere le scritture (su un file di log, come nell'esempio, oppure su /dev/null se vogliamo silenziare il tutto). Questa parte è opzionale (ma utile).
  2. Intercettazione dei segnali attraverso la funzione prctl(): serve per automatizzare la terminazione del processo figlio nel caso di uscita del processo padre (questa parte è veramente interessante, segnatevela).
  3. nel flusso padre, trattamento completo dello status di uscita del processo figlio: ho usato le apposite (e non molto conosciute) macro della wait(): WIFEXITED(), WEXITSTATUS(), WIFSIGNALED(), WTERMSIG(), WIFSTOPPED() e WSTOPSIG(): in questa maniera il controllo di eventuali errori è veramente ben fatto.

Cosa ci manca per terminare? Ma un semplicissimo esempio d'uso! Quindi vi propongo un caso veramente elementare, con un programma padre parent.c e un programma figlio child.c. Il processo padre in questo caso monitorizza solo un figlio, ma può monitorizzarne n  a piacere semplicemente moltiplicando le chiamate di init, start e stop: ogni nuova pm_init() inizializza un nuovo processo da eseguire su cui possiamo eseguire le relative pm_start() e pm_stop(). Semplice e praticissimo. Vediamo il codice!

#include "ProcMonit.h"
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

// nome del processo figlio da avviare
#define CHILD_PROC "child"

// funzione main del processo padre
int main(int argc, char *argv[])
{
// init struttura procmonit
char* argv_pm[] = { (char*)CHILD_PROC, argv[0], (char*)NULL };
ProcMonit procmonit;
pm_init(&procmonit, CHILD_PROC, argv_pm);

// avvio il processo figlio usando procmonit
printf("%s: prenoto lo start del processo %s\n", argv[0], argv_pm[0]);
pm_start(&procmonit);

// main loop di test
for (int i = 0; i < 10; i++) {
printf("%s: loop interno di test: %d\n", argv[0], i);
sleep(3);
}

// fermo il processo figlio usando procmonit
printf("%s: prenoto lo stop del processo %s\n", argv[0], argv_pm[0]);
pm_stop(&procmonit);

// esco con Ok
return EXIT_SUCCESS;
}
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

// funzione main del processo figlio
int main(int argc, char *argv[])
{
// test loop
int i = 0;
for (;;) {
// test indice per chiusura forzata
if (i++ > 100) {
// chiusura forzata
printf("%s: il processo esce\n", argv[0]);
return EXIT_FAILURE;
}

// thread sleep (100 ms)
nanosleep((const struct timespec[]){{0, 100000000L}}, NULL);
}

// esco con Ok
return EXIT_SUCCESS;
}

Potete fare copia-incolla della libreria e degli esempi d'uso e compilare: con i tempi che ho impostato (e le scritte di log) vedrete il processo figlio che termina e si riavvia varie volte. E se provate, allo stesso tempo, a inviare un segnale di terminazione o uccisione al figlio potete mettere alla prova il riavvio automatico dopo un evento esterno. E se inviate un sigkill al padre vedrete che termina anche il figlio. Spero che vi piaccia (e se non vi piace me ne farò una ragione...). Ah, dimenticavo: i sorgenti di questo articolo li potete trovare anche nel mio repository GitHub.

Ciao, e al prossimo post!

domenica 7 marzo 2021

Il buono, il brutto, l'IPC
considerazioni sulle prestazioni della POSIX IPC - pt.3

Il Biondo: Vedi, il mondo si divide in due categorie: chi ha la pistola carica, e chi scava. Tu scavi

E siamo arrivati (finalmente! uff...) all'ultima parte della nostra personale Trilogia del dollaro... ebbene si, lo ammetto: la Trilogia del IPC, non diventerà una pietra miliare come i mitici tre film del grandissimo Sergio Leone, ma comunque io ce l'ho messa tutta, e spero che a qualcuno tutta questa sbrodolata torni utile. E, se non siete d'accordo, questa volta vi manderò a casa il cattivo a convincervi!

...il mondo si divide in due categorie: chi sa usare l'IPC e chi no...

Allora: chi ha letto le prime due parti della serie (qui e qui), oltre a concorrere per il premio "il lettore più paziente dell'anno" può leggere in scioltezza quanto segue. Per gli altri raccomando una veloce lettura previa (almeno per capire de che se sta a parla') e, per punizione, lettura "in ginocchio sui ceci"  (un po' di cultura popolare non guasta mai... anzi: promemoria per un futuro articolo: "Considerazioni sull'uso dei detti popolari nella divulgazione informatica").

E dunque: abbiamo già fornito tre (gagliardi) esempi d'uso di POSIX IPC: FIFO (Named Pipe), Message Queue  e UNIX domain socket (IPC socket). Oggi tocca alla quarta e ultima, la Shared Memory, che ho lasciato in fondo perché è un po' diversa dalle altre, non essendo un tipico mezzo di scambio di messaggi ma, come dice il nome, un sistema di condivisione della memoria. Rivediamo un attimo la definizione:

Shared Memory: la comunicazione tra due o più processi viene raggiunta attraverso un pezzo di memoria condiviso tra tutti i processi. La memoria condivisa deve essere protetta dagli accessi simultanei usando meccanismi di sincronizzazione.

E infatti, in un altro articolo, avevamo già visto un esempio d'uso nella sua forma tipica. Beh, ho deciso di inserire ugualmente in questa specie di "Olimpiadi dell'IPC"  anche la Shared Memory forzandola a scambiare messaggi. Visto che questo uso è una forzatura non potremo aspettarci né grandi prestazioni né un codice particolarmente lineare e senza ridondanze, ma sarà, comunque, un interessante esercizio di programmazione.

E cominciamo con la parte "normale" di questo ciclo di articoli, ossia l’header data.h, il padre processes.c e i due figli writer.c reader.c… vai col codice!

#ifndef DATA_H
#define DATA_H

// nome del memory mapped file
#define MMAP_NAME "mymmap"

// 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 */
// 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 "mmap.h"
#include "data.h"

// funzione main()
int main(int argc, char* argv[])
{
// creo il memory mapped file
shdata *data;
if ((data = memMapOpen(MMAP_NAME, sizeof(Data), true)) == NULL) {
// errore di creazione
printf("%s: non posso creare il memory mapped file (%s)\n", argv[0],
strerror(errno));
exit(EXIT_FAILURE);
}

// 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);

// rimuovo il memory mapped file ed esco
printf("%s: processi terminati\n", argv[0]);
memMapClose(MMAP_NAME, data);
exit(EXIT_SUCCESS);
}
else {
// errore nella fork(): rimuovo il memory mapped file ed esco
printf("%s: fork error (%s)\n", argv[0], strerror(errno));
memMapClose(MMAP_NAME, data);
exit(EXIT_FAILURE);
}
}
// writer.c - main processo figlio
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include "mmap.h"
#include "data.h"

// funzione main()
int main(int argc, char* argv[])
{
// apro il memory mapped file
printf("processo %d partito\n", getpid());
shdata *data;
if ((data = memMapOpen(MMAP_NAME, sizeof(Data), false)) == NULL) {
// errore di apertura
printf("%s: non posso aprire il memory mapped file (%s)\n", argv[0],
strerror(errno));
exit(EXIT_FAILURE);
}

// loop di scrittura messaggi per il reader
Data my_data;
my_data.index = 0;
for (;;) {
// test index per forzare l'uscita
if (my_data.index == N_MESSAGES) {
// il processo esce per indice raggiunto
printf("processo %d terminato (text=%s index=%ld)\n", getpid(),
my_data.text, my_data.index);
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);
memMapWrite(data, &my_data, sizeof(Data));
}

// il processo esce per altro motivo (errore: non gestito in questa versione)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
exit(EXIT_FAILURE);
}
// reader.c - main processo figlio
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <time.h>
#include <sys/time.h>
#include "mmap.h"
#include "data.h"

// funzione main()
int main(int argc, char* argv[])
{
// apro il memory mapped file
printf("processo %d partito\n", getpid());
shdata *data;
if ((data = memMapOpen(MMAP_NAME, sizeof(Data), false)) == NULL) {
// errore di apertura
printf("%s: non posso aprire il memory mapped file (%s)\n", argv[0],
strerror(errno));
exit(EXIT_FAILURE);
}

// 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;
for (;;) {
// leggo un messaggio
memMapRead(data, &my_data, sizeof(Data));

// 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 esce per indice raggiunto
printf("reader: ultimo messaggio ricevuto: %s\n", my_data.text);
printf("processo %d terminato "
"(index=%ld CPU time elapsed: %.3f s - total time elapsed:%ld.%ld s)\n",
getpid(), my_data.index, t_passed, tv_elapsed.tv_sec,
tv_elapsed.tv_usec / 1000);
exit(EXIT_SUCCESS);
}
}

// il processo esce per altro motivo (errore: non gestito in questa versione)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
exit(EXIT_FAILURE);
}

Ok, fino ad adesso nessuna sorpresa, il codice è mooolto simile a quello degli altri esempi visti finora, tranne che le funzioni di read/write sono usate (come avranno notato i lettori più attenti) senza testare il valore di ritorno. E adesso, per rompere la monotonia (e prima che qualcuno si addormenti) vediamo la "forzatura"  di questo codice, ovvero la mini-libreria mmap che ho scritto per usare la memoria condivisa per leggere e scrivere messaggi: puro masochismo però è un codice interessante (spero). Vediamolo, sono due file, mmap.h e mmap.c:

// mmap.h - header mini-libreria IPC con memory mapped file
#include <pthread.h>
#include <stdbool.h>

// struttura per i dati condivisi
typedef struct {
pthread_mutex_t mutex; // mutex comune ai processi
pthread_cond_t cond; // condition variable comune ai processi
bool data_ready; // flag per dati disponibili (true=ready)
size_t len; // lunghezza campo data
char data[1]; // dati da condividere
} shdata;

// prototipi globali
shdata *memMapOpen(const char *mmname, size_t len, bool create);
void memMapClose(const char *mmname, shdata *ptr);
void memMapRead(shdata *ptr, void *buf, size_t count);
void memMapWrite(shdata *ptr, const void *buf, size_t count);
// mmap.c - implementazione mini-libreria IPC con memory mapped file
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include "mmap.h"

// memMapOpen() - apre un memory mapped file
shdata *memMapOpen(
const char *mmname,
size_t len,
bool create)
{
shdata *ptr;

// test se modo create o modo open di un mmfile già creato
if (create) {
// apre un memory mapped file (il file "mmname" è creato in /dev/shm)
int fd;
if ((fd = shm_open(mmname, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)) == -1)
return NULL; // esce con errore

// tronca un memory mapped file
if (ftruncate(fd, sizeof(shdata) + len) == -1)
return NULL; // esce con errore

// mappa un memory mapped file
if ((ptr = mmap(NULL, sizeof(shdata) + len, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0)) == MAP_FAILED)
return NULL; // esce con errore

// chiude la shared memory: questo non compromette il map eseguito
close(fd);

// init mutex in modo "shared memory"
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&ptr->mutex, &attr);
pthread_mutexattr_destroy(&attr);

// init condition variable in modo "shared memory"
pthread_condattr_t attrcond;
pthread_condattr_init(&attrcond);
pthread_condattr_setpshared(&attrcond, PTHREAD_PROCESS_SHARED);
pthread_cond_init(&ptr->cond, &attrcond);
pthread_condattr_destroy(&attrcond);

// init altri dati
ptr->data_ready = false;
ptr->len = len;
}
else {
// apre un memory mapped file (il file "shmname" è creato in /dev/shm)
int fd;
if ((fd = shm_open(mmname, O_RDWR, S_IRUSR | S_IWUSR)) == -1)
return NULL; // esce con errore

// mappa un memory mapped file
if ((ptr = mmap(NULL, sizeof(shdata) + len, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0)) == MAP_FAILED)
return NULL; // esce con errore

// chiude la shared memory: questo non compromette il map eseguito
close(fd);
}

// ritorna il pointer
return ptr;
}

// memMapClose() - chiude un memory mapped file
void memMapClose(
const char *mmname,
shdata *ptr)
{
// rilascio tutte le risorse acquisite
pthread_mutex_destroy(&ptr->mutex);
pthread_cond_destroy(&ptr->cond);
munmap(ptr, sizeof(shdata) + ptr->len);
shm_unlink(mmname);
}

// memMapRead() - legge dati dal mapped-file
void memMapRead(
shdata *ptr,
void *buf,
size_t count)
{
// lock mutex
pthread_mutex_lock(&ptr->mutex);

// aspetta la condizione
while (!ptr->data_ready)
pthread_cond_wait(&ptr->cond, &ptr->mutex);

// legge i dati dal mapped-file e segnala la condizione
memcpy(buf, ptr->data, count);
ptr->data_ready = false;
pthread_cond_signal(&ptr->cond);

// unlock mutex
pthread_mutex_unlock(&ptr->mutex);
}

// memMapWrite() - scrive dati nel mapped-file
void memMapWrite(
shdata *ptr,
const void *buf,
size_t count)
{
// lock mutex
pthread_mutex_lock(&ptr->mutex);

// aspetta la condizione
while (ptr->data_ready)
pthread_cond_wait(&ptr->cond, &ptr->mutex);

// scrive i dati sul mapped-file esegnala la condizione
memcpy(ptr->data, buf, count);
ptr->data_ready = true;
pthread_cond_signal(&ptr->cond);

// unlock mutex
pthread_mutex_unlock(&ptr->mutex);
}

Ok, no? È un codice abbastanza semplificato che, per essere messo in produzione, necessiterebbe di un po' più di controllo degli errori... ma è solo un esercizio, chi userebbe un sistema così per mandare messaggi quando ci sono le funzioni ad-hoc della famiglia Message Queues? Comunque, è un esempio funzionante (compilare ed eseguire per credere, eh!). Ovviamente, visto il tipo di funzionamento della Shared Memory (vedi la definizione qua sopra) nella struttura base shdata sono previsti un mutex e una condition variable per gestire la sincronizzazione degli accessi (meccanismo che è, invece, intrinseco con le altre POSIX IPC viste negli articoli precedenti).

Ci manca solo da descrivere una cosa: il trucco del "char data[1]"  (in mmap.h) usato per rendere generici i dati da condividere. Questo campo è (non a caso) l'ultimo della struttura dati "shdata" che descrive il mapped-file, e funziona così: quando si crea il file si passa, alla memMapOpen(), il size dei dati da scambiare (usando un sizeof): quindi nel nostro caso la dimensione passata è sizeof(Data). Dentro la memMapOpen() il mapped-file viene mappato (usando la system call mmap()) con la dimensione totale richiesta, che è composta da una parte base fissa (la struttura shdata) e dalla parte che potremmo definire "variabile" (la struttura Data). Il risultato finale è un mapped-file impostato per scambiare dati nella sua parte variabile "char data[1]", che di base è lunga "un char" ma che, in realtà, è lunga "sizeof(Data) char" una volta mappato il file. Un trucchetto da niente.

E abbiamo anche i risultati!

sono il padre (14836): attendo la terminazione dei figli
sono il figlio 1 (14837): eseguo il nuovo processo
sono il figlio 2 (14838): eseguo il nuovo processo
processo 14837 partito
processo 14838 partito
processo 14838 terminato (text=un-messaggio-di-test:2000000 index=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 14837 terminato (index=2000000 CPU time elapsed: 4.077 s - total time elapsed:4.657 s)
sono il padre (14836): figlio 14837 terminato (0)
sono il padre (14836): figlio 14838 terminato (0)
./processes: processi terminati

Evidentemente i risultati non sono stupefacenti, ma neanche disastrosi: un messaggio ogni 2 us, e siamo quasi al livello delle IPC socket. Quindi: l'uso improprio della Shared Memory viene bocciato a causa dei risultati non proprio eccellenti e, soprattutto, per la inutile complicazione del codice. Però vi assicuro che, se non la usiamo per leggere/scrivere messaggi, ma la usiamo "come si deve" (tipo l'esempio dell'articolo citato sopra) accedendo  ad essa con un pointer, consente di scrivere codice elegante ed efficiente, che può risultare utile in molti progetti.

Teoricamente l'articolo e il ciclo dovrebbero terminare qui, ma quell'irascibile di mio cuggino mi ha ricordato (con male parole, al solito)  che avevo promesso di fare un confronto finale con i thread:

mio cuggino: E tu vorresti perdere una occasione per sfatare la leggenda urbana che i thread sono più veloci dei processi? Le promesse si mantengono! Non ti leggo più!
io: va bene, ma visto che un confronto di codice e prestazioni coi thread l'avevo già fatto in quell'altro articolo, ti va bene se qui lo sfumo un po'?

E allora sfumerò e sarò brevissimo: senza stare a ripetere codice già visto, provate a immaginare (e magari provate a scriverlo, è un utile e semplice esercizio) un confronto con la Message Queue, che è velocissima e ben si presta all'uso sia multiprocess che multithread (è anche thread-safe!). Scrivete un semplice processo padre che crea la coda esattamente come nell'esempio già visto, e che invece di generare due processi figli, crea due thread. I due thread eseguono due funzioni che devono essere (praticamente) identiche ai main() dell'esempio con la Message Queue (si può fare, ve lo assicuro). Aggiungete un po' di pepe e sale, compilate ed eseguite. I risultati dorrebbero essere questi:

thread 140026602206976 partito
thread 140026593814272 partito
thread 8884 terminato (text=un-messaggio-di-test:2000000 index=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
thread 8884 terminato (index=2000000 CPU time elapsed: 3.848 - total time elapsed:1.931)
./threads: thread terminati

Ma guarda un po'... le prestazioni sono identiche alla versione multiprocess! E non fatevi ingannare dal CPU time che è il doppio del total time: non è un errore, è che su una macchina multi-core come quella che ho usato per i test (un i7 4 core/8 thread) i due thread vengono eseguiti su due thread diversi della CPU e il tempo calcolato nel codice è la somma dei due tempi "reali". E quindi è vero: come già visto anteriormente, i processi (almeno su Linux) hanno una velocità paragonabile a quella dei thread, e la scelta multithread/multiprocess si deve fare secondo altri criteri (ricordate le regole che avevo descritto in quell'altro articolo?). Meditate gente, meditate...

Ciao, e al prossimo post!

P.S. (…comunque, i sorgenti di questo articolo, inclusi quelli non mostrati del test in multithread, li trovate nel mio repository GitHub. Buona lettura!…)