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 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!