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.

src/who_serves.gleam
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:

terminale
curl localhost:8000
curl localhost:8000
curl localhost:8000 localhost:8000
output
Served 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:

src/visits.gleam
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.

src/visit_counter.gleam
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))
}
output
You are visitor number 1
You are visitor number 102

Il 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):

src/visit_server.gleam
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:

terminale
curl localhost:8000
seq 1 50 | xargs -P 50 -I{} curl -s -o /dev/null localhost:8000
curl localhost:8000
output
You 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, e Results per chiedere i totali;
  • un tipo Context(poll: Subject(Message)) e un gestore handle_request(request, context) con tre strade: POST /vote/gleam e POST /vote/erlang registrano il voto e rispondono wisp.no_content() (204), GET /results risponde 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:

output
{"gleam":28,"erlang":14}
404

Suggerimento: 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!)
src/poll.gleam
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 Subject dell’attore, e in futuro altro): si passa come secondo argomento, handle_request(request, context), e a mist si dà una chiusura fn(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.