JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Thread Pools and the Task Model

You already know how to spawn a thread. This guide makes the harder leap: stop thinking in threads at all, and start thinking in tasks. A thread pool is the bridge — a small fixed crew of workers that chew through a queue of work — and the task model is the new way of seeing that the whole rest of this rung is built on.

Why not just spawn a thread per job?

By the end of the synchronization rung you could create an OS thread, hand it a function, and let it run alongside the others. So the obvious design for a server is: a request arrives, spawn a thread to handle it, let the thread exit when it is done. This works, it is easy to write, and for a handful of requests it is perfectly fine. The trouble starts only when the work gets small and frequent — thousands of tiny jobs a second — and that is exactly the regime real systems live in.

An OS thread is not free. Each one wants its own stack — often a default of 1 MiB or 8 MiB of virtual address space reserved up front — plus a kernel control block and scheduler bookkeeping. Creating and destroying one is a system call, not a cheap function call, and the cost of threads does not stop at birth: the more runnable threads exist, the more time the scheduler burns on context switches, each one saving and restoring registers and polluting the CPU caches. Spawn ten thousand threads for ten thousand tiny jobs and you can spend more time managing threads than doing the actual work.

There is a second, subtler problem: spawning a thread per job gives you no control over how many run at once. If twenty thousand requests land together, you get twenty thousand threads all contending for a few CPU cores and gigabytes of stacks, and the machine grinds to a crawl — sometimes it falls over entirely. We want the opposite: a fixed number of workers, sized to the hardware, that absorb any amount of incoming work without multiplying. That is the whole idea of a thread pool.

The pool: a fixed crew and a queue of work

A thread pool inverts the relationship between threads and jobs. Instead of one thread per job, you create a small fixed set of long-lived worker threads once, at startup, and never destroy them. Each worker runs the same humble infinite loop: take a job off a shared queue, run it to completion, come back for another. The jobs come and go by the thousands; the workers stay. The picture to hold is a kitchen: you do not hire a new cook for every order, you keep a handful of cooks and pin orders to a rail they pull from.

That shared queue is the heart of the design, and you have met its pattern before: it is a producer-consumer problem in disguise. The threads submitting work are the producers; the workers are the consumers. The queue, often called the task queue, must be thread-safe because many producers push and many workers pop concurrently — so under the hood it is exactly the mutex plus condition variable machinery you built last rung. A worker that finds the queue empty does not spin and burn CPU; it waits on the condition variable, sleeping until a producer signals that a new task arrived. This is why the pool can sit at near-zero cost when idle and wake instantly when work lands.

submit(task) -->  [ task | task | task | task ]  -->  worker 1  --> run
  (producers)        ^                      ^           worker 2  --> run
                     |    one shared        |           worker 3  --> run
                     |    task queue        |           worker 4  --> run
                  push back              pop front       (fixed crew)

worker loop:   while (running) {
                   task = queue.pop();   // sleeps on cond-var if empty
                   task.run();           // run to completion, then loop
               }
A thread pool: many producers push tasks onto one shared queue; a fixed crew of workers pops and runs them. The number of workers is bounded by hardware, not by the amount of work.

How many workers? The usual starting point for CPU-bound work is one worker per hardware core (or per hardware thread, if the CPU offers two-way SMT). The reasoning is mechanical: a core can only truly run one thing at a time, so more runnable workers than cores just means more context switches with no extra throughput. If the work is I/O-bound — workers that mostly wait on disk or network — you can profitably run more, because a waiting worker is not using its core. Sizing the pool is a real tuning decision, and getting it right is one of the practical skills this rung builds toward.

The real shift: thinking in tasks, not threads

The thread pool is useful machinery, but its deeper gift is conceptual. Once you submit work to a pool, you have stopped asking "which thread runs this?" and started asking only "what is the unit of work, and what does it depend on?" That unit is a task: a self-contained piece of work you can hand off to be run somewhere, sometime, by some worker you do not name and do not care about. Decoupling the work (the what) from the worker (the where and when) is the single most important mental move in this whole rung.

This is the difference between data parallelism and task parallelism, two angles on the same goal. In data parallelism you have one operation and a big pile of data, and you split the data — "apply this filter to all million pixels" becomes a thousand tasks of a thousand pixels each. In task parallelism the operations differ — parse this file, compress that buffer, hash this block — and each becomes its own task. A thread pool happily runs both, because to the pool a task is just an opaque "run me"; it does not know or care whether the tasks are siblings doing the same thing or strangers doing different things.

When tasks depend on tasks: fork-join

Real work is rarely a flat bag of independent jobs. Often one task needs to spawn several smaller tasks and then wait for their results before it can finish — think of summing a huge array by splitting it in half, summing each half in parallel, then adding the two partial sums. This shape is fork-join: a task forks into children, the children run on the pool, and the parent joins by waiting until all children are done, then combines their outputs. It is the most common structure of parallel algorithms, and divide-and-conquer recursions fall straight into it.

Fork-join sounds tidy, but it hides a sharp trap that reveals why a plain thread pool is not the end of the story. Suppose every worker is busy running a parent task, and each parent forks children and then blocks waiting on them. The children are sitting in the queue — but there is no free worker left to pick them up, because every worker is blocked waiting for exactly those children. The pool is fully occupied yet making zero progress: a self-inflicted deadlock. The lesson is that the moment tasks can wait on other tasks, a naive "run to completion, then take the next" worker is not enough.

The fix is to let a worker that is blocked waiting on children go and run those children itself instead of idly sleeping — to dive into the queue while it waits rather than holding its core hostage. Generalizing this idea, so that an idle worker can reach over and grab tasks that piled up on a busy worker, is exactly the work-stealing scheduler of the next guide. For now, just plant the flag: a basic pool is wonderful for independent tasks, but dependent tasks demand a smarter scheduler, and fork-join is the workload that forces the issue.

From pool to runtime: the road ahead

Step back and notice what we have actually built: a tiny scheduler living in user space, on top of the kernel's own scheduler. The OS schedules threads onto cores; our pool schedules tasks onto threads. This is the seed of M-to-N threading — M lightweight tasks multiplexed onto N OS threads — and it is the architecture under nearly every modern concurrency runtime, from Go's goroutines to Rust's and Java's async executors. A general thread pool that owns the workers, the queue, the scheduling policy, and the lifecycle is exactly what people mean by an executor or a runtime.

Two questions now press on us, and each one opens a later guide. First: when you submit a task, how do you get its result back, given the worker runs it later on some other thread? You cannot just return a value across that gap. The answer is a little box that stands in for a not-yet-computed value — a future, paired with the promise that fills it — which guide 3 builds in full. Second: if a worker must run a task to completion before taking the next, what happens when a task wants to pause in the middle, say to wait for a network read, without stalling its whole worker?

So this guide is the foundation stone for the four that follow. You now have the load-bearing ideas: the pool that bounds and reuses threads, the queue that feeds it, and above all the task model that lets you reason about work instead of workers. Carry these forward. Work-stealing (guide 2) makes the scheduler smarter; futures and async/await (guides 3 and 4) make tasks able to wait and return results without blocking; structured concurrency, actors, and channels (guide 5) give you disciplined ways to spawn, cancel, and let tasks talk. Every one of them is a refinement of the simple, sturdy machine you understood here.