De net/http server van Go maakt gebruik van een goroutine-per-verbinding model in combinatie met de M:N planningsstrategie van de runtime. Wanneer de server een TCP-verbinding accepteert, wordt onmiddellijk een lichte goroutine aangemaakt om de gehele levenscyclus van die verbinding te behandelen, waardoor de hoofd acceptatielus terug kan keren en onmiddellijk de volgende verbinding kan ontvangen. Deze goroutines worden gemultiplexed op een beperkte pool van OS-threads door de Go scheduler, die goroutines die blokkeren op I/O parkeert en uitvoerbare goroutines opnieuw inplant op beschikbare threads. Deze architectuur stelt de server in staat om honderden duizenden gelijktijdige verbindingen te onderhouden met slechts een handvol kernel-threads, waardoor de geheugendoorbelasting van traditionele thread-per-verbinding servers wordt vermeden.
We moesten een real-time telemetriegateway bouwen die in staat was om gegevens van 50.000 IoT-apparaten gelijktijdig in te nemen via persistente HTTP/1.1-verbindingen.
Probleembeschrijving: Ons initiële prototype met Python en Twisted bood de benodigde gelijktijdigheid, maar werd al snel ononderhoudbaar door complexe callbackketens en diep geneste foutafhandeling. Toen we probeerden een Java thread-per-verbinding benadering te gebruiken om de code te vereenvoudigen, stuitten we op de threadlimiet van het besturingssysteem bij ongeveer 32.000 verbindingen, waardoor de JVM instortte met OutOfMemoryError: unable to create new native thread omdat elke thread meer dan 1MB virtueel geheugen verbruikte.
Verschillende overwegingen voor oplossingen:
Asyncio met expliciete statusmachines: We hebben overwogen om over te stappen naar Python's asyncio om een enkele gebeurtenislus met coroutines te gebruiken. Dit zou de geheugendruk aanzienlijk verlagen in vergelijking met threads, maar het zou vereisen dat we al onze protocollogica herschreven naar async/await-syntaxis en introduceerde het risico om per ongeluk de gebeurtenislus te blokkeren met CPU-intensievelijke operaties. Foutopsporing van stack traces over asynchrone grenzen bleek ook berucht moeilijk voor ons ontwikkelingsteam.
Horizontale sharding van JVM-instanties: We overwegen om tien kleinere Java-instanties achter een load balancer te draaien, waarbij elke instantie 5.000 threads behandelt. Deze aanpak loste de per-proces threadlimiet op, maar introduceerde aanzienlijke operationele complexiteit, vereiste extra hardwarebronnen en compliceerde het beheer van gedeelde status en verbindingstickiness over de cluster. De operationele overhead van het onderhouden van deze micro-cluster overtrof de voordelen van het blijven bij Java.
Het goroutine-per-verbinding model van Go: We kozen ervoor om de gateway opnieuw te implementeren in Go, gebruikmakend van de standaardbibliotheek's net/http en net pakketten. De Serve methode van de server spawn meteen een lichte goroutine voor elke geaccepteerde TCP-verbinding, en de scheduler van de Go runtime multiplexeert deze transparant op een beperkte pool van OS-threads. Dit stelde ons in staat om eenvoudige, synchrone ogende I/O-code te schrijven die zou schalen naar honderden duizenden verbindingen zonder handmatige statusmachinebeheer.
Gekozen oplossing en waarom: We kozen de Go implementatie omdat deze de schaalbaarheid van event-gedreven systemen combineerde met de eenvoud van threaded programmeren. De runtime behandelt automatisch de complexiteit van plannings en non-blocking I/O, waardoor onze ontwikkelaars zich konden concentreren op de bedrijfslogica in plaats van op gelijktijdigheidsprimitieven. Bovendien betekende de initiële stapelgrootte van 2KB van de goroutine dat we theoretisch miljoenen verbindingen binnen ons geheugensbudget konden afhandelen.
Resultaat: Het productiesysteem beheerde met succes 75.000 gelijktijdige persistente verbindingen op een enkele server met 8 cores, met een verbruik van minder dan 4GB RAM. De CPU-utilisatie bleef stabiel op 35-40% omdat de scheduler efficiënt I/O-latentie verstopte, en we elimineerden de operationele last van het beheren van een cluster van geshardde Java-instanties.
Hoe voorkomt de Go scheduler een thundering herd probleem wanneer duizenden goroutines blokkeren op dezelfde kanaalontvangst?
De Go scheduler gebruikt een first-in-first-out (FIFO) wachtrij voor kanalen, en niet een semafoor-stijl wake-all. Wanneer een zender naar een kanaal schrijft, wekt de scheduler precies één wachtende goroutine uit de ontvangwachtrij (degene die het langst heeft gewacht). Dit zorgt ervoor dat slechts één goroutine de waarde consumeert, waardoor het thundering herd probleem wordt voorkomen waarbij meerdere goroutines wakker worden, concurreren voor de lock, en alle behalve één weer in slaap gaan. Kandidaten gaan vaak onterecht ervan uit dat kanaaloperaties naar alle wachtenden broadcasten zoals voorwaardevariabelen.
Waarom kan het verhogen van GOMAXPROCS boven het aantal fysieke CPU-kernen de prestaties van een I/O-gebonden Go HTTP-server verlagen?
Hoewel de scheduler van Go preemptief is sinds versie 1.14, verhoogt het hebben van meer OS-threads (M) dan kernen de overhead van contextwisselingen op kernel-niveau. Voor I/O-gebonden servers kunnen overdreven threads ertoe leiden dat de scheduler meer tijd besteedt aan het beheren van runqueues en threadovergangen dan het uitvoeren van gebruikerscode. Bovendien verbruikt elke OS-thread kernelbronnen (geheugen voor thread-lokale opslag en kernelstapels), wat het besturingssysteem onder druk kan zetten wanneer het excessief wordt opgeschaald boven de noodzakelijke parallelisme.
Hoe behandelt de Go's net/http server de TCP SO_BACKLOG wachtrij wanneer het aanname tempo van goroutines tijdelijk achterblijft bij de verbinding aankomst snelheid?
De server vertrouwt op de backlog wachtrij van de kernel (gereguleerd door net.ListenConfig's Backlog of systeemstandaarden). Als goroutines traag zijn om te spawnen of handlers traag zijn om verbindingen van de luisteraar te accepteren, plaatst de kernel binnenkomende SYNs in de backlog. Zodra de backlog vol is, weigert de kernel nieuwe verbindingen via TCP RST. De Accept() lus van Go draait in zijn eigen goroutine en zou idealiter snel handler goroutines moeten spawnen. Echter, als het spawnen van handlers wordt vertraagd (bijvoorbeeld door GC-pauzes of mutex-concurrentie in middleware), vallen verbindingen weg. Kandidaten missen vaak dat Go geen gebruikersruimte verbinding queuing implementeert; het hangt volledig af van de kernel backlog, en het afstemmen van SOMAXCONN of ListenConfig.Backlog is cruciaal voor het absorberen van pieken.