Salta al contenuto
Note per Studenti Esercizio - File mappati in memoria con mmap e processi che li condividono (laboratorio 8)

Esercizio - File mappati in memoria con mmap e processi che li condividono (laboratorio 8)

In questa pagina 3

Testo (laboratorio 8 del corso di Sistemi Operativi, Ing. Informatica UniPD).

  1. Task 1. Il programma mmaptest.c crea un file da 100 MB, ci fa 1 000 000 di accessi casuali con mmap() e poi gli stessi accessi con le system call lseek() e read(), stampando i due tempi. Compilarlo ed eseguirlo: i tempi sono diversi? Perché?
  2. Task 2. Modificare il programma in modo che due processi (padre e figlio) condividano lo stesso file mappato in memoria. Il figlio scrive un messaggio (per esempio "Hello world!") sul file e termina; il padre aspetta il figlio, legge il messaggio dal file e lo stampa. Suggerimenti: l'opzione MAP_SHARED dei flag di mmap() e la funzione sprintf(). Salvare il programma in mmapshared.c.

Teoria: Sistemi di I-O e memory-mapped I-OIl sistema operativo nasconde la varietà dei dispositivi con driver e un'interfaccia uniforme (dispositivi a blocchi, a caratteri, di rete, timer, ioctl); l'hardware è fatto di porte, bus e controller con registri di dato, stato e controllo, indirizzati con istruzioni di I/O dedicate o con il memory-mapped I/O; la CPU comunica con il dispositivo con il polling (busy wait, adatto a dispositivi veloci), con le interruzioni (linea IRQ, vettore, priorità, mascherabili) o con il DMA (trasferimenti di blocchi senza CPU, un'interruzione alla fine); le chiamate possono essere bloccanti, non bloccanti o asincrone; le istruzioni di I/O sono privilegiate e si passa dalle system call; il kernel gestisce errori, buffer e protezione; mmap() mappa un file nello spazio di indirizzi di un processo: accesso con load e store, caricamento pigro con il demand paging, meno chiamate di sistema e meno copie, condivisione con MAP_SHARED.Sistemi di I-O e memory-mapped I-O →.


Task 1: mmap contro lseek e read

Il programma

Il programma del corso, con due correzioni per usarlo bene (la variabile letta non viene mai usata, quindi con le ottimizzazioni il compilatore potrebbe eliminare il ciclo con mmap, e i valori restituiti da ftruncate, lseek e read vanno controllati):

c
/* confronto.c - accessi casuali a un file da 100 MB: mmap contro lseek + read.
 * Compilare:  gcc -O0 -Wall -Wextra -o confronto confronto.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>

#define NOMEFILE "testfile"
#define DIMENSIONE (1024L * 1024L * 100L) /* 100 MB */
#define ACCESSI 1000000

int main(void) {
    int fd = open(NOMEFILE, O_CREAT | O_TRUNC | O_RDWR, S_IRUSR | S_IWUSR);
    if (fd < 0) { perror("open"); return EXIT_FAILURE; }
    if (ftruncate(fd, DIMENSIONE) < 0) { perror("ftruncate"); return EXIT_FAILURE; }

    /* 1) accessi casuali con mmap: ogni accesso e' un normale load */
    volatile char *dati = mmap(NULL, DIMENSIONE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (dati == MAP_FAILED) { perror("mmap"); return EXIT_FAILURE; }
    srand(time(NULL));
    char byte = 0;
    clock_t inizio = clock();
    for (int i = 0; i < ACCESSI; i++)
        byte ^= dati[rand() % DIMENSIONE];      /* volatile: il compilatore non toglie la lettura */
    printf("mmap():               %.2f s\n", (double)(clock() - inizio) / CLOCKS_PER_SEC);

    /* 2) gli stessi accessi con due system call ciascuno */
    inizio = clock();
    for (int i = 0; i < ACCESSI; i++) {
        char b;
        if (lseek(fd, rand() % DIMENSIONE, SEEK_SET) < 0) { perror("lseek"); return EXIT_FAILURE; }
        if (read(fd, &b, 1) != 1) { perror("read"); return EXIT_FAILURE; }
        byte ^= b;
    }
    printf("lseek() + read():     %.2f s\n", (double)(clock() - inizio) / CLOCKS_PER_SEC);

    munmap((void *)dati, DIMENSIONE);
    close(fd);
    remove(NOMEFILE);
    printf("controllo: %d\n", byte); /* usa 'byte': il ciclo non si puo' eliminare */
    return EXIT_SUCCESS;
}

(Compilato con zig cc per Linux, senza avvisi; non eseguito, quindi non ci sono tempi misurati.)

Cosa fa, passo per passo

  1. open con O_CREAT | O_TRUNC | O_RDWR: crea il file (lettura e scrittura, permessi 600600).
  2. ftruncate(fd, DIMENSIONE): dà al file la lunghezza di 100 MB (senza di essa il file ha lunghezza 0 e non si può mappare niente). È un file sparso: i blocchi non sono ancora allocati; la lettura di una zona mai scritta restituisce zeri.
  3. mmap(NULL, DIMENSIONE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0): mappa tutto il file a partire dall'offset 0, a un indirizzo scelto dal kernel.
  4. Primo ciclo: dati[rand() % DIMENSIONE] è una normale lettura di memoria. Se la pagina non è ancora in RAM c'è un page fault e il kernel la porta nella page cache; altrimenti la lettura è una load.
  5. Secondo ciclo: lseek più read sono due system call (due passaggi da modalità utente a kernel) e la read copia 1 byte dalla page cache a un buffer dell'utente.

Perché i tempi sono diversi

Ci si aspetta che la versione con mmap() sia molto più veloce:

  • Niente system call: i 2 000 000 di chiamate del secondo ciclo hanno un costo fisso ciascuna (cambio di modalità), mentre nel primo ciclo l'accesso è una load nel codice utente. Le system call costano centinaia di nanosecondi ciascuna, a fronte di pochi nanosecondi per una load in cache.
  • Niente copia: read copia i dati dal kernel al buffer utente; con mmap la pagina della page cache è mappata direttamente nello spazio del processo.
  • Costo dei page fault: nel primo ciclo c'è un fault per ogni pagina toccata la prima volta (con 1 000 000 di accessi casuali su 25 600 pagine da 4 KB quasi tutte le pagine vengono toccate), ma sono al più 25 600 contro 2 000 000 di system call. Quando il secondo ciclo parte, le pagine sono già nella page cache.

Quindi mmap() conviene soprattutto per accessi casuali e a pezzi piccoli; per la lettura sequenziale di un file grande la differenza è minore.

Task 2: due processi che condividono un file mappato

Il programma

c
/* mmapshared.c - padre e figlio condividono un file mappato in memoria.
 * Compilare:  gcc -Wall -Wextra -o mmapshared mmapshared.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/wait.h>

#define NOMEFILE "testfile"
#define DIMENSIONE 4096 /* una pagina */

int main(void) {
    int fd = open(NOMEFILE, O_CREAT | O_TRUNC | O_RDWR, S_IRUSR | S_IWUSR);
    if (fd < 0) { perror("open"); return EXIT_FAILURE; }
    if (ftruncate(fd, DIMENSIONE) < 0) { perror("ftruncate"); return EXIT_FAILURE; }

    /* MAP_SHARED: le scritture arrivano al file e sono visibili a chi lo mappa */
    char *dati = mmap(NULL, DIMENSIONE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (dati == MAP_FAILED) { perror("mmap"); return EXIT_FAILURE; }
    close(fd); /* la mappatura resta valida anche dopo la close */

    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return EXIT_FAILURE; }

    if (pid == 0) { /* figlio: scrive */
        snprintf(dati, DIMENSIONE, "%s", "Hello world!");
        printf("figlio: ho scritto \"%s\"\n", dati);
        return EXIT_SUCCESS;
    }

    wait(NULL); /* padre: aspetta, poi legge */
    printf("padre: ho letto \"%s\"\n", dati);
    munmap(dati, DIMENSIONE);
    return EXIT_SUCCESS;
}

(Compilato con zig cc per Linux, senza avvisi; non eseguito. L'uscita attesa, in quest'ordine: figlio: ho scritto "Hello world!" e poi padre: ho letto "Hello world!".)

Perché funziona

  • MAP_SHARED fa sì che la mappatura sia una vista condivisa delle stesse pagine del file (della page cache): la fork copia la tabella delle pagine del padre, quindi anche il figlio ha la mappatura, e le due tabelle puntano agli stessi frame fisici. Una scrittura del figlio è subito visibile al padre, senza nessuna system call di comunicazione.
  • Con MAP_PRIVATE sarebbe diverso: alla prima scrittura il kernel farebbe una copia privata della pagina (copy-on-write) e il padre non vedrebbe il messaggio.
  • wait(NULL) sincronizza: il padre legge solo dopo che il figlio ha scritto e terminato. Senza di esso il padre potrebbe leggere prima, e stampare una stringa vuota.
  • Il messaggio si ritrova anche nel file testfile (il kernel lo scrive su disco quando la pagina viene scaricata, o prima con msync).
  • Nel programma del corso la ftruncate è fatta a 4096 byte; i byte non scritti sono zeri, quindi %s stampa la stringa fino al terminatore.

Errori tipici

  • Dimenticare ftruncate su un file nuovo: mmap ha successo ma il primo accesso oltre la fine del file dà SIGBUS.
  • Usare MAP_PRIVATE e aspettarsi che il padre veda le scritture del figlio.
  • Non controllare MAP_FAILED (che è (void *)-1, non NULL).
  • Dimenticare di sincronizzare padre e figlio (wait o un semaforo).
  • Credere che close(fd) annulli la mappatura: va fatto munmap.

Versione ripasso

Task 1. File da 100 MB con ftruncate, mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0), 1 000 000 di letture casuali con una load contro lseek + read (2 system call e una copia a ogni accesso). Atteso: mmap molto più veloce (niente cambio di modalità, niente copia, pagine nella page cache, al più 25 600 page fault). Il compilatore può eliminare il ciclo se il valore letto non si usa (volatile).

Task 2. open, ftruncate, mmap con MAP_SHARED, fork; il figlio fa snprintf(dati, ..., "Hello world!"); il padre fa wait(NULL) e stampa. Le tabelle delle pagine puntano agli stessi frame, quindi il padre vede la scrittura. Con MAP_PRIVATE sarebbe copy-on-write e il padre non vedrebbe nulla.

Codice essenziale (Task 2). fd = open(NOME, O_CREAT|O_TRUNC|O_RDWR, S_IRUSR|S_IWUSR); ftruncate(fd, 4096); dati = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); close(fd); pid = fork(); nel figlio snprintf(dati, 4096, "%s", "Hello world!") ed exit; nel padre wait(NULL), printf("%s", dati), munmap(dati, 4096). Uscita attesa: prima il figlio ("ho scritto"), poi il padre ("ho letto" lo stesso messaggio).

Perché mmap è più veloce (Task 1). 1 000 000 di accessi casuali su 100 MB (25 600 pagine da 4 KB): mmap usa una load per accesso, con al più 25 600 page fault in tutto; lseek + read costa 2 system call (2 000 000 di cambi di modalità) e una copia kernel →\to utente di 1 byte a ogni accesso. L'accesso a pagine già nella page cache costa pochi ns, una system call centinaia di ns. Con ottimizzazioni il ciclo con mmap può sparire se il valore letto non si usa: volatile. MAP_SHARED: le tabelle di padre e figlio puntano agli stessi frame; MAP_PRIVATE: copy-on-write, il padre non vede la scrittura.

Errori tipici: niente ftruncate (SIGBUS); MAP_PRIVATE; MAP_FAILED non controllato; niente wait; munmap e close confusi.

Teoria: Sistemi di I-O e memory-mapped I-OIl sistema operativo nasconde la varietà dei dispositivi con driver e un'interfaccia uniforme (dispositivi a blocchi, a caratteri, di rete, timer, ioctl); l'hardware è fatto di porte, bus e controller con registri di dato, stato e controllo, indirizzati con istruzioni di I/O dedicate o con il memory-mapped I/O; la CPU comunica con il dispositivo con il polling (busy wait, adatto a dispositivi veloci), con le interruzioni (linea IRQ, vettore, priorità, mascherabili) o con il DMA (trasferimenti di blocchi senza CPU, un'interruzione alla fine); le chiamate possono essere bloccanti, non bloccanti o asincrone; le istruzioni di I/O sono privilegiate e si passa dalle system call; il kernel gestisce errori, buffer e protezione; mmap() mappa un file nello spazio di indirizzi di un processo: accesso con load e store, caricamento pigro con il demand paging, meno chiamate di sistema e meno copie, condivisione con MAP_SHARED.Sistemi di I-O e memory-mapped I-O →.

Teoria collegata