Zig's Io.Threaded is elegant

Concurrency vs Parallelisme

Citerend uit @tedinski:

Concurrency gaat over het afhandelen van (asynchrone, niet-deterministische) gebeurtenissen.
Parallelisme gaat over het gebruiken van hardwarebronnen om meer tegelijkertijd te doen.

Ik denk dat deze definitie correct is, maar hij biedt niet direct een bruikbaar intuïtief begrip. Is concurrency hetzelfde als state transducers? Ja, overduidelijk, maar dat verduidelijkt niet echt hoe je het programmeert.

Voor de intuïtie gebruik ik graag deze twee lakmoestesten. Ten eerste: parallelisme is deterministisch of "declaratief":

use rayon::prelude::*;

fn sum_of_squares(input: &[i32]) -> i32 {
    input.par_iter()
        .map(|i| i * i)
        .sum()
}

Je beschrijft hoe je het probleem moet opsplitsen in onafhankelijke partities en implementeert een functie om één partitie per keer te verwerken. Het is de taak van het platform om te verifiëren dat de partitionering correct is (zonder races), alle partities te verwerken en de controle terug te geven zodra dit is voltooid.

Ten tweede: concurrency houdt onvermijdelijk annulering in. Wanneer je twee asynchrone berekeningen tegelijk uitvoert, komt er een moment waarop één berekening merkt dat de tweede berekening niet langer nodig is en actief moet worden geannuleerd. In het algemeen is het niet mogelijk om simpelweg te wachten tot de andere berekening is voltooid: vaak is de reden waarom je deze wilt annuleren juist omdat je hebt geleerd dat deze niet kan voltooien (bijvoorbeeld omdat deze wacht op een bericht dat nooit zal aankomen).

"Gewoon threads gebruiken"

En dat is het probleem met de aanpak "gebruik gewoon threads".

Er zijn meer problemen, waarvan de belangrijkste is dat het spawnen van veel threads vaak een wijziging in de systeembrede configuratie vereist, wat voor de meeste applicaties geen optie is. Maar het ontbreken van annulering zorgt ervoor dat je vroeg of laat tegen een muur aanloopt. Het probleem zijn de syscalls. In elke loop-structuur is het eenvoudig genoeg om iets te doen als:

while (true) {
    if (is_canceled()) return error.Canceled; /// Makkelijk!
    ...
}

Maar de thread is in plaats daarvan geblokkeerd binnen de syscall in de kernel. Programmeertaal-API's bieden over het algemeen geen manier om deze te ontblokkeren:

const read_size = try read(fd, buffer); // ???

Zou het niet cool zijn als we gewoon standaard OS-threads en blokkerende API's konden gebruiken, nieuwe zaken als io_uring konden vermijden, maar nog steeds elk werk betrouwbaar konden annuleren? Dat is precies wat Zig's std.Io.Threaded biedt.

SIGIO

De manier waarop dit op POSIX werkt, is een beetje vreemd. Het blijkt dat de kernel eigenlijk een omweg biedt om een blokkerende syscall te annuleren: signalen. Wanneer een thread geblokkeerd is in de kernel en er wordt een signaal naar de thread gestuurd, wordt de thread gewekt en geeft de syscall EINTR terug. Het is gebruikelijk om in dergelijke gevallen de syscall simpelweg in een loop opnieuw te proberen, maar dat hoeft niet.

Op zichzelf zijn signalen geen annuleringsmechanisme — het signaleren van een thread is inherent onbetrouwbaar (racy); het signaal kan worden afgeleverd voordat de relevante syscall start, of nadat deze is voltooid. Omgekeerd kan een syscall worden onderbroken door een signaal dat niets met annulering te maken heeft.

Het werkelijke protocol is als volgt: de annulerende thread zet een vlag in het gedeelde geheugen om annulering aan te vragen, en stuurt vervolgens in een loop signalen naar de geannuleerde thread, totdat de annulering is bevestigd (via een andere waarde voor een vlag in het gedeelde geheugen). Bij het ontvangen van EINTR van een syscall, controleert de thread die mogelijk wordt geannuleerd de waarde van de vlag en probeert ofwel de syscall opnieuw, of bevestigt de annulering en begint met het afwikkelen (unwinding). Zie signalCanceledSyscall en bijvoorbeeld fileReadPositionalPosix voor de twee helften van dit protocol.

Aan de gebruikerskant wordt het annuleringsverzoek gematerialiseerd als error.Canceled. Foutafhandeling als functie is een combinatie van annulering, vertakking en rapportage, en Zig implementeert de eerste twee. Annulering is geen fout, niet omdat het een toevallig succes is, maar omdat, omgekeerd, een fout een annulering is plus een payload.

Op Windows is er een veel directere methode: NtCancelSynchronousIoFile. Tussen fibers, IO Completion Ports, Job-objecten en dit, lijkt het erop dat NT een beter doordacht concurrency-verhaal heeft dan Unix.

Eerdere methoden

In Java bestaat er een vergelijkbaar mechanisme voor thread-interruptie. Cruciaal is dat dit het onderbreken van syscalls niet ondersteunt: IOException en InterruptedException zijn beide checked exceptions en niet aan elkaar gerelateerd, wat betekent dat IO-functies niet onderbreekbaar zijn. In Zig wissen de reader- en writer-interfaces fouten volledig uit (type erase) en ondersteunen ze daarom annulering, hoewel dit extra zorg vereist bij de afhandeling, bovenop het gebruikelijke "vergeet niet te flushen".

pthread_cancel implementeert een soortgelijk signal+vlag-mechanisme. Het integreert echter niet met annulering op taalniveau (try, defer), wat opruiming na annulering omslachtig en traag maakt. In het algemeen komt veel angst rondom concurrency voort uit het feit dat het precies in de schemerzone valt tussen de kernel, de runtime en de taal. Er is (op interrupts na) bijna geen concurrency op de CPU; het is een illusie met een gemengd auteurschap. De taal is meestal het best uitgerust om dit probleem aan te pakken, maar traditioneel wordt het afgehandeld door de kernel en libc, met nadelige effecten op het taalontwerp.

Een ander probleem met pthread_cancel is dat het de gehele thread vernietigt, wat acceptabel zou zijn als threads goedkoop waren. Het aanmaken van threads is echter nog steeds traag, en de geconfigureerde systeemlimiet voor het aantal threads is doorgaans laag. Daarom is het meestal een goed idee om OS-threads in een pool te plaatsen. Zig's Io lost dit probleem op ingenieuze wijze door op interfaceniveau een onderscheid te maken tussen "mag concurrent runnen" en "moet concurrent runnen":

https://kristoff.it/blog/asynchrony-is-not-concurrency/

Dit bereikt een effect dat vergelijkbaar is met dat van std::launch policy (item 36 in Effective Modern C++). Door expliciet te benoemen wat er gebeurt (io.async versus io.concurrent), maakt Zig het gemakkelijker om te begrijpen wat er daadwerkelijk aan de hand is, en krijgt het preciezere signatures (concurrent is altijd foutgevoelig, async nooit). Uiteraard wordt concurrent ondersteund door een thread pool, die alleen terugvalt op het spawnen van een nieuwe thread wanneer de pool is uitgeput.