fork() Linux to wywołanie systemowe które zna każdy programista systemowy. Wywołujesz je, masz dwa procesy. Prosty model — i właśnie dlatego prowadzi do błędów których nie rozumiesz, bo nie wiesz co tak naprawdę się stało pod spodem.
Co mówi dokumentacja — i dlaczego to za mało
man fork powie Ci, że wywołanie tworzy nowy proces będący kopią procesu rodzica. Zwraca 0 w procesie dziecka i PID dziecka w procesie rodzica. Klasyczny przykład wygląda tak:
pid_t pid = fork();
if (pid == 0) {
// tu jesteśmy w dziecku
} else if (pid > 0) {
// tu jesteśmy w rodzicu
} else {
// fork() się nie powiódł
}Każdy kto przeszedł przez kurs systemów operacyjnych zna ten schemat. Problem polega na tym, że dokumentacja opisuje interfejs, nie implementację. A implementacja to miejsce gdzie rzeczy stają się interesujące.
fork() Linux i Copy-on-Write: dlaczego fork jest szybki
Gdyby fork() naprawdę kopiował cały proces — wszystkie strony pamięci, stos, stertę, dane — byłoby to katastrofalnie wolne. Serwer webowy obsługujący tysiąc żądań na sekundę, każde przez nowy proces, zatrzymałby się w miejscu.
Linux rozwiązuje to przez mechanizm Copy-on-Write (CoW).
Po wywołaniu fork() dziecko i rodzic współdzielą te same strony pamięci fizycznej. Żadne dane nie są kopiowane. Obie strony widzą identyczną zawartość, ale jądro ustawia te strony jako tylko do odczytu dla obu procesów.
Kopiowanie następuje dopiero wtedy, gdy którykolwiek z procesów — rodzic lub dziecko — próbuje zapisać do danej strony. Jądro wyłapuje to jako page fault, tworzy kopię konkretnej strony, przypisuje ją do procesu który chciał pisać, i kontynuuje wykonanie. Drugi proces nadal widzi oryginał.
W praktyce oznacza to, że fork() jest tani — O(1) w czasie, niezależnie od rozmiaru procesu. Płacisz tylko za te strony pamięci które faktycznie modyfikujesz po rozwidleniu.
Deskryptory plików i subtelność której nie widać
Kolejną rzeczą którą fork() Linux robi automatycznie — a o której większość tutoriali milczy — jest dziedziczenie deskryptorów plików.
Rodzic i dziecko dziedziczą te same deskryptory plików — nie kopie, ale dosłownie ten sam entry w tablicy otwartych plików jądra. Oba procesy współdzielą offset pozycji w pliku.
To ma bezpośrednie konsekwencje:
Jeśli rodzic ma otwarty plik i zapisał do pozycji 100, dziecko widzi offset jako 100. Jeśli dziecko zapisze 50 bajtów, offset przeskakuje do 150 — i rodzic, gdy następnie zapisuje, zaczyna od 150, nie od 100.
Klasyczny błąd w kodzie który robi fork() bez zamknięcia lub zduplikowania deskryptorów: rodzic i dziecko piszą do tego samego pliku logów i wzajemnie nadpisują swoje dane, bo offset jest współdzielony. Nie pojawia się żaden błąd. Logi są po prostu losowo pomieszane.
Poprawne podejście to dup2() lub zamknięcie deskryptorów których dziecko nie potrzebuje — natychmiast po fork(), zanim dziecko zrobi cokolwiek innego.
Signal handlers, muteksy i problemy których nie spodziewasz się w dziecku
Po fork() dziecko dziedziczy signal handlers zdefiniowane przez rodzica. To jest udokumentowane i często oczekiwane.
Co jest mniej oczywiste: dziecko dziedziczy również stan muteksów.
Jeśli w momencie wywołania fork() inny wątek rodzica trzyma muteks — na przykład w trakcie alokacji pamięci przez malloc() — dziecko startuje ze zablokowanym muteksem którego nikt nigdy nie zwolni. Dziecko jest jednowątkowe, wątek który trzymał muteks nie istnieje w przestrzeni adresowej dziecka. Deadlock gwarantowany.
To jest jeden z powodów dla których POSIX definiuje pthread_atfork() — mechanizm rejestrowania handlerów wywoływanych przed i po fork(), które mogą czyścić stan muteksów. W praktyce jest trudny w użyciu i rzadko implementowany poprawnie.
Wniosek praktyczny: jeśli twój program jest wielowątkowy i używa fork(), wiesz co robisz albo masz poważny problem którego jeszcze nie odkryłeś.
Sam fork() Linux rzadko jest celem — jest środkiem.
Sam fork() rzadko jest celem — jest środkiem. Standardowy wzorzec to fork() + exec():
pid_t pid = fork();
if (pid == 0) {
execv("/usr/bin/program", args);
// jeśli execv zwróci — coś poszło nie tak
exit(1);
}execv() zastępuje obraz procesu dziecka całkowicie nowym programem. Wszystko co załadował fork() — cały adresowy obszar rodzica — zostaje wyrzucone i zastąpione nowym plikiem wykonywalnym.
Tu ujawnia się spryt CoW. Skoro dziecko natychmiast wywołuje exec() i wyrzuca cały odziedziczony obraz pamięci — żadna strona nie jest faktycznie kopiowana. Koszt CoW, który i tak jest opóźniony do momentu zapisu, nigdy nie zostaje poniesiony. fork() + exec() jest tani, bo CoW oznacza że dziecko nigdy nie pisze do odziedziczonych stron przed ich wyrzuceniem.
To jest fundament na którym stoi cały model procesu w Unixie — i dlaczego uruchamianie nowych procesów jest w Linuksie szybsze niż mogłoby się wydawać.
Zombie i sieroty — dwa problemy które widzisz w ps aux
Dwa problemy które fork() Linux generuje jeśli nie zadbasz o właściwe sprzątanie: zombie i sieroty.
Przy dużej liczbie procesów zombie zajmują wpisy w tablicy procesów jądra — a ta ma ograniczony rozmiar. Serwer który spawnuje procesy bez właściwego wait() może w końcu wyczerpać limit i przestać tworzyć nowe procesy.
W ps aux rozpoznasz zombie po literze Z w kolumnie STATE i <defunct> przy nazwie procesu.
Sierota to odwrotna sytuacja — rodzic kończy pracę przed dzieckiem. Dziecko zostaje przejęte przez proces init (PID 1), który wywołuje wait() przy jego zakończeniu. Sieroty są zazwyczaj nieszkodliwe — init sprząta po nich automatycznie.
Co to zmienia w praktyce
fork() Linux pojawia się w miejscach których nie zawsze się spodziewasz.
Za każdym razem gdy coś niespodziewanego dzieje się pamięcią lub plikami po rozwidleniu — wiesz teraz gdzie szukać. To samo podejście hipotez i dedukcji które opisałem w artykule o debugowaniu stosuje się tutaj bezpośrednio.
Model „fork kopiuje proces” nie jest fałszywy. Jest po prostu za mało precyzyjny żeby rozwiązywać problemy.
Jak to zbadać samemu
Żeby zobaczyć fork() Linux w akcji na własnym systemie, użyj dwóch narzędzi które są zawsze dostępne.
# Obserwuj tworzenie procesów i wywołania systemowe
strace -f -e trace=fork,clone,execve twój_program
# Sprawdź mapę pamięci procesu przed i po fork()
cat /proc/PID/mapsstrace -f śledzi wywołania systemowe we wszystkich procesach potomnych — flaga -f oznacza „follow forks”. Widząc surowe wywołania systemowe zamiast abstrakcji biblioteki C, przestajesz zgadywać co robi system.
To, co dzieje się z przestrzenią adresową i stronami copy-on-write po wywołaniu fork(), łączy się bezpośrednio z mechaniką błędu opisaną w tekście o anatomii segfaulta — od MMU przez kernel po core dump w gdb. Proces, który właśnie utworzyłeś, możesz następnie odizolować natywnymi prymitywami kernela — o czym jest materiał o cgroups v2 bez Dockera.
