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.
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
workerstasks run at the same time and at mostcapacitywait. With all workers busy and the queue full, the nextsubmitthrowsRejectedExecutionException; a rejected task never runs. - Each accepted task gets its own
Future. A task that throws completes its future exceptionally (ExecutionExceptionwrapping the cause); other tasks are unaffected. close(): every latersubmitis rejected; queued and running tasks finish. It returns immediately — useawaitTerminationto 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.awaitTerminationreturnstrueonce everything has finished after close/abort,falseif the timeout expired first.- Invalid sizes →
IllegalArgumentException;submit(null)→NullPointerException.
Worked example
new WorkerPool(1, 1).submit(A)starts running (A waits on a latch).submit(B)is accepted and waits in the queue.submit(C)→RejectedExecutionException: one running + one queued is the limit.- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- Implementing a Bounded Blocking Queue, for admission, closure, and predicate invariants below the worker lifecycle.