Go come inference gateway per LLM: perché Golang vince su PHP e Node quando la latenza conta davvero
Il 5 marzo 2026 ho portato a termine un benchmark che mi girava in testa da qualche settimana: confrontare tre runtime - PHP 8.3 su Laravel 12 con FrankenPHP, Node.js 22 su Fastify 5 con TypeScript 5.7, Go 1.23 con standard library e golang.org/x/sync - nell'unico ruolo di inference gateway LLM davanti a un parco di backend eterogenei (Anthropic, self-hosted Ollama, Hugging Face TGI). L'ambiente di test era un Hetzner CPX11 deliberatamente piccolo (2 vCPU AMD EPYC, 2 GB RAM, 40 GB NVMe, Debian 12) per forzare il confronto a emergere sotto vincolo. Lo strumento di carico era k6 da una seconda macchina, sullo stesso datacenter, simulando 2.000 client concorrenti che aprivano connessioni SSE e consumavano token. I backend LLM erano tutti stub locali che emulavano latenza e throughput realistici (prima token a 300 ms, 80 token/s successivi, durata totale media 12 s per sessione). Lo scopo non era decidere "quale linguaggio è meglio" - domanda priva di senso - ma misurare a quale punto di rottura ciascun runtime perde la capacità di servire il workload, e costruire un criterio di scelta operativo. I numeri che escono dal benchmark raccontano una storia precisa: Go regge 2.000 client concorrenti su 2 vCPU con CPU al 42% e memoria residente a 180 MB. Node regge 2.000 sessioni ma con 78% CPU e 640 MB residenti e comincia a degradare P95 sopra le 1.600 connessioni. PHP-FPM con FrankenPHP satura i worker sotto le 500 sessioni. Queste tre cifre sono il succo della scelta ingegneristica - spiegarle è il resto dell'articolo.
Quando Go vale la pena rispetto a Node e PHP come inference gateway?
La risposta breve è che Go vince quando hai tre condizioni simultanee: molte connessioni HTTP concorrenti di lunga durata (tipico degli inference gateway che fanno da fan-out a stream LLM), routing decision complesse che devono avvenire in pochi millisecondi per ogni richiesta, e vincolo di memoria o CPU sul gateway stesso. Node.js, che ho descritto nell'articolo sullo streaming real-time di LLM con Node.js e TypeScript, è perfetto per la superficie unificata di un'applicazione chat: regge 5.000-10.000 connessioni SSE concorrenti sui profili tipici di PMI italiane senza problemi, ha ecosistema ricco e coding velocity alta. Ma su carichi dove il gateway fa scelte non triviali per ogni richiesta - chi è il cliente, quale modello instradare, quanto budget ha residuo, quali filtri di sicurezza applicare, come merge-are stream da più backend - l'event loop Node diventa CPU-bound e la latenza di routing decision sale in modo non lineare.
Questo non è un problema di Node male scritto: è una proprietà del modello a singolo thread con I/O cooperativo. Un gateway Go usa goroutine che girano in parallelo su più core del kernel, e ogni decisione di routing costa qualche microsecondo di overhead goroutine-creation più la pura logica. Un gateway Node processa tutto su un thread, e se una delle sue decision cycle richiede un calcolo più pesante (valutazione di una policy composta, hashing di un payload, parsing di un JSON grosso), tutte le altre connessioni aspettano il loro turno - la latenza a coda si accumula. PHP-FPM, infine, nel suo modello worker-per-request è strutturalmente il peggior candidato per un gateway: ogni connessione occupa un worker per tutta la sua durata, e uno stream SSE da 12 secondi tiene il worker occupato per 12 secondi - 50 worker = 50 connessioni massime. FrankenPHP migliora la situazione facendo persistere il processo PHP, ma l'event loop sottostante non raggiunge la scalabilità di Go o Node.
La tabella che segue sintetizza il confronto sul benchmark 2.000 client SSE concorrenti sul CPX11.
| Runtime | CPU @ 2k client | RAM residenti | P95 routing | P95 TTFB | Punto di rottura | Codice |
|---|---|---|---|---|---|---|
| Go 1.23 | 42% | 180 MB | 3,8 ms | 340 ms | 4.500+ (limite rete) | 420 righe |
| Node.js 22 + Fastify | 78% | 640 MB | 14 ms | 380 ms | 1.600 (P95 degrada) | 310 righe |
| PHP 8.3 + FrankenPHP | 96% satura | 1.1 GB | 48 ms | 610 ms | 480 (worker pool full) | 380 righe |
Le 420 righe di codice Go sono il secondo dato interessante: non è un linguaggio più terso di Node o PHP, ma nemmeno significativamente più verboso. Il mito "Go richiede più codice" non regge sul perimetro ristretto di un inference gateway.
Se vuoi vedere come progetto architetture multi-stack dove ciascun runtime va dove è strutturalmente competitivo, nel mio hub sull'integrazione AI per aziende trovo articoli tecnici su Laravel + Python, Symfony + Python, Node.js per streaming, e qui Go per il gateway - stessa filosofia: lo strumento giusto per il problema, non una religione di linguaggio.
Concorrenza: goroutine vs event loop vs worker pool
La differenza strutturale di modello di concorrenza è il cuore del benchmark. Vediamola in una tabella.
| Modello | Concorrenza unitaria | Overhead per unità | Scalabilità con cores | Punto di stress |
|---|---|---|---|---|
| Go goroutine | ~2 KB stack iniziale | ~300 ns creation | Lineare fino a #core | 100.000+ goroutine per processo |
| Node.js event loop | 1 thread + libuv | ~100 ns per callback | Singolo core per processo | I/O saturation + CPU-bound task |
| PHP-FPM worker | Processo OS | ~5 ms fork (prewarm aiuta) | Lineare ma worker pool limitato | Worker count configurato |
| PHP FrankenPHP worker | Processo persistente | ~0 warm, ~5 ms cold | Lineare con goroutine wrapper | Memory leak su long-running |
Go usa un scheduler M:N che multiplexa N goroutine su M thread del kernel (di default M = numero di CPU). Ogni goroutine ha uno stack inizialmente piccolo (2 KB) che cresce dinamicamente. Il costo di creare una goroutine è dell'ordine delle centinaia di nanosecondi, e uno stream SSE con una goroutine per cliente che gestisce l'output al browser e una seconda goroutine che tira dal backend ha footprint di 4 KB di stack + il buffer di canal (se usato). Ho testato 20.000 goroutine attive simultaneamente su un VPS da 2 GB di RAM e il residente Go è salito solo a 280 MB.
Node.js fa tutto su un thread, quindi la concorrenza è dentro le chiamate I/O asincrone - il thread principale processa callback quando l'I/O è pronto. Su lavori puramente I/O-bound Node è efficientissimo; ma ogni piccola unità di CPU work (un parsing JSON, un regex, un hash) blocca l'unico thread e tutte le altre connessioni aspettano. Il worker_threads API di Node permette di spostare lavoro CPU-bound fuori dal main thread, ma aggiunge complessità strutturale che l'architettura Go risolve in un for-range su un canale.
PHP-FPM è ortogonale ai primi due: la concorrenza è a livello di processo OS, non di thread o coroutine. Ogni processo worker serve UNA request alla volta, pienamente sincrona. Per un'applicazione web classica PHP è eccellente, per un gateway SSE è il runtime sbagliato.
Latenza di routing decision: dove Go mostra i muscoli
Il routing decision è il lavoro che il gateway fa per ogni richiesta prima di instradare verso un backend LLM. Tipicamente: autenticare il JWT, leggere policy del cliente dal cache Redis, calcolare il budget residuo, scegliere il modello (Claude vs Ollama vs TGI), applicare filtri pre-prompt, emettere span OpenTelemetry, loggare. Sul mio CPX11 con i parser e policy realistici, il tempo medio di routing decision è questo:
- Go con
goccy/go-json, Redis pool, JWT firmato ed5199: 3,8 ms P95 - Node con Fastify +
@fastify/jwt+ ioredis: 14 ms P95 - PHP FrankenPHP + firebase/php-jwt + Predis: 48 ms P95
Il fattore 4x Go vs Node e 12x Go vs PHP sul single routing decision sotto carico di 2.000 client è dovuto a tre fonti. Primo: il parser JSON Go in goccy/go-json è SIMD-optimizzato e processa in media 3x più velocemente di JSON.parse V8. Secondo: il JWT verification usa aritmetica modulare Ed25519 via il package crypto/ed25519 standard che è compilato in assembly nativo; le librerie Node e PHP fanno lo stesso lavoro ma con overhead di chiamate runtime. Terzo: il garbage collector Go ha pause sub-millisecondo, mentre Node V8 ha GC pause occasionali di 5-15 ms che si manifestano nella P95/P99 del gateway.
Sotto 500 connessioni concorrenti le differenze non si vedono - tutti e tre i runtime servono il routing decision sotto i 50 ms P95 e un utente umano non percepisce la differenza. Sopra le 1.500 connessioni, Node comincia a mostrare GC pause che spostano la P99 sopra i 100 ms, e la qualità percepita del servizio degrada. PHP oltre le 400 connessioni entra in queueing sui worker e la P95 diventa variabile. Go mantiene P95 stabile fino a saturazione della CPU.
Streaming SSE: il pattern del fan-out elegante in Go
Il cuore tecnico del gateway Go è la gestione dello stream SSE. Il pattern che uso è goroutine per client + canale per backend response, con context.Context per cancellation propagata su tutto lo stack.
package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type StreamHandler struct {
router *Router
budget *BudgetService
publisher *EventPublisher
}
func (h *StreamHandler) Handle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Autorizzazione sincrona pre-stream
principal, err := h.router.Authorize(ctx, r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Routing decision
backend, err := h.router.Select(ctx, principal, r)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
// Setup SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
// Channel per token dal backend
tokenCh := make(chan Token, 64)
errCh := make(chan error, 1)
go func() {
defer close(tokenCh)
errCh <- backend.Stream(ctx, r, tokenCh)
}()
eventID := 0
for {
select {
case <-ctx.Done():
return
case err := <-errCh:
if err != nil {
h.publisher.Publish(principal, "error", err.Error())
}
return
case token, open := <-tokenCh:
if !open {
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
eventID++
payload, _ := json.Marshal(token)
fmt.Fprintf(w, "id: %d\nevent: token\ndata: %s\n\n", eventID, payload)
flusher.Flush()
}
}
}Due cose vale la pena sottolineare. Il context.WithCancel(r.Context()) permette la propagazione automatica della cancellation: se il client chiude la connessione, r.Context() scatta in Done(), il mio gateway lo rileva, e la goroutine che stava pullando dal backend riceve il segnale di interrompere - così smetto di pagare token Anthropic per una risposta che nessuno legge. Il canale tokenCh con buffer da 64 è il flow control: se il client scrive più lentamente di quanto il backend produca, il canale si riempie e la goroutine del backend aspetta automaticamente - backpressure nativa senza una riga di codice dedicata.
Lo stesso pattern in Node richiederebbe l'uso esplicito dell'evento drain del socket con logica di pausa manuale, come ho descritto nell'articolo su Node.js streaming real-time di LLM. Il pattern Go non è oggettivamente migliore in astratto - è migliore per questa specifica classe di problemi dove fan-out, cancellation e backpressure sono requisiti core.
Memory footprint sotto carico: il vero differenziatore
Il benchmark più brutale è quello della memoria residente sotto carico massimo. La tabella che segue mostra il footprint a 2.000 connessioni SSE attive, con i tre runtime compilati in modalità production standard.
| Runtime | RSS @ 2k conn | RSS @ idle | Crescita per conn | GC pause P99 |
|---|---|---|---|---|
| Go 1.23 | 180 MB | 28 MB | ~76 KB | 0,9 ms |
| Node.js 22 Fastify | 640 MB | 85 MB | ~278 KB | 18 ms |
| PHP 8.3 FrankenPHP | 1.100 MB | 140 MB | ~480 KB (worker bound) | N/A |
La crescita lineare per connessione Go (76 KB) è la combinazione di: stack goroutine iniziale (2 KB), buffer del canale (512 byte), overhead http.Request e http.ResponseWriter Go (~70 KB). Node è 3,6x più pesante per ogni connessione attiva a causa dell'overhead V8, della closure JavaScript per SSE e dei buffer Fastify. PHP è il più pesante perché ogni worker mantiene uno heap PHP separato, anche con FrankenPHP dove il processo è persistente.
Su un VPS da 2 GB di RAM, Go tiene in piedi 10.000+ connessioni senza swap. Node arriva a ~3.500 prima di OOM. PHP-FPM satura ben prima per conteggio worker configurato, indipendente da RAM.
Quando NON usare Go come gateway
Se il tuo team non ha esperienza Go e la tua applicazione non gestisce oltre le 500 connessioni concorrenti, adottare Go per il gateway è un investimento che non ripaga. Node.js con Fastify è più che sufficiente, e il costo di apprendimento Go (riconfigurare il tooling, il deployment, il CI, formare il team) supera il beneficio di performance. Se il tuo gateway deve integrarsi strettamente con un framework applicativo esistente in PHP/Laravel - perché usa policy definite in Laravel Gates, chiama Eloquent per leggere utenti, applica middleware di Symfony Security - il passaggio a Go rompe l'unità concettuale della codebase: meglio tenere il gateway in PHP con FrankenPHP e accettare il limite, o separarlo in un servizio Node.js che parla HTTP con il dominio PHP.
Se stai facendo un prototipo che servirà al massimo 100 utenti interni per sei mesi, il linguaggio del gateway non conta - scrivi in quello che il team padroneggia e passa al problema vero. Lo stesso principio vale su molte decisioni architetturali AI realm: la performance perfetta è rilevante solo dove la scala lo richiede, e sotto una certa scala stai ottimizzando il secondario perdendo di vista il primario.
Go come inference gateway si giustifica quando hai contemporaneamente: migliaia di connessioni SSE concorrenti attese in produzione, budget di memoria o CPU sul gateway stretto, un team con almeno una persona che scrive Go quotidianamente o è pronta a farlo, requisito di latenza P99 sotto i 50 ms per routing decision. In quel punto di ottimo, Go non è un'ottimizzazione prematura - è la scelta naturale che paga in stabilità operativa e costo infrastrutturale.
Ci sono architetture AI dove il gateway smette di essere "il componente che inoltra HTTP" e diventa "il componente critico che tiene insieme decine di backend LLM eterogenei, migliaia di client concorrenti, policy di budget per tenant, cost tracking granulare, audit trail, tracing distribuito". In quelle architetture, la scelta del runtime del gateway è architetturalmente la più importante: un gateway PHP è strutturalmente incapace di gestire il carico, un gateway Node è capace ma inefficiente, un gateway Go è la primitiva naturale. La scelta non è ideologica e non riguarda quale linguaggio amiamo: riguarda quale modello di concorrenza fa con meno fatica il lavoro che il gateway deve fare. Se domani Rust con Tokio e async SSE raggiungerà meglio quel profilo, sceglierò Rust. Se tra cinque anni Node avrà modello multi-thread con un GC incrementale competitivo, tornerò a Node. Il punto stabile è: strumento giusto per il problema, sempre.
Se stai valutando l'introduzione di un gateway dedicato per un'applicazione AI in scala, con molti backend LLM eterogenei e volumi di traffico che pressano la tua attuale architettura PHP o Node, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.