Milioni di WebSocket e via

Ciao a tutti! Mi chiamo Sergey Kamardin e sono uno sviluppatore di Mail.Ru.

Questo articolo spiega come abbiamo sviluppato il server WebSocket ad alto carico con Go.

Se hai familiarità con WebSocket, ma sai poco di Go, spero che troverai ancora questo articolo interessante in termini di idee e tecniche per l'ottimizzazione delle prestazioni.

1. Introduzione

Per definire il contesto della nostra storia, bisogna dire alcune parole sul perché abbiamo bisogno di questo server.

Mail.Ru ha molti sistemi stateful. La memorizzazione della posta elettronica dell'utente è una di queste. Esistono diversi modi per tenere traccia dei cambiamenti di stato all'interno di un sistema e sugli eventi di sistema. Principalmente ciò avviene attraverso il polling periodico del sistema o le notifiche del sistema sui suoi cambiamenti di stato.

Entrambi i modi hanno i loro pro e contro. Ma quando si tratta di posta, più velocemente un utente riceve nuova posta, meglio è.

Il polling della posta comporta circa 50.000 query HTTP al secondo, il 60% delle quali restituisce lo stato 304, il che significa che non ci sono cambiamenti nella cassetta postale.

Pertanto, al fine di ridurre il carico sui server e accelerare la consegna della posta agli utenti, è stata presa la decisione di reinventare la ruota scrivendo un server editore-abbonato (noto anche come bus, broker di messaggi o evento- canale) che riceverebbero notifiche sui cambiamenti di stato da un lato e abbonamenti per tali notifiche dall'altro.

In precedenza:

Adesso:

Il primo schema mostra com'era prima. Il browser eseguiva periodicamente il polling dell'API e chiedeva le modifiche allo Storage (servizio di cassetta postale).

Il secondo schema descrive la nuova architettura. Il browser stabilisce una connessione WebSocket con l'API di notifica, che è un client per il server Bus. Alla ricezione di una nuova e-mail, Storage invia una notifica al riguardo a Bus (1) e Bus ai suoi abbonati (2). L'API determina la connessione per inviare la notifica ricevuta e la invia al browser dell'utente (3).

Quindi oggi parleremo dell'API o del server WebSocket. Guardando al futuro, ti dirò che il server avrà circa 3 milioni di connessioni online.

2. Il modo idiomatico

Vediamo come implementeremmo alcune parti del nostro server usando semplici funzionalità Go senza alcuna ottimizzazione.

Prima di procedere con net / http, parliamo di come invieremo e ricevere i dati. I dati che si trovano al di sopra del protocollo WebSocket (ad esempio oggetti JSON) verranno in seguito denominati pacchetti.

Iniziamo a implementare la struttura del canale che conterrà la logica di invio e ricezione di tali pacchetti tramite la connessione WebSocket.

2.1. Channel struct

Vorrei attirare la vostra attenzione sul lancio di due goroutine di lettura e scrittura. Ogni goroutine richiede un proprio stack di memoria che può avere una dimensione iniziale da 2 a 8 KB a seconda del sistema operativo e della versione di Go.

Per quanto riguarda il numero di 3 milioni di connessioni online sopra menzionato, avremo bisogno di 24 GB di memoria (con lo stack di 4 KB) per tutte le connessioni. E questo senza la memoria allocata per la struttura del canale, i pacchetti in uscita ch.send e altri campi interni.

2.2. Goroutine I / O

Diamo un'occhiata all'implementazione del "lettore":

Qui usiamo bufio.Reader per ridurre il numero di syscalls read () e leggere quanti ne sono permessi dalla dimensione del buffer buf. All'interno del ciclo infinito, ci aspettiamo che arrivino nuovi dati. Ricorda le parole: aspettati che arrivino nuovi dati. Torneremo da loro più tardi.

Lasceremo da parte l'analisi e l'elaborazione dei pacchetti in entrata, poiché non è importante per le ottimizzazioni di cui parleremo. Tuttavia, buf ora merita la nostra attenzione: per impostazione predefinita, è 4 KB che significa altri 12 GB di memoria per le nostre connessioni. C'è una situazione simile con lo "scrittore":

Esaminiamo attraverso il canale di pacchetti in uscita c.send e li scriviamo nel buffer. Questo è, come possono già intuire i nostri lettori attenti, altri 4 KB e 12 GB di memoria per i nostri 3 milioni di connessioni.

2.3. HTTP

Abbiamo già una semplice implementazione del canale, ora abbiamo bisogno di avere una connessione WebSocket con cui lavorare. Dato che siamo ancora sotto la voce della Via Idiomatica, facciamolo nel modo corrispondente.

Nota: se non si conosce il funzionamento di WebSocket, è necessario ricordare che il client passa al protocollo WebSocket tramite uno speciale meccanismo HTTP chiamato Upgrade. Dopo l'elaborazione corretta di una richiesta di aggiornamento, il server e il client utilizzano la connessione TCP per scambiare frame binari WebSocket. Ecco una descrizione della struttura del telaio all'interno della connessione.

Si noti che http.ResponseWriter esegue l'allocazione della memoria per bufio.Reader e bufio.Writer (entrambi con buffer da 4 KB) per l'inizializzazione * http.Request e l'ulteriore scrittura della risposta.

Indipendentemente dalla libreria WebSocket utilizzata, dopo una risposta corretta alla richiesta di aggiornamento, il server riceve i buffer I / O insieme alla connessione TCP dopo la chiamata responseWriter.Hijack ().

Suggerimento: in alcuni casi, go: linkname può essere utilizzato per riportare i buffer alla sincronizzazione. Bobina all'interno di net / http tramite la chiamata net / http.putBufio {Reader, Writer}.

Pertanto, abbiamo bisogno di altri 24 GB di memoria per 3 milioni di connessioni.

Quindi, per un totale di 72 GB di memoria per l'applicazione che non fa ancora nulla!

3. Ottimizzazioni

Rivediamo ciò di cui abbiamo parlato nella parte introduttiva e ricordiamo come si comporta una connessione utente. Dopo essere passato a WebSocket, il client invia un pacchetto con gli eventi rilevanti o in altre parole si iscrive agli eventi. Quindi (senza tenere conto dei messaggi tecnici come ping / pong), il client potrebbe non inviare nient'altro per l'intera durata della connessione.

La durata della connessione può durare da alcuni secondi a diversi giorni.

Quindi per la maggior parte del tempo il nostro Channel.reader () e Channel.writer () sono in attesa della gestione dei dati per la ricezione o l'invio. Insieme a loro in attesa ci sono i buffer I / O di 4 KB ciascuno.

Ora è chiaro che certe cose potrebbero essere fatte meglio, no?

3.1. netpoll

Ricordi l'implementazione di Channel.reader () che prevedeva che arrivassero nuovi dati bloccandosi sulla chiamata conn.Read () all'interno di bufio.Reader.Read ()? Se c'erano dati nella connessione, il runtime di Go "ha svegliato" il nostro goroutine e gli ha permesso di leggere il pacchetto successivo. Successivamente, la goroutine è stata nuovamente bloccata in attesa di nuovi dati. Vediamo come Go runtime comprende che la goroutine deve essere "svegliata".

Se osserviamo l'implementazione conn.Read (), vedremo al suo interno la chiamata net.netFD.Read ():

Go utilizza i socket in modalità non bloccante. EAGAIN afferma che non ci sono dati nel socket e di non rimanere bloccati nella lettura dal socket vuoto, il sistema operativo ci restituisce il controllo.

Vediamo un syscall read () dal descrittore del file di connessione. Se read restituisce l'errore EAGAIN, il runtime effettua la chiamata pollDesc.waitRead ():

Se approfondiamo, vedremo che netpoll è implementato usando epoll in Linux e kqueue in BSD. Perché non utilizzare lo stesso approccio per le nostre connessioni? Potremmo allocare un buffer di lettura e avviare la lettura goroutine solo quando è veramente necessario: quando ci sono dati realmente leggibili nel socket.

Su github.com/golang/go, c'è il problema dell'esportazione delle funzioni di netpoll.

3.2. Sbarazzarsi di goroutine

Supponiamo di avere l'implementazione di netpoll per Go. Ora possiamo evitare di avviare la goroutine Channel.reader () con il buffer interno e iscriverci all'evento di dati leggibili nella connessione:

È più facile con Channel.writer () perché possiamo eseguire goroutine e allocare il buffer solo quando inviamo il pacchetto:

Si noti che non gestiamo i casi quando il sistema operativo restituisce EAGAIN sulle chiamate di sistema write (). Appoggiamo il runtime Go per questi casi, perché in realtà è raro per questo tipo di server. Tuttavia, se necessario, potrebbe essere gestito allo stesso modo.

Dopo aver letto i pacchetti in uscita da ch.send (uno o più), il writer finirà il suo funzionamento e libererà lo stack goroutine e il buffer di invio.

Perfezionare! Abbiamo risparmiato 48 GB eliminando lo stack e i buffer I / O all'interno di due goroutine in esecuzione continua.

3.3. Controllo delle risorse

Un gran numero di connessioni comporta non solo un elevato consumo di memoria. Durante lo sviluppo del server, abbiamo riscontrato ripetute condizioni di competizione e deadlock, spesso seguiti dal cosiddetto self-DDoS, una situazione in cui i client delle applicazioni hanno cercato di connettersi dilagante al server, interrompendolo ulteriormente.

Ad esempio, se per qualche motivo non potessimo improvvisamente gestire i messaggi ping / pong, ma il gestore delle connessioni inattive continuasse a chiudere tali connessioni (supponendo che le connessioni fossero interrotte e quindi non fornissero dati), il client sembrava perdere ogni connessione N secondi e ha provato a riconnettersi invece di aspettare eventi.

Sarebbe bello se il server bloccato o sovraccarico smettesse di accettare nuove connessioni e il bilanciamento precedente (ad esempio, nginx) passasse la richiesta all'istanza del server successiva.

Inoltre, indipendentemente dal carico del server, se tutti i client improvvisamente desiderano inviarci un pacchetto per qualsiasi motivo (presumibilmente a causa di un bug), i 48 GB precedentemente salvati saranno di nuovo utili, poiché torneremo allo stato iniziale della goroutine e del buffer per ogni connessione.

Pool Goroutine

Possiamo limitare il numero di pacchetti gestiti contemporaneamente usando un pool goroutine. Ecco come appare un'implementazione ingenua di tale pool:

Ora il nostro codice con netpoll è il seguente:

Quindi ora leggiamo il pacchetto non solo sull'aspetto dei dati leggibili nel socket, ma anche sulla prima opportunità di riprendere la goroutine gratuita nel pool.

Allo stesso modo, cambieremo Send ():

Invece di go ch.writer (), vogliamo scrivere in una delle goroutine riutilizzate. Pertanto, per un pool di N goroutine, possiamo garantire che con N richieste gestite simultaneamente e N + 1 arrivato non assegneremo un buffer N + 1 per la lettura. Il pool goroutine ci consente anche di limitare Accept () e Upgrade () di nuove connessioni ed evitare la maggior parte delle situazioni con DDoS.

3.4. Aggiornamento zero-copy

Scostiamo un po 'dal protocollo WebSocket. Come già accennato, il client passa al protocollo WebSocket utilizzando una richiesta di aggiornamento HTTP. Ecco come appare:

Cioè, nel nostro caso abbiamo bisogno della richiesta HTTP e delle sue intestazioni solo per passare al protocollo WebSocket. Questa conoscenza e ciò che è memorizzato all'interno di http.Request suggerisce che per motivi di ottimizzazione, potremmo probabilmente rifiutare allocazioni e copie non necessarie durante l'elaborazione delle richieste HTTP e abbandonare il server net / http standard.

Ad esempio, http.Request contiene un campo con lo stesso tipo di intestazione che viene riempito incondizionatamente con tutte le intestazioni di richiesta copiando i dati dalla connessione alle stringhe di valori. Immagina quanti dati extra potrebbero essere conservati all'interno di questo campo, ad esempio per un'intestazione di cookie di grandi dimensioni.

Ma cosa prendere in cambio?

Implementazione di WebSocket

Sfortunatamente, tutte le librerie esistenti al momento dell'ottimizzazione del nostro server ci hanno permesso di effettuare l'aggiornamento solo per il server standard net / http. Inoltre, nessuna delle (due) librerie ha permesso di utilizzare tutte le ottimizzazioni di lettura e scrittura di cui sopra. Perché queste ottimizzazioni funzionino, dobbiamo avere un'API di livello piuttosto basso per lavorare con WebSocket. Per riutilizzare i buffer, abbiamo bisogno che le funzioni procotol siano così:

func ReadFrame (io.Reader) (Frame, errore)
Errore di WriteFrame (io.Writer, Frame)

Se avessimo una libreria con tale API, potremmo leggere i pacchetti dalla connessione come segue (la scrittura dei pacchetti sembrerebbe la stessa):

In breve, era tempo di creare la nostra biblioteca.

github.com/gobwas/ws

Dal punto di vista ideologico, la libreria ws è stata scritta in modo da non imporre agli utenti la sua logica operativa del protocollo. Tutti i metodi di lettura e scrittura accettano interfacce io.Reader e io.Writer standard, il che consente di utilizzare o non utilizzare il buffering o altri wrapper I / O.

Oltre alle richieste di aggiornamento da net / http standard, ws supporta l'aggiornamento a zero copie, la gestione delle richieste di aggiornamento e il passaggio a WebSocket senza allocazioni di memoria o copie. ws.Upgrade () accetta io.ReadWriter (net.Conn implementa questa interfaccia). In altre parole, potremmo usare lo standard net.Listen () e trasferire immediatamente la connessione ricevuta da ln.Accept () a ws.Upgrade (). La libreria consente di copiare qualsiasi dato di richiesta per uso futuro nell'applicazione (ad esempio, Cookie per verificare la sessione).

Di seguito sono riportati i benchmark dell'elaborazione delle richieste di aggiornamento: server net / http standard contro net.Listen () con aggiornamento zero-copy:

Aggiornamento benchmarkHTTP 5156 ns / op 8576 B / op 9 alloc / op
Aggiornamento benchmark Benchmark 973 ns / op 0 B / op 0 allocs / op

Il passaggio a ws e upgrade a zero copie ci ha consentito di risparmiare altri 24 GB: lo spazio allocato per i buffer I / O su elaborazione richiesta dal gestore net / http.

3.5. Sommario

Strutturiamo le ottimizzazioni di cui ti ho parlato.

  • Una goroutine di lettura con un buffer all'interno è costosa. Soluzione: netpoll (epoll, kqueue); riutilizzare i buffer.
  • Una goroutine di scrittura con un buffer all'interno è costosa. Soluzione: avviare la goroutine quando necessario; riutilizzare i buffer.
  • Con una tempesta di connessioni, netpoll non funzionerà. Soluzione: riutilizzare le goroutine con il limite sul loro numero.
  • net / http non è il modo più veloce per gestire l'aggiornamento a WebSocket. Soluzione: utilizzare l'aggiornamento zero-copy su una connessione TCP nuda.

Ecco come potrebbe apparire il codice del server:

4. Conclusione

L'ottimizzazione prematura è la radice di tutti i mali (o almeno la maggior parte di essi) nella programmazione. Donald Knuth

Naturalmente, le ottimizzazioni di cui sopra sono rilevanti, ma non in tutti i casi. Ad esempio, se il rapporto tra risorse libere (memoria, CPU) e il numero di connessioni online è piuttosto elevato, probabilmente non ha senso ottimizzare. Tuttavia, puoi trarre grandi benefici dal sapere dove e cosa migliorare.

Grazie per l'attenzione!

5. Riferimenti

  • https://github.com/mailru/easygo
  • https://github.com/gobwas/ws
  • https://github.com/gobwas/ws-examples
  • https://github.com/gobwas/httphead
  • Versione russa di questo articolo