Ontwerpprincipes en -patronen voor zeer gelijktijdige toepassingen
1. Overzicht
In deze tutorial bespreken we enkele van de ontwerpprincipes en -patronen die in de loop van de tijd zijn vastgesteld om zeer gelijktijdige applicaties te bouwen.
Het is echter de moeite waard om op te merken dat het ontwerpen van een gelijktijdige applicatie een breed en complex onderwerp is, en daarom kan geen enkele tutorial claimen dat deze volledig is in de behandeling ervan. Wat we hier zullen behandelen, zijn enkele van de populaire trucs die vaak worden gebruikt!
2. Basisprincipes van gelijktijdigheid
Laten we, voordat we verder gaan, wat tijd besteden aan het begrijpen van de basisprincipes. Om te beginnen moeten we ons begrip van wat we een concurrent programma noemen, verduidelijken. We verwijzen naar een programma dat gelijktijdig is als er meerdere berekeningen tegelijkertijd plaatsvinden.
Merk nu op dat we hebben gezegd dat berekeningen tegelijkertijd plaatsvinden - dat wil zeggen dat ze tegelijkertijd bezig zijn. Ze kunnen echter wel of niet tegelijkertijd worden uitgevoerd. Het is belangrijk om het verschil te begrijpen als het gelijktijdig uitvoeren van berekeningen wordt parallel genoemd.
2.1. Hoe gelijktijdige modules te maken?
Het is belangrijk om te begrijpen hoe we gelijktijdige modules kunnen maken. Er zijn talloze opties, maar we zullen ons hier concentreren op twee populaire keuzes:
- Werkwijze: Een proces is een exemplaar van een lopend programma dat is geïsoleerd van andere processen in dezelfde machine. Elk proces op een machine heeft zijn eigen geïsoleerde tijd en ruimte. Daarom is het normaal gesproken niet mogelijk om geheugen tussen processen te delen, en ze moeten communiceren door berichten door te geven.
- Draad: Een rode draad, aan de andere kant, is slechts een deel van een proces. Er kunnen meerdere threads binnen een programma zijn die dezelfde geheugenruimte delen. Elke thread heeft echter een unieke stapel en prioriteit. Een thread kan native zijn (native gepland door het besturingssysteem) of groen (gepland door een runtime-bibliotheek).
2.2. Hoe werken gelijktijdige modules samen?
Het is best ideaal als gelijktijdige modules niet hoeven te communiceren, maar dat is vaak niet het geval. Dit geeft aanleiding tot twee modellen van gelijktijdige programmering:
- Gedeelde herinnering: In dit model gelijktijdige modules werken samen door gedeelde objecten in het geheugen te lezen en te schrijven. Dit leidt vaak tot vervlechting van gelijktijdige berekeningen, waardoor race-omstandigheden ontstaan. Daarom kan het op niet-deterministische wijze tot onjuiste toestanden leiden.
- Bericht overslaan: In dit model gelijktijdige modules werken samen door berichten aan elkaar door te geven via een communicatiekanaal. Hier verwerkt elke module inkomende berichten op volgorde. Omdat er geen gedeelde status is, is het relatief eenvoudiger om te programmeren, maar dit is nog steeds niet vrij van racecondities!
2.3. Hoe worden gelijktijdige modules uitgevoerd?
Het is alweer een tijdje geleden dat de wet van Moore een muur raakte met betrekking tot de kloksnelheid van de processor. In plaats daarvan, omdat we moeten groeien, zijn we begonnen met het verpakken van meerdere processors op dezelfde chip, vaak multicore-processors genoemd. Maar toch, het is niet gebruikelijk om te horen over processors met meer dan 32 cores.
Nu weten we dat een enkele kern slechts één thread of reeks instructies tegelijk kan uitvoeren. Het aantal processen en threads kan echter respectievelijk honderden en duizenden bedragen. Dus, hoe werkt het echt? Dit is waar het besturingssysteem simuleert voor ons gelijktijdigheid. Het besturingssysteem bereikt dit door tijdverspilling - wat in feite betekent dat de processor regelmatig, onvoorspelbaar en niet-deterministisch tussen threads schakelt.
3. Problemen bij gelijktijdig programmeren
Als we de principes en patronen bespreken om een gelijktijdige applicatie te ontwerpen, is het verstandig om eerst te begrijpen wat de typische problemen zijn.
Onze ervaring met gelijktijdig programmeren omvat voor een zeer groot deel met behulp van native threads met gedeeld geheugen. Daarom zullen we ons concentreren op enkele van de veelvoorkomende problemen die eruit voortkomen:
- Wederzijdse uitsluiting (synchronisatieprimitieven): Interleaving threads moeten exclusieve toegang hebben tot de gedeelde status of het gedeelde geheugen om de juistheid van programma's te garanderen. De synchronisatie van gedeelde bronnen is een populaire methode om tot wederzijdse uitsluiting te komen. Er zijn verschillende synchronisatieprimitieven beschikbaar om te gebruiken, bijvoorbeeld een slot, monitor, semafoor of mutex. Programmeren voor wederzijdse uitsluiting is echter foutgevoelig en kan vaak leiden tot prestatieproblemen. Er zijn verschillende goed besproken kwesties die hiermee verband houden, zoals deadlock en livelock.
- Contextwisseling (zware garens): Elk besturingssysteem heeft native, zij het gevarieerde, ondersteuning voor gelijktijdige modules zoals proces en thread. Zoals besproken, is een van de fundamentele services die een besturingssysteem biedt, het plannen van threads voor uitvoering op een beperkt aantal processors door middel van time-slicing. Dit betekent in feite dat threads worden vaak gewisseld tussen verschillende statussen. Tijdens het proces moet hun huidige staat worden opgeslagen en hervat. Dit is een tijdrovende activiteit die rechtstreeks van invloed is op de algehele doorvoer.
4. Ontwerppatronen voor hoge gelijktijdigheid
Nu we de basisprincipes van gelijktijdig programmeren en de veelvoorkomende problemen daarin begrijpen, is het tijd om enkele van de veelvoorkomende patronen te begrijpen om deze problemen te vermijden. We moeten herhalen dat gelijktijdig programmeren een moeilijke taak is die veel ervaring vereist. Daarom kan het volgen van enkele van de gevestigde patronen de taak gemakkelijker maken.
4.1. Op acteur gebaseerde gelijktijdigheid
Het eerste ontwerp dat we zullen bespreken met betrekking tot gelijktijdig programmeren, wordt het Actormodel genoemd. Dit is een wiskundig model van gelijktijdige berekening dat in feite alles als een actor behandelt. Acteurs kunnen berichten aan elkaar doorgeven en in reactie op een bericht lokale beslissingen nemen. Dit werd voor het eerst voorgesteld door Carl Hewitt en heeft een aantal programmeertalen geïnspireerd.
Scala's belangrijkste constructie voor gelijktijdige programmering zijn actoren. Acteurs zijn normale objecten in Scala die we kunnen maken door de instantiatie Acteur klasse. Bovendien biedt de Scala Actors-bibliotheek veel nuttige actor-bewerkingen:
class myActor verlengt Actor {def act () {while (true) {ontvangen {// Voer een actie uit}}}}
In het bovenstaande voorbeeld is een oproep naar de te ontvangen methode binnen een oneindige lus schort de actor op totdat er een bericht binnenkomt. Bij aankomst wordt het bericht uit de mailbox van de acteur verwijderd en worden de nodige maatregelen genomen.
Het acteursmodel elimineert een van de fundamentele problemen met gelijktijdig programmeren: gedeeld geheugen. Acteurs communiceren via berichten en elke actor verwerkt berichten uit zijn exclusieve mailboxen opeenvolgend. We voeren acteurs echter uit via een threadpool. En we hebben gezien dat native threads zwaar kunnen zijn en daarom beperkt in aantal.
Er zijn natuurlijk andere patronen die ons hierbij kunnen helpen - daar komen we later op terug!
4.2. Gelijktijdigheid op basis van gebeurtenissen
Gebeurtenisgebaseerde ontwerpen pakken expliciet het probleem aan dat native threads duur zijn om te spawnen en te gebruiken. Een van de op evenementen gebaseerde ontwerpen is de evenementlus. De gebeurtenislus werkt met een gebeurtenisprovider en een set gebeurtenishandlers. In deze opstelling de gebeurtenislus blokkeert de gebeurtenisprovider en verzendt een gebeurtenis bij aankomst naar een gebeurtenishandler.
In feite is de gebeurtenislus niets anders dan een gebeurtenisverzender! De gebeurtenislus zelf kan op slechts één native thread worden uitgevoerd. Dus, wat gebeurt er echt in een gebeurtenislus? Laten we eens kijken naar de pseudo-code van een heel eenvoudige gebeurtenislus voor een voorbeeld:
while (true) {events = getEvents (); voor (e in events) processEvent (e); }
Kortom, alles wat onze evenementlus doet, is continu zoeken naar evenementen en, wanneer evenementen worden gevonden, deze verwerken. De aanpak is heel eenvoudig, maar plukt de vruchten van een evenementgestuurd ontwerp.
Het bouwen van gelijktijdige applicaties met dit ontwerp geeft meer controle over de applicatie. Het elimineert ook enkele van de typische problemen van multi-threaded applicaties, bijvoorbeeld deadlock.
JavaScript implementeert de gebeurtenislus om asynchrone programmering aan te bieden. Het houdt een call-stack bij om alle uit te voeren functies bij te houden. Het houdt ook een gebeurteniswachtrij bij voor het verzenden van nieuwe functies voor verwerking. De gebeurtenislus controleert constant de oproepstapel en voegt nieuwe functies toe vanuit de gebeurteniswachtrij. Alle asynchrone aanroepen worden verzonden naar de web-API's, meestal geleverd door de browser.
De gebeurtenislus zelf kan op een enkele thread worden uitgevoerd, maar de web-API's bieden afzonderlijke threads.
4.3. Niet-blokkerende algoritmen
Bij niet-blokkerende algoritmen leidt het opschorten van één thread niet tot het opschorten van andere threads. We hebben gezien dat we slechts een beperkt aantal native threads in onze applicatie kunnen hebben. Nu, een algoritme dat blokkeert op een thread, verlaagt de doorvoer duidelijk aanzienlijk en voorkomt dat we zeer gelijktijdige applicaties bouwen.
Niet-blokkerende algoritmen maak gebruik van de atomaire primitieve vergelijk en verwissel die wordt geleverd door de onderliggende hardware. Dit betekent dat de hardware de inhoud van een geheugenlocatie vergelijkt met een bepaalde waarde, en alleen als ze hetzelfde zijn, wordt de waarde bijgewerkt naar een nieuwe gegeven waarde. Dit ziet er misschien eenvoudig uit, maar het biedt ons in feite een atomaire bewerking die anders synchronisatie zou vereisen.
Dit betekent dat we nieuwe datastructuren en bibliotheken moeten schrijven die gebruik maken van deze atomaire operatie. Dit heeft ons een enorme reeks wachtvrije en vergrendelingsvrije implementaties in verschillende talen opgeleverd. Java heeft verschillende niet-blokkerende datastructuren, zoals AtomicBoolean, AtomicInteger, AtomicLong, en AtomicReference.
Overweeg een toepassing waarbij meerdere threads proberen toegang te krijgen tot dezelfde code:
boolean open = false; if (! open) {// Doe iets open = false; }
Het is duidelijk dat de bovenstaande code niet thread-safe is, en het gedrag ervan in een omgeving met meerdere threads kan onvoorspelbaar zijn. Onze opties hier zijn om dit stuk code te synchroniseren met een slot of om een atomaire bewerking te gebruiken:
AtomicBoolean open = nieuwe AtomicBoolean (false); if (open.compareAndSet (false, true) {// Doe iets}
Zoals we kunnen zien, gebruik je een niet-blokkerende datastructuur zoals AtomicBoolean helpt ons thread-safe code te schrijven zonder ons over te geven aan de nadelen van sloten!
5. Ondersteuning in programmeertalen
We hebben gezien dat er meerdere manieren zijn waarop we een gelijktijdige module kunnen construeren. Hoewel de programmeertaal een verschil maakt, is het vooral hoe het onderliggende besturingssysteem het concept ondersteunt. Echter, zoals thread-based concurrency ondersteund door native threads stuit op nieuwe muren met betrekking tot schaalbaarheid hebben we altijd nieuwe opties nodig.
Het implementeren van enkele van de ontwerppraktijken die we in de vorige sectie hebben besproken, blijkt effectief te zijn. We moeten echter in gedachten houden dat het programmeren als zodanig gecompliceerd is. Wat we echt nodig hebben, is iets dat de kracht biedt van op threads gebaseerde gelijktijdigheid zonder de ongewenste effecten die het met zich meebrengt.
Een oplossing die voor ons beschikbaar is, zijn groene draden. Groene threads zijn threads die zijn gepland door de runtime-bibliotheek in plaats van native te worden gepland door het onderliggende besturingssysteem. Hoewel dit niet alle problemen in op threads gebaseerde gelijktijdigheid wegneemt, kan het in sommige gevallen zeker betere prestaties opleveren.
Nu is het niet triviaal om groene draden te gebruiken, tenzij de programmeertaal die we gebruiken dit ondersteunt. Niet elke programmeertaal heeft deze ingebouwde ondersteuning. Wat we losjes groene draden noemen, kan ook op zeer unieke manieren worden geïmplementeerd door verschillende programmeertalen. Laten we eens kijken naar enkele van deze opties die voor ons beschikbaar zijn.
5.1. Goroutines in Go
Goroutines in de Go-programmeertaal zijn lichtgewicht threads. Ze bieden functies of methoden die gelijktijdig met andere functies of methoden kunnen worden uitgevoerd. Goroutines zijn extreem goedkoop omdat ze om te beginnen slechts een paar kilobytes aan stapelgrootte innemen.
Het belangrijkste is dat goroutines worden gemultiplexed met een kleiner aantal native threads. Bovendien communiceren goroutines met elkaar via kanalen, waardoor toegang tot gedeeld geheugen wordt vermeden. We krijgen vrijwel alles wat we nodig hebben, en raad eens - zonder iets te doen!
5.2. Processen in Erlang
In Erlang wordt elke uitvoeringsdraad een proces genoemd. Maar het is niet helemaal zoals het proces dat we tot nu toe hebben besproken! Erlang-processen zijn lichtgewicht met een kleine geheugenvoetafdruk en snel te maken en weg te gooien met lage planningsoverhead.
Onder de motorkap zijn Erlang-processen niets anders dan functies waarvoor de runtime de planning afhandelt. Bovendien delen Erlang-processen geen gegevens en communiceren ze met elkaar door berichten door te geven. Dit is de reden waarom we deze "processen" in de eerste plaats noemen!
5.3. Vezels in Java (voorstel)
Het verhaal van gelijktijdigheid met Java is een voortdurende evolutie geweest. Java had om te beginnen ondersteuning voor groene discussies, althans voor Solaris-besturingssystemen. Dit is echter stopgezet vanwege hindernissen die buiten het bestek van deze tutorial vallen.
Sindsdien draait het bij concurrency in Java om native threads en hoe je er slim mee kunt werken! Maar om voor de hand liggende redenen kunnen we binnenkort een nieuwe concurrency-abstractie in Java hebben, genaamd fiber. Project Loom stelt voor voortzettingen introduceren samen met vezels, wat de manier waarop we gelijktijdige applicaties schrijven kan veranderen op Java!
Dit is slechts een voorproefje van wat er beschikbaar is in verschillende programmeertalen. Er zijn veel interessantere manieren waarop andere programmeertalen hebben geprobeerd om te gaan met concurrency.
Bovendien is het vermeldenswaard dat een combinatie van ontwerppatronen die in de vorige sectie zijn besproken, samen met de programmeertaalondersteuning voor een groene draadachtige abstractie, buitengewoon krachtig kan zijn bij het ontwerpen van zeer gelijktijdige toepassingen.
6. Toepassingen met hoge gelijktijdigheid
Een real-world applicatie heeft vaak meerdere componenten die via de draad met elkaar in wisselwerking staan. We openen het meestal via internet en het bestaat uit meerdere services zoals proxyservice, gateway, webservice, database, directoryservice en bestandssystemen.
Hoe zorgen we voor een hoge gelijktijdigheid in dergelijke situaties? Laten we eens kijken naar enkele van deze lagen en de opties die we hebben voor het bouwen van een zeer gelijktijdige applicatie.
Zoals we in de vorige sectie hebben gezien, is de sleutel tot het bouwen van applicaties met hoge gelijktijdigheid het gebruik van enkele van de ontwerpconcepten die daar worden besproken. We moeten de juiste software kiezen voor de klus - software die al een aantal van deze praktijken bevat.
6.1. Weblaag
Het web is doorgaans de eerste laag waar gebruikersverzoeken binnenkomen, en voorzieningen voor hoge gelijktijdigheid zijn hier onvermijdelijk. Laten we eens kijken wat enkele van de opties zijn:
- Knooppunt (ook wel NodeJS of Node.js genoemd) is een open-source, platformonafhankelijke JavaScript-runtime gebouwd op de V8 JavaScript-engine van Chrome. Node werkt redelijk goed bij het afhandelen van asynchrone I / O-bewerkingen. De reden dat Node het zo goed doet, is omdat het een gebeurtenislus implementeert over een enkele thread. De gebeurtenislus met behulp van callbacks verwerkt alle blokkeerbewerkingen zoals I / O asynchroon.
- nginx is een open-source webserver die we vaak gebruiken als een reverse proxy onder zijn andere toepassingen. De reden dat nginx hoge gelijktijdigheid biedt, is dat het een asynchrone, gebeurtenisgestuurde benadering gebruikt. nginx werkt met een masterproces in één thread. Het hoofdproces onderhoudt de werkprocessen die de feitelijke verwerking uitvoeren. Daarom verwerkt de werker elk verzoek gelijktijdig.
6.2. Applicatielaag
Bij het ontwerpen van een applicatie zijn er verschillende tools om ons te helpen bouwen voor hoge gelijktijdigheid. Laten we een paar van deze bibliotheken en frameworks bekijken die voor ons beschikbaar zijn:
- Akka is een toolkit geschreven in Scala voor het bouwen van zeer gelijktijdige en gedistribueerde applicaties op de JVM. Akka's benadering van het omgaan met concurrency is gebaseerd op het actormodel dat we eerder hebben besproken. Akka creëert een laag tussen de actoren en de onderliggende systemen. Het framework behandelt de complexiteit van het maken en plannen van threads, het ontvangen en verzenden van berichten.
- Project Reactor is een reactieve bibliotheek voor het bouwen van niet-blokkerende applicaties op de JVM. Het is gebaseerd op de Reactive Streams-specificatie en richt zich op het efficiënt doorgeven van berichten en vraagbeheer (tegendruk). Reactoroperators en planners kunnen hoge doorvoersnelheden voor berichten aanhouden. Verschillende populaire frameworks bieden reactorimplementaties, waaronder Spring WebFlux en RSocket.
- Netty is een asynchroon, gebeurtenisgestuurd netwerkapplicatieframework. We kunnen Netty gebruiken om zeer gelijktijdige protocolservers en clients te ontwikkelen. Netty maakt gebruik van NIO, een verzameling Java-API's die asynchrone gegevensoverdracht via buffers en kanalen mogelijk maakt. Het biedt ons verschillende voordelen, zoals een betere doorvoer, lagere latentie, minder resourceverbruik en het minimaliseren van onnodige geheugenkopieën.
6.3. Gegevenslaag
Ten slotte is geen enkele applicatie compleet zonder zijn gegevens, en de gegevens komen uit permanente opslag. Wanneer we hoge concurrency bespreken met betrekking tot databases, blijft de meeste aandacht bij de NoSQL-familie. Dit komt voornamelijk door lineaire schaalbaarheid die NoSQL-databases kunnen bieden, maar die moeilijk te bereiken is in relationele varianten. Laten we eens kijken naar twee populaire tools voor de gegevenslaag:
- Cassandra is een gratis en open-source gedistribueerde NoSQL-database dat een hoge beschikbaarheid, hoge schaalbaarheid en fouttolerantie biedt op standaardhardware. Cassandra biedt echter geen ACID-transacties die meerdere tabellen beslaan. Dus als onze applicatie geen sterke consistentie en transacties vereist, kunnen we profiteren van Cassandra's low-latency-operaties.
- Kafka is een gedistribueerd streamingplatform. Kafka slaat een stroom records op in categorieën die onderwerpen worden genoemd. Het kan lineaire horizontale schaalbaarheid bieden voor zowel producenten als consumenten van de platen en tegelijkertijd een hoge betrouwbaarheid en duurzaamheid bieden. Partities, replica's en makelaars zijn enkele van de fundamentele concepten waarop het massaal gedistribueerde gelijktijdigheid biedt.
6.4. Cachelaag
Welnu, geen enkele webtoepassing in de moderne wereld die streeft naar hoge gelijktijdigheid, kan het zich veroorloven om elke keer in de database te komen. Dat laat ons toe om een cache te kiezen - bij voorkeur een in-memory cache die onze zeer gelijktijdige applicaties kan ondersteunen:
- Hazelcast is een gedistribueerde, cloudvriendelijke objectopslag in het geheugen en rekenengine die een breed scala aan gegevensstructuren ondersteunt, zoals Kaart, Set, Lijst, MultiMap, RingBuffer, en HyperLogLog. Het heeft ingebouwde replicatie en biedt hoge beschikbaarheid en automatische partitionering.
- Redis is een datastructuuropslag in het geheugen die we voornamelijk als cache gebruiken. Het biedt een sleutelwaarde-database in het geheugen met optionele duurzaamheid.De ondersteunde datastructuren omvatten strings, hashes, lijsten en sets. Redis heeft ingebouwde replicatie en biedt hoge beschikbaarheid en automatische partitionering. Voor het geval we geen persistentie nodig hebben, kan Redis ons een functierijke, genetwerkte in-memory cache met uitstekende prestaties bieden.
Natuurlijk hebben we nauwelijks de oppervlakte bekrast van wat ons ter beschikking staat in ons streven om een zeer gelijktijdige applicatie te bouwen. Het is belangrijk op te merken dat, meer dan beschikbare software, onze vereiste ons zou moeten leiden om een passend ontwerp te maken. Sommige van deze opties zijn mogelijk geschikt, terwijl andere misschien niet geschikt zijn.
En laten we niet vergeten dat er nog veel meer opties beschikbaar zijn die wellicht beter aansluiten op onze vereisten.
7. Conclusie
In dit artikel hebben we de basisprincipes van gelijktijdig programmeren besproken. We begrepen enkele van de fundamentele aspecten van de concurrency en de problemen die het kan veroorzaken. Verder hebben we enkele ontwerppatronen doorgenomen die ons kunnen helpen de typische problemen bij gelijktijdig programmeren te vermijden.
Ten slotte hebben we enkele van de frameworks, bibliotheken en software doorgenomen die voor ons beschikbaar zijn voor het bouwen van een zeer gelijktijdige end-to-end-applicatie.