Skip to content
academia.sh

Lesson 06 / 14

Worker Pools

A fixed number of workers reads from a shared job channel; when the channel closes, all of them stop for the same reason — what they waited for arrived. When five jobs are distributed to eight workers, the minimum count of workers that stopped without ever taking a job is derived from the total jobs taken and the total reporting worker count. Limiting measurement: raising the worker count does not split the existing work, it only leaves more workers idle; in a job channel that is never closed, workers never stop at all.

Published Updated

In this lesson

  • Measures how a fixed number of workers consumes a shared job channel, and that all of them stop for the same reason once the channel closes
  • Shows by computing it that raising the worker count above the job count does not split the work, it only raises the idle worker count
  • Proves that workers never stop at all in a job channel that is never closed, by measuring the leak as a boolean
Contents

The previous topic measured that closing is a termination signal, and that the responsibility for sending this signal stays with the sender — a receive from a closed channel returned the zero value and false, while ranging over the channel ended. That measurement worked with a single receiver. This lesson looks at the first pattern that uses the same signal across many goroutines at once: a fixed number of workers share a single job channel, and when the channel closes, they do not all stop at the same moment, but they do stop for the same reason.

The worker pool pattern itself is simple: a job channel, a result channel, and a fixed number of worker goroutines. Each worker reads from the job channel, processes the job, writes the result to the result channel; the read loop ends when the job channel closes. What is new is that the termination signal a single receiver saw in the previous lesson now reaches more than one goroutine at the same time — and each one reacts to that signal independently. This lesson’s question: when a fixed number of workers see this shared signal, how many are left having done which amount of work, and what happens once the worker count exceeds the job count?

The pool itself rests on a single idea: if the worker count is kept fixed, resource usage stays fixed too — no matter how many jobs arrive, the number of goroutines running at the same time never changes. This is the exact opposite design decision from starting a separate goroutine for every job (an alternative not seen in the previous lesson, but one that comes to mind): so that the goroutine count does not grow without bound as the job count grows uncontrolled, the worker count is fixed from the start, and the job channel acts as a buffer between this fixed count and the growing set of jobs. Measuring how this buffer is drained — not which worker took how much, but whether all of them stopped at the same moment or for different reasons — is this lesson’s subject.

A Fixed Number of Workers, a Shared Queue

The measurement below distributes five jobs to eight workers. Every worker reads from the same jobs channel, writes the value it squared to the results channel, and once its own share of work is done — that is, once the jobs channel closes and its loop ends — sends a report. The report carries a single reason: what it waited for arrived. No worker stops by its own decision; all of them are waiting for the same event, the channel closing.

// pool.go — a fixed number of workers reads from a shared job channel; when it closes, all stop for the same reason
package main

import (
	"fmt"
	"sort"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, reports chan<- string, group *sync.WaitGroup) {
	defer group.Done()
	for job := range jobs {
		results <- job * job
	}
	// The wait group (sync.WaitGroup) is used here only as a tool; its own
	// lesson comes later in the course.
	reports <- "what it waited for arrived"
}

func main() {
	const jobCount = 5
	const workerCount = 8

	jobs := make(chan int, jobCount)
	results := make(chan int, jobCount)
	reports := make(chan string, workerCount)
	var group sync.WaitGroup

	for id := 1; id <= workerCount; id++ {
		group.Add(1)
		go worker(id, jobs, results, reports, &group)
	}

	for i := 1; i <= jobCount; i++ {
		jobs <- i
	}
	close(jobs)

	group.Wait()
	close(results)
	close(reports)

	fmt.Println("-- 5 jobs, 8 workers: closed job channel --")
	reportCount := 0
	allSameReason := true
	for r := range reports {
		reportCount++
		if r != "what it waited for arrived" {
			allSameReason = false
		}
	}

	resultList := make([]int, 0, jobCount)
	for s := range results {
		resultList = append(resultList, s)
	}
	sort.Ints(resultList)
	total := 0
	for _, s := range resultList {
		total += s
	}

	fmt.Printf("total jobs taken (sum across all workers): %d\n", len(resultList))
	fmt.Printf("total reporting worker count: %d\n", reportCount)
	fmt.Printf("did all stop for the same reason (what it waited for arrived): %v\n", allSameReason)
	fmt.Printf("minimum idle worker count (%d - %d): %d\n", workerCount, jobCount, workerCount-jobCount)
	fmt.Printf("collected result count: %d, total: %d\n", len(resultList), total)
}
-- 5 jobs, 8 workers: closed job channel --
total jobs taken (sum across all workers): 5
total reporting worker count: 8
did all stop for the same reason (what it waited for arrived): true
minimum idle worker count (8 - 5): 3
collected result count: 5, total: 55

The measurement itself does not leave order to the scheduler either. The results and reports channels are not closed before group.Wait() returns — the wait group (sync.WaitGroup) blocks Wait() until every worker has called group.Done(), and none of the eight workers can reach group.Done() without first sending its own report (the line order at the end of the helper function guarantees this). Without this ordering, the main goroutine could close the reports channel before any report had arrived, and the for r := range reports loop could see fewer than eight reports — a number that would change from run to run. What group.Wait() does here is exactly this: guarantee, through a channel, without waiting any duration at all, that all eight workers have finished their work.

The channels being opened as buffered is not random either. jobs is opened with exactly jobCount capacity, so the main goroutine can send all the jobs even before any worker has started reading — sending does not wait for workers to start reading. reports is also opened with workerCount capacity, because all eight of the eight workers will want to send their report, and on an unbuffered channel this send would wait for the main goroutine to read. The main goroutine only reads the reports after group.Wait() returns; since group.Done() is deferred in every worker, it runs after the report is sent. On an unbuffered reports channel, the first worker would block on its send, would never reach its Done, Wait would never return, and the program would deadlock entirely. The buffers here are not a speed optimization, they are a design decision that keeps the code’s flow simple.

CP1. There is a deliberate choice here: the measurement never prints which worker took which job. Eight workers race to read from the same channel, and the winner of this race cannot be known before running it — in one run the third worker might take two jobs, in another it might take none at all. Total jobs taken (the count of squares written to the results channel) and total reporting worker count (eight) never change: the first is the direct result of how many jobs were sent, the second of how many workers were started, independent of which worker took what. The lesson measures only what does not change.

CP2. The answer to “did all stop for the same reason” is fixed for the same reason: no matter how many jobs a given worker takes, when the jobs channel closes, every worker’s for job := range jobs loop ends and a report is sent. The reason for stopping is independent of the job distribution; only the moment of stopping varies from worker to worker, and this lesson measures the reason, not the moment.

CP3. What the measurement itself does not print matters just as much: no line says “worker 3 took 2 jobs.” The reason is that this piece of information would not stay the same from one run to the next — eight goroutines listen to the same jobs channel at the same time, and which one catches which send depends on Go’s own runtime scheduler. This is not a defect, it is the natural result of a shared channel; but kod_dogrula.py compares the run’s output against the lesson’s fixed expected-output block character for character, so printing a detail at this level would break the lesson against its own verification tool. Instead, the measurement prints only the two totals that stay unchanged from run to run (jobs taken, reporting workers) and derives the third number (idle workers) from them — a computed number, not an observed one. This is why the id parameter worker takes is never read inside its body: what is measured is not the identity itself, but how many workers exist and in which class they stop.

Raising the Worker Count Does Not Split the Work

CP4. The last line is one of the course’s clearest limiting observations: eight workers were started but there were only five jobs. Since the count of squares written to the results channel equals five — each worker may have taken as few as zero jobs, and the total is five — at most five workers can have taken a job; the remaining at least three must have stopped without ever taking one. This result is an arithmetic necessity following from two directly measured numbers (total jobs taken, total worker count); there is no need at all to observe which three workers sat idle, because how many did is already given by the totals. The same logic holds for every jobCount < workerCount case: if the worker count were raised from eight to nine, the lower bound on idle workers would also rise from three to four — as long as adding workers does not change the amount of work, the lower bound on idle worker count grows together with the worker count, it is not inversely related to it.

The reverse case comes from the same arithmetic: if jobCount were greater than workerCount (twenty jobs, eight workers, say), the lower bound on idle workers would drop to zero — all eight of the eight workers would be forced to take at least one job, because all twenty jobs must be processed somewhere and there are only eight channel ends to process them. Sitting idle only happens when the job count is less than the worker count; the pool itself handles both directions through the same code path, with no special case added at all.

This is where it becomes visible that raising the worker count does not split the existing work: the five jobs in the job channel can only be taken five times, no matter how many workers are listening. Adding an eighth worker does not cause the fifth job to be broken into smaller pieces — it only leaves more workers waiting in line, with empty hands once the channel closes. If a job itself is not a divisible unit (like the job * job above, an atomic step processing a single integer), raising the worker count above the job count buys not parallelism but only idle workers. In a real system this is usually harmless — an idle goroutine consumes no resources, it only listens on a channel — but for a designer planning to size the pool to the workload, this measurement says: raising the worker count does not guarantee a speedup unless the work itself can be split into smaller pieces. This pool arrangement is not compared here against the executor concept — the pool comparison from M07/K04 Concurrency and Performance is not repeated here; what is measured is only Go’s own channel-based mechanism.

Sizing the pool therefore requires looking at the work’s own shape: if a job is an indivisible step like squaring a single integer, the benefit flattens out the moment the worker count passes the job count. If a job carries a larger set (the elements of a slice, say), sending that set’s sub-parts separately to the same job channel makes the worker count meaningful again — but that is a decision about how the work itself is split, not something the pool resolves on its own.

An Unclosed Queue: Workers Never Stop

The measurement above closed the jobs channel with close(jobs) and made all of them stop. What would happen if this closing line were forgotten? The measurement below builds the same arrangement but never closes the job channel at all.

// leak.go — if the job channel is never closed, workers never stop; the leak is measured as a boolean
package main

import (
	"fmt"
	"runtime"
)

func leakyWorker(id int, jobs <-chan int, results chan<- int) {
	for job := range jobs {
		results <- job * job
	}
	// This line is never reached: the jobs channel never closes.
}

func main() {
	base := runtime.NumGoroutine()

	const workerCount = 4
	jobs := make(chan int) // never closed
	results := make(chan int, workerCount)

	for id := 1; id <= workerCount; id++ {
		go leakyWorker(id, jobs, results)
	}

	fmt.Println("-- job channel never closed --")
	fmt.Printf("started worker count: %d\n", workerCount)
	fmt.Printf("did a non-stopping worker remain: %v\n", runtime.NumGoroutine() > base)
}
-- job channel never closed --
started worker count: 4
did a non-stopping worker remain: true

CP5. Four workers were started and none of them ever took a job — because no value was ever sent on the jobs channel. But that is not the real observation: all four of the four workers wait forever on the for job := range jobs line, because the channel neither carries a value nor closes. This is the worker pool arrangement’s counterpart to the previous lesson’s “stopping on its own is also a contract” observation: a worker stopping is not a decision of its own body, it is a decision made by whoever manages the job channel. runtime.NumGoroutine() staying above the base count is the only proof of this — the program terminates normally with no error and no warning at all, even though four goroutines are left hanging in the background. How many goroutines remain is not printed here, only the fact that some remain at all.

This measurement’s correctness does not depend on run timing: the moment a go statement runs, the new goroutine is registered with the runtime, even before it has run any code at all — this is why, by the time the loop ends and runtime.NumGoroutine() is called, all four workers have already entered the count. The measurement therefore does not need to wait for the four workers to actually become blocked — the registration itself is enough, because what matters is not what the workers are doing right now, but whether they exist at all.

This single leak might not cause an observable problem in a short-lived program — four goroutines are cleaned up along with the memory of a process that is ending anyway. The real risk is in the pattern repeating: if a request handler builds its own pool on every call and forgets to close the job channel, the leaked worker count grows together with incoming requests. This lesson measures a single leak, not a multiplied one — but the boolean itself is already warning enough: a true answer is the sign of a design in which the channel will never close, no matter how many goroutines pile up.

Summary

  • A fixed number of workers reads from a shared job channel; when the channel closes, all of them stop for the same reason (what it waited for arrived), independent of which worker took which job.
  • Total jobs taken and total reporting worker count are two fixed values independent of job distribution; from the difference between the two, the minimum count of workers that took no job at all is computed.
  • Raising the worker count above the job count does not split the existing work; it only raises the idle worker count, because each job is an indivisible unit taken exactly once.
  • If the job channel is never closed, workers wait forever; the proof of the leak is measured only as a boolean — how many goroutines remain is never printed.
  • The responsibility for closing stays with the sender here too — in a worker pool, the sender is the side that distributes the work.

Next Step

In this lesson, every worker read from the same channel, and the channel closing reached all of them at the same time. But a single source is not always enough: sometimes a job comes from a stream that needs to be consumed faster than a single producer could produce it, and that stream itself must be fanned out to more than one consumer. The next lesson measures this fan-out — a single source spread across many consumers, with the results gathered back into a single channel — and this time the question is different from the worker pool’s: with more than one producer, which one holds the responsibility for closing the gathering channel?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close