Hva er GenServer, og hvorfor bør du bryr deg?

I denne artikkelen vil du lære grunnleggende om samtidighet i Elixir og se hvordan du kan gyte prosesser, sende og motta meldinger og lage langsiktige prosesser. Også du vil lære om GenServer, se hvordan det kan brukes i søknaden din, og oppdag noen godbiter det gir deg.

Som du sikkert vet, er Elixir et funksjonelt språk som brukes til å bygge feiltolerante, samtidige systemer som håndterer mange samtidige forespørsler. BEAM (Erlang virtuell maskin) bruker prosesser å utføre ulike oppgaver samtidig, noe som betyr at for eksempel at en enkelt forespørsel ikke blokkerer en annen. Prosesser er lette og isolerte, noe som betyr at de ikke deler noe minne, og selv om en prosess krasjer, kan andre fortsette å løpe.

Beam prosesser er svært forskjellige fra OS prosesser. I utgangspunktet kjører BEAM i en OS-prosess og bruker sin egen planleggere. Hver planlegger opptar en CPU-kjernen, kjører i en egen tråd, og kan håndtere tusenvis av prosesser samtidig (det blir svinger å utføre). Du kan lese litt mer om BEAM og multithreading på StackOverflow.

Så, som du ser, Beam prosesser (jeg vil bare si "prosesser" fra nå av) er svært viktige i Elixir. Språket gir deg noen verktøy på lavt nivå for å håndtere prosesser manuelt, vedlikeholde staten og håndtere forespørslene. Men få mennesker bruker dem - det er mer vanlig å stole på Open Telecom Platform (OTP) rammeverk for å gjøre det. 

OTP har i dag ikke noe å gjøre med telefoner - det er en generell ramme for å bygge komplekse samtidige systemer. Den definerer hvordan applikasjonene dine skal struktureres og gir en database, samt en rekke svært nyttige verktøy for å lage serverprosesser, gjenopprette feil, utføre logging etc. I denne artikkelen vil vi snakke om en serveradferd kalt GenServer som leveres av OTP.  

Du kan tenke på GenServer som en abstraksjon eller en hjelper som forenkler arbeidet med serverprosesser. For det første vil du se hvordan du kan gyte prosesser ved hjelp av noen lavnivåfunksjoner. Da bytter vi til GenServer og ser hvordan det forenkler ting for oss ved å fjerne behovet for å skrive kjedelig (og ganske generisk) kode hver gang. La oss komme i gang!

Det hele starter med Spawn

Hvis du spurte meg hvordan du lager en prosess i Elixir, ville jeg svare: spawn den! gyte / 1 er en funksjon definert inne i Kernel modul som returnerer en ny prosess. Denne funksjonen aksepterer en lambda som vil bli utført i den opprettede prosessen. Så snart utførelsen er ferdig, utgår prosessen også:

gyte (fn -> IO.puts ("hi") ende) |> IO.inspect # => hei # => #PID<0.72.0>

Så her spawn returnerte et nytt prosess-ID. Hvis du legger til en forsinkelse til lambda, vil strengen "hei" bli skrevet ut etter en tid:

gyte (fn ->: timer.sleep (5000) IO.puts ("hi") ende) |> IO.inspect # => #PID<0.82.0> # => (etter 5 sekunder) "hei"

Nå kan vi gyte så mange prosesser som vi vil, og de vil bli kjørt samtidig:

spawn_it = fn (num) -> spawn (fn ->: timer.sleep (5000) IO.puts ("hei # num") slutten) Enum.each (1 ... 10, fn (_) -> spawn_it . (: rand.uniform (100)) ende) # => (alt skrives ut samtidig, etter 5 sekunder) # => hei 5 # => hei 10 etc ... 

Her spionerer vi ti prosesser og skriver ut en teststreng med et tilfeldig tall. : rand er en modul levert av Erlang, så navnet heter et atom. Det er kult at alle meldingene skal skrives ut samtidig, etter fem sekunder. Det skjer fordi alle ti prosessene blir utført samtidig.

Sammenlign det med følgende eksempel som utfører samme oppgave, men uten å bruke gyte / 1:

dont_spawn_it = fn (num) ->: timer.sleep (5000) IO.puts ("hei # num") avslutte Enum.each (1 ... 10, fn (_) -> dont_spawn_it. 100)) ende) # => (etter 5 sekunder) hei 70 # => (etter ytterligere 5 sekunder) hei 45 # => etc ... 

Mens denne koden kjører, kan du gå til kjøkkenet og lage en annen kopp kaffe, da det tar nesten et minutt å fullføre. Hver melding vises i rekkefølge, noe som selvfølgelig ikke er optimal!

Du kan spørre: "Hvor mye minne bruker en prosess?" Vel, det avhenger, men i utgangspunktet opptar det et par kilobytes, som er et svært lite nummer (selv min gamle bærbare har 8 GB minne, for ikke å nevne kule moderne servere).

Så langt så bra. Før vi begynner å jobbe med GenServer, kan vi diskutere enda en viktig ting: passerer og mottar meldinger.

Arbeide med meldinger

Det er ingen overraskelse at prosesser (som er isolerte, som du husker) trenger å kommunisere på en eller annen måte, spesielt når det gjelder å bygge mer eller mindre komplekse systemer. For å oppnå dette kan vi bruke meldinger.

En melding kan sendes ved hjelp av en funksjon med ganske åpenbart navn: send / 2. Den aksepterer et mål (port, prosess ID eller et prosessnavn) og den faktiske meldingen. Etter at meldingen er sendt, vises den i postkasse av en prosess og kan behandles. Som du ser, er den generelle ideen veldig lik vår daglige aktivitet med å utveksle e-post.

En postkasse er i utgangspunktet en "første i første ut" (FIFO) kø. Etter at meldingen er behandlet, blir den fjernet fra køen. For å begynne å motta meldinger, trenger du-gjett hva! -En mottar makro. Denne makroen inneholder en eller flere klausuler, og en melding er tilpasset dem. Hvis en kamp er funnet, behandles meldingen. Ellers sendes meldingen tilbake til postkassen. På toppen av det kan du sette en valgfri etter klausul som kjører hvis en melding ikke ble mottatt i den angitte tiden. Du kan lese mer om sende / 2 og motta i de offisielle dokumentene.

Ok, nok med teorien-la oss prøve å jobbe med meldingene. Først og fremst, send noe til den nåværende prosessen:

send (self (), "hei!")

Selve / 0-makroen gir tilbake pid av anropsprosessen, noe som er akkurat det vi trenger. Ikke utelate runde parentes etter funksjonen, da du får en advarsel angående tvetydighetskampen.

Nå mottar meldingen mens du stiller inn etter klausul:

mottar gjør msg -> IO.puts "Yay, en melding: # msg" msg after 1000 -> IO.puts: stderr, "Jeg vil ha meldinger!" end |> IO.puts # => Yay, en melding: hei! # => hallo!

Legg merke til at klausulen returnerer resultatet av å evaluere den siste linjen, slik at vi får "hei!" string.

Husk at du kan innføre så mange klausuler som nødvendig:

send (selv), : ok, "hei!") mottar gjør : ok, msg -> IO.puts "Yay, en melding: # msg" msg : error, msg -> IO .puts: stderr, "Oh no, noe dårlig har skjedd: # msg" _ -> IO.puts "Jeg vet ikke hva denne meldingen er ..." etter 1000 -> IO.puts: stderr, "Jeg vil ha meldinger!" ende |> IO.puts

Her har vi fire klausuler: en til å håndtere en suksessmelding, en annen til å håndtere feil, og deretter en "fallback" -klausul og en timeout.

Hvis meldingen ikke samsvarer med noen av klausulene, blir den lagret i postkassen, som ikke alltid er ønskelig. Hvorfor? Fordi når en ny melding kommer, blir de gamle behandlet i første hode (fordi postboksen er en FIFO-kø), og senker programmet ned. Derfor kan en "fallback" -klausul komme til nytte.

Nå som du vet hvordan du skal gyte prosesser, send og motta meldinger, la oss se på et litt mer komplisert eksempel som innebærer å skape en enkel server som svarer på ulike meldinger.

Arbeider med serverprosessen

I det forrige eksempelet sendte vi bare en melding, mottok den og utførte noe arbeid. Det er greit, men ikke veldig funksjonelt. Vanligvis skjer det at vi har en server som kan svare på ulike meldinger. Med "server" mener jeg en langvarig prosess bygget med en gjentakende funksjon. For eksempel, la oss lage en server for å utføre noen matematiske ligninger. Det kommer til å motta en melding som inneholder den forespurte operasjonen og noen argumenter.

Start med å opprette serveren og looping-funksjonen:

defmodule MathServer gjør def start gjør gyte og hør / 0 ende defp lytte mottar gjør : sqrt, caller, arg -> IO.puts arg _ -> IO.puts: stderr, "Ikke implementert." slutt listen () slutten

Så hevdet vi en prosess som fortsetter å lytte til innkommende meldinger. Etter at meldingen er mottatt, vil lytte / 0 funksjon blir kalt igjen, og dermed skape en endeløs sløyfe. Inne i lytte / 0 funksjon, legger vi til støtte for : sqrt melding, som vil beregne kvadratroten til et tall. De arg vil inneholde det faktiske nummeret for å utføre operasjonen mot. Også, vi definerer en tilbakekallingsbestemmelse.

Du kan nå starte serveren og tildele prosess-ID til en variabel:

math_server = MathServer.start IO.inspect math_server # => #PID<0.85.0>

Strålende! La oss legge til en implementeringsfunksjon å faktisk utføre beregningen:

defmodule MathServer gjør # ... def sqrt (server, arg) send (: noen navn, : sqrt, self (), arg) slutten

Bruk denne funksjonen nå:

MathServer.sqrt (math_server, 3) # => 3

For nå skriver det bare ut det overførte argumentet, så juster koden som dette for å utføre den matematiske operasjonen:

defmodule MathServer gjør # ... defp lytte mottar gjør : sqrt, caller, arg -> send (: noe navn, : result, do_sqrt (arg)) _ -> IO.puts: stderr, "Ikke implementert." avslutte lytting () ende defp do_sqrt (arg) gjør: math.sqrt (arg) slutten

Nå sendes en annen melding til serveren som inneholder resultatet av beregningen. 

Hva er interessant er at sqrt / 2 funksjon sender rett og slett en melding til serveren som ber om å utføre en operasjon uten å vente på resultatet. Så, i utgangspunktet, det utfører en asynkron samtale.

Tydeligvis ønsker vi å ta tak i resultatet til enhver tid, så koden en annen offentlig funksjon:

def grab_result mottar gjør : result, result -> resultat etter 5000 -> IO.puts: stderr, "Timeout" slutten

Nå bruk det:

math_server = MathServer.start MathServer.sqrt (math_server, 3) MathServer.grab_result |> IO.puts # => 1.7320508075688772

Det fungerer! Selvfølgelig kan du til og med opprette et basseng av servere og distribuere oppgaver mellom dem, og oppnå samtidighet. Det er praktisk når forespørslene ikke relaterer seg til hverandre.

Møt GenServer

OK, vi har dekket en håndfull funksjoner som gjør at vi kan lage langsiktige serverprosesser og sende og motta meldinger. Dette er flott, men vi må skrive for mye boilerplate-kode som starter en serverløkke (start / 0), svarer på meldinger (lytte / 0 privat funksjon), og returnerer et resultat (grab_result / 0). I mer komplekse situasjoner kan det hende at vi også trenger å lede en delt tilstand eller håndtere feilene.

Som jeg sa i begynnelsen av artikkelen, er det ikke nødvendig å gjenoppfinne en sykkel. I stedet kan vi bruke GenServer-atferd som allerede gir all kjelepoden for oss og har god støtte for serverprosesser (som vi så i forrige avsnitt).

Oppførsel i Elixir er en kode som implementerer et felles mønster. For å bruke GenServer må du definere en spesiell tilbakeringingsmodul som tilfredsstiller kontrakten som diktert av oppførselen. Spesielt bør det implementere noen tilbakeringingsfunksjoner, og den faktiske implementeringen er opp til deg. Etter tilbakekallingen er skrevet, vil adferdsmodul kan bruke dem.

Som nevnt av docs, krever GenServer seks tilbakekallinger som skal implementeres, selv om de også har standard implementering. Det betyr at du bare kan omdefinere de som krever litt tilpasset logikk.

Første ting først: Vi må starte serveren før du gjør noe annet, så fortsett til neste avsnitt!

Starte serveren

For å demonstrere bruken av GenServer, la oss skrive en CalcServer som tillater brukere å bruke ulike operasjoner til et argument. Resultatet av operasjonen vil bli lagret i a server state, og så kan en annen operasjon også påføres det. Eller en bruker kan få et sluttresultat av beregningene.

Først av alt, bruk bruk makroen til å koble til GenServer:

defmodule CalcServer bruker GenServer-slutt

Nå må vi omdefinere noen tilbakeringinger.

Den første er init / 1, som påberopes når en server startes. Det overførte argumentet brukes til å angi en startserverens tilstand. I det enkleste tilfellet bør denne tilbakeringingen returnere : ok, initial_state tuple, selv om det finnes andre mulige returverdier som : stopp, grunn, som får serveren til å stoppe umiddelbart.

Jeg tror vi kan tillate brukere å definere den opprinnelige tilstanden for vår server. Vi må imidlertid sjekke at det bestått argumentet er et tall. Så bruk en vaktklausul for det:

defmodule CalcServer bruker GenServer def init (initial_value) når is_number (initial_value) gjør : ok, initial_value ende def init (_) gjør : stop, "Verdien må være et heltall!" slutten

Nå kan du bare starte serveren ved å bruke start / 3-funksjonen, og gi din CalcServer som tilbakekallingsmodul (det første argumentet). Det andre argumentet vil være den opprinnelige tilstanden:

GenServer.start (CalcServer, 5.1) |> IO.inspect # => : ok, #PID<0.85.0>

Hvis du prøver å sende et ikke-nummer som et annet argument, blir serveren ikke startet, noe som er akkurat det vi trenger.

Flott! Nå som serveren vår kjører, kan vi begynne å kode matematiske operasjoner.

Håndtering av asynkrone forespørsler

Asynkrone forespørsler kalles kaster i GenServers vilkår. For å utføre en slik forespørsel, bruk cast / 2-funksjonen, som godtar en server og den faktiske forespørselen. Det ligner på sqrt / 2 funksjon som vi kodet når vi snakket om serverprosesser. Det bruker også "brann og glemme" tilnærming, noe som betyr at vi ikke venter på forespørselen om å fullføre.

For å håndtere de asynkrone meldingene, brukes en håndboks / 2 tilbakeringing. Den aksepterer en forespørsel og en stat og skal svare med en tuple : noreply, new_state i det enkleste tilfellet (eller : stop, reason, new_state for å stoppe serverløkken). For eksempel, la oss håndtere en asynkron : sqrt støpt:

def handle_cast (: sqrt, state) gjør : noreply,: math.sqrt (state) slutten 

Slik opprettholder vi vår serveres tilstand. I utgangspunktet var nummeret (passert da serveren ble startet) 5.1. Nå oppdaterer vi staten og setter den til : Math.sqrt (5,1).

Kode grensesnittfunksjonen som benytter støpt / 2:

def sqrt (pid) gjør GenServer.cast (pid,: sqrt) slutten

For meg ligner dette en ond trollmann som kaster en stave, men bryr seg ikke om virkningen det forårsaker.

Merk at vi trenger et prosess-ID for å utføre casten. Husk at når en server er vellykket startet, en tuple : ok, pid returneres. Derfor, la oss bruke mønstermatching for å trekke ut prosess-id:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid)

Hyggelig! Den samme tilnærmingen kan brukes til å implementere, si, multiplikasjon. Koden vil bli litt mer kompleks da vi må passere det andre argumentet, en multiplikator:

def multiply (pid, multiplikator) slutter GenServer.cast (pid, : multiply, multiplikator)

De støpt Funksjonen støtter bare to argumenter, så jeg må bygge en tuple og sende et ekstra argument der.

Nå tilbakekallingen:

def handle_cast (: multiply, multiplikator, state) gjør : noreply, state * multiplikator slutten

Vi kan også skrive en enkelt handle_cast tilbakeringing som støtter operasjonen, samt stopper serveren hvis operasjonen er ukjent:

def hand_cast (drift, state) gjør saksoperasjon gjør: sqrt -> : noreply: math.sqrt (state) : multiplikere, multiplikator -> : noreply, state * multiplikator _ -> : stop, "Ikke implementert", state slutten

Bruk nå den nye grensesnittfunksjonen:

CalcServer.multiply (pid, 2)

Flott, men for tiden er det ikke mulig å få et resultat av beregningene. Derfor er det på tide å definere enda en tilbakeringing.

Håndtering av synkron forespørsler

Hvis asynkrone forespørsler blir kastet, blir synkroniserte navnene navngitt samtaler. For å kjøre slike forespørsler, bruk anrop / 3-funksjonen, som aksepterer en server, forespørsel og en valgfri timeout som tilsvarer fem sekunder som standard.

Synkron forespørsler brukes når vi vil vente til svaret faktisk kommer fra serveren. Typisk brukstilfelle er å få litt informasjon som et resultat av beregninger, som i dagens eksempel (husk grab_result / 0 funksjon fra en av de foregående delene).

For å behandle synkrone forespørsler, a handle_call / 3 tilbakeringing er utnyttet. Den aksepterer en forespørsel, en tuple som inneholder serverens pid, og et begrep som identifiserer samtalen, samt den aktuelle tilstanden. I det enkleste tilfellet bør det svare med en tuple : svar, svar, new_state

Kode denne tilbakeringingen nå:

def handle_call (: result, _ state) gjør : svar, state, state end

Som du ser, er ingenting komplisert. De svare og den nye staten er lik den nåværende tilstanden som jeg ikke vil endre noe etter at resultatet ble returnert.

Nå grensesnittet Resultatet / 1 funksjon:

def resultat (pid) gjør GenServer.call (pid,: result) ende

Det var det! Den endelige bruken av CalcServer er vist nedenfor:

: ok, pid = GenServer.start (CalcServer, 5.1) CalcServer.sqrt (pid) CalcServer.multiply (pid, 2) CalcServer.result (pid) |> IO.puts # => 4.516635916254486

aliasing

Det blir litt kjedelig å alltid gi et prosess-ID når du ringer til grensesnittfunksjonene. Heldigvis er det mulig å gi prosessen et navn, eller en alias. Dette gjøres ved start av serveren ved innstilling Navn:

GenServer.start (CalcServer, 5.1, navn:: calc) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Vær oppmerksom på at jeg ikke lagrer pid nå, selv om du kanskje vil gjøre mønstermatching for å forsikre deg om at serveren faktisk ble startet.

Nå blir grensesnittfunksjonene litt enklere:

def sqrt gjør GenServer.cast (: calc,: sqrt) ende def multipliserer (multiplikator) gjør GenServer.cast (: calc, : multiply, multiplikator) ende def result gjør GenServer.call (: calc,: result) end

Ikke glem at du ikke kan starte to servere med samme alias.

Alternativt kan du introdusere enda en grensesnittfunksjon start / 1 inne i modulen din og dra fordel av __MODULE __ / 0 makroen, som returnerer den nåværende modulens navn som et atom:

defmodule CalcServer bruker GenServer def start (initial_value) gjør GenServer.start (CalcServer, initial_value, navn: __MODULE__) avslutte def sqrt gjør GenServer.cast (__ MODULE__,: sqrt) ende def multiply (multiplikator) gjør GenServer.cast (__ MODULE__,  : multiplikere, multiplikator) ende def resultat gjør GenServer.call (__ MODULE__,: result) ende # ... ende CalcServer.start (6.1) CalcServer.sqrt CalcServer.multiply (2) CalcServer.result |> IO.puts

Avslutning

En annen tilbakeringing som kan omdefineres i modulen din kalles terminat / 2. Den aksepterer en grunn og nåværende tilstand, og det kalles når en server skal avslutte. Dette kan skje når du for eksempel sender et feil argument til multiplisere / 1 grensesnitt funksjon:

# ... CalcServer.multiply (2)

Tilbakeringingen kan se slik ut:

def terminate (_reason, _state) gjør IO.puts "Serveren avsluttet" slutten

Konklusjon

I denne artikkelen har vi dekket grunnleggende om samtidighet i Elixir og diskutert funksjoner og makroer som spawn, motta, og sende. Du har lært hvilke prosesser som er, hvordan du oppretter dem, og hvordan du sender og mottar meldinger. Vi har også sett hvordan du bygger en enkel, langvarig serverprosess som svarer til både synkron og asynkron melding.

I tillegg har vi diskutert GenServer-oppførsel og sett hvordan det forenkler koden ved å introdusere ulike tilbakekallinger. Vi har jobbet med i det, terminere, handle_call og handle_cast tilbakeringinger og opprettet en enkel beregningsserver. Hvis noe virket uklart for deg, ikke nøl med å legge inn dine spørsmål!

Det er mer til GenServer, og selvfølgelig er det umulig å dekke alt i en artikkel. I mitt neste innlegg vil jeg forklare hva veiledere er og hvordan du kan bruke dem til å overvåke dine prosesser og gjenopprette dem fra feil. Inntil da, lykkelig koding!