Go-workerpools vs één goroutine per taak voor achtergrondtaken
Go-workerpools vs één goroutine per taak: leer hoe elk model doorvoer, geheugengebruik en backpressure beïnvloedt bij achtergrondverwerking en langlopende workflows.

Welk probleem lossen we op?
De meeste Go-services doen meer dan HTTP-verzoeken beantwoorden. Ze draaien ook achtergrondwerk: mails versturen, afbeeldingen schalen, facturen genereren, data synchroniseren, events verwerken of een zoekindex herbouwen. Sommige jobs zijn snel en onafhankelijk. Andere vormen lange workflows waarbij elke stap van de vorige afhangt (kaart belasten, wachten op bevestiging, dan de klant informeren en rapportage bijwerken).
Wanneer mensen "Go worker pools vs goroutine-per-task" vergelijken, proberen ze meestal één productieprobleem op te lossen: hoe voer je veel achtergrondwerk uit zonder de service traag, duur of onstabiel te maken.
Je merkt de impact op een paar plaatsen:
- Latency: achtergrondwerk neemt CPU, geheugen, databaseverbindingen en netwerkbandbreedte in beslag van gebruikersverzoeken.
- Kosten: onbeperkte concurrency dwingt je naar grotere machines, meer databasecapaciteit of hogere queue- en API-kosten.
- Stabiliteit: bursts (imports, marketingmailingen, retry-stormen) kunnen timeouts, OOM-crashes of kettingreacties veroorzaken.
De echte afweging is eenvoud versus controle. Voor elke taak een goroutine starten is makkelijk te schrijven en vaak prima bij lage of natuurlijk beperkte volumes. Een workerpool voegt structuur toe: vaste concurrency, duidelijke limieten en een natuurlijke plek voor timeouts, retries en metrics. De prijs is extra code en een beslissing over wat er gebeurt als het systeem druk is (wachten taken, worden ze geweigerd of ergens anders opgeslagen?).
Dit gaat over dagelijkse achtergrondverwerking: doorvoer, geheugen en backpressure (hoe je overbelasting voorkomt). Het behandelt niet alle queuetechnologieën, gedistribueerde workflow-engines of exact-once semantiek.
Als je volledige apps bouwt met achtergrondlogica met een platform zoals AppMaster (appmaster.io), komen dezelfde vragen snel terug. Je bedrijfsprocessen en integraties hebben nog steeds limieten nodig rond databases, externe API's en e-mail/SMS-providers zodat één drukke workflow niet alles vertraagt.
Twee veelvoorkomende patronen in gewone bewoordingen
Goroutine-per-task
Dit is de eenvoudigste aanpak: wanneer een job binnenkomt, start je een goroutine om het af te handelen. De “queue” is vaak wat het werk triggert, zoals een channel-receiver of een directe oproep vanuit een HTTP-handler.
Een typisch patroon is: ontvang een job en doe dan go handle(job). Soms is er nog een channel bij betrokken, maar alleen als overdrachtspunt, niet als limiter.
Het werkt meestal goed wanneer jobs vooral op I/O wachten (HTTP-calls, databasequeries, uploads), het volume bescheiden is en bursts klein of voorspelbaar.
Het nadeel is dat concurrency kan groeien zonder duidelijke grens. Dat kan geheugen doen pieken, te veel verbindingen openen of een downstream-service overbelasten.
Workerpool
Een workerpool start een vast aantal worker-goroutines en voedt ze met jobs uit een queue, meestal een in-memory buffered channel. Elke worker loopt in een lus: pak een job, verwerk hem, herhaal.
Het belangrijkste verschil is controle. Het aantal workers is een harde limit op concurrency. Als jobs sneller binnenkomen dan workers ze afkrijgen, wachten jobs in de queue (of worden afgewezen als de queue vol is).
Workerpools passen goed wanneer werk CPU-intensief is (afbeeldingsverwerking, rapportgeneratie), wanneer je voorspelbaar resourcegebruik nodig hebt, of wanneer je een database of derde partij tegen bursts moet beschermen.
Waar de queue leeft
Beide patronen kunnen een in-memory channel gebruiken, wat snel is maar bij herstart verdwijnt. Voor "moet niet verliezen" jobs of lange workflows verhuist de queue vaak buiten het proces (een databasetabel, Redis of een message broker). In die opzet kies je nog steeds tussen goroutine-per-task en workerpools, maar nu draaien ze als consumers van de externe queue.
Als het systeem plotseling 10.000 e-mails moet versturen, kan goroutine-per-task proberen ze allemaal tegelijk af te vuren. Een pool kan 50 tegelijk versturen en de rest gecontroleerd laten wachten.
Throughput: wat verandert en wat niet
Het is gebruikelijk om een groot verschil in doorvoer tussen workerpools en goroutine-per-task te verwachten. Meestal wordt ruwe doorvoer echter door iets anders beperkt, niet door hoe je goroutines start.
Doorvoer raakt vaak een plafond bij de langzaamste gedeelde resource: database- of externe API-limieten, schijf- of netwerkbandbreedte, CPU-intensief werk (JSON/PDF/afbeeldingsresizing), locks en gedeelde staat, of downstream-services die traag worden onder load.
Als een gedeelde resource de bottleneck is, maakt het starten van meer goroutines het werk niet sneller af. Het creëert vooral meer wachttijd bij hetzelfde knelpunt.
Goroutine-per-task kan winnen wanneer taken kort zijn, grotendeels I/O-bound en niet concurreren om gedeelde limieten. Het opstarten van een goroutine is goedkoop en Go scheduleert grote aantallen goed. In een "fetch, parse, write one row"-stijl lus kan dit CPU's bezig houden en netwerklatentie verbergen.
Workerpools winnen wanneer je dure resources moet begrenzen. Als elke job een DB-verbinding vasthoudt, bestanden opent, grote buffers alloceert of een API-quota raakt, houdt vaste concurrency de service stabiel terwijl je de maximale veilige doorvoer bereikt.
Latency (vooral p99) is waar het verschil vaak zichtbaar wordt. Goroutine-per-task kan bij lage load goed lijken en vervolgens instorten als te veel taken zich opstapelen. Pools introduceren wachttijd (jobs wachten op een vrije worker), maar het gedrag is consistenter omdat je een thundering herd op hetzelfde limit voorkomt.
Een simpel mentaal model:
- Als werk goedkoop en onafhankelijk is, kan meer concurrency de doorvoer verhogen.
- Als werk wordt geblokkeerd door een gedeelde limiet, verhoogt meer concurrency vooral de wachttijd.
- Als je geeft om p99, meet wachttijd in de queue apart van verwerkingstijd.
Geheugen- en resourcegebruik
Veel van het worker-pool vs goroutine-per-task debat gaat eigenlijk over geheugen. CPU kun je vaak schalen. Geheugenstoringen zijn plotselinger en kunnen de hele service neerhalen.
Een goroutine is goedkoop, maar niet gratis. Elke goroutine start met een kleine stack die groeit naarmate er dieper wordt aangeroepen of grote lokale variabelen worden vastgehouden. Er is ook scheduler- en runtime-overhead. Tienduizenden goroutines kunnen prima zijn. Honderdduizenden kunnen een verrassing zijn als elke goroutine referenties naar grote jobdata houdt.
De grotere verborgen kost is vaak niet de goroutine zelf, maar wat die levend houdt. Als taken sneller binnenkomen dan ze klaar zijn, creëert goroutine-per-task een onbeperkte backlog. De “queue” kan impliciet zijn (goroutines wachtend op locks of I/O) of expliciet (een buffered channel, een slice, een in-memory batch). Hoe dan ook groeit geheugen met de backlog.
Workerpools helpen omdat ze een limiet afdwingen. Met vaste workers en een begrensde queue krijg je een reëel geheugenlimiet en een duidelijke faalmodus: zodra de queue vol is, blokkeer je, shed je load of push je terug naar upstream.
Een snelle vuistregel:
- Piekgoroutines = workers + in-flight jobs + "wachtende" jobs die je creëerde
- Geheugen per job = payload (bytes) + metadata + alles waarnaar wordt verwezen (requests, gedecodeerde JSON, DB-rijen)
- Piekkbacklog geheugen ~= wachtende jobs * geheugen per job
Voorbeeld: als elke job een payload van 200 KB vasthoudt (of een objectgraph van 200 KB referentieert) en je laat 5.000 jobs zich ophopen, is dat ongeveer 1 GB alleen al aan payloads. Zelfs als goroutines magisch gratis waren, is de backlog dat niet.
Backpressure: het systeem van smelten behoeden
Backpressure is simpel: wanneer werk sneller binnenkomt dan je het kunt afhandelen, duwt het systeem op een gecontroleerde manier terug in plaats van stilletjes op te stapelen. Zonder backpressure wordt het niet alleen trager. Je krijgt timeouts, geheugenstijging en fouten die moeilijk te reproduceren zijn.
Je merkt meestal het ontbreken van backpressure wanneer een burst (imports, mails, exports) patronen triggert zoals stijgend geheugen dat niet daalt, groeiende queue-tijden terwijl CPU bezig blijft, latencypieken voor niet-gerelateerde verzoeken, retries die zich opstapelen, of fouten zoals "too many open files" en connection pool exhaustie.
Een praktisch hulpmiddel is een begrensd channel: cap hoeveel jobs kunnen wachten. Producers blokkeren wanneer het channel vol is, wat jobcreatie bij de bron vertraagt.
Blokkeren is niet altijd de juiste keuze. Voor optioneel werk kies je een expliciet beleid zodat overload voorspelbaar is:
- Drop taken met weinig waarde (bijv. dubbele notificaties)
- Batch veel kleine taken in één schrijf of één API-call
- Delay werk met jitter om retry-stormen te vermijden
- Defer naar een persistent queue en retourneer snel
- Shed load met een duidelijke fout wanneer al overbelast
Rate limiting en timeouts zijn ook backpressure-tools. Rate limiting beperkt hoe snel je een afhankelijkheid raakt (emailprovider, database, externe API). Timeouts beperken hoe lang een worker vast kan zitten. Samen voorkomen ze dat een trage afhankelijkheid uitmondt in een volledige outage.
Voorbeeld: maandafsluitingsstatementgeneratie. Als 10.000 verzoeken tegelijk binnenkomen, kunnen onbeperkte goroutines 10.000 PDF-renders en uploads triggeren. Met een begrensde queue en vaste workers render je en retry je op een veilig tempo.
Hoe bouw je stap voor stap een workerpool
Een workerpool begrenst concurrency door een vast aantal workers te draaien en ze te voeden met jobs uit een queue.
1) Kies een veilige concurrency-limiet
Begin met waarop je jobs hun tijd vooral besteden.
- Voor CPU-intensief werk, houd workers dichtbij het aantal CPU-cores.
- Voor I/O-intensief werk (DB, HTTP, opslag) kun je hoger gaan, maar stop zodra afhankelijkheden timeouts of throttling laten zien.
- Voor gemengd werk, meet en stel bij. Een redelijke start is vaak 2x tot 10x het aantal CPU-cores, en dan tunen.
- Respecteer gedeelde limieten. Als de DB-pool 20 verbindingen is, zullen 200 workers over die 20 heen vechten.
2) Kies de queue en stel de grootte in
Een buffered channel is gebruikelijk omdat het ingebouwd en eenvoudig te begrijpen is. De buffer is je schokdemper voor bursts.
Kleine buffers tonen overload snel (senders blokkeren eerder). Grotere buffers egaliseren pieken maar kunnen problemen verbergen en geheugen en latency verhogen. Dimensioneer de buffer doelbewust en bepaal wat er gebeurt als hij vol is.
3) Maak elke taak annuleerbaar
Geef een context.Context door aan elke job en zorg dat de jobcode deze gebruikt (DB, HTTP). Zo stop je netjes bij deploys, shutdowns en timeouts.
func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan<- Job {
jobs := make(chan Job, queueSize)
for i := 0; i < workers; i++ {
go func() {
for {
select {
case <-ctx.Done():
return
case j := <-jobs:
_ = handle(ctx, j)
}
}
}()
}
return jobs
}
4) Voeg de metrics toe die je echt gebruikt
Als je maar een paar cijfers volgt, maak het deze:
- Queue-diepte (hoe achterloopt je systeem)
- Worker busy time (hoe verzadigd de pool is)
- Taakduur (p50, p95, p99)
- Foutpercentage (en retry-aantallen als je retries gebruikt)
Dat is genoeg om worker-aantal en queue-grootte te tunen op bewijs, niet op giswerk.
Veelgemaakte fouten en valkuilen
De meeste teams hebben geen last van het kiezen van het "verkeerde" patroon. Ze krijgen problemen door kleine defaults die veranderen in storingen bij traffic spikes.
Wanneer goroutines zich vermenigvuldigen
De klassieke val is voor elke job één goroutine spawnnen tijdens een burst. Een paar honderd is prima. Honderdduizenden kunnen de scheduler, heap, logs en sockets overspoelen. Zelfs als elke goroutine klein is, telt de totale kost op en herstel kost tijd omdat het werk al in uitvoering is.
Een andere fout is een enorm buffered channel als "backpressure" zien. Een grote buffer is gewoon een verborgen queue. Het kan tijd kopen, maar het verbergt problemen totdat je tegen een geheugenmuur aanloopt. Als je een queue nodig hebt, kies een doordachte grootte en bepaal wat er gebeurt als hij vol is (blokkeer, drop, later retryen of persist naar opslag).
Verborgen knelpunten
Veel achtergrondjobs zijn niet CPU-bound. Ze worden beperkt door iets downstream. Als je die limieten negeert, overweldigt een snelle producer een trage consumer.
Veelvoorkomende valkuilen:
- Geen cancel of timeout, waardoor workers voor altijd kunnen blokkeren op een API-call of DB-query
- Worker-aantallen gekozen zonder echte limieten te checken zoals DB-verbindingen, schijf-I/O of derde partij rate caps
- Retries die load versterken (directe retries over 1.000 gefaalde jobs)
- Één gedeelde lock of transactie die alles serialiseert, waardoor “meer workers” alleen overhead toevoegt
- Ontbrekende zichtbaarheid: geen metrics voor queue-diepte, job-leeftijd, retry-aantal en worker-utilisatie
Voorbeeld: een nachtelijke export triggert 20.000 "send notification" jobs. Als elke taak je database en een emailprovider raakte, overschrijd je makkelijk connection pools of quotas. Een pool van 50 workers met per-task timeouts en een kleine queue maakt de limiet duidelijk. Eén goroutine per taak plus een gigantische buffer laat het systeem goed lijken totdat het dat niet meer is.
Voorbeeld: bursty exports en notificaties
Stel je een supportteam voor dat data nodig heeft voor een audit. Één persoon klikt op de knop "Export", dan doen een paar teamgenoten hetzelfde en plotseling heb je 5.000 exportjobs binnen een minuut. Elke export leest uit de database, formatteert een CSV, slaat een bestand op en stuurt een notificatie (e-mail of Telegram) als het klaar is.
Met een goroutine-per-task aanpak voelt het systeem even snel. Alle 5.000 jobs starten bijna direct en het lijkt alsof de queue snel leegt. Dan verschijnen de kosten: duizenden gelijktijdige databasequeries vechten om verbindingen, geheugen stijgt terwijl jobs buffers vasthouden, en timeouts worden normaal. Taken die snel hadden kunnen aflopen, raken vast achter retries en trage queries.
Met een workerpool is de start trager maar het verloop rustiger. Met 50 workers doen er maar 50 exports tegelijk zwaar werk. Databasegebruik blijft binnen voorspelbare grenzen, buffers worden vaker hergebruikt en latency is stabieler. Totale voltooiingstijd is ook makkelijker te schatten: ruwweg (jobs / workers) * gemiddelde taakduur, plus wat overhead.
Het belangrijke verschil is niet dat pools magisch sneller zijn. Het is dat ze voorkomen dat het systeem zichzelf pijn doet tijdens bursts. Een gecontroleerde run van 50 tegelijk is vaak sneller klaar dan 5.000 jobs die elkaar in de weg zitten.
Waar je backpressure toepast hangt af van wat je wilt beschermen:
- Op API-niveau: weiger of vertraag nieuwe exportaanvragen als het systeem druk is.
- Bij de queue: accepteer verzoeken maar plaats jobs in de queue en drain ze op een veilig tempo.
- In de workerpool: cap concurrency voor de dure delen (DB-reads, bestandsgeneratie, notificatieverzending).
- Per resource: splits in aparte limieten (bijv. 40 workers voor exports maar slechts 10 voor notificaties).
- Bij externe calls: rate-limit e-mail/SMS/Telegram zodat je niet geblokkeerd wordt.
Korte checklist voor je live gaat
Voordat je achtergrondjobs in productie draait, loop je limieten, zichtbaarheid en foutafhandeling na. De meeste incidenten komen niet door "trage code" maar door ontbrekende vangrails bij spikes of flaky dependencies.
- Stel harde max-concurrency per afhankelijkheid in. Neem niet één globaal getal aan en hoop dat het voor alles past. Cap DB-writes, uitgaande HTTP-calls en CPU-intensief werk apart.
- Maak de queue begrensd en observeerbaar. Zet een echt limiet op pending jobs en exposeer metrics: queue-diepte, leeftijd van de oudste job en verwerkingssnelheid.
- Voeg retries met jitter en een dead-letter pad toe. Retry selectief, spreid retries, en verplaats na N fouten het job naar een dead-letter queue of "failed" tabel met genoeg detail om te reviewen en opnieuw te starten.
- Controleer shutdown-gedrag: drain, cancel, resume veilig. Bepaal wat er gebeurt bij deploy of crash. Maak jobs idempotent zodat reprocessing veilig is en sla voortgang op voor lange workflows.
- Bescherm het systeem met timeouts en circuit breakers. Elke externe call heeft een timeout nodig. Als een afhankelijkheid down is, faal snel (of pauzeer intake) in plaats van werk op te stapelen.
Praktische volgende stappen
Kies het patroon dat past bij hoe je systeem er op een normale dag uitziet, niet op een perfecte dag. Als werk in bursts aankomt (uploads, exports, mailcampagnes), is een vaste workerpool met een begrensde queue meestal de veiligere default. Als werk steady is en elke taak klein, kan goroutine-per-task prima zijn, mits je ergens nog limieten afdwingt.
De winnende keuze is meestal die welke falen saai maakt. Pools maken limieten zichtbaar. Goroutine-per-task maakt het makkelijk om limieten te vergeten totdat de eerste echte spike komt.
Begin simpel, voeg daarna grenzen en zichtbaarheid toe
Begin met iets eenvoudigs, maar voeg vroeg twee controls toe: een grens op concurrency en een manier om queueing en fouten te zien.
Een praktisch rollout-plan:
- Definieer je workloadvorm: bursty, steady of mixed (en wat een "peak" is).
- Zet een harde cap op in-flight werk (pool size, semaphore of begrensd channel).
- Bepaal wat er gebeurt als de cap bereikt is: blokkeer, drop of geef een duidelijke fout terug.
- Voeg basismetrics toe: queue-diepte, tijd-in-queue, verwerkingstijd, retries en dead letters.
- Load-test met een burst die 5x je verwachte piek is en kijk naar geheugen en latency.
Wanneer een pool niet genoeg is
Als workflows minuten tot dagen kunnen lopen, kan een simpele pool worstelen omdat werk niet alleen "doe het één keer" is. Je hebt state, retries en resumability nodig. Dat betekent meestal voortgang persisteren, idempotente stappen gebruiken en backoff toepassen. Het kan ook betekenen dat je één grote job opsplitst in kleinere stappen zodat je na een crash veilig kunt hervatten.
Als je sneller een volledige backend met workflows wilt uitrollen, kan AppMaster (appmaster.io) een praktische optie zijn: je modelleert data en bedrijfslogica visueel en het genereert echte Go-code voor de backend zodat je dezelfde discipline rond concurrency-limieten, queueing en backpressure behoudt zonder alles handmatig te hoeven verbinden.
FAQ
Gebruik standaard een workerpool wanneer jobs in bursts binnenkomen of gedeelde limieten aanraken zoals DB-verbindingen, CPU of externe API-quotas. Gebruik één goroutine per taak wanneer het volume bescheiden is, taken kort zijn en je nog steeds ergens een duidelijk limiet hebt (bijv. een semaphore of rate limiter).
Een goroutine per taak is snel te schrijven en kan bij lage belasting hoge doorvoer geven, maar bij pieken kan het een onbeperkte backlog creëren. Een workerpool legt een harde concurrency-grens vast en biedt een duidelijke plek voor timeouts, retries en metrics, wat meestal zorgt voor voorspelbaarder gedrag in productie.
Meestal niet veel. In de meeste systemen wordt de doorvoer begrensd door een gedeelde bottleneck zoals de database, een externe API, schijf I/O of CPU-zware stappen. Meer goroutines zullen dat plafond niet verleggen; ze verhogen vooral wachttijd en contentie.
Goroutine-per-task heeft vaak betere latency bij lage belasting, maar kan bij hoge belasting sterk verslechteren omdat alles tegelijk concurreert. Een pool kan wachttijd toevoegen, maar houdt p99 meestal stabieler door een thundering herd op dezelfde afhankelijkheden te voorkomen.
De goroutine zelf is zelden de grootste kostenpost; het is de backlog. Als taken zich opstapelen en elk payloads of grote objecten vasthouden, kan het geheugen snel stijgen. Een workerpool met een begrensde queue maakt dat tot een gedefinieerd geheugenplafond en voorspelbaar overload-gedrag.
Backpressure betekent dat je het accepteren van nieuw werk vertraagt of stopt wanneer het systeem al druk is, in plaats van werk onzichtbaar te laten opstapelen. Een begrensde queue is een eenvoudige vorm: wanneer deze vol is, blokkeren producers of geef je een fout terug, wat runaway geheugen en verbindingsexhaustie voorkomt.
Begin bij de echte limiet. Voor CPU-zware jobs start je rond het aantal CPU-cores. Voor I/O-zware werk kun je hoger gaan, maar stop met verhogen zodra database, netwerk of externe APIs timeouts of throttling beginnen te geven. Houd rekening met poolgroottes van afhankelijkheden (bijv. DB-verbindingen).
Kies een grootte die normale bursts opvangt maar geen problemen minutenlang verbergt. Kleine buffers tonen overload snel; grote buffers kunnen geheugen verhogen en gebruikers langer laten wachten voordat fouten zichtbaar worden. Bepaal vooraf wat er gebeurt als de queue vol is: block, reject, drop of persisteren elders.
Gebruik per job context.Context en zorg dat database- en HTTP-calls dit respecteren. Stel timeouts in voor externe calls en maak shutdown-gedrag expliciet zodat workers netjes stoppen zonder hangende goroutines of halfafgemaakte taken achter te laten.
Meet queue-diepte, tijd in de wachtrij, taakduur (p50/p95/p99) en fout-/retry-aantallen. Deze metrics laten zien of je meer workers, een kleinere queue, strengere timeouts of sterkere rate limiting tegen een afhankelijkheid nodig hebt.


