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.

sabato 16 gennaio 2021

Per un pugno di IPC
considerazioni sulle prestazioni della POSIX IPC - pt.1

Joe (lo straniero): Chissà cosa diavolo c'è in quella diligenza.
Silvanito: Facile da sapere, ti ci avvicini e dai una guardatina dentro, se ti sparano addosso vuol dire che c'è oro.

Per la serie “battere il ferro finché è caldo” rimarremo ancora per un po’ sul tema dei Processi già affrontato negli ultimi due articoli (qui e qui) e parleremo di IPC, ossia di Comunicazione tra Processi (e, in particolare, ci soffermeremo sulla POSIX IPC). Questo sarà il primo di una serie, perché il tema è vasto, e merita un buon approfondimento. Dubito che riuscirò a scrivere qualcosa di memorabile come il primo episodio della Trilogia del dollaro citata nel titolo, però è sempre meglio avere delle buone fonti di ispirazione. E se a qualcuno non piacciono questi articoli gli mando a casa Joe (lo straniero) per convincerlo…

…come hai detto? Non ti interessa la POSIX IPC?...

Allora: la comunicazione tra processi si può realizzare in varie maniere, vediamo una lista delle principali disponibili sui sistemi POSIX:

  1. Message Queue – comunicazione tra due o più processi con capacità full duplex. I processi comunicano tra loro pubblicando un messaggio e recuperandolo dalla coda. Una volta recuperato, il messaggio non è più disponibile nella coda.
  2. 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.
  3. FIFO (Named Pipe) – comunicazione tra due processi non correlati (ottenuti con fork + exec, per esempio). Come spiegato dal nome stesso, il mezzo usato è un file di tipo FIFO. È una comunicazione full duplex, il che significa che il primo processo può comunicare con il secondo processo e viceversa allo stesso tempo. Si può usare anche con processi correlati (ottenuti con fork senza exec), ma non ha molto senso: meglio usare la Anonymous Pipe in questo caso.
  4. Pipe (Anonymous Pipe) – comunicazione tra due processi correlati (ottenuti con fork senza exec). Il meccanismo è half duplex che significa che il primo processo comunica con il secondo processo. Per ottenere un full duplex, e cioè permettere al secondo processo di comunicare con il primo processo, è necessaria un’altra pipe.
  5. UNIX domain socket (IPC socket) – comunicazione che usa i socket del Kernel (che non usano TCP/IP)
  6. Internet domain socket (Network socket) – comunicazione che usa i socket di rete classici basati su TCP/IP.

Possiamo aggiungere qualche altro dettaglio: la POSIX Message Queue e la POSIX Shared Memory hanno degli equivalenti Unix System V: SysV Message Queue e SysV Shared Memory. Le versioni SysV sono concettualmente molto simili a quelle POSIX (che, tra l’altro, sono nate dopo, nel disegno di standardizzazione Single UNIX Specification). E poi esiste, ovviamente, anche la versione Windows di IPC, che in alcuni casi è abbastanza simile a quelle di POSIX e SysV (del resto Windows NT è nato molto tempo dopo UNIX, ed ha attinto molte idee da lì e da openVMS, ma questa è un altra storia…).

In questa serie di articoli ho deciso di analizzare solo le versioni POSIX perché hanno una API un po’ più user-friendly (e anche per non gettare troppa carne al fuoco). Comunque SysV IPC e Windows IPC sono nella lista degli articoli futuri, non vi preoccupate. È solo una questione di tempo…

Cercheremo, quindi, di mostrare, attraverso un codice chiaro e ben commentato, come implementare questi meccanismi di IPC e, al contempo, getteremo un occhio alle prestazioni. Vi preannuncio che in rete c’è molta documentazione sull’argomento, ma è difficile trovare un condensato dei vari metodi con inclusi anche i dettagli delle prestazioni. Quindi penso e spero che questi articoli saranno molto utili!

(…e dopo cotanta introduzione si allontana, sorridendo amabilmente, tra gli applausi del pubblico…)

E vediamo il piano dell’opera: della lista dei 6 punti qua sopra scarteremo il 4 (Pipe) perché l’obiettivo è la comunicazione più difficile, quella tra processi non correlati (amo il rischio). E scarteremo anche la 6 (Internet domain socket) perché tratteremo solo processi in esecuzione sulla stessa macchina, quindi sarebbe un controsenso usare i Network socket disponendo degli IPC socket.

Ho cercato di scrivere il codice delle varie versioni nella maniera più omogenea possibile, in maniera che i vari sorgenti siano quasi sovrapponibili. Inoltre, per poter confrontare i risultati, ho usato gli stessi metodi di scambio dati per tutte le versioni, quindi avremo un processo che scrive e uno che legge, con un tipo di comunicazione simile a quello usato nei classici esempi di Socket Server/Client che sicuramente avrete tutti già visto. Ho usato questo metodo anche per la Shared Memory: so che è un po improprio (il metodo migliore e più semplice è quello usato nell’ultimo articolo) ma si può fare…

Ok, da cosa cominciamo? Io direi dal meccanismo più classico, quello della Fifo (Named Pipe), che ripete il metodo di comunicazione molto usato anche a livello user interface con la Pipe di  shell (e bash). E, sulla falsariga dell’articolo precedente, avremo 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. Gli header file del caso e gli eventuali file di libreria.

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 <sys/types.h>
#include <sys/stat.h>
#include "data.h"

// funzione main()
int main(int argc, char* argv[])
{
// creo il file fifo (named pipe)
if (mkfifo(FIFO_PATH, DEFFILEMODE) == -1) {
// errore di creazione
printf("%s: non posso creare il file fifo (%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 file fifo ed esco
printf("%s: processi terminati\n", argv[0]);
remove(FIFO_PATH);
exit(EXIT_SUCCESS);
}
else {
// errore nella fork(): rimuovo il file fifo ed esco
printf("%s: fork error (%s)\n", argv[0], strerror(errno));
remove(FIFO_PATH);
exit(EXIT_FAILURE);
}
}

Come avrete notato è quasi identico a quello visto nell’ultimo articolo, ma con due differenze fondamentali:
  1. La prima operazione eseguita è la creazione del file FIFO (e cioè della Named Pipe) che sarà usata dai processi figli.
  2. Il padre non forza più l’uscita dei figli dopo 10 secondi, ma ne attende solo la terminazione: i figli usciranno autonomamente per permetterci il test delle prestazioni.

E ora vediamo l’header file data.h che ci mostra il tipo di dati scambiati e definisce il path della Named Pipe:

#ifndef DATA_H
#define DATA_H

// path del file fifo (named pipe)
#define FIFO_PATH "myfifo"

// 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include "data.h"

// funzione main()
int main(int argc, char *argv[])
{
// apro il file fifo (named pipe) in modo scrittura
printf("processo %d partito\n", getpid());
int fd;
if ((fd = open(FIFO_PATH, O_WRONLY)) == -1) {
// errore di apertura
printf("%s: non posso aprire il file fifo (%s)\n", argv[0], strerror(errno));
exit(EXIT_FAILURE);
}

// 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 file fifo ed esce per indice raggiunto
printf("processo %d terminato (text=%s index=%ld)\n",
getpid(), my_data.text, my_data.index);
close(fd);
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 (write(fd, &my_data, sizeof(Data)) != -1);

// il processo chiude il file fifo ed esce per altro motivo (errore)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
close(fd);
exit(EXIT_FAILURE);
}

Anche questo codice è semplicissimo e auto-esplicativo: dopo aver aperto lo stesso file FIFO del padre esegue un loop di 2000000 (due milioni! Chi offre di più?) di write() 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 nella pipe, 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 write error si ferma il loop anticipatamente e si va all’uscita generica di errore.

Ma si, dai, passiamo al reader!

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

// funzione main()
int main(int argc, char *argv[])
{
// apro il file fifo (named pipe) in modo lettura
printf("processo %d partito\n", getpid());
int fd;
if ((fd = open(FIFO_PATH, O_RDONLY)) == -1) {
// errore di apertura
printf("%s: non posso aprire il fifo (%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;
while (read(fd, &my_data, sizeof(Data)) != -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 file fifo 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);
close(fd);
exit(EXIT_SUCCESS);
}
}

// il processo chiude il file fifo ed esce per altro motivo (errore)
printf("processo %d terminato con errore (%s)\n", getpid(), strerror(errno));
close(fd);
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 puo` affermare che la IPC attraverso Named Pipe è 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. Visto che è il primo test non voglio anticiparvi se le prestazioni sono buone o cattive (spoiler: sono buone), comunque vediamo:

sono il padre (6850): attendo la terminazione dei figli
sono il figlio 1 (6851): eseguo il nuovo processo
sono il figlio 2 (6852): eseguo il nuovo processo
processo 6851 partito
processo 6852 partito
processo 6852 terminato (text=un-messaggio-di-test:2000000 index=2000000)
reader: ultimo messaggio ricevuto: un-messaggio-di-test:2000000
processo 6851 terminato (index=2000000 CPU time elapsed: 1.732 - total time elapsed:1.747)
sono il padre (6850): figlio 6851 terminato (0)
sono il padre (6850): figlio 6852 terminato (0)
./processes: processi terminati

Allora: il nostro test mostra che, usando la POSIX Named Pipe, abbiamo scambiato due milioni di messaggi tra i due processi in 1.732 secondi (quindi un messaggio ogni 0,87 us). Notare che non ho scritto nessuna ottimizzazione del sistema di lettura/scrittura: anche se i messaggi sono corti (“un-messaggio-di-test:nnnn”) si spediscono ugualmente sizeof(Data) byte (sono ben 1032 byte): ottimizzando per spedire solo il minimo necessario sarebbe ancora più veloce (spoiler: scriverò, prima o poi, un articolo sull’argomento). Nei prossimi post vedremo codice e risultati delle altre POSIX IPC in analisi: vi preannuncio che sarà molto interessante, ma non trattenete il respiro nell’attesa, mi raccomando…

Ciao, e al prossimo post!

domenica 20 dicembre 2020

Processi o Thread?
considerazioni sulla scelta tra Processi e Thread - pt.2

Claudia Wilson Gator: Adesso che ci siamo incontrati hai nulla in contrario se non ci vediamo più?

Nel capolavoro Magnolia c’è una scena in cui Claudia (una bravissima Melora Walters) dice a Jim (un altrettanto bravo John C.Reilly) la frase qui sopra. Ecco, la reazione di Claudia è la stessa che hanno molti programmatori dopo i primi esperimenti col multithreading, quando si trovano ad affrontare per la prima volta cosucce come race-condition o starvation. In quel caso la tentazione, forte, è di non usare più i threadcome se usare i processi fosse una passeggiata! Si, forse i processi sono un po’ più facili da controllare e sincronizzare ma non bisogna sceglierli solo per questo motivo. Bisogna, invece, fare sempre una unica considerazione: nel mio progetto cosa è (tecnicamente) meglio usare? E una volta trovata la risposta (magari aiutandosi con lo specchietto dello scorso articolo) agire di conseguenza, senza mai spaventarsi e/o preoccuparsi: risolvere problemi è il nostro lavoro (ebbene si: è un lavoro da masochisti).

...ti avevo detto di usare i processi, ma tu sei un testone...
Allora: dove eravamo rimasti? Si, ora ricordo, si parlava di processi e thread, e l’articolo (che sicuramente conoscete a memoria) si era concluso mostrando un semplicissimo programma multithread ed una promessa, questa:

…è un esempio veramente semplice che mi permetterà di mostrarvi, nella seconda parte dell’articolo, una applicazione multiprocess che fa esattamente la stessa cosa, oltretutto con un codice veramente molto simile e con risultati di esecuzione abbastanza interessanti…

Bene, le promesse bisogna mantenerle, quindi è il turno della applicazione-replica in multiprocess. Vediamo come è strutturata:

  1. Un programma padre che crea i due processi figli (lo chiameremo, ad esempio, processes.c). Equivale al main() del programma multithread dello scorso articolo.
  2. Un programma figlio che verrà lanciato due volte dal programma padre (lo chiameremo, ad esempio, process.c). Equivale al threadfunc() del programma multithread dello scorso articolo.
  3. Una mini-libreria di comunicazione IPC che usa un memory-mapped file (la chiameremo, ad esempio, mmap.c). Contiene la struttura dati da condividere, che è equivalente alla shdata del programma multithread dello scorso articolo.

Ed ora bando alle ciance, cominciamo con 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 "mmap.h"

// funzione main()
int main(int argc, char* argv[])
{
    // crea il memory mapped file
    const char *mmname = "mydata";
    shdata *data;
    if ((data = memMapOpen(mmname, true)) == NULL) {
        printf("sono il padre (%d): memMapOpen error\n", getpid());
        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 = "process";
        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 = "process";
        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 10 sec per fermare i figli\n", getpid());
        sleep(10);
        data->stop = true;

        // attende la terminazione dei processi figli
        int status;
        pid_t wpid;
        while ((wpid = wait(&status)) > 0)
            printf("sono il padre (%d): figlio %d terminato (%d)\n", getpid(),
                   (int)wpid, status);

        // chiude il memory mapped file ed esce
        printf("%s: processi terminati: counter=%ld\n", argv[0], data->counter);
        memMapClose(mmname, data);
        exit(EXIT_SUCCESS);
    }
    else {
        // errore nella fork()
        printf("error: %s\n", strerror(errno));
        memMapClose(mmname, data);
        exit(EXIT_FAILURE);
    }
}
Direi che il codice è sufficientemente chiaro, visto che è strutturalmente semplice e ben commentato. Qualche dubbio potrebbe sorgere a chi è poco pratico della fork(), per cui consiglio, al solito, la lettura del manuale UNIX/Linux nel link, dove viene bene illustrata. Per essere brevi (visto che questo non è un articolo sui segreti della fork()) posso solo riassumere che questa system call “sdoppia” il processo padre creandone uno identico che è il figlio. I due processi proseguono autonomamente dopo la chiamata di sistema e, normalmente, il codice prevede due flussi diversi in base al risultato della fork(). Spesso, ma non sempre, il processo figlio esegue un altro programma attraverso la funzione exec(): e questo è il nostro caso. Tra l’altro questo è il caso (si fa per dire) “peggiore”: eseguendo un altro programma la separazione tra i processi è totale, quindi farli parlare tra di loro è un po’ più complicato.

Il programma multithread dello scorso articolo aveva due thread e, visto che questo nuovo esempio doveva essere identico, processes.c crea, come detto sopra al punto 1, due figli: notare il “trucchetto” che ho usato per realizzare questo (segnatevelo, non è del tutto usuale): le due fork() vengono chiamate in una espressione AND. Perché? Forse non è evidente a prima vista, ma la fork() “sdoppiando” il processo chiamante, deve essere chiamata “in cascata” con una certa cautela, visto che il numero di processi aumenterà con progressione geometrica di ragione 2, quindi è facile ottenere 2, 4, 8… processi, ma per averne tre, come nel nostro caso (un padre e due figli) bisogna operare come mostrato nell’esempio. E aggiungo una curiosità: cosa succede se metto una fork() in un loop? Succede che il sistema collassa, perché avete creato una fork bomb!

E ora passiamo al codice dei processi figli, process.c:

// process.c - main processo figlio
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "mmap.h"

// funzione main()
int main(int argc, char* argv[])
{
    // apre il memory mapped file
    const char *mmname = "mydata";
    shdata *data;
    if ((data = memMapOpen(mmname, false)) == NULL) {
        printf("processo %d: memMapOpen error\n", getpid());
        exit(EXIT_FAILURE);
    }

    // process loop
    printf("processo %d partito\n", getpid());
    unsigned long i = 1;
    for (;;) {
        // lock mutex
        pthread_mutex_lock(&data->mutex);

        // incrementa i counter
        data->counter++;
        i++;

        // unlock mutex
        pthread_mutex_unlock(&data->mutex);

        // test stop flag
        if (data->stop) {
            // il processo esce
            printf("processo %d terminato dal padre (i=%ld counter = %ld)\n",
                   getpid(), i, data->counter);
            exit(EXIT_SUCCESS);
        }

        // sleep processo (uso usleep solo per comodità)
        usleep(1000);
    }

    // il processo esce per altro motivo che lo stop flag
    printf("processo %d terminato localmente (i=%ld counter = %ld)\n",
           getpid(), i, data->counter);
    exit(EXIT_SUCCESS);
}
Non so se avete notato: è, come promesso, praticamente identico alla funzione threadfunc() del programma multithread dello scorso articolo, con l’unica differenza (quasi trascurabile) del modo di accesso ai dati condivisi, che qui usa l’IPC fornito dalla mini-libreria mmap. Visto che questo codice l’ho, praticamente, già` descritto in altri articoli, possiamo passare subito all’ultima parte, la mmap: vai col codice!
// 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
    bool            stop;       // flag per stop processi
    unsigned long   counter;    // dato comune ai processi
} shdata;

// prototipi globali
shdata *memMapOpen(const char *mmname, bool create);
int    memMapClose(const char *mmname, shdata *ptr);
questo era l’header mmap.h. La struttura dei dati condivisi shdata è, come promesso, esattamente identica a quella usata nel programma multithread dello scorso articolo. E ora ci manca solo l’implementazione mmap.c:
// mmap.c - implementazione mini-libreria IPC con memory mapped file
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <sys/mman.h>
#include "mmap.h"

// memMapOpen() - apre un memory mapped file
shdata *memMapOpen(
    const char *mmname,     // nome del memory mapped file
    bool       create)      // flag di creazione/apertura
{
    shdata *ptr;

    // test se modo create o modo open di un file già creato
    if (create) {
        // crea la shared memory (il file "mmname" è creato in /dev/shm)
        int fd;
        if ((fd = shm_open(mmname, O_CREAT | O_RDWR, 0666)) < 0) {
            fprintf(stderr, "shm_open error: %s\n", strerror(errno));
            return NULL;
        }

        // tronca la shared memory
        if (ftruncate(fd, sizeof(shdata)) < 0) {
            fprintf(stderr, "ftruncate error: %s\n", strerror(errno));
            close(fd);
            return NULL;
        }

        // mappa il memory mapped file
        if ((ptr = mmap(NULL, sizeof(shdata),
                        PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) < 0) {
            fprintf(stderr, "mmap error: %s\n", strerror(errno));
            close(fd);
            return NULL;
        }

        // 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 altri dati comuni ai processi
        ptr->stop    = false;
        ptr->counter = 0;
    }
    else {
        // apre la shared memory (il file "mmname" è creato in /dev/shm)
        int fd;
        if ((fd = shm_open(mmname, O_RDWR, 0666)) < 0) {
            fprintf(stderr, "shm_open error: %s\n", strerror(errno));
            return NULL;
        }

        // mappa il memory mapped file
        if ((ptr = mmap(NULL, sizeof(shdata),
                        PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) < 0) {
            fprintf(stderr, "mmap error: %s\n", strerror(errno));
            close(fd);
            return NULL;
        }

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

    // ritorna il pointer
    return ptr;
}

// memMapClose() - chiude un memory mapped file
int memMapClose(
    const char *mmname,     // nome del memory mapped file
    shdata     *ptr)        // pointer alla memoria condivisa
{
    // elimina il mutex
    if (pthread_mutex_destroy(&ptr->mutex) < 0) {
        fprintf(stderr, "pthread_mutex_destroy error: %s\n", strerror(errno));
        return -1;
    }

    // un-mappa il memory mapped file
    if (munmap(ptr, sizeof(shdata)) < 0) {
        fprintf(stderr, "munmap error: %s\n", strerror(errno));
        return -1;
    }

    // rimuove la shared memory
    if (shm_unlink(mmname) < 0) {
        fprintf(stderr, "shm_unlink error: %s\n", strerror(errno));
        return -1;
    }

    // success
    return 0;
}
Come avrete notato ho scritto una mini-libreria di una semplicità disarmante. Contiene solo due chiamate, una, la memMapOpen(), per creare/aprire il memory-mapped file (e inizializzare i dati) e un’altra, la memMapClose(), per chiuderlo. Non ci sono funzioni di lettura, scrittura, ecc.: una volta aperta la memoria condivisa ci si accede esattamente come se fosse una variabile (pointer) del programma. Una cosa veramente semplicissima.

E ora cosa ci manca per compiere la promessa? Ah, si, avevo detto …con risultati di esecuzione abbastanza interessanti… E allora vediamo cosa scrivono sul terminale i nostri due programmi (threads.c e processes.c)  durante l’esecuzione (su UNIX o Linux, ovviamente, una versione per Windows non userebbe fork(), ma questa è un altra storia…):

programma multithread "threads"
-------------------------------
thread 139969076664064 partito
thread 139969068271360 partito
thread 139969076664064 terminato dal main (i=9067 counter=18134))
thread 139969068271360 terminato dal main (i=9068 counter=18135))
./threads: thread terminati: counter=18135

programma multiprocess "processes"
----------------------------------
sono il padre (5780): attendo 10 sec per fermare i figli
sono il figlio 1 (5781): eseguo il nuovo processo
sono il figlio 2 (5782): eseguo il nuovo processo
processo 5781 partito
processo 5782 partito
processo 5782 terminato dal padre (i=9051 counter = 18099)
processo 5781 terminato dal padre (i=9051 counter = 18100)
sono il padre (5780): figlio 5781 terminato (0)
sono il padre (5780): figlio 5782 terminato (0)
./processes: processi terminati: counter=18100
Allora: entrambi i programmi operano per 10 secondi (come da codice, e se uno non si fida può eseguirli usando il comando time), e risulta che eseguono lo stesso numero di cicli (notare i valori dei contatori). Ossia: la versione multiprocess ha, esattamente le stesse prestazioni della versione multithread! Ecco perché parlavo di risultati interessanti… Tra l’altro anche usando una sleep di 1 us invece di una sleep di 1 ms (o, addirittura, senza nessuna sleep), si ottengono risultati comparabili, questo nel caso che qualcuno pensasse che il test è falsato da tempi di riposo troppo lunghi.

Ovviamente non sto dicendo che, magicamente, gli heavy weight process sono esattamente intercambiabili con i light weight process, ma, come evidenziato nel precedente articolo, le differenze prestazionali sono meno evidenti di quello che si crede (il context-switch dei thread è più leggero ma non così tanto). Concludendo: se vi fidate di me, quando avete dei dubbi seguite lo specchietto (quello in 3 punti) del precedente articolo, non ve ne pentirete!

Ciao, e al prossimo post!