Analisi di scalabilità: da ~10 a 100 giocatori concorrenti (parti critiche + previsioni) #171
Labels
No labels
alpha:wave-0
alpha:wave-1
alpha:wave-2
alpha:wave-3
area:assets
area:combat
area:ecology
area:infra
area:render
area:scripting
area:ui
area:world
enhancement
epic
migration
post-alpha
roadmap
tech-debt
type:bug
type:chore
type:design
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
marco/IsoMmo#171
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Analisi di scalabilità: da ~10 a 100 giocatori concorrenti
Analisi statica del codice (nessun benchmark eseguito). Obiettivo: individuare cosa si rompe passando da ~10 a 100 player concorrenti, ignorando l'hardware — quindi il focus è sulla complessità algoritmica, sulle allocazioni e sul fatto che tutto il lavoro CPU converge su un unico thread di simulazione (design ModernUO-style,
Simulation, #54). Il tetto reale non è "quanti core", è "il thread di simulazione riesce a chiudere il lavoro di un tick in < 100 ms".Costanti misurate (
GameOptions)TickMillisecondsMoveCooldownTicksAoiRadiusPersistenceIntervalSecondsChatMinIntervalMsOutboundCapacity(WebSocketConnection)ProtocolJsonSerialize→string→UTF8.GetBytesParti critiche (ordinate per leva)
🔴 1. Il reconcile del tick è O(N × E) — quadratico nei player
WorldTick.ReconcileAsynccicla per ogni osservatore (foreach observerId in connections.PlayerIds) e per ciascuno chiama quattro query spaziali full-scan (PlayersWithin,ItemsWithin,MobilesWithin,CorpsesWithin). OgniWithinitera l'intera collezione calcolando la distanza Chebyshev (es.PlayerRegistry.Within,ItemRegistry.Within:402, stesso pattern per Mobile/Corpse). Nessun indice spaziale.Costo per tick ≈
N × (N + I + M + C)(N=player, I=item, M=creature, C=corpse).Il termine player–player è O(N²):
Cioè: 10× i player ⇒ 100× il lavoro di scan. Con un mondo popolato (ipotesi I≈500, M≈200, C≈20):
100 × 820 = 82.000check/tick ⇒ 820k check/s contro ~8,5k/s a 10 player. Il check Chebyshev è economico (~ns), quindi a 100 non è l'aritmetica a saturare — è quello che segue.🔴 2. Serializzazione JSON per-destinatario, sul thread di simulazione
WebSocketConnection.SendAsyncesegueProtocolJson.Serialize(message)+UTF8.GetBytesinline e sincrono sul thread chiamante. Nel reconcile del tick quel thread è il thread sim. OgniSendToAsync(listener, msg)ri-serializza lo stesso messaggio da capo. UnPlayerMovedvisto da K osservatori viene serializzato K volte.Worst case (cluster denso di 100, tutti che si muovono al cap): ~50 movers/tick × ~99 osservatori ≈ 4.950
PlayerMoved/tick, ognuno serializzato indipendentemente sul thread sim:A ~1–3 µs per serializzazione sono 5–15 ms/tick solo di JSON, sopra allo scan e alla GC. Somma reconcile + scan + GC + serializzazione + say/swing/spell (anch'essi fan-out per-osservatore) e sotto evento il tick sfora i 100 ms → i tick accumulano ritardo → movimento a scatti per tutti → la coda (illimitata) di
InvokeAsyncsi allunga → spirale di latenza.🟠 3. Pressione sulla GC (allocazioni per-tick)
Ogni
Withinalloca unaList<T>nuova e ogni entità in range alloca un record di stato fresco (ToState(),MobileState,GroundItem,CorpseState) ad ogni tick:4 × Nper tick → a 100 = 400 liste/tick = 4.000/s.PlayerState/tick = 100k/s (+ item/mobile/corpse).Su un thread singolo, la GC Gen0 continua si traduce in pause che mangiano il budget di 100 ms. Bonus:
metrics.UpdateWorldCounts(...)inWorldTickfa due enumerazioni LINQ complete (Count(predicate)) su tutti i mobile e tutti gli item ad ogni tick, solo per una gauge.🟠 4. Il thread di simulazione è l'unico collo di bottiglia globale
Ogni intento del client (
move,say,attack,pickup, parole di spell…) fa il marshalling sul thread sim viaSimulation.InvokeAsync(canale unbounded), serializzato con il tick attraverso un solo reader. A 100 player: ~500 mosse/s + say + attack + spell + i connect. Coda illimitata ⇒ sotto sovraccarico niente drop, solo latenza crescente. È corretto per la consistenza (atomicità senza lock), ma significa che tick-scan + tutta la serializzazione + tutti gli intenti vivono su un core logico. Ignorando l'hardware, questo è il tetto.🟡 5. Persistenza: build dei blob sul thread sim + riscrittura completa del file
WorldPersister.SaveAsyncogni 15 s chiamaworld.SerializeOnlinePlayers()viaInvokeAsync— costruisce i blob di tutti i player (posizione+vitals+skill+item) sul thread sim, sincrono, bloccando il tick. A 10 player è trascurabile; a 100 con inventari veri è un hiccup misurabile ogni 15 s. PoiPlayerStore.FlushAsyncriscrive l'interoworld.sav(temp → replace atomico) off-thread — riscrittura completa ogni 15 s, che cresce con il numero totale di account salvati (i blob offline restano in memoria e nel file). Off-thread va bene per l'IO, ma il build on-thread e la riscrittura full sono da tenere d'occhio.🟡 6. Auth (SQLite) sotto "login storm"
SQLite è single-writer. 100 primi-login quasi simultanei (creazione account = INSERT) si serializzano sul write-lock; un restart del server con riconnessione di massa può dare timeout/latenza in login. Le letture (login di ritorno) con WAL vanno bene. Nota collegata: la stessa ondata di riconnessioni colpisce il connect-path del GameServer (ogni connect =
InvokeAsyncconRestorePlayer+PlayersWithin) in coda dietro i tick.🟡 7.
OutboundCapacity = 256→ disconnessioni sotto caricoIn un cluster denso ogni client riceve ~500 msg/s. Un client che si impunta per > ~0,5 s supera i 256 in coda e viene chiuso d'ufficio. A 100 player (più msg/s per client) un hiccup di rete transitorio produce più disconnessioni che a 10.
Previsioni sintetiche (10 vs 100, worst case denso)
Verdetto: il design è pulito e corretto, e a 10 player tutte queste voci sono invisibili. Sono tutte O(N²) / full-scan / per-destinatario: diventano il muro esattamente a scala 100, e soprattutto sotto clustering (evento, città, PvP di gruppo). Lo scenario sparso regge molto meglio, ma le query full-scan restano.
Interventi consigliati (per leva decrescente)
Withinda O(tutte le entità) a O(celle nel raggio + entità in quelle celle), e il termine player O(N²) in ~O(N·k) con k = vicini medi. Leva più alta di tutte.ToState()per entità immutate, eliminare ilCount(predicate)full-scan delle metriche ad ogni tick (aggiornare le gauge in modo incrementale).ProtocolVersion.Current = 10) e c'è già la direzione mappa binaria (#143/#115) — stesso seam.SQLITE_BUSY; eventuale limite di concorrenza sul connect-path.Analisi statica del codice; nessuna misura runtime. I valori I≈500 / M≈200 / C≈20 e "cluster denso di 100" sono ipotesi di scenario, non dati osservati — il prossimo passo naturale è un load-test sintetico (bot headless) per validare le previsioni con le gauge Prometheus già esposte (#159): tempo-tick, msg/s, byte salvati.