Lezione 6 di 7 · 25 min di lettura
Tanti utenti insieme
Dove entrano in gioco i processi del modulo 7. Un processo per ogni connessione, un crash che non tocca gli altri, e uno stato condiviso tra tutte le richieste, custodito da un attore e passato ai gestori in un contesto.
Un processo per ogni connessione
Un sito vero non ha un utente alla volta: ne ha dieci, cento, migliaia, e tutti fanno richieste nello stesso momento. Come fa il nostro server a rispondere a tutti, se il gestore è una semplice funzione?
La risposta sta nel modulo 7. Per ogni connessione che si apre, mist avvia un processo nuovo della BEAM, ed è quel processo a leggere le richieste e a chiamare il tuo gestore. Possiamo vederlo: process.self() restituisce il Pid del processo che sta eseguendo il codice (lezione 7.2), e un gestore può metterlo nella risposta.
import gleam/erlang/process
import gleam/string
import mist
import wisp.{type Request, type Response}
import wisp/wisp_mist
pub fn main() -> Nil {
let assert Ok(_) =
wisp_mist.handler(handle_request, wisp.random_string(64))
|> mist.new
|> mist.port(8000)
|> mist.start
process.sleep_forever()
}
fn handle_request(_request: Request) -> Response {
let me = string.inspect(process.self())
wisp.ok() |> wisp.string_body("Served by " <> me <> "\n")
}Avvialo, e dal secondo terminale fai tre chiamate a curl; l’ultima chiede due indirizzi in una volta sola:
curl localhost:8000
curl localhost:8000
curl localhost:8000 localhost:8000Served by //erl(<0.127.0>)
Served by //erl(<0.128.0>)
Served by //erl(<0.129.0>)
Served by //erl(<0.129.0>)(I numeri sul tuo computer saranno diversi.) Ogni curl apre una connessione nuova, e riceve un processo nuovo. L’ultimo curl, invece, apre una connessione e ci fa passare due richieste, una dopo l’altra (è il keep-alive della lezione 9.1): stessa connessione, stesso processo.
Cento utenti collegati vogliono dire cento processi, che la BEAM fa girare insieme, sfruttando tutti i core del processore (lezione 7.2). Mentre un processo aspetta, per esempio perché il gestore legge un file lento, gli altri continuano a rispondere. E un processo costa pochissimo: nella lezione 7.3 ne abbiamo avviati centomila in meno di un secondo.
Un crash resta dov’è
I processi sono anche isolati: non condividono memoria, e se uno si schianta gli altri non se ne accorgono. Prova a togliere use <- wisp.rescue_crashes dal logged_server della lezione 9.4 e a chiedere /crash. Il client riceve comunque un 500: è mist a mandarlo, perché anche lui intercetta gli errori del gestore, poi chiude la connessione e lascia terminare il suo processo. Nel terminale del server compare un rapporto d’errore bello lungo, con dentro il messaggio del panic e un ChildTerminated: il processo di quella connessione è terminato, e il suo supervisore l’ha annotato. Subito dopo, curl localhost:8000/ risponde normalmente.
È il “lascia che si schianti” della lezione 7.6: un errore in una richiesta rovina quella richiesta, non il server. Con rescue_crashes il risultato per l’utente è lo stesso, ma il log è più ordinato, ed è il gestore stesso a scegliere la risposta.
Una memoria condivisa
Il rovescio dell’isolamento: se ogni richiesta gira in un processo suo, dove si mette un dato che devono vedere tutte? Un contatore delle visite, i libri aggiunti con POST, i messaggi di una chat. Gleam non ha variabili globali da modificare, e anche se le avesse, i processi non condividono memoria.
La risposta la conosci già dalla lezione 7.5: un attore, cioè un processo che custodisce lo stato e riceve messaggi. Tutti i processi delle connessioni gli mandano richieste, e lui le serve una alla volta. Ecco il contatore, in un modulo suo, come nella lezione 7.5:
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
pub opaque type Message {
Visit(reply_to: Subject(Int))
}
pub fn start() -> Result(Subject(Message), actor.StartError) {
case actor.new(0) |> actor.on_message(handle_message) |> actor.start {
Ok(started) -> Ok(started.data)
Error(error) -> Error(error)
}
}
pub fn visit(counter: Subject(Message)) -> Int {
actor.call(counter, waiting: 1000, sending: Visit)
}
fn handle_message(count: Int, message: Message) -> actor.Next(Int, Message) {
case message {
Visit(reply_to) -> {
actor.send(reply_to, count + 1)
actor.continue(count + 1)
}
}
}Un solo messaggio, Visit, che fa due cose insieme: conta la visita e risponde con il numero nuovo. Farle in un messaggio solo è importante, e ci torniamo tra poco.
Il contesto
Resta un problema: il gestore riceve solo la richiesta, ma adesso gli serve anche il Subject del contatore. La soluzione usata da tutti i progetti wisp è un tipo contesto (context): un record con tutto ciò che i gestori devono poter usare, passato come secondo argomento.
import gleam/erlang/process.{type Subject}
import gleam/http.{Get}
import gleam/int
import gleam/io
import visits
import wisp.{type Request, type Response}
import wisp/simulate
pub type Context {
Context(visits: Subject(visits.Message))
}
pub fn handle_request(_request: Request, context: Context) -> Response {
let number = visits.visit(context.visits)
wisp.ok()
|> wisp.string_body("You are visitor number " <> int.to_string(number))
}
pub fn main() -> Nil {
let assert Ok(counter) = visits.start()
let context = Context(visits: counter)
let response = handle_request(simulate.request(Get, "/"), context)
io.println(simulate.read_body(response))
let done = process.new_subject()
int.range(from: 0, to: 100, with: Nil, run: fn(_, _) {
process.spawn(fn() {
handle_request(simulate.request(Get, "/"), context)
process.send(done, Nil)
})
Nil
})
int.range(from: 0, to: 100, with: Nil, run: fn(_, _) {
process.receive_forever(done)
})
let response = handle_request(simulate.request(Get, "/"), context)
io.println(simulate.read_body(response))
}You are visitor number 1
You are visitor number 102Il main fa quello che farebbe un server affollato: dopo la prima visita, avvia cento processi, ognuno dei quali chiama il gestore con una richiesta finta, poi aspetta che abbiano finito tutti (lo schema della lezione 7.3), e infine fa un’ultima visita. Uno, più cento, più uno: centodue. Nessuna visita persa, anche se i cento processi hanno chiamato il gestore tutti insieme.
Accenderlo davvero
Per il server vero serve un ultimo passaggio: mist vuole un gestore con un argomento solo, la richiesta. Lo costruiamo con una funzione anonima che si ricorda il contesto, una chiusura (lezione 2.3):
import gleam/erlang/process
import mist
import visit_counter.{Context}
import visits
import wisp
import wisp/wisp_mist
pub fn main() -> Nil {
let assert Ok(counter) = visits.start()
let context = Context(visits: counter)
let handler = fn(request) { visit_counter.handle_request(request, context) }
let assert Ok(_) =
wisp_mist.handler(handler, wisp.random_string(64))
|> mist.new
|> mist.port(8000)
|> mist.start
process.sleep_forever()
}L’attore si avvia una volta, in main, prima del server; poi ogni processo di connessione riceve lo stesso contesto, e quindi lo stesso Subject. Su Linux e macOS puoi mettere il server sotto pressione con xargs, che qui lancia 50 curl contemporaneamente:
curl localhost:8000
seq 1 50 | xargs -P 50 -I{} curl -s -o /dev/null localhost:8000
curl localhost:8000You are visitor number 1
You are visitor number 52(Le cinquanta risposte di mezzo finiscono in /dev/null, cioè da nessuna parte.) Cinquanta connessioni, cinquanta processi, un contatore esatto.
C’è però un limite, che conosci dalla lezione 7.6: lo stato di un attore vive in memoria. Se spegni il server, il contatore riparte da zero. Per conservare i dati tra un avvio e l’altro, un sito vero li scrive su disco, di solito in un database; un attore resta comunque utile per quello che deve essere veloce e può andare perso, come una cache o i visitatori collegati in questo momento.
Quiz
Il contatore delle visite è in un attore. Cosa succede se due utenti visitano la pagina esattamente nello stesso istante?
Esercizio · sul tuo computer
Il sondaggio
Crea src/poll.gleam, un sondaggio con due opzioni:
- un attore con uno stato
Tally(gleam: Int, erlang: Int)e tre messaggi:VoteGleam,VoteErlang, eResultsper chiedere i totali; - un tipo
Context(poll: Subject(Message))e un gestorehandle_request(request, context)con tre strade:POST /vote/gleamePOST /vote/erlangregistrano il voto e rispondonowisp.no_content()(204),GET /resultsrisponde con i totali in JSON; tutto il resto è404.
Nel main, avvia l’attore e poi 42 processi, ognuno dei quali manda un voto con simulate: il processo numero n (da 0 a 41) vota erlang se n % 3 == 0, altrimenti gleam. Aspetta che abbiano finito tutti, poi stampa il corpo di GET /results, e il codice di stato di GET /vote/python:
{"gleam":28,"erlang":14}
404Suggerimento: ogni processo può mandare a main il codice di stato della sua risposta, e main può controllare con let assert 204 = process.receive_forever(done) che sia andato tutto bene.
Mostra una soluzione (prima prova da solo!)
import gleam/erlang/process.{type Subject}
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/json
import gleam/otp/actor
import wisp.{type Request, type Response}
import wisp/simulate
pub type Tally {
Tally(gleam: Int, erlang: Int)
}
pub type Message {
VoteGleam
VoteErlang
Results(reply_to: Subject(Tally))
}
pub type Context {
Context(poll: Subject(Message))
}
fn handle_message(
tally: Tally,
message: Message,
) -> actor.Next(Tally, Message) {
case message {
VoteGleam -> actor.continue(Tally(..tally, gleam: tally.gleam + 1))
VoteErlang -> actor.continue(Tally(..tally, erlang: tally.erlang + 1))
Results(reply_to) -> {
actor.send(reply_to, tally)
actor.continue(tally)
}
}
}
pub fn handle_request(request: Request, context: Context) -> Response {
case wisp.path_segments(request), request.method {
["vote", "gleam"], Post -> vote(context, VoteGleam)
["vote", "erlang"], Post -> vote(context, VoteErlang)
["results"], Get -> results(context)
_, _ -> wisp.not_found()
}
}
fn vote(context: Context, message: Message) -> Response {
actor.send(context.poll, message)
wisp.no_content()
}
fn results(context: Context) -> Response {
let tally = actor.call(context.poll, waiting: 1000, sending: Results)
json.object([
#("gleam", json.int(tally.gleam)),
#("erlang", json.int(tally.erlang)),
])
|> json.to_string
|> wisp.json_response(200)
}
pub fn main() -> Nil {
let assert Ok(started) =
actor.new(Tally(gleam: 0, erlang: 0))
|> actor.on_message(handle_message)
|> actor.start
let context = Context(poll: started.data)
let done = process.new_subject()
int.range(from: 0, to: 42, with: Nil, run: fn(_, n) {
let path = case n % 3 {
0 -> "/vote/erlang"
_ -> "/vote/gleam"
}
process.spawn(fn() {
let response = handle_request(simulate.request(Post, path), context)
process.send(done, response.status)
})
Nil
})
int.range(from: 0, to: 42, with: Nil, run: fn(_, _) {
let assert 204 = process.receive_forever(done)
Nil
})
let response = handle_request(simulate.request(Get, "/results"), context)
io.println(simulate.read_body(response))
let response = handle_request(simulate.request(Get, "/vote/python"), context)
io.println(int.to_string(response.status))
}Qui un voto è un actor.send, senza risposta: il gestore non ha bisogno di sapere il totale, quindi non aspetta. Ogni voto è comunque un messaggio solo, e l’attore lo conta da sé, quindi nessuna corsa critica. Per semplicità attore e gestore stanno nello stesso modulo; in un progetto vero l’attore andrebbe in un modulo suo, con Message opaco, come visits.
Ricapitolando
- mist avvia un processo per ogni connessione: tante connessioni, tanti processi, che la BEAM fa girare insieme. Richieste sulla stessa connessione (
keep-alive) usano lo stesso processo. - I processi sono isolati: un crash in una richiesta la fa fallire con un
500, ma il server continua a servire tutti gli altri. - Uno stato condiviso da tutte le richieste vive in un attore, avviato una volta in
main. - Il contesto è un record con quello che serve ai gestori (il
Subjectdell’attore, e in futuro altro): si passa come secondo argomento,handle_request(request, context), e a mist si dà una chiusurafn(request) { handle_request(request, context) }. - Ogni operazione che legge e modifica lo stato è un messaggio solo, così l’attore la esegue tutta insieme: niente corse critiche.
- Lo stato di un attore si perde quando il server si spegne: per i dati da conservare serve un file o un database.
Nella prossima lezione, l’ultima del modulo, metteremo insieme tutto quello che abbiamo visto in un progetto vero: un libro degli ospiti, con pagine per le persone e un’API per i programmi.