Designprincipper og mønstre til meget samtidige applikationer
1. Oversigt
I denne vejledning diskuterer vi nogle af de designprincipper og mønstre, der er blevet etableret over tid for at opbygge meget samtidige applikationer.
Det er dog værd at bemærke, at design af en samtidig applikation er et bredt og komplekst emne, og derfor kan ingen vejledning hævde at være udtømmende i behandlingen. Hvad vi dækker her er nogle af de populære tricks, der ofte bruges!
2. Grundlæggende om samtidighed
Lad os bruge lidt tid på at forstå det grundlæggende, inden vi fortsætter. Til at begynde med skal vi afklare vores forståelse af, hvad vi kalder et samtidigt program. Vi henviser til et program, der er samtidig hvis der sker flere beregninger på samme tid.
Bemærk nu, at vi har nævnt beregninger, der sker på samme tid - det vil sige, de er i gang på samme tid. Imidlertid udføres de måske eller måske ikke samtidigt. Det er vigtigt at forstå forskellen som samtidig udføres beregninger kaldet parallel.
2.1. Hvordan oprettes samtidige moduler?
Det er vigtigt at forstå, hvordan vi kan skabe samtidige moduler. Der er mange muligheder, men vi fokuserer på to populære valg her:
- Behandle: En proces er en forekomst af et kørende program, der er isoleret fra andre processer i samme maskine. Hver proces på en maskine har sin egen isolerede tid og plads. Derfor er det normalt ikke muligt at dele hukommelse mellem processer, og de skal kommunikere ved at sende meddelelser.
- Tråd: En tråd er derimod bare et segment af en proces. Der kan være flere tråde i et program, der deler det samme hukommelsesrum. Hver tråd har dog en unik stak og prioritet. En tråd kan være indfødt (oprindeligt planlagt af operativsystemet) eller grøn (planlagt af et runtime-bibliotek).
2.2. Hvordan interagerer samtidige moduler?
Det er ret ideelt, hvis samtidige moduler ikke behøver at kommunikere, men det er ofte ikke tilfældet. Dette giver anledning til to modeller af samtidig programmering:
- Delt hukommelse: I denne model samtidige moduler interagerer ved at læse og skrive delte objekter i hukommelsen. Dette fører ofte til sammenfletning af samtidige beregninger, der forårsager raceforhold. Derfor kan det ikke-deterministisk føre til forkerte tilstande.

- Meddelelse videregives: I denne model samtidige moduler interagerer ved at sende meddelelser til hinanden gennem en kommunikationskanal. Her behandler hvert modul indgående meddelelser sekventielt. Da der ikke er nogen delt tilstand, er det relativt nemmere at programmere, men dette er stadig ikke frit for race betingelser!

2.3. Hvordan udføres samtidige moduler?
Det er et stykke tid siden Moores lov ramte en mur med hensyn til processorens klokkehastighed. I stedet for, da vi skal vokse, er vi begyndt at pakke flere processorer på den samme chip, ofte kaldet multicore-processorer. Men stadig er det ikke almindeligt at høre om processorer, der har mere end 32 kerner.
Nu ved vi, at en enkelt kerne kun kan udføre en tråd eller et sæt instruktioner ad gangen. Antallet af processer og tråde kan dog være i henholdsvis hundreder og tusinder. Så hvordan fungerer det virkelig? Det er her operativsystemet simulerer samtidighed for os. Operativsystemet opnår dette ved tidsskæring - hvilket effektivt betyder, at processoren skifter mellem tråde ofte, uforudsigeligt og ikke-deterministisk.
3. Problemer i samtidig programmering
Når vi diskuterer principper og mønstre for at designe en samtidig applikation, ville det være klogt at først forstå, hvad de typiske problemer er.
For en meget stor del involverer vores erfaring med samtidig programmering ved hjælp af indfødte tråde med delt hukommelse. Derfor vil vi fokusere på nogle af de almindelige problemer, der stammer fra det:
- Gensidig udelukkelse (synkroniseringsprimitiver): Interleaving tråde skal have eksklusiv adgang til delt tilstand eller hukommelse for at sikre, at programmerne er korrekte. Synkroniseringen af delte ressourcer er en populær metode til at opnå gensidig udelukkelse. Der er flere synkroniseringsprimitiver til rådighed til brug - for eksempel en lås, skærm, semafor eller mutex. Programmering til gensidig udelukkelse er imidlertid fejlbehæftet og kan ofte føre til præstationsflaskehalse. Der er flere godt diskuterede spørgsmål relateret til dette som dødvande og livelock.
- Kontekstskift (tunge tråde): Hvert operativsystem har indbygget, omend forskelligartet, understøttelse af samtidige moduler som proces og tråd. Som diskuteret er en af de grundlæggende tjenester, som et operativsystem leverer, at planlægge tråde, der skal udføres på et begrænset antal processorer gennem tidsskæring. Nu betyder det effektivt det tråde skiftes ofte mellem forskellige tilstande. I processen skal deres nuværende tilstand gemmes og genoptages. Dette er en tidskrævende aktivitet, der direkte påvirker den samlede kapacitet.
4. Design mønstre til høj samtidighed
Nu hvor vi forstår det grundlæggende ved samtidig programmering og de almindelige problemer deri, er det tid til at forstå nogle af de almindelige mønstre for at undgå disse problemer. Vi må gentage, at samtidig programmering er en vanskelig opgave, der kræver en masse erfaring. Derfor kan det gøre opgaven lettere at følge nogle af de etablerede mønstre.
4.1. Skuespillerbaseret samtidighed
Det første design, vi vil diskutere med hensyn til samtidig programmering kaldes Actor Model. Dette er en matematisk model for samtidig beregning, der grundlæggende behandler alt som en skuespiller. Skuespillere kan sende meddelelser til hinanden og som svar på en besked kan de tage lokale beslutninger. Dette blev først foreslået af Carl Hewitt og har inspireret en række programmeringssprog.
Scalas primære konstruktion til samtidig programmering er skuespillere. Skuespillere er normale objekter i Scala, som vi kan skabe ved at instantiere Skuespiller klasse. Scala Actors-biblioteket giver desuden mange nyttige skuespillerhandlinger:
klasse myActor udvider skuespiller {def act () {while (true) {modtag {// Udfør en handling}}}}
I eksemplet ovenfor er et opkald til modtage metode inde i en uendelig løkke suspenderer skuespilleren, indtil en besked ankommer. Ved ankomsten fjernes beskeden fra skuespillerens postkasse, og de nødvendige handlinger udføres.

Skuespillermodellen eliminerer et af de grundlæggende problemer med samtidig programmering - delt hukommelse. Skuespillere kommunikerer gennem beskeder, og hver skuespiller behandler meddelelser fra sine eksklusive postkasser i rækkefølge. Vi udfører imidlertid skuespillere over en trådpulje. Og vi har set, at indfødte tråde kan være tunge og derfor begrænset i antal.
Der er selvfølgelig andre mønstre, der kan hjælpe os her - vi dækker dem senere!
4.2. Begivenhedsbaseret samtidighed
Begivenhedsbaseret design adresserer eksplicit problemet med, at native threads er dyre at gyde og betjene. Et af de begivenhedsbaserede design er begivenhedssløjfen. Begivenhedssløjfen fungerer med en begivenhedsudbyder og et sæt begivenhedshåndterere. I denne opsætning, begivenhedssløjfen blokerer for begivenhedsudbyderen og sender en begivenhed til en begivenhedshåndterer ved ankomsten.
Dybest set er begivenhedssløjfen intet andet end en begivenhedsforsendelse! Selve begivenhedssløjfen kan kun køre på en enkelt indfødt tråd. Så hvad sker der virkelig i en begivenhedssløjfe? Lad os se på pseudokoden for en virkelig enkel begivenhedssløjfe for et eksempel:
while (true) {events = getEvents (); til (e in events) processEvent (e); }
Grundlæggende er alt, hvad vores begivenhedssløjfe gør, konstant at lede efter begivenheder og, når begivenheder findes, behandle dem. Fremgangsmåden er virkelig enkel, men den høster fordelen ved et begivenhedsdrevet design.
Bygning af samtidige applikationer ved hjælp af dette design giver mere kontrol over applikationen. Det eliminerer også nogle af de typiske problemer med applikationer med flere gevind - for eksempel dødvande.

JavaScript implementerer hændelsesløkken for at tilbyde asynkron programmering. Det opretholder en opkaldsstak for at holde styr på alle de funktioner, der skal udføres. Det opretholder også en begivenhedskø til afsendelse af nye funktioner til behandling. Begivenhedssløjfen kontrollerer konstant opkaldstakken og tilføjer nye funktioner fra begivenhedskøen. Alle asynkroniserede opkald sendes til web-API'erne, typisk leveret af browseren.
Selve begivenhedssløjfen kan køre fra en enkelt tråd, men web-API'erne leverer separate tråde.
4.3. Ikke-blokerende algoritmer
I ikke-blokerende algoritmer fører suspension af en tråd ikke til suspension af andre tråde. Vi har set, at vi kun kan have et begrænset antal indfødte tråde i vores applikation. Nu, en algoritme, der blokerer på en tråd, bringer naturligvis gennemstrømningen markant ned og forhindrer os i at opbygge meget samtidige applikationer.
Ikke-blokerende algoritmer altid gøre brug af sammenligne-og-bytte atomprimitiv, der leveres af den underliggende hardware. Dette betyder, at hardwaren sammenligner indholdet på en hukommelsesplacering med en given værdi, og kun hvis de er de samme, opdaterer den værdien til en ny given værdi. Dette kan se simpelt ud, men det giver os effektivt en atomoperation, der ellers ville kræve synkronisering.
Dette betyder, at vi er nødt til at skrive nye datastrukturer og biblioteker, der gør brug af denne atomoperation. Dette har givet os et stort sæt ventetid og låsefri implementeringer på flere sprog. Java har flere ikke-blokerende datastrukturer som f.eks AtomicBoolean, AtomicInteger, AtomicLongog AtomicReference.
Overvej et program, hvor flere tråde forsøger at få adgang til den samme kode:
boolsk åben = falsk; hvis (! åben) {// Gør noget åbent = falsk; }
Det er klart, at koden ovenfor ikke er trådsikker, og dens adfærd i et multitrådet miljø kan være uforudsigelig. Vores muligheder her er enten at synkronisere dette stykke kode med en lås eller bruge en atomoperation:
AtomicBoolean åben = ny AtomicBoolean (falsk); if (open.compareAndSet (false, true) {// Gør noget}
Som vi kan se, ved hjælp af en ikke-blokerende datastruktur som AtomicBoolean hjælper os med at skrive trådsikker kode uden at forkæle ulemperne ved låse!
5. Support i programmeringssprog
Vi har set, at der er flere måder, hvorpå vi kan konstruere et samtidigt modul. Mens programmeringssproget gør en forskel, er det mest, hvordan det underliggende operativsystem understøtter konceptet. Imidlertid, som trådbaseret samtidighed understøttet af indfødte tråde rammer nye vægge med hensyn til skalerbarhed har vi altid brug for nye muligheder.
Implementering af nogle af de designpraksis, vi diskuterede i sidste afsnit, viser sig at være effektiv. Vi skal dog huske på, at det komplicerer programmeringen som sådan. Det, vi virkelig har brug for, er noget, der giver kraften i trådbaseret samtidighed uden de uønskede effekter, det medfører.
En løsning til rådighed for os er grønne tråde. Grønne tråde er tråde, der er planlagt af runtime-biblioteket i stedet for at blive planlagt indbygget af det underliggende operativsystem. Selvom dette ikke slipper af med alle problemerne i trådbaseret samtidighed, kan det helt sikkert give os bedre ydeevne i nogle tilfælde.
Nu er det ikke trivielt at bruge grønne tråde, medmindre det programmeringssprog, vi vælger at bruge, understøtter det. Ikke alle programmeringssprog har denne indbyggede support. Også det, vi løst kalder grønne tråde, kan implementeres på meget unikke måder af forskellige programmeringssprog. Lad os se nogle af disse muligheder, der er tilgængelige for os.
5.1. Goroutines i Go
Goroutines i Go-programmeringssproget er lette tråde. De tilbyder funktioner eller metoder, der kan køre samtidigt med andre funktioner eller metoder. Goroutines er ekstremt billigt, da de kun indtager et par kilobyte i stakstørrelse til at begynde med.
Vigtigst er det, at goroutines multiplexes med et mindre antal indfødte tråde. Desuden kommunikerer goroutines med hinanden ved hjælp af kanaler og undgår derved adgang til delt hukommelse. Vi får stort set alt, hvad vi har brug for, og gæt hvad - uden at gøre noget!
5.2. Processer i Erlang
I Erlang kaldes hver udførelsestråd en proces. Men det er ikke helt som den proces, vi har diskuteret hidtil! Erlang processer er lette med et lille hukommelsesfodaftryk og er hurtige at skabe og bortskaffe med lavt planlægningsomkostninger.
Under emhætten er Erlang-processer intet andet end funktioner, som runtime håndterer planlægning for. Desuden deler Erlang-processer ingen data, og de kommunikerer med hinanden ved meddelelsesoverførsel. Dette er grunden til, at vi i første omgang kalder disse "processer"!
5.3. Fibre i Java (forslag)
Historien om samtidighed med Java har været en kontinuerlig udvikling. Java havde understøttelse af grønne tråde, i det mindste til Solaris-operativsystemer, til at begynde med. Dette blev dog afbrudt på grund af forhindringer uden for omfanget af denne vejledning.
Siden da handler samtidighed i Java om indfødte tråde, og hvordan man arbejder smart med dem! Men af åbenlyse grunde kan vi snart få en ny samtidige abstraktion i Java, kaldet fiber. Project Loom foreslår at introducere fortsættelser sammen med fibre, hvilket kan ændre den måde, vi skriver samtidige applikationer på i Java!
Dette er blot et smugkig på, hvad der er tilgængeligt på forskellige programmeringssprog. Der er langt mere interessante måder, hvorpå andre programmeringssprog har forsøgt at håndtere samtidighed.
Desuden er det værd at bemærke, at en kombination af designmønstre, der blev diskuteret i sidste afsnit, sammen med understøttelse af programmeringssprog til en grøn trådlignende abstraktion, kan være ekstremt effektiv, når man designer meget samtidige applikationer.
6. Anvendelser med høj samtidighed
En applikation fra den virkelige verden har ofte flere komponenter, der interagerer med hinanden over ledningen. Vi har typisk adgang til det over internettet, og det består af flere tjenester som proxytjeneste, gateway, webservice, database, katalogtjeneste og filsystemer.
Hvordan sikrer vi høj samtidighed i sådanne situationer? Lad os undersøge nogle af disse lag og de muligheder, vi har til at opbygge en meget samtidig applikation.
Som vi har set i det foregående afsnit, er nøglen til at opbygge applikationer med høj samtidighed at bruge nogle af de designkoncepter, der er diskuteret der. Vi er nødt til at vælge den rigtige software til jobbet - dem, der allerede indeholder nogle af disse fremgangsmåder.
6.1. Weblag
Internettet er typisk det første lag, hvor brugeranmodninger ankommer, og klargøring til høj samtidighed er uundgåelig her. Lad os se, hvad der er nogle af mulighederne:
- Node (også kaldet NodeJS eller Node.js) er en open source, cross-platform JavaScript runtime bygget på Chromes V8 JavaScript-motor. Node fungerer ganske godt til håndtering af asynkrone I / O-operationer. Årsagen til, at Node gør det så godt, er fordi det implementerer en begivenhedssløjfe over en enkelt tråd. Begivenhedssløjfen ved hjælp af tilbagekald håndterer alle blokeringshandlinger som I / O asynkront.
- nginx er en open source-webserver, som vi ofte bruger som en omvendt proxy blandt dets andre anvendelser. Årsagen til, at nginx giver høj samtidighed, er, at den bruger en asynkron, begivenhedsdrevet tilgang. nginx fungerer med en masterproces i en enkelt tråd. Masterprocessen opretholder arbejdsprocesser, der udfører den faktiske behandling. Derfor behandler arbejdstagerne hver anmodning samtidigt.
6.2. Applikationslag
Mens vi designer en applikation, er der flere værktøjer, der hjælper os med at opbygge til høj samtidighed. Lad os undersøge et par af disse biblioteker og rammer, der er tilgængelige for os:
- Akka er et værktøjssæt skrevet i Scala til opbygning af meget samtidige og distribuerede applikationer på JVM. Akkas tilgang til håndtering af samtidighed er baseret på den aktørmodel, vi diskuterede tidligere. Akka skaber et lag mellem aktørerne og de underliggende systemer. Rammen håndterer kompleksiteten ved oprettelse og planlægning af tråde, modtagelse og afsendelse af meddelelser.
- Projektreaktor er et reaktivt bibliotek til opbygning af ikke-blokerende applikationer på JVM. Det er baseret på specifikationerne for Reactive Streams og fokuserer på effektiv meddelelsesoverføring og styring af efterspørgsel (modtryk). Reaktoroperatører og planlæggere kan opretholde høje gennemstrømningshastigheder for meddelelser. Flere populære rammer giver reaktorimplementeringer, herunder Spring WebFlux og RSocket.
- Netty er en asynkron, hændelsesdrevet netværksapplikationsramme. Vi kan bruge Netty til at udvikle meget samtidige protokolservere og klienter. Netty udnytter NIO, som er en samling af Java API'er, der tilbyder asynkron dataoverførsel gennem buffere og kanaler. Det giver os flere fordele som bedre kapacitet, lavere ventetid, mindre ressourceforbrug og minimere unødvendig hukommelseskopi.
6.3. Datalag
Endelig er ingen applikationer komplette uden dens data, og data kommer fra vedvarende lagring. Når vi diskuterer høj samtidighed med hensyn til databaser, forbliver det meste af fokus på NoSQL-familien. Dette skyldes primært lineær skalerbarhed, som NoSQL-databaser kan tilbyde, men det er svært at opnå i relationelle varianter. Lad os se på to populære værktøjer til datalaget:
- Cassandra er en gratis og open source NoSQL distribueret database der giver høj tilgængelighed, høj skalerbarhed og fejltolerance på råvarehardware. Cassandra leverer dog ikke ACID-transaktioner, der spænder over flere tabeller. Så hvis vores applikation ikke kræver stærk konsistens og transaktioner, kan vi drage fordel af Cassandras operationer med lav latenstid.
- Kafka er en distribueret streamingplatform. Kafka gemmer en strøm af poster i kategorier kaldet emner. Det kan give lineær vandret skalerbarhed for både producenter og forbrugere af pladerne, samtidig med at det giver høj pålidelighed og holdbarhed. Partitioner, replikaer og mæglere er nogle af de grundlæggende koncepter, som det giver massivt distribueret samtidighed med.
6.4. Cache-lag
Nå, ingen webapplikationer i den moderne verden, der sigter mod høj samtidighed, har råd til at ramme databasen hver gang. Det efterlader os til at vælge en cache - helst en in-memory cache, der kan understøtte vores meget samtidige applikationer:
- Hazelcast er en distribueret, skyvenlig objektlager i hukommelsen og beregne motor, der understøtter en bred vifte af datastrukturer såsom Kort, Sæt, Liste, MultiMap, RingBufferog HyperLogLog. Det har indbygget replikering og tilbyder høj tilgængelighed og automatisk partitionering.
- Redis er en datastrukturlager i hukommelsen, som vi primært bruger som en cache. Det giver en nøgleværdidatabase i hukommelsen med valgfri holdbarhed.De understøttede datastrukturer inkluderer strenge, hashes, lister og sæt. Redis har indbygget replikering og tilbyder høj tilgængelighed og automatisk partitionering. Hvis vi ikke har brug for vedholdenhed, kan Redis tilbyde os en funktionsrig, netværksbaseret cache med hukommelse med fremragende ydeevne.
Selvfølgelig har vi næppe ridset overfladen af, hvad der er tilgængeligt for os i vores bestræbelse på at opbygge en meget samtidig applikation. Det er vigtigt at bemærke, at vores krav mere end tilgængelig software skal guide os til at skabe et passende design. Nogle af disse muligheder kan være egnede, mens andre måske ikke er egnede.
Og lad os ikke glemme, at der er mange flere muligheder, der kan være bedre egnet til vores krav.
7. Konklusion
I denne artikel diskuterede vi det grundlæggende ved samtidig programmering. Vi forstod nogle af de grundlæggende aspekter af samtidigheden og de problemer, den kan føre til. Desuden gennemgik vi nogle af designmønstrene, der kan hjælpe os med at undgå de typiske problemer i samtidig programmering.
Endelig gennemgik vi nogle af de rammer, biblioteker og software, der var tilgængelige for os til opbygning af en meget samtidig, ende-til-slut-applikation.