Il server net/http del Go impiega un modello goroutine-per-connessione combinato con la strategia di scheduling M:N del runtime. Quando il server accetta una connessione TCP, genera immediatamente una leggera goroutine per gestire l'intero ciclo di vita di quella connessione, consentendo al ciclo principale di accettazione di restituire e ricevere immediatamente la connessione successiva. Queste goroutine sono multiplexate su un pool limitato di thread OS dal scheduler del Go, che parcheggia le goroutine che eseguono I/O bloccante e riprogramma quelle eseguibili su thread disponibili. Questa architettura consente al server di mantenere centinaia di migliaia di connessioni concorrenti utilizzando solo un numero limitato di thread del kernel, evitando il sovraccarico di memoria dei tradizionali server thread-per-connessione.
Avevamo bisogno di costruire un gateway di telemetria in tempo reale capace di ingerire dati da 50.000 dispositivi IoT simultaneamente su connessioni HTTP/1.1 persistenti.
Descrizione del problema: Il nostro prototipo iniziale usando Python con Twisted forniva la necessaria concorrenza ma divenne rapidamente non manutenibile a causa di catene di callback complesse e gestione degli errori profondamente annidate. Quando abbiamo tentato di adottare un approccio thread-per-connessione in Java per semplificare il codice, abbiamo incontrato il limite dei thread del sistema operativo a circa 32.000 connessioni, causando il crash della JVM con OutOfMemoryError: unable to create new native thread poiché ogni thread consumava più di 1MB di memoria virtuale.
Diverse soluzioni considerate:
Asyncio con macchine a stati espliciti: Abbiamo valutato la possibilità di migrare a asyncio di Python per utilizzare un unico ciclo di eventi con coroutines. Questo avrebbe ridotto significativamente l'impronta di memoria rispetto ai thread, ma avrebbe richiesto di riscrivere tutta la nostra logica di parsing dei protocolli in sintassi async/await e avrebbe introdotto il rischio di bloccare accidentalmente il ciclo di eventi con operazioni intensive per la CPU. Anche il debug degli stack trace attraverso i confini asincroni si è dimostrato notoriamente difficile per il nostro team di sviluppo.
Sharding orizzontale delle istanze JVM: Abbiamo considerato di eseguire dieci istanze Java più piccole dietro un bilanciatore di carico, con ciascuna istanza che gestisce 5.000 thread. Questo approccio ha risolto il limite di thread per processo ma ha introdotto complessità operativa sostanziale, richiedendo risorse hardware aggiuntive e complicando la gestione dello stato condiviso e della persistenza delle connessioni nell'intero cluster. Il sovraccarico operativo per mantenere questo micro-cluster ha superato i benefici di rimanere con Java.
Modello goroutine-per-connessione del Go: Abbiamo scelto di reimplementare il gateway in Go, sfruttando i pacchetti net/http e net della libreria standard. Il metodo Serve del server genera automaticamente una leggera goroutine per ogni connessione TCP accettata, e lo scheduler del runtime Go multiplexa in modo trasparente queste ultime su un pool limitato di thread OS. Questo ci ha permesso di scrivere codice I/O che appare semplice e sincrono, che si adatta a centinaia di migliaia di connessioni senza la gestione manuale delle macchine a stati.
Soluzione scelta e perché: Abbiamo selezionato l'implementazione in Go perché offriva la scalabilità dei sistemi a eventi combinata con la semplicità della programmazione con thread. Il runtime gestisce automaticamente la complessità della programmazione e dell'I/O non bloccante, consentendo ai nostri sviluppatori di concentrarsi sulla logica di business piuttosto che sui primitivi di concorrenza. Inoltre, la dimensione iniziale dello stack di 2KB delle goroutine significava che teoricamente potevamo gestire milioni di connessioni all'interno del nostro budget di memoria.
Risultato: Il sistema di produzione ha gestito con successo 75.000 connessioni persistenti concorrenti su un singolo server a 8 core, consumando meno di 4GB di RAM. L'utilizzo della CPU è rimasto stabile al 35-40% poiché lo scheduler ha efficientemente nascosto la latenza I/O, e abbiamo eliminato l'onere operativo di gestire un cluster di istanze Java sharded.
Come fa lo scheduler di Go a prevenire un problema di gregge tempesto quando migliaia di goroutine sono bloccate sullo stesso canale di ricezione?
Lo scheduler di Go utilizza una coda di attesa first-in-first-out (FIFO) per i canali, non una wake-all stile semaforo. Quando un mittente scrive su un canale, lo scheduler risveglia esattamente una goroutine in attesa dalla coda di ricezione (quella che ha atteso più a lungo). Questo assicura che solo una goroutine consumi il valore, prevenendo il gregge tempesta dove più goroutine si svegliano, competono per il blocco e tutte tranne una tornano a dormire. I candidati spesso supposcono erroneamente che le operazioni sui canali vengano trasmesse a tutti i portatori in attesa come variabili di condizione.
Perché aumentare GOMAXPROCS oltre il numero di core fisici della CPU potrebbe degradare le prestazioni di un server HTTP Go legato all'I/O?
Sebbene lo scheduler di Go sia preemptive dalla versione 1.14, avere più thread OS (M) rispetto ai core aumenta il sovraccarico delle switch di contesto a livello di kernel. Per i server legati all'I/O, thread eccessivi possono portare lo scheduler a impiegare più tempo nella gestione delle runqueues e dei passaggi di thread rispetto all'esecuzione del codice utente. Inoltre, ogni thread OS consuma risorse di kernel (memoria per lo storage locale del thread e stack di kernel), il che può mettere sotto pressione il sistema operativo quando scalato eccessivamente oltre la necessaria concorrenza.
Come gestisce il server net/http di Go la coda SO_BACKLOG TCP quando il tasso di accettazione delle goroutine temporaneamente rallenta rispetto al tasso di arrivo delle connessioni?
Il server si basa sulla coda di backlog di ascolto del kernel (controllata da net.ListenConfig's Backlog o dai valori predefiniti di sistema). Se le goroutine sono lente a partire o i gestori sono lenti ad accettare connessioni dall'ascoltatore, il kernel mette in coda gli SYN in arrivo nel backlog. Una volta che il backlog si riempie, il kernel rifiuta nuove connessioni tramite TCP RST. Il ciclo Accept() di Go viene eseguito in una sua goroutine e dovrebbe idealmente generare rapidamente goroutine gestore. Tuttavia, se la generazione dei gestori è ritardata (ad esempio a causa di pause GC o contesa mutex nel middleware), le connessioni vengono perse. I candidati spesso trascurano che Go non implementa una coda di connessione nello spazio utente; dipende interamente dal backlog del kernel, e la regolazione di SOMAXCONN o ListenConfig.Backlog è cruciale per assorbire i picchi.