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).
- Task 1. Il programma
mmaptest.ccrea un file da 100 MB, ci fa 1 000 000 di accessi casuali conmmap()e poi gli stessi accessi con le system calllseek()eread(), stampando i due tempi. Compilarlo ed eseguirlo: i tempi sono diversi? Perché? - 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_SHAREDdei flag dimmap()e la funzionesprintf(). Salvare il programma inmmapshared.c.
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):
/* 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
openconO_CREAT | O_TRUNC | O_RDWR: crea il file (lettura e scrittura, permessi ).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.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.- 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 è unaload. - Secondo ciclo:
lseekpiùreadsono due system call (due passaggi da modalità utente a kernel) e lareadcopia 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
loadnel codice utente. Le system call costano centinaia di nanosecondi ciascuna, a fronte di pochi nanosecondi per unaloadin cache. - Niente copia:
readcopia i dati dal kernel al buffer utente; conmmapla 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
/* 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_SHAREDfa sì che la mappatura sia una vista condivisa delle stesse pagine del file (della page cache): laforkcopia 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_PRIVATEsarebbe 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 conmsync). - Nel programma del corso la
ftruncateè fatta a 4096 byte; i byte non scritti sono zeri, quindi%sstampa la stringa fino al terminatore.
Errori tipici
- Dimenticare
ftruncatesu un file nuovo:mmapha successo ma il primo accesso oltre la fine del file dàSIGBUS. - Usare
MAP_PRIVATEe aspettarsi che il padre veda le scritture del figlio. - Non controllare
MAP_FAILED(che è(void *)-1, nonNULL). - Dimenticare di sincronizzare padre e figlio (
waito un semaforo). - Credere che
close(fd)annulli la mappatura: va fattomunmap.
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 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.