Lezen en schrijven zijn minder symmetrisch dan je (waarschijnlijk) denkt

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

Dit zijn ruwe systeemoproepen; gebruikersprogramma's kunnen ze direct gebruiken, maar dat doen ze meestal niet. Normaal gesproken gaan ze via een gebufferde I/O-laag. In de C-standaardbibliotheek betekent dit FILE* en de functies fread en fwrite, die count opsplitsen in een product van twee waarden als een overblijfsel van record-gebaseerde I/O, maar verder equivalent zijn.

Stel voor de concretheid dat we communiceren met daadwerkelijke opslag (dus geen pipe, socket, virtueel bestandssysteem, etc.). Conceptueel gezien pakt een "read"-operatie (zoals read of fread) bytes uit een bestand op een schijf en plaatst deze in een specifieke geheugenbuffer. Een "write"-operatie neemt bytes uit een geheugenbuffer en schrijft deze naar de schijf. Dat klinkt symmetrisch, maar er zijn enkele belangrijke gedragsmatige asymmetrieën tussen hen, vooral wanneer er fouten optreden. De redenen hiervoor hebben te maken met buffering.

Gebufferde I/O

In het algemeen gaan I/O-operaties in je programma niet direct naar een opslagapparaat; gegevens maken hun weg via verschillende bufferlagen (de meeste kunnen worden uitgeschakeld met diverse vlaggen, maar in normaal gebruik staan deze lagen aan). Deze lagen zijn er met een goede reden:

Aan de kant van de kernel is er wat traditioneel de "buffer cache" wordt genoemd. Opslagapparaten zijn "blokapparaten", wat betekent dat ze gegevens in blokken opslaan. De blokgrootte hangt af van het apparaat:

  • Bij oude harde schijven was dit vaak 512 bytes.
  • CD's en DVD's gebruiken doorgaans 2k-blokken.
  • Nieuwere opslagapparaten maken nu gebruik van 4k-blokken.

Blokapparaten lezen alleen volledige blokken tegelijk. Dit betekent dat willekeurige, op bytes uitgelijnde I/O-verzoeken — zoals "lees 100 bytes van de schijf vanaf byte-offset 1234567" of "schrijf 2000 bytes naar locatie 987654" — helemaal niet direct naar het apparaat kunnen worden gestuurd. De buffer cache wordt gebruikt om deze verzoeken te vertalen naar blok-uitgelijnde lees- en schrijfoperaties die het apparaat begrijpt. Schrijfacties die niet op blokken zijn uitgelijnd, vereisen ook het lezen van de vorige inhoud van het blok die niet wordt overschreven; deze komen ebenfalls in de buffer cache terecht. En zoals de naam al zegt, fungeert het ook als een cache.

Aan de gebruikerszijde hebben we ook buffers, zij het om een andere reden: read en write zijn systeemoproepen en brengen daarom een overgang naar de kernel-space en terug met zich mee. Ook moeten ze bij elke aanroep controleren op fouten en deze rapporteren. Daarnaast moeten ze het eigenlijke werk doen: de gegevens kopiëren vanuit (read) of naar (write) de buffer cache. De overhead van systeemoproepen varieert per OS, maar het is veilig om aan te nemen dat het hele proces in het beste geval minstens een paar honderd klokcycli in beslag neemt.

Om te voorkomen dat de overhead het eigenlijke werk volledig domineert, wil je over het algemeen telkens ten minste een paar kilobytes lezen of schrijven. Ter referentie zijn typische I/O-buffergroottes op het moment van schrijven 4096 bytes (bijv. Visual C++ 2013 FILE, Go bufio.Reader/bufio.Writer) of 8192 bytes (bijv. GNU libc FILE, Java BufferedReader/BufferedWriter).

Vaak zijn er nog meer buffers. De meeste harde schijven en RAID-controllers hebben hun eigen caches, en het is niet ongebruikelijk dat code in de gebruikersruimte meerdere lagen buffering heeft om diverse redenen. Dit is voldoende om de basisstructuur te illustreren.

Al deze buffers worden op vrijwel dezelfde manier gebruikt voor lezen en schrijven. Dus waar zit de gedragsmatige asymmetrie? Je moet nadenken over de staat van de wereld nadat je een leesoproep doet, en hoe die verschilt van de staat na een schrijforoep.

Wat er gebeurt bij een I/O-operatie

Laten we eerst kijken naar wat er gebeurt bij een leesoproep: stel je opent een C FILE* en wilt de eerste 100 bytes lezen via fread.

  1. De C-standaard I/O-bibliotheek merkt dat de buffers momenteel leeg zijn en probeert ze te vullen door een read systeemoproep te doen voor bijvoorbeeld 4k aan gegevens.
  2. De kernel vraagt vervolgens aan het bestandssysteem waar de gegevens voor de eerste 4k van het bestand zich bevinden.
  3. De kernel controleert de buffer cache om te zien of er al een kopie in het geheugen staat. Zo niet, dan wordt er een blok-leesopdracht naar het opslagapparaat gestuurd.
  4. De kernel zorgt ervoor dat die 4k aan gegevens in de buffer cache komen en kopieert ze van daaruit naar de standaard I/O-buffers in het geheugen van de gebruikersruimte, waarna de functie terugkeert.
  5. De standaard I/O-bibliotheek bekijkt het resultaat van de systeemoproep, werkt de status van de I/O-buffers bij en kopieert vervolgens de 100 gevraagde bytes naar de geheugenbuffer die de applicatie heeft aangeleverd.

En wat als er iets misgaat? Stel dat het bestand kleiner is dan 100 bytes, er een fout optreedt bij het lezen van de schijf, of het bestand op een netwerkbestandssysteem staat dat momenteel onbereikbaar is. Als dat gebeurt, vangen we dat op: als er iets misgaat bij het vullen van de buffer cache, merkt de kernel dit en geeft een passende foutmelding terug aan de I/O-bibliotheek, die dit op haar beurt kan doorgeven aan de applicatie. In elk geval zal alles wat mis kan gaan, misgaan voordat de fread-aanroep terugkeert. De tussenliggende lagen hoeven alleen maar te zorgen dat de foutinformatie bewaard blijft zodat deze op het juiste moment aan de applicatie kan worden doorgegeven.

Laten we nu de andere kant bekijken: we openen een nieuw bestand met een 4k schrijfbuffer [1] en voeren een fwrite van 100 bytes uit.

  1. De I/O-bibliotheek kopieert de 100 bytes van de applicatiebuffer naar de schrijfbuffer.
  2. De functie keert onmiddellijk terug en rapporteert succes.

De onderliggende write systeemoproep wordt pas uitgevoerd wanneer de buffer vol is, of wanneer deze wordt geflusht als gevolg van het aanroepen van fflush, fseek, fclose of iets dergelijks.

Een snelle denkbeeldige handopsteek: wie van jullie controleert gewoontegetrouw de return-codes van fread of fwrite? En van hen die "ja" zeggen: wie vergeet niet ook de return-codes van fflush, fseek of fclose te controleren? Waarschijnlijk niet velen. Als je dat niet doet, controleer je eigenlijk helemaal niet of je schrijfacties zijn geslaagd. Hoewel deze opmerkingen C-specifiek zijn, geldt dit algemene patroon voor alle gebufferde writer-implementaties. Gebufferd schrijven stelt de eigenlijke write systeemoproep uit; dat is juist het doel. Maar dat betekent dat ook de foutrapportage wordt uitgesteld!

Meer buffers

Dit type probleem is niet beperkt tot buffering in de gebruikersruimte. De implementatie van write zelf heeft soortgelijke problemen: over het algemeen zijn je gegevens na een succesvolle write-aanroep in de buffer cache terechtgekomen, maar hebben ze de eigenlijke opslag nog niet bereikt. De kernel zal zijn best doen om die gegevens uiteindelijk naar de opslag te schrijven (hopelijk binnen enkele seconden), maar bij een apparaatfout of een systeemcrash kunnen die gegevens alsnog verloren gaan. Tegenwoordig gebeurt dit relatief zeldzaam, dus maken we ons daar niet teveel zorgen over, toch? Behalve wij die dat wel doen.

Bovendien zal write er alles aan doen om nare verrassingen te voorkomen bij het schrijven naar lokale bestandssystemen (bijvoorbeeld: zelfs bij vertraagde write-back wil je er vroegtijdig voor zorgen dat er vrije ruimte op de schijf is gereserveerd [2], zodat je niet tijdens de write-back zonder ruimte komt te zitten). Echter, op POSIX-systemen kunnen er nog steeds schrijf-fouten optreden waarover je pas bij close wordt geïnformeerd, vooral wanneer netwerkbestandssystemen zoals NFS of SMB/CIFS in het spel zijn. Als je deze systeemoproepen gebruikt en niet de return-code van close controleert, kun je fouten missen.

Dit brengt ons bij een ander punt: zelfs op lokale bestandssystemen heb je alleen de garantie dat de gegevens de buffer cache hebben bereikt. Ze zijn niet noodzakelijkerwijs al op het opslagapparaat terechtgekomen! Als je dat wilt (bijvoorbeeld omdat je zojuist belangrijke gegevens hebt geschreven en zeker wilt weten dat ze echt zijn opgeslagen), moet je fsync [3] aanroepen op de file descriptor voordat je deze sluit. Het Windows-equivalent hiervan is FlushFileBuffers.

Dus, als je ervoor zorgt dat je de foutcodes bij elke write controleert en je fsync uitvoert voordat je sluit (terwijl je opnieuw de foutcodes controleert), betekent dat dan dat je veilig bent en de gegevens succesvol naar permanente opslag zijn geschreven?

Er zijn nog twee laatste complicaties. Ten eerste hebben RAID-controllers en opslagapparaten zelf ook caches. Ze zouden genoeg condensatoren moeten hebben zodat ze, als het systeem plotseling stroomverlies lijdt, nog voldoende stroom hebben om je gegevens veilig te schrijven. Hopelijk is dat ook echt zo. Succes.

Ten tweede: de gegevens kunnen wel op de opslag zijn terechtgekomen, maar dat betekent niet noodzakelijkerwijs dat ze ook zichtbaar zijn, omdat de metadata die nodig is om ze te bereiken mogelijk nog niet is geschreven. Citat uit de Linux man-page over fsync(2):

"Het aanroepen van fsync() garandeert niet noodzakelijkerwijs dat de vermelding in de directory die het bestand bevat ook de schijf heeft bereikt. Hiervoor is ook een expliciete fsync() op een file descriptor voor de directory nodig."

Voor beter of slechter kan ik me niet herinneren dat ik dit ooit in de praktijk in code heb gezien. Ik weet eerlijk gezegd niet wat de werkelijke garanties zijn die populaire Linux-bestandssystemen over deze zaken bieden. Als je echt, echt belangrijke gegevens beheert, kun je dat waarschijnlijk het beste zelf uitzoeken.

Conclusie en samenvatting

Buffering aan de leeszijde is geweldig en vrijwel transparant, omdat als er iets misgaat, dit gebeurt voordat je de gegevens ziet en je een correcte foutcode krijgt.

Buffering aan de schrijfzijde is veel lastiger, omdat het het eigenlijke schrijven en de foutrapportage uitstelt op een manier waar de meeste programmeurs zich bewust van zouden moeten zijn, maar dat meestal niet zijn. Weinigen zijn op de hoogte van de stappen die nodig zijn om ervoor te zorgen dat gegevens veilig in de opslag terechtkomen, en sommige OS-abstracties maken het er niet gemakkelijker op (zie het fsync-citaat hierboven). Hier betreden we riskant terrein.

***

Voetnoten

[1] Volledige buffering, niet de line-buffering modus, voor het geval iemand heel nauwkeurig wil zijn.

[2] Werkelijke blokallocatie — het selecteren van welke fysieke locatie op het apparaat de schrijfacties terechtkomen — wordt in moderne bestandssystemen vaak uitgesteld om het makkelijker te maken om waar mogelijk grotendeels aaneengesloten ruimte toe te wijzen aan grote bestanden. Echter, zelfs bij vertraagde allocatie wil je bijhouden hoeveel ruimte er beschikbaar zal zijn op het apparaat zodra alle huidige wachtende schrijfacties zijn voltooid. Zo kun je correct "out of disk space" fouten teruggeven, in plaats van te ontdekken dat je geen ruimte meer hebt 10 seconden nadat de gebruiker de app heeft afgesloten waarmee hij zijn 'Belangrijke Document' bewerkte en opsloeg. Dat zou namelijk erg slecht zijn. Dit klinkt alsof het slechts een kwestie van boekhouden is, maar het wordt complex bij bestandssystemen die extents gebruiken in plaats van bitmap-gebaseerde blokallocatie: het verkrijgen van de laatste paar discontinue blokken op het apparaat kan betekenen dat je extra ruimte nodig hebt om de file extents op te slaan! Kortom: dit is erg lastig om correct te implementeren.

[3] Ja, de naam ziet eruit alsof het onderdeel is van het C-bibliotheek gebufferde I/O-pakket, maar het is een echte systeemoproep.