Rafael Romão's Coding Labs

Lab L02 · Java + Go

Implementing a bounded worker pool

Build an executor that runs submitted tasks on a fixed number of worker threads, with a fixed-size waiting line in front of them. When every worker is busy and the line is full, submit rejects the task immediately instead of blocking the caller or growing memory. close() stops new submissions but lets every accepted task finish; abort() throws away queued work and interrupts running work.

The task

A notification service sends push messages; each send is a task. The push provider tolerates at most N concurrent calls, and a traffic burst must not pile up unbounded work in memory. So the service runs N workers, lets at most Q tasks wait, and turns everything beyond that into an immediate rejection the caller can translate into “503, retry later”. On deploy it stops accepting work and lets in-flight sends complete. You may build it on ThreadPoolExecutor (the reference does) or from your own threads plus the L01 queue; either way the rules below must hold.

submit()admit?close() shuts thisqueue · capacity = 3EDworker 1: Aworker 2: Bworker 3: CFutureFutureFutureRejectedExecutionExceptionworkers busy and queue full, or closedworkers + capacity = most tasks inside
Admission is the only decision point: a task is either accepted (queued or running, with a Future) or rejected on the spot. Closing shuts the gate without discarding anything already inside.

What you implement

public final class WorkerPool {
    public WorkerPool(int workers, int capacity);        // both must be > 0
    public <T> Future<T> submit(Callable<T> task);      // RejectedExecutionException if saturated or closed
    public void close();                                 // stop admission; accepted tasks still run; does not wait
    public int abort();                                  // stop admission, cancel queued, interrupt running; returns # cancelled
    public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
}

Rules

  • At most workers tasks run at the same time and at most capacity wait. With all workers busy and the queue full, the next submit throws RejectedExecutionException; a rejected task never runs.
  • Each accepted task gets its own Future. A task that throws completes its future exceptionally (ExecutionException wrapping the cause); other tasks are unaffected.
  • close(): every later submit is rejected; queued and running tasks finish. It returns immediately — use awaitTermination to wait.
  • abort(): queued tasks are removed and their futures cancelled (they never run); running tasks are interrupted, which only stops tasks that respond to interruption. Returns how many queued tasks were removed.
  • awaitTermination returns true once everything has finished after close/abort, false if the timeout expired first.
  • Invalid sizes → IllegalArgumentException; submit(null) → NullPointerException.

Worked example

  1. new WorkerPool(1, 1). submit(A) starts running (A waits on a latch).
  2. submit(B) is accepted and waits in the queue.
  3. submit(C) → RejectedExecutionException: one running + one queued is the limit.
  4. Release A → B starts. close(); submit(D) → rejected. B finishes; awaitTermination(1, SECONDS) → true.

Files and commands

Read the tests first, then replace each TODO in the starter until they pass. Java starter: WorkerPool.java · tests: WorkerPoolTest.java. Go starter: pool.go · tests: abort_test.go, pool_test.go.

cd java
./gradlew test --tests 'labs.workerpool.*'

cd go
go test -race -count=1 ./workerpool
Reveal the reference solutions

Java: WorkerPool.java
Go: pool.go

When to use

Use this lab when independent tasks need a fixed execution limit and a bounded waiting queue. Configure positive worker and queue counts. Submission rejects immediately when the queue cannot accept more work or admission has closed; rejection does not mean the task ran. Each accepted submission receives its own completion result. Closing is idempotent, stops admission, and drains accepted tasks without waiting for them itself.

The implementations live under solutions/ in the workerpool packages. Close drains; abort skips queued work and requests cooperative termination of active tasks. Neither operation forcibly kills arbitrary callbacks. There is no automatic deadline, persistent queue, or fairness guarantee. Tasks that block forever prevent termination. Queued closures and returned results can retain arbitrarily large object graphs; bounding task count is not a byte bound.

Steps

  1. Separate admission from completion. Go serializes submission and closing the job channel with one mutex. A nonblocking channel send either accepts the task or reports saturation. Java delegates this boundary to a fixed-size thread pool executor with an array blocking queue and abort rejection policy. It does not use the standard unbounded executor factory.
  2. Bound execution and waiting separately. At most the configured worker count can execute, and the waiting queue has its own capacity. A burst does not create a thread or goroutine for each submission. Saturation is a caller-visible policy, not a hidden wait.
  3. Deliver results without blocking workers. Go gives each task a one-element buffered result channel, so abandoning a result does not stop a worker. Java returns a future. Both retain the result as long as the caller retains the result handle. Java callers must inspect futures to observe exceptions.
  4. Handle task failures. Go converts a recovered task panic to an error and continues the worker loop; ordinary errors pass through. Java futures capture task exceptions. Neither path promises recovery from process termination, runtime-fatal conditions, or tasks deliberately terminating the executing goroutine.
  5. Close then join. Go exposes a completion channel that closes after all workers exit. Java exposes a timed termination wait. A timeout means unfinished work remains; it is not evidence that work was cancelled. The task owner must supply cooperative cancellation if required.
  6. Prove the saturation scenario. With one worker and one queue slot, use a latch to hold the first task after it starts. Accept a second task, reject a third, close admission, release the first task, and verify both accepted results and termination. No sleep is needed to establish queue occupancy. Test that a failed task does not prevent a later task completing.

Checklist

Abort and recall

The Go and Java references now also support abort. Go skips not-started tasks with an abort result and cancels the pool context for context-aware tasks. Java cancels queued futures and interrupts active tasks. Join termination separately; arbitrary callbacks cannot be forcibly stopped. Rebuild admission from memory. Variation: race admission with abort; acceptance requires one terminal outcome for every accepted task and no queued task silently lost.

  • Both execution and waiting work are bounded
  • Submission and shutdown cannot race into a send on a closed channel
  • Every accepted task has an observable result
  • A dropped result handle does not block a worker
  • Termination waits are distinguished from cancellation requests

Signals

Working: the controlled saturation test rejects exactly the excess submission and drains both accepted tasks.

Going wrong: shutdown loses accepted work, an unobserved future hides a failure, or queue saturation silently grows caller threads. Do not invoke a blocking termination wait from a task whose own completion is needed for termination.

Related