Překvapuje mě, že se vyskytl problém, ale zdá se, že je to problém na Linuxu (testoval jsem na Ubuntu 16.04 LTS běžícím na VMWare Fusion VM na mém Macu) – ale na mém Macu s macOS 10.13 problém nebyl. 4 (High Sierra) a neočekával bych, že to bude problém ani na jiných variantách Unixu.
Jak jsem poznamenal v komentáři:
Za každým streamem je popis otevřeného souboru a popisovač otevřeného souboru. Když se proces rozvětví, podřízený prvek má svou vlastní sadu deskriptorů otevřených souborů (a datových proudů souborů), ale každý deskriptor souboru v podřízeném prvku sdílí popis otevřeného souboru s nadřazeným prvkem. KDYŽ (a to je velké 'if') podřízený proces uzavírající deskriptory souborů nejprve provedl ekvivalent
lseek(fd, 0, SEEK_SET)
, pak by to také umístilo deskriptor souboru pro nadřazený proces, což by mohlo vést k nekonečné smyčce. Nikdy jsem však neslyšel o knihovně, která by toto vyhledávala; není důvod to dělat.
Viz POSIX open()
a fork()
pro více informací o deskriptorech otevřených souborů a popisech otevřených souborů.
Otevřené deskriptory souborů jsou soukromé pro proces; popisy otevřených souborů jsou sdíleny všemi kopiemi deskriptoru souboru vytvořeného počáteční operací „otevření souboru“. Jednou z klíčových vlastností popisu otevřeného souboru je aktuální pozice hledání. To znamená, že podřízený proces může změnit aktuální pozici hledání pro rodiče – protože je to v popisu sdíleného otevřeného souboru.
neof97.c
Použil jsem následující kód – mírně upravenou verzi originálu, která se čistě kompiluje s přísnými možnostmi kompilace:
#include "posixver.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
enum { MAX = 100 };
int main(void)
{
if (freopen("input.txt", "r", stdin) == 0)
return 1;
char s[MAX];
for (int i = 0; i < 30 && fgets(s, MAX, stdin) != NULL; i++)
{
// Commenting out this region fixes the issue
int status;
pid_t pid = fork();
if (pid == 0)
{
exit(0);
}
else
{
waitpid(pid, &status, 0);
}
// End region
printf("%s", s);
}
return 0;
}
Jedna z úprav omezuje počet cyklů (děti) na pouhých 30. Použil jsem datový soubor se 4 řádky po 20 náhodných písmenech plus nový řádek (celkem 84 bajtů):
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
Spustil jsem příkaz pod strace
na Ubuntu:
$ strace -ff -o st-out -- neof97
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
…
uGnxGhSluywhlAEBIXNP
plRXLszVvPgZhAdTLlYe
ywYaGKiRtAwzaBbuzvNb
eRsjPoBaIdxZZtJWfSty
$
Bylo 31 souborů s názvy ve tvaru st-out.808##
kde hash byla 2-ciferná čísla. Soubor hlavního procesu byl poměrně velký; ostatní byly malé, s jednou z velikostí 66, 110, 111 nebo 137:
$ cat st-out.80833
lseek(0, -63, SEEK_CUR) = 21
exit_group(0) = ?
+++ exited with 0 +++
$ cat st-out.80834
lseek(0, -42, SEEK_CUR) = -1 EINVAL (Invalid argument)
exit_group(0) = ?
+++ exited with 0 +++
$ cat st-out.80835
lseek(0, -21, SEEK_CUR) = 0
exit_group(0) = ?
+++ exited with 0 +++
$ cat st-out.80836
exit_group(0) = ?
+++ exited with 0 +++
$
Náhodou se stalo, že první 4 děti vykazovaly jedno ze čtyř chování – a každá další skupina 4 dětí vykazovala stejný vzorec.
To ukazuje, že tři ze čtyř dětí skutečně dělaly lseek()
na standardním vstupu před ukončením. Očividně jsem teď viděl, jak to dělá knihovna. Nemám ponětí, proč se to považuje za dobrý nápad, ale empiricky se to děje.
neof67.c
Tato verze kódu používá samostatný proud souboru (a deskriptor souboru) a fopen()
místo freopen()
také naráží na problém.
#include "posixver.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
enum { MAX = 100 };
int main(void)
{
FILE *fp = fopen("input.txt", "r");
if (fp == 0)
return 1;
char s[MAX];
for (int i = 0; i < 30 && fgets(s, MAX, fp) != NULL; i++)
{
// Commenting out this region fixes the issue
int status;
pid_t pid = fork();
if (pid == 0)
{
exit(0);
}
else
{
waitpid(pid, &status, 0);
}
// End region
printf("%s", s);
}
return 0;
}
To také vykazuje stejné chování, kromě toho, že deskriptor souboru, na kterém probíhá hledání, je 3
místo 0
. Takže dvě z mých hypotéz jsou vyvráceny – souvisí to s freopen()
a stdin
; oba jsou ve druhém testovacím kódu zobrazeny nesprávně.
Předběžná diagnóza
IMO, to je chyba. Neměli byste být schopni narazit na tento problém. S největší pravděpodobností jde o chybu v knihovně Linuxu (GNU C) spíše než v jádře. Je to způsobeno lseek()
v dětských procesech. Není jasné (protože jsem se nešel podívat na zdrojový kód), co knihovna dělá nebo proč.
GLIBC Bug 23151
Chyba GLIBC 23151 – Rozvětvený proces s neuzavřeným souborem provádí před ukončením vyhledávání a může způsobit nekonečnou smyčku v nadřazeném I/O.
Chyba byla vytvořena 2018-05-08 USA/Pacifik a byla uzavřena jako NEPLATNÁ do 2018-05-09. Uvedený důvod byl:
Přečtěte si prosím http://pubs.opengroup.org/onlinepubs/9699919799/functions/V2_chap02.html#tag_15_05_01, zejména tento odstavec:
Všimněte si, že po
fork()
, dva úchyty existují tam, kde jeden existoval dříve. […]
POSIX
Úplná část POSIX, na kterou se odkazuje (kromě slovesných poznámek, že to není pokryto standardem C), je toto:
2.5.1 Interakce deskriptorů souborů a standardních I/O streamů
K popisu otevřeného souboru lze přistupovat prostřednictvím deskriptoru souboru, který je vytvořen pomocí funkcí jako
open()
nebopipe()
nebo prostřednictvím streamu, který je vytvořen pomocí funkcí jakofopen()
nebopopen()
. Buď deskriptor souboru nebo proud se nazývá "handle" v popisu otevřeného souboru, na který odkazuje; otevřený popis souboru může mít několik popisovačů.Úchyty lze vytvořit nebo zničit explicitní akcí uživatele, aniž by to ovlivnilo základní popis otevřeného souboru. Některé ze způsobů, jak je vytvořit, zahrnují
fcntl()
,dup()
,fdopen()
,fileno()
afork()
. Mohou být zničeny minimálněfclose()
,close()
aexec
funkce.Deskriptor souboru, který se nikdy nepoužívá v operaci, která by mohla ovlivnit posun souboru (například
read()
,write()
nebolseek()
) se nepovažuje za popisovač pro tuto diskusi, ale mohl by k němu vzniknout (například v důsledkufdopen()
,dup()
nebofork()
). Tato výjimka nezahrnuje deskriptor souboru, který je základem streamu, ať už byl vytvořen pomocífopen()
nebofdopen()
, pokud není přímo použit aplikací k ovlivnění posunu souboru.read()
awrite()
funkce implicitně ovlivňují offset souboru;lseek()
explicitně to ovlivňuje.Výsledek volání funkcí zahrnující libovolný jeden handle ("aktivní handle") je definován jinde v tomto svazku POSIX.1-2017, ale pokud jsou použity dva nebo více handlerů a kterýkoli z nich je stream, aplikace musí zajistit, aby jejich akce byly koordinovány, jak je popsáno níže. Pokud tak neučiníte, výsledek není definován.
Handle, který je proudem, je považován za uzavřený, když je
fclose()
nebofreopen()
s neúplným názvem souboru se na něm provede (profreopen()
s nulovým názvem souboru je definováno implementací, zda je vytvořen nový popisovač nebo znovu použit stávající), nebo když proces vlastnící daný stream skončí sexit()
,abort()
nebo kvůli signálu. Deskriptor souboru je uzavřen znakemclose()
,_exit()
neboexec()
funkce, když je na tomto deskriptoru souboru nastavena funkce FD_CLOEXEC.
[sic] Použití 'non-full' je pravděpodobně překlep pro 'non-null'.
Aby se úchyt stal aktivním úchytem, musí aplikace zajistit, aby byly následující akce provedeny mezi posledním použitím úchytu (aktuální aktivní úchyt) a prvním použitím druhého úchytu (budoucím aktivním úchytem). Druhý úchyt se pak stane aktivním úchytem. Veškerá činnost aplikace ovlivňující posun souboru na prvním popisovači bude pozastavena, dokud se znovu nestane aktivním popisovačem souboru. (Pokud má funkce proudu jako základní funkci funkci, která ovlivňuje posun souboru, funkce proudu se považuje za funkci ovlivňující posun souboru.)
Aby tato pravidla platila, nemusí být ovladače ve stejném procesu.
Všimněte si, že po
fork()
, dva úchyty existují tam, kde jeden existoval dříve. Aplikace zajistí, že pokud je někdy možné přistupovat k oběma ovladačům, oba jsou ve stavu, kdy by se ten druhý mohl stát aktivním ovladačem jako první. Aplikace se připraví nafork()
přesně jako by to byla změna aktivní rukojeti. (Pokud je jedinou akcí prováděnou jedním z procesů jeden zexec()
funkcí nebo_exit()
(nikoliexit()
), k rukojeti se v tomto procesu nikdy nepřistupuje.)Pro první rukojeť platí první použitelná podmínka níže. Po provedení níže požadovaných akcí a pokud je úchyt stále otevřený, aplikace jej může zavřít.
Pokud se jedná o deskriptor souboru, není vyžadována žádná akce.
Pokud je jedinou další akcí, kterou lze provést na libovolném popisovači tohoto otevřeného deskriptoru souboru, zavřít jej, není třeba provádět žádnou akci.
Pokud se jedná o stream, který nemá vyrovnávací paměť, není třeba provádět žádnou akci.
Pokud se jedná o proud, který je ukládán do vyrovnávací paměti řádku a poslední bajt zapsaný do proudu byl
<newline>
(to znamená, jako byputc('\n')
byla nejnovější operace na tomto streamu), není třeba provádět žádnou akci.Pokud se jedná o stream, který je otevřený pro zápis nebo připojení (ale ne také otevřený pro čtení), aplikace buď provede
fflush()
nebo bude proud uzavřen.Pokud je stream otevřen pro čtení a je na konci souboru (
feof()
je pravda), není třeba podnikat žádné kroky.Pokud je stream otevřen v režimu, který umožňuje čtení a základní popis otevřeného souboru odkazuje na zařízení, které je schopné vyhledávat, aplikace buď provede
fflush()
nebo bude proud uzavřen.Pro druhou rukojeť:
- Pokud byl některý z předchozích aktivních ovladačů použit funkcí, která explicitně změnila offset souboru, s výjimkou výše uvedených požadavků pro první ovladač, aplikace provede
lseek()
nebofseek()
(podle typu rukojeti) na vhodné místo.Pokud aktivní popisovač přestane být přístupný dříve, než budou splněny požadavky na první popisovač výše, stav popisu otevřeného souboru se stane nedefinovaným. K tomu může dojít během funkcí, jako je
fork()
nebo_exit()
.
exec()
funkce znepřístupní všechny proudy, které jsou otevřené v době, kdy jsou volány, nezávisle na tom, které proudy nebo deskriptory souborů mohou být dostupné pro nový obraz procesu.Když jsou tato pravidla dodržována, bez ohledu na posloupnost použitých úchytů, implementace zajistí, že aplikace, i když se skládá z několika procesů, bude přinášet správné výsledky:žádná data se při zápisu neztratí ani nebudou duplikovat a všechna data budou zapsána. pořadí, s výjimkou případů, kdy to požadovali. Je definováno implementací, zda a za jakých podmínek je veškerý vstup vidět právě jednou.
O každé funkci, která funguje na streamu, se říká, že má nula nebo více „základních funkcí“. To znamená, že funkce stream sdílí určité vlastnosti se základními funkcemi, ale nevyžaduje, aby mezi implementacemi funkce stream a jejími základními funkcemi existoval nějaký vztah.
Exegeze
To je těžké čtení! Pokud vám není jasný rozdíl mezi popisovačem otevřeného souboru a popisem otevřeného souboru, přečtěte si specifikaci open()
a fork()
(a dup()
nebo dup2()
). Definice pro deskriptor souboru a popis otevřeného souboru jsou také relevantní, pokud jsou stručné.
V kontextu kódu v této otázce (a také pro nechtěné podřízené procesy vytvářené při čtení souboru) máme otevřený popisovač souborového streamu pouze pro čtení, který ještě nenarazil na EOF (takže feof()
nevrátí true, i když je čtená pozice na konci souboru).
Jednou z klíčových částí specifikace je:Aplikace se připraví na fork()
přesně jako by to byla změna aktivní rukojeti.
To znamená, že kroky uvedené pro „první popisovač souboru“ jsou relevantní a při jejich procházení je první použitelná podmínka poslední:
- Pokud je stream otevřen v režimu, který umožňuje čtení a základní popis otevřeného souboru odkazuje na zařízení, které je schopno vyhledávat, aplikace buď provede
fflush()
nebo bude proud uzavřen.
Pokud se podíváte na definici fflush()
, najdete:
Pokud streamovat ukazuje na výstupní proud nebo aktualizační proud, ve kterém nebyla zadána nejnovější operace,
fflush()
způsobí, že všechna nezapsaná data pro tento proud budou zapsána do souboru, [CX] ⌦ a časová razítka poslední úpravy dat a poslední změny stavu souboru základního souboru budou označeny pro aktualizaci.U proudu otevřeného pro čtení s popisem základního souboru, pokud soubor ještě není v EOF a soubor je schopen vyhledávat, musí být offset základního otevřeného souboru nastaven na pozici souboru proudu, a všechny znaky posunuté zpět do streamu pomocí
ungetc()
neboungetwc()
které nebyly následně přečteny z proudu, budou vyřazeny (bez další změny offsetu souboru). ⌫
Není přesně jasné, co se stane, když použijete fflush()
do vstupního proudu spojeného s nehledatelným souborem, ale to není náš bezprostřední zájem. Pokud však píšete obecný kód knihovny, možná budete muset vědět, zda je základní deskriptor souboru vyhledatelný, než provedete fflush()
na proudu. Případně použijte fflush(NULL)
aby systém udělal vše, co je nezbytné pro všechny I/O streamy, s tím, že tím dojde ke ztrátě všech vrácených znaků (přes ungetc()
atd).
lseek()
operace uvedené v strace
Zdá se, že výstup implementuje fflush()
sémantika spojující offset souboru otevřeného popisu souboru s pozicí souboru streamu.
Takže pro kód v této otázce se zdá, že fflush(stdin)
je nutné před fork()
aby byla zajištěna konzistence. Pokud to neuděláte, vede to k nedefinovanému chování ('pokud to neuděláte, výsledek je nedefinovaný') — jako je smyčkování na neurčito.
Volání exit() zavře všechny popisovače otevřených souborů. Po rozvětvení mají podřízený a rodič identické kopie zásobníku provádění, včetně ukazatele FileHandle. Když dítě skončí, zavře soubor a resetuje ukazatel.
int main(){
freopen("input.txt", "r", stdin);
char s[MAX];
prompt(s);
int i = 0;
char* ret = fgets(s, MAX, stdin);
while (ret != NULL) {
//Commenting out this region fixes the issue
int status;
pid_t pid = fork(); // At this point both processes has a copy of the filehandle
if (pid == 0) {
exit(0); // At this point the child closes the filehandle
} else {
waitpid(pid, &status, 0);
}
//End region
printf("%s", s);
ret = fgets(s, MAX, stdin);
}
}