mmap — kiedy memory-mapped I/O bije read() i write()

Dlaczego bazy danych nie czytają plików przez read()

Klasyczny sposób czytania pliku to open(), potem pętla read() kopiująca dane do bufora w userspace. Działa, jest przewidywalny i dla większości aplikacji wystarczający. Tymczasem bazy danych, runtime’y JVM, linkery i każdy system operujący na wielkich plikach robią coś innego: mapują plik bezpośrednio do przestrzeni adresowej przez mmap, po czym czytają go jak zwykłą tablicę w pamięci.

Różnica nie jest kosmetyczna. read() wykonuje syscall i kopiuje dane z page cache do twojego bufora — dwie kopie tego samego bajtu w RAM. mmap eliminuje tę kopię: twój wskaźnik wskazuje bezpośrednio na strony page cache, a kernel ładuje je leniwie (demand paging) dopiero przy faktycznym dostępie. Dla pliku 50 GB, z którego czytasz losowe fragmenty, to różnica fundamentalna.

Ten artykuł rozkłada mmap: mechanikę demand paging, różnicę MAP_SHARED vs MAP_PRIVATE, relację z page cache, oraz — równie ważne — kiedy mmap jest pułapką: małe pliki, random writes, ryzyko SIGBUS przy truncacji.

Czym jest mmap — mapowanie zamiast kopiowania

mmap (memory map) to syscall tworzący nowe mapowanie w wirtualnej przestrzeni adresowej procesu. Mapowanie może wskazywać na plik (file-backed) lub na anonimową pamięć (anonymous — właśnie tego używa allocator glibc dla dużych alokacji). Po zmapowaniu pliku, dostęp do pamięci jest tłumaczony przez MMU na dostęp do odpowiednich fragmentów pliku.

#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
    int fd = open("/data/large.bin", O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    struct stat st;
    fstat(fd, &st);                    // potrzebujemy rozmiaru pliku

    /* Mapuj cały plik do przestrzeni adresowej — bez kopiowania */
    char *data = mmap(
        NULL,                          // kernel wybiera adres
        st.st_size,                    // rozmiar mapowania
        PROT_READ,                     // tylko odczyt
        MAP_PRIVATE,                   // prywatna kopia-przy-zapisie
        fd,
        0                              // offset w pliku
    );
    if (data == MAP_FAILED) { perror("mmap"); return 1; }

    close(fd);   // fd można zamknąć — mapowanie pozostaje ważne

    /* Czytaj plik jak zwykłą tablicę — kernel ładuje strony leniwie */
    printf("Pierwszy bajt: 0x%02x\n", data[0]);
    printf("Bajt na 1 GB: 0x%02x\n", data[1024L * 1024 * 1024]);
    // Każdy dostęp do nieczytanej strony → page fault → kernel ładuje z dysku

    munmap(data, st.st_size);          // zwolnij mapowanie
    return 0;
}

Kluczowa obserwacja: po mmap można zamknąć deskryptor pliku — mapowanie trzyma własną referencję. Dostęp do data[0] czy data[1GB] wygląda jak zwykła indeksacja tablicy, ale pod spodem uruchamia maszynerię page fault, którą opisywaliśmy w kontekście anatomii segfaulta — z tą różnicą, że tu fault jest legalny i kernel go obsługuje, ładując odpowiednią stronę z dysku.

Demand paging — leniwe ładowanie

To jest sedno przewagi mmap. mmap sam w sobie nie czyta niczego z dysku — tworzy tylko mapowanie i wypełnia tablicę stron wpisami oznaczonymi jako „nieobecne” (not present). Dopiero gdy program faktycznie dotknie konkretnego adresu, MMU generuje page fault, a kernel ładuje tę jedną stronę (4 KB) z dysku.

# Zobacz demand paging w akcji — minor vs major page faults
$ /usr/bin/time -v ./mmap_program 2>&1 | grep -i fault
    Major (requiring I/O) page faults: 1247    # strony wczytane z dysku
    Minor (reclaiming a frame) page faults: 89 # strony już w page cache

# Porównaj liczbę page faults dla read() vs mmap na tym samym pliku
$ strace -c -e trace=read,mmap ./read_version  2>&1 | tail -5
$ strace -c -e trace=mmap ./mmap_version 2>&1 | tail -5

Konsekwencja praktyczna: jeśli mapujesz plik 50 GB, ale czytasz z niego tylko 100 MB rozrzucone losowo — kernel wczyta z dysku tylko te ~100 MB, strona po stronie, na żądanie. read() wymagałby albo wczytania całości, albo skomplikowanej logiki lseek() + read() dla każdego fragmentu. To dlatego random access do wielkich plików to flagowy przypadek użycia mmap.

MAP_SHARED vs MAP_PRIVATE — fundamentalna różnica

Flaga trybu mapowania determinuje co dzieje się przy zapisie i czy zmiany są widoczne dla innych procesów. To rozróżnienie jest źródłem zarówno mocy, jak i subtelnych bugów.

AspektMAP_SHAREDMAP_PRIVATE
Zapis trafia do plikuTak (przez page cache)Nie (copy-on-write)
Widoczność dla innych procesówTak — współdzielone stronyNie — prywatna kopia po zapisie
Mechanizm przy zapisieStrona oznaczona dirty, writebackCoW — kopia strony dla zapisującego
ZastosowanieIPC, pamięć współdzielona, zapis do plikuŁadowanie kodu (.text), read-only dane

MAP_SHARED czyni strony współdzielonymi: wiele procesów mapujących ten sam plik widzi te same fizyczne strony RAM, a zapis jednego procesu jest natychmiast widoczny dla pozostałych. To podstawa shared memory IPC — najszybszej formy komunikacji międzyprocesowej, bo eliminuje jakiekolwiek kopiowanie.

MAP_PRIVATE używa copy-on-write — tego samego mechanizmu, który napędza wywołanie fork(): dopóki tylko czytasz, dzielisz strony z page cache. Pierwszy zapis do strony tworzy prywatną kopię, widoczną tylko dla twojego procesu. Tak ładowany jest segment .text każdego programu — wszystkie instancje /bin/bash współdzielą te same strony kodu, dopóki żadna ich nie modyfikuje.

Shared memory między procesami

/* Proces A — zapisuje do współdzielonego mapowania anonimowego */
#include <sys/mman.h>
#include <string.h>

int main(void) {
    /* Anonimowe MAP_SHARED — współdzielone z procesami potomnymi po fork() */
    char *shared = mmap(
        NULL, 4096,
        PROT_READ | PROT_WRITE,
        MAP_SHARED | MAP_ANONYMOUS,    // brak pliku, czysta pamięć
        -1, 0
    );

    if (fork() == 0) {
        /* Dziecko widzi zapisy rodzica — te same fizyczne strony */
        strcpy(shared, "wiadomość od dziecka");
        return 0;
    }

    wait(NULL);
    printf("Rodzic czyta: %s\n", shared);   // widzi zapis dziecka
    munmap(shared, 4096);
    return 0;
}

Relacja z page cache

Tu mmap pokazuje swoją prawdziwą naturę. Gdy mapujesz plik file-backed, strony mapowania są stronami page cache — tego samego cache, którego Linux używa do buforowania wszystkich operacji plikowych. To dlatego mmap eliminuje kopię: read() kopiuje z page cache do twojego bufora, a mmap mapuje page cache bezpośrednio w twoją przestrzeń adresową.

Praktyczna konsekwencja: jeśli plik jest już w page cache (bo ktoś go niedawno czytał), mmap + dostęp to czyste minor page faults — zero I/O dyskowego, samo zmapowanie istniejących stron. To samo czyni mmap doskonałym do plików czytanych wielokrotnie przez różne procesy: wszyscy współdzielą jedną kopię w page cache.

# Sprawdź ile pliku jest w page cache (przed i po mmap)
$ vmtouch /data/large.bin
           Files: 1
     Directories: 0
  Resident Pages: 0/12800  0%      # nic w cache

$ ./mmap_program /data/large.bin   # mapuje i czyta fragmenty
$ vmtouch /data/large.bin
  Resident Pages: 3200/12800  25%  # tylko dotknięte strony w cache

# Kontrola zachowania cache przez madvise w kodzie:
# MADV_SEQUENTIAL — readahead agresywny (czytanie liniowe)
# MADV_RANDOM     — wyłącz readahead (random access)
# MADV_DONTNEED   — zwolnij strony z cache (po przetworzeniu)
# MADV_WILLNEED   — prefetch (zapowiedź dostępu)

madvise() to klucz do wydajności mmap: informujesz kernel o wzorcu dostępu, a on dostosowuje readahead. Dla czytania liniowego MADV_SEQUENTIAL włącza agresywny prefetch; dla random access MADV_RANDOM wyłącza marnotrawny readahead.

Kiedy mmap jest pułapką

mmap nie jest uniwersalnie lepszy od read(). Istnieją scenariusze, w których jest wolniejszy, bardziej ryzykowny lub po prostu niewłaściwy.

ScenariuszProblem
Małe pliki (< kilka KB)Narzut mmap/munmap + page fault > koszt prostego read()
Sekwencyjny odczyt razread() z buforem bywa szybszy — prostszy readahead
Random writes do plikuDirty pages + nieprzewidywalny writeback, trudna kontrola
Plik może być skróconySIGBUS przy dostępie do strony za nowym końcem pliku
Sieciowe systemy plików (NFS)Semantyka spójności mmap na NFS jest problematyczna
Potrzeba obsługi błędów I/Oread() zwraca błąd; mmap sygnalizuje przez SIGBUS/SIGSEGV

Najgroźniejsza pułapka to SIGBUS. Jeśli zmapujesz plik, a następnie inny proces (lub ty sam) skróci go przez truncate(), dostęp do strony za nowym końcem pliku wygeneruje SIGBUS — sygnał, który domyślnie zabija proces. W przeciwieństwie do read(), które grzecznie zwróciłoby błąd lub krótszy odczyt, mmap nie ma sposobu na „miękkie” zgłoszenie tego problemu w czasie dostępu.

/* Obsługa SIGBUS przy ryzyku truncacji — zaawansowany wzorzec */
#include <signal.h>
#include <setjmp.h>

static sigjmp_buf jump_buffer;

void sigbus_handler(int sig) {
    siglongjmp(jump_buffer, 1);   // wyskocz z dostępu który spowodował SIGBUS
}

int safe_access(char *mapped_data, size_t offset) {
    signal(SIGBUS, sigbus_handler);

    if (sigsetjmp(jump_buffer, 1) == 0) {
        volatile char c = mapped_data[offset];   // może rzucić SIGBUS
        return c;
    } else {
        /* Tu trafiamy po SIGBUS — plik został skrócony */
        fprintf(stderr, "Dostęp poza końcem pliku (truncacja)\n");
        return -1;
    }
}

mmap vs read() — decision matrix

ScenariuszWybór
Random access do wielkiego pliku (>100 MB)mmap — demand paging, zero zbędnego I/O
Plik czytany wielokrotnie przez wiele procesówmmap — współdzielony page cache
Shared memory IPC między procesamimmap (MAP_SHARED) — najszybszy IPC
Sekwencyjny odczyt całości razread() — prostszy, dobry readahead
Małe pliki konfiguracyjneread() — narzut mmap się nie zwraca
Plik o zmiennym rozmiarze / ryzyko truncacjiread() — brak ryzyka SIGBUS
Streaming, pipe, socketread() — mmap wymaga seekable fd
Krytyczna obsługa błędów I/Oread() — błędy jako wartość zwracana
Bardzo wysoki throughput I/O (>200k IOPS)io_uring — completion-based, omija oba

Diagnostyka — narzędzia

NarzędzieZastosowanie
cat /proc/<pid>/mapsWszystkie mapowania procesu (file-backed i anonimowe)
cat /proc/<pid>/smapsSzczegóły per-mapowanie: RSS, dirty, swap, shared/private
pmap -x <pid>Czytelne zestawienie mapowań z rozmiarami
vmtouchIle pliku jest w page cache; manualne ładowanie/zwalnianie
/usr/bin/time -vMajor vs minor page faults programu
strace -e mmap,munmap,madviseFaktyczne wywołania mapowania i podpowiedzi
perf stat -e page-faultsProfil page faultów pod obciążeniem

Podsumowanie: mmap jako narzędzie precyzyjne

mmap to nie „szybszy read()” — to inny model dostępu do plików, który eliminuje kopiowanie przez bezpośrednie mapowanie page cache w przestrzeń adresową procesu. Dla random access do wielkich plików, współdzielenia pamięci między procesami i plików czytanych wielokrotnie, jest fundamentalnie lepszym narzędziem. Dla małych plików, czystego streamingu i scenariuszy wymagających twardej obsługi błędów I/O — read() pozostaje właściwym wyborem.

Kluczowe zrozumienia: demand paging ładuje strony leniwie przy dostępie, nie przy mmap; MAP_SHARED współdzieli strony i zapisuje do pliku, MAP_PRIVATE używa copy-on-write; strony file-backed mapowania stronami page cache; a SIGBUS przy truncacji to realne ryzyko produkcyjne. Ta wiedza domyka obraz zarządzania pamięcią — od malloc przez page faults po bezpośredni dostęp do plików.

Gdy następnym razem zobaczysz bazę danych mapującą wielogigabajtowy plik zamiast czytać go przez read() — już wiesz, że to nie przypadek, tylko świadoma decyzja inżynierska oparta na demand paging i współdzielonym page cache.


Powiązane artykuły

Zostaw komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry