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

Po co mapować plik zamiast czytać go przez read()

Klasyczny odczyt pliku to open() i pętla read() kopiująca dane do bufora. Część baz danych, linkerów i innych programów wybiera mmap(): mapuje plik w przestrzeni adresowej i korzysta z niego przez dostęp do pamięci. To jeden z modeli I/O, a nie wspólna strategia wszystkich baz danych.

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;
    if (fstat(fd, &st) == -1) { perror("fstat"); close(fd); return 1; }
    if (st.st_size <= 0) { close(fd); return 0; }                    // potrzebujemy rozmiaru pliku

    /* Mapuj cały plik do przestrzeni adresowej — bez kopiowania */
    unsigned 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"); close(fd); 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]);
    if (st.st_size > 1024L * 1024 * 1024) printf("Bajt na 1 GB: 0x%02x\n", data[1024L * 1024 * 1024]);
    // Brak mapowania strony może wywołać fault; dane mogą już być w cache.

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

Po mmap() można zamknąć deskryptor — mapowanie trzyma własną referencję. Późniejszy dostęp może wywołać page fault obsługiwany przez kernel. Sam fault nie oznacza błędu programu; rozróżnienie rozwijam w anatomii segfaulta.

Demand paging — leniwe ładowanie

Przy zwykłym mapowaniu bez flag wymuszających wcześniejsze ładowanie mmap() ustala zakres i uprawnienia. Strony oraz ich wpisy w tablicach stron mogą być dołączane dopiero przy dostępie. Page fault może skorzystać z istniejącego page cache albo wymagać I/O; readahead może pobrać więcej danych niż strona, której właśnie dotknąłeś.

# 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ę syscalli read/mmap; strace nie zlicza page faultów
$ strace -c -e trace=read,mmap ./read_version  2>&1 | tail -5
$ strace -c -e trace=mmap ./mmap_version 2>&1 | tail -5

Przy losowym dostępie nie musisz wczytywać całego pliku. mmap() pozwala odwoływać się do wybranych zakresów, a pread() oferuje odczyt z podanego offsetu bez osobnego lseek(). Rzeczywista ilość I/O zależy od cache, readaheadu i wzorca dostępu.

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 udostępnia zmiany procesom mapującym ten sam obszar. Nadal potrzebujesz synchronizacji dostępu, np. muteksa współdzielonego między procesami; widoczność zmian nie jest też gwarancją ich trwałego zapisu na dysku. Mechanikę oczekiwania opisuje artykuł o futeksach.

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

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
/* 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 (shared == MAP_FAILED) { perror("mmap"); return 1; }
    pid_t pid = fork();
    if (pid == -1) { perror("fork"); munmap(shared, 4096); return 1; }
    if (pid == 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 zwykłych buforowanych 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ą.

Jeśli dane są już w page cache, dołączenie ich do mapowania może obejść się bez odczytu z urządzenia. Ten wspólny mechanizm dla read() i mmap() rozwijam w artykule o page cache w Linuksie. Prywatne strony zmienione przez CoW mają już inny charakter niż oryginalne strony pliku.

# 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 — porzuć strony mapowania; nie gwarantuje usunięcia z page cache
# 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 przez globalny sigjmp_buf nie jest uniwersalnym sposobem zabezpieczenia odczytu, zwłaszcza w programie wielowątkowym. Najpierw ustal, kto może skrócić plik, i skoordynuj jego cykl życia. Gdy potrzebujesz błędów zwracanych przez zwykłe wywołanie, rozważ pread().

mmap vs read() — decision matrix

ScenariuszWybór
Random access do wielkiego pliku (>100 MB)Porównaj mmap z pread na tych samych zakresach danych
Plik czytany wielokrotnie przez wiele procesówmmap — współdzielony page cache
Shared memory IPC między procesamimmap (MAP_SHARED) z uzgodnionym mechanizmem synchronizacji
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/ORozważ io_uring i zmierz batching oraz kolejkę operacji; nie ma uniwersalnego progu IOPS

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, może być właściwym 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.

O wyborze decyduje wzorzec dostępu i sposób obsługi błędów. Sam brak wywołań read() nie mówi, ile danych pobrano z urządzenia ani ile czasu kosztowały page faulty.

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.

W repozytorium jest eksperyment mmap kontra read w Pythonie. Jego mm.read() i wycinki tworzą obiekty bytes, więc nie jest to pomiar dostępu bez kopiowania. Metody losowe generują różne offsety, a tworzenie pliku rozgrzewa cache — traktuj wyniki jako obserwację tego skryptu, nie rozstrzygnięcie wydajności API.


Powiązane artykuły

Weryfikacja techniczna: mmap(2), madvise(2).

Dodaj komentarz

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

Przewijanie do góry