Potoki w Linuksie — dlaczego EOF nie przychodzi po zakończeniu procesu

Proces produkujący dane zakończył się z kodem 0. Odbiorca przeczytał jego wynik, ale kolejny odczyt nadal czeka. Dodanie opóźnienia niczego nie zmienia. To dobry moment, żeby przestać pytać „czy dziecko jeszcze działa?” i zapytać „kto nadal ma otwarty koniec zapisu?”.

W tym artykule badamy zwykły anonimowy potok w Linuksie, jednego odbiorcę i odczyty o dodatnim rozmiarze. Nie analizujemy gniazd, ponownego otwierania FIFO ani trybu pakietowego pipe2(). Eksperyment używa Pythona jako cienkiej warstwy nad deskryptorami, a nie jako modelu buforowania strumieni tekstowych.

EOF opisuje stan kanału, nie status procesu

Dla pustego potoku są dwa różne przypadki: istnieje jeszcze otwarty koniec zapisu albo nie istnieje już żaden. W pierwszym blokujący odczyt czeka, a nieblokujący zgłasza EAGAIN. W drugim odczyt zwraca 0, czyli EOF. Zakończenie konkretnego producenta nie wystarcza, jeśli gdzieś pozostał inny deskryptor zapisu. To reguła opisana w pipe(7).

Wynik read() mniejszy od zamówionego rozmiaru nie oznacza końca. Odczyt czterech bajtów z żądanych 64 jest poprawnym wynikiem. Natomiast wywołanie read() z rozmiarem 0 nie nadaje się do wykrywania EOF: może zwrócić zero bez sprawdzania stanu, który chcesz zbadać. W przykładzie każde żądanie odczytu ma rozmiar 64 bajtów. Zobacz read(2).

Numer deskryptora nie jest właścicielem całego potoku

Deskryptor jest uchwytem w procesie. dup() tworzy drugi uchwyt do tego samego opisu otwartego pliku. Zamknięcie pierwotnego numeru nie zamyka duplikatu. W przypadku końca zapisu potoku oznacza to możliwość zachowania kanału otwartego przez uchwyt, którego program już nie używa. Szczegóły współdzielenia opisuje dup(2).

To wyjaśnienie posługuje się modelem osiągalnych uchwytów, a nie dosłownym licznikiem wszystkich deskryptorów wewnątrz jądra. Nie należy z niego wnioskować, że każde dup() zwiększa konkretny licznik „writers” w implementacji. Do diagnozy interesuje nas, czy nadal istnieje odniesienie utrzymujące otwarty koniec zapisu.

Po fork() rodzic i dziecko mają deskryptory wskazujące te same opisy otwartych plików. Dlatego w typowym układzie rodzic–odbiorca i dziecko–producent rodzic zamyka niepotrzebny mu koniec zapisu, a dziecko koniec odczytu. Samo zamknięcie w dziecku nie usuwa kopii rodzica. Jest to zastosowanie reguł fork(2), szerzej wyjaśnionych w artykule o fork(), copy-on-write i deskryptorach.

Eksperyment: producent nie żyje, ale EOF jeszcze nie ma

Zapisz poniższy kompletny program jako pipe_eof.py. Wymaga Linuksa i Pythona 3.10 lub nowszego; używa wyłącznie biblioteki standardowej. Tworzy jeden proces potomny i dwa małe potoki, bez plików roboczych, sudo ani zmian ustawień systemu. Uruchom go jako osobny skrypt.

Rodzic celowo zachowuje extra, duplikat końca zapisu. subprocess.run() wraca po zakończeniu dziecka. Pierwotny uchwyt w jest następnie zamykany, a extra pozostaje otwarty. Odczyty nieblokujące pozwalają wykazać brak EOF bez wieszania programu. ExitStack porządkuje zamykanie deskryptorów także przy wyjątkach; jawne kontrole działają również po uruchomieniu Pythona z opcją -O.

#!/usr/bin/env python3
"""Bounded Linux pipe experiment; creates only a four-byte child output."""
import os
import select
import subprocess
import sys
from contextlib import ExitStack


def require(condition: bool, message: str) -> None:
    if not condition:
        raise RuntimeError(message)


def experiment() -> None:
    if not sys.platform.startswith("linux"):
        raise SystemExit("This experiment requires Linux.")
    with ExitStack() as cleanup:
        r, w = os.pipe()
        cleanup.callback(os.close, r)
        writers = ExitStack()
        cleanup.callback(writers.close)
        writers.callback(os.close, w)
        extra = os.dup(w)
        extra_writer = ExitStack()
        cleanup.callback(extra_writer.close)
        extra_writer.callback(os.close, extra)
        os.set_blocking(r, False)
        child = subprocess.run(
            [sys.executable, "-I", "-c",
             "import os; n = os.write(1, b'DATA'); raise SystemExit(n != 4)"],
            stdout=w, stdin=subprocess.DEVNULL, close_fds=True,
            check=True, timeout=5,
        )
        writers.close()
        print(f"producer exit: {child.returncode}")
        first = os.read(r, 64)
        require(first == b"DATA", f"Unexpected payload: {first!r}")
        print(f"payload: {first!r}")
        try:
            result = os.read(r, 64)
        except BlockingIOError:
            print("extra writer open: EAGAIN")
        else:
            raise RuntimeError(f"Expected EAGAIN, got {result!r}")
        extra_writer.close()
        result = os.read(r, 64)
        require(result == b"", f"Expected EOF, got {result!r}")
        print(f"last writer closed: {result!r} (EOF)")

    with ExitStack() as cleanup:
        r, w = os.pipe()
        cleanup.callback(os.close, r)
        writers = ExitStack()
        cleanup.callback(writers.close)
        writers.callback(os.close, w)
        os.set_blocking(r, False)
        require(os.write(w, b"TAIL") == 4, "Short fixture write")
        writers.close()
        poller = select.poll()
        poller.register(r, select.POLLIN)
        events = poller.poll(0)
        mask = next((mask for fd, mask in events if fd == r), 0)
        require(bool(mask & select.POLLHUP), "Expected POLLHUP")
        print("buffered after close: POLLHUP")
        tail = os.read(r, 64)
        require(tail == b"TAIL", f"Lost buffered bytes: {tail!r}")
        print(f"buffered payload: {tail!r}")
        require(os.read(r, 64) == b"", "Expected EOF after draining")
        print("drained: EOF")


if __name__ == "__main__":
    experiment()
python3 pipe_eof.py

TEST WYKONANY: Python 3.12.14, Linux 6.18.55, x86_64, środowisko izolowane. Poniżej rzeczywisty wynik wykonania, nie pomiar wydajności:

producer exit: 0
payload: b'DATA'
extra writer open: EAGAIN
last writer closed: b'' (EOF)
buffered after close: POLLHUP
buffered payload: b'TAIL'
drained: EOF

Pierwsze cztery linie rozdzielają trzy obserwacje: dziecko zakończyło się poprawnie, jego cztery bajty zostały odebrane, a następny odczyt nadal zgłaszał EAGAIN. Dopiero zamknięcie extra zmieniło wynik na pusty ciąg bajtów. Nie użyliśmy sleep() ani założenia, że po określonym czasie proces „powinien już nie działać”.

Program korzysta z os.read(), którego wynik nie przechodzi przez bufor tekstowy. os.set_blocking(r, False) ustawia O_NONBLOCK. Interfejsy os.pipe() i os.dup() w użytej wersji Pythona tworzą deskryptory niedziedziczne przy exec; nie zapobiega to utrzymaniu extra w samym rodzicu. Dokumentacja: os.

POLLHUP nie daje pozwolenia na wyrzucenie końcówki danych

Druga część programu zapisuje TAIL i zamyka koniec zapisu przed odczytem. poll() zgłasza POLLHUP, ale następujący odczyt nadal zwraca TAIL. EOF pojawia się dopiero po odebraniu tych bajtów. To oddzielny warunek od samego zamknięcia nadawcy, zgodny z opisem POLLHUP w poll(2).

Wnioskiem dla pętli zdarzeń jest konieczność obsłużenia danych, które jeszcze czekają. Zamykanie odbiorcy natychmiast po zobaczeniu HUP może utracić ostatni fragment odpowiedzi. Nasz program wymaga bitu POLLHUP, ale nie porównuje całej maski z jedną stałą: inne bity mogą wystąpić jednocześnie. Przypadek jest celowo pozbawiony konkurencyjnego czytelnika, który mógłby odebrać dane między obserwacją a odczytem.

Dlaczego close-on-exec nie naprawia wszystkiego

FD_CLOEXEC działa przy udanym execve(), nie przy samym fork(). Ogranicza przedostawanie się zbędnych uchwytów do nowego programu, ale nie zamyka uchwytu w rodzicu ani w dziecku, które nie wykonuje exec. Regułę zachowania deskryptorów opisuje execve(2).

W przykładzie close_fds=True ogranicza niezamierzone przekazanie deskryptorów do uruchamianego programu; stdout=w jest zamierzonym przekierowaniem. Żadna z tych opcji nie odbiera rodzicowi jego extra. Praktyczna poprawka musi dotyczyć właściciela zbędnego uchwytu. Ustawienie flagi w innym procesie nie zastąpi tej zmiany.

Nie pomyl braku EOF z blokadą przez pełny bufor

Ten eksperyment czeka na producenta przed odczytem tylko dlatego, że producent zapisuje dokładnie cztery bajty do początkowo pustego potoku. Nie kopiuj tej kolejności do przechwytywania nieograniczonego wyjścia. Dziecko może zablokować się na zapisie, podczas gdy rodzic czeka na jego zakończenie. Dokumentacja subprocess ostrzega przed takim użyciem Popen.wait() z PIPE; communicate() obsługuje wymianę danych, ale przechowuje odczyt w pamięci.

Rozdziel hipotezy: „brakuje zamknięcia nadawcy” oraz „nikt nie odbiera produkowanych danych”. Pierwszy przypadek odtworzyliśmy; drugiego celowo nie wywołujemy. Dla dużego strumienia zaprojektuj bieżący odbiór i ograniczenia pamięci. Timeout pomaga wykryć brak postępu, lecz sam nie wskazuje, która hipoteza jest prawdziwa.

Jak szukać zgubionego uchwytu

Zacznij od własności zasobów: kto tworzy potok, kto wykonuje dup()/dup2(), które procesy dziedziczą uchwyty i na jakiej ścieżce każdy je zamyka. Uwzględnij obsługę błędów i pomocnicze procesy. Nazwa zmiennej „producer” nie dowodzi, że tylko ten proces ma koniec zapisu.

Na maszynie pozwalającej śledzić własne procesy można uruchomić:

strace -f -e trace=pipe,pipe2,dup,dup2,dup3,fcntl,read,write,close,poll,wait4 python3 pipe_eof.py

ŚLEDZENIE ZABLOKOWANE: w środowisku testowym strace nie uzyskał PTRACE_TRACEME: Operation not permitted. Program uruchomiony bez tracera przeszedł kontrole. Nie przedstawiamy zatem fikcyjnego trace’u. Flaga -f śledzi procesy potomne, a filtr ogranicza wyświetlane wywołania; sprawdź strace(1). Nie zmieniaj globalnych zabezpieczeń tylko na potrzeby tej demonstracji.

W śladzie szukaj relacji: utworzenie potoku, utworzenie dodatkowego odniesienia, zamknięcie jednego uchwytu i późniejsze zamknięcie ostatniego. Numery deskryptorów nie muszą być takie same pomiędzy uruchomieniami. Wynik EAGAIN jest tutaj dowodem stanu nieblokującego odczytu, a nie błędem transmisji.

Granice eksperymentu i wniosek inżynierski

Test wykazuje wpływ duplikatu w rodzicu i zachowanie danych po zamknięciu nadawcy. Nie mierzy pojemności potoku, narzutu schedulera ani przepustowości; nie testuje wszystkich wersji Pythona i jądra. Wariant z uchwytem pozostawionym w innym procesie wynika z dokumentacji, nie z oddzielnego pomiaru w tym przykładzie. Kod nie jest biblioteką obsługi dowolnych strumieni.

Gdy czekasz na EOF, audytujesz czas życia kanału. Zakończenie procesu jest tylko jedną z operacji wpływających na ten czas. W praktyce warto projektować własność deskryptorów tak samo jawnie jak własność pamięci: każde dodatkowe odniesienie potrzebuje uzasadnienia i miejsca zamknięcia.

Źródła i zakres weryfikacji

Źródła odczytano 25.10.2026. Podręczniki Linux man-pages 6.19; dokumentacja API Python 3.11, wykonanie Python 3.12.14. Różne wersje dokumentacji i interpretera są podane jawnie; test potwierdza tylko użyte interfejsy w opisanym środowisku.

Dodaj komentarz

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

Przewijanie do góry