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.

domenica 14 gennaio 2018

Remapped File
come sincronizzare un Memory Mapped File in C - pt.2

Beh, credo che sia ora di pubblicare la seconda parte di Remapped File. Spero che vi siate ricaricate bene durante le feste, magari guardando un gran film come Primer, che contiene internamente vari remake di se stesso: potete rivederlo quante volte volete e ogni volta scoprirete dettagli nuovi e, inevitabilmente, perderete dettagli vecchi, entrando in un loop temporale senza fine come i protagonisti stessi del film. Vi assicuro che il post di questo mese non è complicato da capire come Primer, di cui si trovano in rete addirittura pagine wiki dedicate alle timeline con tanto di descrizioni grafiche...
...è nato prima l'uovo o la gallina?...
Allora, andiamo avanti con la nostra libreria per IPC. Dopo aver descritto l'header file (libmmap.h) e un doppio esempio di uso (datareader.c e datawriter.c) è venuto il momento di descrivere la vera e propria implementazione. Ovviamente chi non ha letto la prima parte deve vergognarsi e andare subito a leggerla, e poi tornare qui.

Tornati? Ok, bando alle ciance... vai col codice!
#include <string.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include "libmmap.h"

// memMapOpenMast() - apre un mapped-file come master
ShmData *memMapOpenMast(
    const char *shmname,    // nome del mapped-file
    size_t     len)         // size del campo data da condividere
{
    // apre un mapped-file (il file "shmname" é creato in /dev/shm)
    int fd;
    if ((fd = shm_open(shmname, O_CREAT|O_RDWR, S_IRUSR|S_IWUSR)) == -1)
        return NULL;    // esce con errore

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

    // mappa un mapped-file
    ShmData *shmdata;
    if ((shmdata = mmap(NULL, sizeof(ShmData) + len,
            PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == MAP_FAILED)
        return NULL;    // esce con errore

    // init semaforo
    if (sem_init(&shmdata->sem, 1, 1) == -1)
        return NULL;    // esce con errore

    // init flag di data_ready e lunghezza
    shmdata->data_ready = false;
    shmdata->len = len;

    // ritorna il descrittore
    return shmdata;
}

// memMapOpenMast() - apre un mapped-file come slave
ShmData *memMapOpenSlav(
    const char *shmname,    // nome del mapped-file
    size_t     len)         // size del campo data da condividere
{
    // apre un mapped-file (il file "shmname" é creato in /dev/shm)
    int fd;
    if ((fd = shm_open(shmname, O_RDWR, S_IRUSR|S_IWUSR)) == -1)
        return NULL;    // esce con errore

    // mappa un mapped-file
    ShmData *shmdata;
    if ((shmdata = mmap(NULL, sizeof(ShmData) + len,
            PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == MAP_FAILED)
        return NULL;    // esce con errore

    // init semaforo
    if (sem_init(&shmdata->sem, 1, 1) == -1)
        return NULL;    // esce con errore

    // ritorna il descrittore
    return shmdata;
}

// memMapClose() - chiude un mapped-file
int memMapClose(
    const char *shmname,    // nome del mapped-file
    ShmData    *shmdata)    // pointer al mapped-file
{
    // elimina semaforo
    if (sem_destroy(&shmdata->sem) < 0)
        return -1;      // esce con errore

    // un-mappa un mapped-file
    if (munmap(shmdata, sizeof(ShmData)) < 0)
        return -1;      // esce con errore

    // cancella un mapped-file
    if (shm_unlink(shmname) < 0)
        return -1;      // esce con errore

    // esce con Ok
    return 0;
}

// memMapFlush() - flush di un mapped-file
int memMapFlush(
    ShmData *shmdata)       // pointer al mapped-file
{
    // sync su disco di un mapped-file
    return msync(shmdata, sizeof(ShmData) + shmdata->len, MS_SYNC);
}

// memMapRead() - legge dati dal mapped-file
int memMapRead(
    void    *dest,
    ShmData *src)
{
    // lock memoria
    sem_wait(&src->sem);

    // test presenza dati nel mapped-file
    if (src->data_ready) {
        // legge dati dal mapped-file
        memcpy(dest, src->data, src->len);
        src->data_ready = false;

        // unlock memoria ed esce
        sem_post(&src->sem);
        return 1;
    }
    else {
        // unlock memoria ed esce
        sem_post(&src->sem);
        return 0;
    }
}

// memMapWrite() - scrive dati nel mapped-file
void memMapWrite(
    ShmData    *dest,
    const void *src)
{
    // lock memoria
    sem_wait(&dest->sem);

    // scrive dati nel mapped-file
    memcpy(dest->data, src, dest->len);
    dest->data_ready = true;

    // unlock memoria ed esce
    sem_post(&dest->sem);
}
Come si nota è abbastanza semplice e conciso, e, come sempre, il codice è auto-esplicativo, ampiamente commentato e con commenti che parlano da soli.

Tutte le funzioni usano, internamente, le opportune system call Linux/POSIX per trattare il nostro Memory Mapped File. In caso di errore su una system call si ritorna immediatamente -1, e questo ci permette, a livello applicativo, di usare direttamente strerror() per verificare l'errore (e questo è un argomento che i miei lettori più affezionati dovrebbero conoscere bene...). Come si nota ci sono due funzioni di open, una master e una slave: perché? Perché, il tipo di comunicazione scelto è (leggermente) asimmetrico, quindi è necessario che uno dei due estremi (il writer) apra il canale, mentre l'altro (il reader) accede al canale sono quando lo trova creato. Quindi adesso si comprende meglio (spero) come funzionano le due funzioni descritte nella prima puntata del post. Questo meccanismo asimmetrico ricorda molto il meccanismo Client/Server che si usa coi socket (altro argomento già affrontato qui), e, infatti, questa libreria è una alternativa al classico IPC coi socket.

Le funzioni di read e write si limitano a usare memcpy() per copiare i dati dal/sul mapped-file. E, come anticipato nel post precedente, le letture e scritture usano un meccanismo di sincronizzazione (un POSIX unnamed semaphore) che viene inizializzato nelle funzioni di open: semplicemente chi accede ai dati mette in rosso (lock) il semaforo (quindi chi arriva dopo si ferma) e quando ha finito lo rimette in verde (unlock).

La funzione di flush è solo un wrapper per la chiamata msync() e, normalmente, non è necessario usarla: con questa libreria noi vogliamo trattare file mappati in memoria per condividere dati tra processi, per cui, non solo ci interessa poco che il file abbia una immagine reale sul disco, ma, per una semplice questione di prestazioni dovremmo evitare di scaricare realmente sul disco tutti i cambi effettuati in memoria, se no tanto varrebbe far comunicare i processi con dei file veri. Quindi a cosa serve il flush? Serve solo ad avere una eventuale versione reale e aggiornata del file condiviso, nel caso volessimo trattarlo anche con le classiche funzioni open(), close(), read(), ecc. Per questo nel file msgwriter.c descritto nel post precedente la chiamata memMapFlush() non viene usata.

E veniamo all'altra novità introdotta in questa nuova versione della libmmap: i dati trattati sono, ora, generici, quindi le funzioni di read e write usano dei void* come argomenti: questo è un vantaggio notevole, perché permette di scambiare dati in IPC usando qualsiasi formato; una struttura complessa oppure una singola variabile (chessoio? un int). Ad esempio nell'ultimo post ho definito un tipo Data (una struct con un campo text) da usare a livello applicativo e che si può passare come argomento alle read e write senza neppure fare un cast. Una grande flessibilità, simile a quella delle funzioni della libc come la memcpy(), ma con un qualcosa in più: la dimensione (e, indirettamente, il tipo) dei dati che si scambiano viene passata, una volta per tutte, durante la fase di open (attraverso il parametro len), quindi le funzioni di read e write non hanno il classico campo "size_t len" che uno si aspetterebbe.

Ci manca solo da descrivere una cosa: il trucco del "char data[1]" usato per rendere generici i dati da condividere. Questo campo è (non a caso) l'ultimo della struttura dati che descrive il mapped-file, e funziona così: quando si crea il file si passa, alla memMapOpenMast(), il size dei dati da scambiare (con un operatore sizeof, vedi l'esempio dell'ultimo post), e, come si nota nel codice della memMapOpenMast(), il mapped-file viene mappato usando la system call mmap() passandogli un argomento length che indica la dimensione del mapped-file in oggetto: nel nostro caso si passa "sizeof(ShmData) + len", quindi il mapped-file è impostato per scambiare dati nella sua parte variabile "char data[1]", che di base è lunga un char, ma che, in realtà, è lunga len char una volta mappato il file. Un trucchetto da niente

Spero che la nuova versione della libreria vi sia piaciuta. Vi assicuro che, con solo qualche miglioria (tipo la gestione di tutti gli errori interni possibili, lo sdoppiamento del semaforo per gestire lock read/write, aggiungere meccanismi di accesso blocking/nonblocking... va beh, forse un è un po' più di qualche miglioria!), si potrebbe usare anche in progetti professionali... e scusate se è poco!

Ciao, e al prossimo post!

sabato 9 dicembre 2017

Remapped File
come sincronizzare un Memory Mapped File in C - pt.1

Questo post è un remake. È il remake di un mio post (in due puntate) di quasi due anni fa, Irrational File (forza, forza, andate a rileggerlo, non fatevi pregare!). Normalmente i remake lasciano l'amaro in bocca, non aggiungono nulla di nuovo e, se l'originale era un gran film, difficilmente riescono a eguagliarlo. Ci sono delle eccezioni, però, come The Thing di J.Carpenter (uff, anche questo film l'ho già usato...) che è un capolavoro superiore al (pur buono) originale del 1951. Ecco, questo post appartiene (spero) ai remake buoni, perché, come vedrete, aggiunge molto all'originale.
...un immagine di The Thing per il remake di Irrational Man: poche idee e ben confuse, vedo...
Allora, il post originale descriveva una (semplice) libreria che avevo scritto per condividere dati tra processi (IPC) usando come mezzo di comunicazione un Memory Mapped File. In una frase del vecchio post (che riporto parzialmente) si preannunciava già questo remake: "...per un uso semplice questo metodo va più che bene, ma, per applicazioni più complesse [...] bisognerebbe usare un sistema più evoluto [...] un mutex o un semaforo (ma questa è un altra storia, magari in futuro farò un post sull'argomento)...". Ecco, ora ci siamo: questa è la versione con gli accessi sincronizzati della nostra vecchia libmmap, quasi pronta per un uso professionale.

Ripetendo la struttura del vecchio post (e se no, che remake sarebbe?) ho diviso il tutto in due puntate: nella prima descriverò, a mo' di specifica funzionale, l'header file (libmmap.h) e un esempio di uso (data.h, datareader.c e datawriter.c) basato su due applicazioni comunicanti. Nella seconda puntata descriverò la vera e propria implementazione della libreria (libmmap.c).

Cominciamo: vediamo l'header-file, libmmap.h:
#ifndef LIBMMAP_H
#define LIBMMAP_H

#include <semaphore.h>
#include <stdbool.h>

#define MAPNAME "/shmdata"

// struttura del mapped-file
typedef struct {
    sem_t  sem;         // semaforo di sincronizzazione accessi
    bool   data_ready;  // flag di data ready (true=ready)
    size_t len;         // lunghezza campo data
    char   data[1];     // dati da condividere
} ShmData;

// prototipi globali
ShmData *memMapOpenMast(const char *shmname, size_t len);
ShmData *memMapOpenSlav(const char *shmname, size_t len);
int     memMapClose(const char *shmname, ShmData *shmdata);
int     memMapFlush(ShmData *shmdata);
int     memMapRead(void *dest, ShmData *src);
void    memMapWrite(ShmData *dest, const void *src);

#endif /* LIBMMAP_H */
Semplice e auto-esplicativo, no? La nostra libreria usa una struttura dati ShmData per mappare il mapped-file: qui subito notiamo il primo grosso miglioramento ottenuto: c'è un semaforo (un POSIX unnamed semaphore) per sincronizzare gli accessi alla memoria, il che rende la nostra libreria adatta a un vero uso multitask (e multithread), che era il primo obiettivo programmato. Il meccanismo di sincronizzazione l'ho scelto in maniera oculata tra i vari disponibili: magari un giorno scriverò un post specifico sull'argomento, ma, per il momento, mi limiterò a dire che il metodo scelto è semplice da implementare, molto funzionale e si adatta perfettamente allo scopo da raggiungere (e scusate se è poco!).

Il flag di data_ready si usa (protetto dal semaforo) per segnalare la disponibilità di nuovi dati da leggere. Poi abbiamo, dulcis in fundo, i campi len e data che ci conducono al secondo obiettivo che mi ero prefisso: i dati scambiati sono, ora, generici, con formato e lunghezza che vengono decisi a livello applicativo. Notare, per l'appunto, che il campo data è un array di dimensione 1: questo è una sorta di trucco (da usare con le dovute precauzioni) abbastanza usato nel C per trattare dati generici di forma e lunghezza non disponibili a priori. Nella nostra struct il campo deve essere, ovviamente, posto come ultimo membro, rendendola così una specie di struttura con size variabile. Comunque nel prossimo post vedremo meglio come funziona il tutto.

libmmap.h termina con i prototipi delle funzioni che compongono la libreria: abbiamo due funzioni di apertura, che ci permettono di aprire un mapped-file in modo Master o Slave (nel prossimo post vedremo il perché di questa doppia apertura); poi abbiamo una funzione di chiusura, una di flush, e, ovviamente, due funzioni per scrivere e leggere dati. Queste ultime due funzioni ci confermano il discorso di genericità appena descritto: le variabili di read/write sono di tipo void*, quindi adatte ad accettare qualsiasi tipo di dato. Visto che il formato dei dati da scambiare viene spostato a livello applicativo ho scritto un header-file (di esempio), data.h, che viene incluso dalle due applicazioni comunicanti:
#ifndef DATA_H
#define DATA_H

// definizione struttura data per applicativi di esempio
typedef struct {
    int  type;          // tipo di dati
    int  data_a;        // un dato (esempio)
    int  data_b;        // un altro dato (esempio)
    char text[1024];    // testo dei dati
} Data;

#endif /* DATA_H */
Come vedete ho scelto di usare una struttura dati che include un campo testo, ma si può scambiare qualsiasi cosa, anche solo un semplice int, per esempio. Vediamo ora la prima applicazione d'uso, datawriter.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include "libmmap.h"
#include "data.h"
#include "mysleep.h"

// main del programma di test
int main(int argc, char *argv[])
{
    // apre mapped-file
    ShmData *shmdata;
    if ((shmdata = memMapOpenMast(MAPNAME, sizeof(Data)))) {
        // file aperto: start loop di scrittura
        for (int i = 0; i < 100; i++) {
            // compone dati per il reader
            Data data;
            snprintf(data.text, sizeof(data.text), "nuovi dati %d", i);

            // scrive dati nel mapped-file
            memMapWrite(shmdata, &data);
            printf("ho scritto: %s\n", data.text);

            // loop sleep
            mySleep(100);
        }

        // chiude mapped-file
        memMapClose(MAPNAME, shmdata);
    }
    else {
        // esce con errore
        printf("non posso aprire il file %s (%s)\n", MAPNAME, strerror(errno));
        return EXIT_FAILURE;
    }

    // esce con Ok
    return EXIT_SUCCESS;
}
Semplice, no? Apre il file condiviso in memoria (in modo Master) e lo usa per scrivere dati (in loop) per l'altra applicazione di test, datareader.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include "libmmap.h"
#include "data.h"
#include "mysleep.h"

// main del programma di test
int main(int argc, char *argv[])
{
    // apre aspettando che un writer apra come master il mapped-file
    ShmData *shmdata;
    while ((shmdata = memMapOpenSlav(MAPNAME, sizeof(Data))) == NULL) {
        // accetta solo l'errore di file non ancora esistente
        if (errno != ENOENT) {
            // esce con errore
            printf("non posso aprire il file %s (%s)\n", MAPNAME, strerror(errno));
            return EXIT_FAILURE;
        }

        // loop sleep
        mySleep(100);
    }

    // file aperto: start loop di lettura
    for (int i = 0; i < 100; i++) {
        // cerca dati da leggere nel mapped-file
        Data data;
        if (memMapRead(&data, shmdata)) {
            // mostra i dati letti
            printf("mi hai scritto: %s\n", data.text);
        }

        // loop sleep
        mySleep(100);
    }

    // chiude mapped-file ed esce con Ok
    memMapClose(MAPNAME, shmdata);
    return EXIT_SUCCESS;
}
Il reader è, come si vede, una applicazione speculare del writer (legge invece di scrivere). Notare che, in entrambe le applicazioni, si testano gli errori sulle funzioni di open e si chiude (se necessario) l'esecuzione mostrando l'errore con strerror(): questo è possibile perché (come vedremo nel prossimo post) le funzioni di apertura escono in caso di errore delle funzioni della libc che usano internamente, e, a quel punto, è disponibile con errno la descrizione dell'errore che si è verificato (ma di questo ne abbiamo parlato ampiamente negli ultimi due post, ricordate?).

Usando le due applicazioni in due terminali differenti cosa vedremo?
Nel terminale 1:
aldo@mylinux:~/blogtest$ ./datawriter
ho scritto: nuovi dati 1
ho scritto: nuovi dati 2
ho scritto: nuovi dati 3
^C

Nel terminale 2:
aldo@mylinux:~/blogtest$ ./datareader
mi hai scritto: nuovi dati 1
mi hai scritto: nuovi dati 2
mi hai scritto: nuovi dati 3
Per mandare a dormire i loop ho usato la funzione mySleep(), che è una nostra vecchia conoscenza: si pùo inserire in una libreria a parte o nella stessa libreria libmmap (io ho usato un file a parte mysleep.c con un suo header-file mysleep.h che contiene solo il prototipo). In questo semplice esempio le due applicazioni comunicanti si possono fermare usando CTRL-C, e (quando proverete a usare la libreria) potrete verificare che avviando/fermando/riavviando entrambe le applicazioni, in qualsiasi ordine, si ri-sincronizzano sempre senza problemi.

Per oggi abbiamo finito. In attesa della seconda parte potreste provare a immaginare a come sarà l'implementazione della libreria che vi presenterò... ma si avvicina il Natale e immagino (e spero) che abbiate cose più interessanti da pensare in questo periodo...

Ciao, e al prossimo post!