A bounded blocking queue, a worker that owns its thread, and a fixed-size pool that composes them — for Delphi and Free Pascal.
Four small units. Not a framework.
Pool := TWorkerPool.Create(4, 32); // 4 workers, queue of 32
try
Pool.Submit(TMyJob.Create, 5000); // waits up to 5 s if the queue is full
Pool.WaitIdle(30000);
Assert(Pool.Submitted = Pool.Completed + Pool.Faulted + Pool.Dropped);
Pool.Shutdown(10000); // drain what is queued, then join
finally
Pool.Free;
end;The first question a reviewer should ask, so it is answered before anything else.
System.Threading (TTask, TParallel) and TThreadedQueue<T> are
Delphi-only. Neither exists in Free Pascal — and Free Pascal is what makes it
possible to test a concurrency library in CI at all: Delphi cannot be licensed on
a hosted runner, and its Community Edition refuses command-line compilation even
locally. A library built on the RTL's threading types could only ever be tested
by hand, on one machine, by its author. For code whose entire subject is races,
that is not testing.
So the constraint came first and the design followed: TThread,
TCriticalSection, TEvent and the interlocked intrinsics — all of which exist
on both compilers — and a suite that runs on every push, including four builds
that deliberately remove a safety property and must fail.
If you are Delphi-only and want a task API, use System.Threading. This is
for code that has to build on both, or that wants its concurrency claims
checked rather than asserted.
It is also the second half of a pair.
delphi-concurrent-log
ends its README saying that logging across a slow sink without blocking callers
wants "a queue in front of it — a sink that hands entries to a background writer
thread… intentionally left out of the core". demo/AsyncSink.dpr is that.
With Boss:
boss install github.com/ramonruanxc/delphi-concurrent-pool
Or add src to your search path. Requires Delphi 10.1 Berlin or later, or Free
Pascal 3.2 with -Mdelphi.
Every .dpr here lists its units with explicit in '...' paths, so opening one
in the Delphi IDE and building works with nothing to configure. Free Pascal
resolves units from -Fu and ignores those, so the FPC command lines below pass
the paths.
Build the test suite with assertions on (-Sa, or a Delphi debug config).
One of the safety mechanisms is an assertion; without it that test proves
nothing, and the suite says so at startup.
Types -> Atomic -> Queue -> Worker -> Pool
A line, with no back-edges. Types holds what more than one unit needs, which is
why it exists at all.
A 32-bit interlocked counter. It is a record, and that is deliberate: the two ways a record counter goes wrong are the interesting part of the unit, and both are invisible to the compiler.
type
TThing = class
public
Handled: TAtomicCounter; // valid: a class field
constructor Create;
end;
constructor TThing.Create;
begin
inherited Create;
Handled.Init; // required, once
end;Valid as a class field, a global, or an array element — after Init. Not
valid as a local, a value parameter, or a const parameter. Rather than leave
that as documentation, a guard makes both mistakes fail loudly on first use:
- an uninitialised local is not zeroed (measured: it read back
22735472on FPC/i386-win32,-1599891312on FPC/x86_64-linux and7060772on Delphi 12), so its magic field is garbage and the guard trips; - a by-value copy moves the record, so
@Selfstops matching and the copy trips on its first mutation.
Return values follow Delphi's TInterlocked, so porting code does not silently
change meaning: Increment, Decrement and Add return the value after;
Exchange and CompareExchange return the value before.
A ring with a lock and two manual-reset events whose signalled state mirrors the predicate.
function Push(const AItem: T; ATimeoutMs: Cardinal): TQueueWait;
function Pop(out AItem: T; ATimeoutMs: Cardinal): TQueueWait;
procedure Close;
function DrainAndClose: Integer;TQueueWait has three values, not two, because two cannot be acted upon:
qwTimeout means try again, qwClosed means stop, forever. A Boolean
collapses them and makes the pool's drain loop unimplementable.
Manual-reset is not a style preference. An auto-reset event is a binary latch —
two pushes in quick succession wake one consumer and the second item sits
unnoticed (measured) — and SetEvent on one releases exactly one waiter, where
Close has to release all of them. Measured on both targets: one SetEvent on a
manual-reset event released four parked waiters, and a waiter arriving afterwards
passed with no further signal.
Close is a latch: once closed, both events are set and never reset, so a
thread arriving later still finds them signalled. That is what makes producer
parked on a full queue, then Close return qwClosed instead of hanging — the
deadlock bounded queues usually ship with.
The full state matrix, tested line by line:
| push, room available | qwOK |
| push, full | blocks to the deadline → qwTimeout |
push parked, then Close |
qwClosed |
push after Close |
qwClosed, immediately |
pop, items present (even after Close) |
qwOK — drains first |
| pop, empty and open | blocks to the deadline → qwTimeout |
| pop, empty and closed | qwClosed |
Push(x, 0) / Pop(x, 0) |
try only, never blocks |
Close twice, from any thread |
no-op, never raises |
Count is advisory — true when read, stale the moment you act on it.
Owns a TThread it never exposes: no Handle, no Priority, no Suspend, no
Resume. Re-publishing the footguns this unit removes would undo the point.
- No thread until
Start. Destroying a worker that was never started has nothing to wait on, which removes the never-started-suspended-TThreadhazard entirely. Destroyjoins, unconditionally.Executereads the worker's fields, so abandoning a live thread and then freeing the object is a use-after-free, andTerminateThreadleaks whatever locks that thread held. Cancellation is cooperative; a task that never checks its token cannot be stopped;Destroyblocks untilRunreturns.WaitFor(timeout)exists so a caller can decide what to do about a slow worker — a destructor is not offered that choice.- Self-join raises
EPoolSelfJoininstead of waiting forever. A hang gives no stack, no message, and burns a CI runner until the job times out. - Faults are captured, not swallowed. An exception that escapes
Executelands inTThread.FatalException, which nothing reads — the thread just "ends" and the work disappears.FaultClassNameandFaultMessageare recorded instead. The handler has a bareelsebecauseon E: Exceptiondoes not catchraise TObject.Create(measured on both compilers).
Cancellation is an interface with two methods:
ICancellationToken = interface
function IsCancelled: Boolean;
function WaitCancelled(ATimeoutMs: Cardinal): Boolean;
end;WaitCancelled is the one that earns the interface. A poll-only token cannot
release a task that is parked in a wait, so Cancel followed by a join would
deadlock by design. demo/Pipeline.dpr shows a parked task released in a few
hundred milliseconds instead of waiting out a 30-second timeout.
Visibly a composition of the other three, which is the point.
function Submit(const ARunnable: IRunnable; ATimeoutMs: Cardinal = 0): TQueueWait;
function WaitIdle(ATimeoutMs: Cardinal): Boolean;
function Shutdown(ATimeoutMs: Cardinal): Boolean; // drain, then stop
function ShutdownNow(ATimeoutMs: Cardinal): Boolean; // abandon, then stopTwo named methods rather than a Boolean parameter, because Shutdown(True)
tells the reader nothing. Submit is non-blocking by default; pass a timeout to
get back-pressure. After a shutdown it returns qwClosed — it never raises and
never blocks.
The accounting is an invariant, not a report:
Submitted = Completed + Faulted + Dropped
asserted at the end of every pool test. That is a race-free proof that work is never lost and never run twice, which is a much stronger statement than a counter reached N.
- Locks are leaf-level. No component calls user code, another component, or
an allocation-heavy operation while holding a lock.
Item.Runis invoked with nothing held, which is why submitting from inside a running task works — and why there is no lock nesting in the library, so no lock order to invert. - Every public method takes its lock exactly once, doing its work through
non-locking private helpers. Re-entrancy is never relied on, so it does not
matter whether
TCriticalSectionis a recursive mutex on a given platform. ACountthat locked, called from inside aPushthat already held the lock, would work on Windows and deadlock on Linux — the worst possible split. - No
Sleepinsrc/. Waiting is on events whose state mirrors a predicate. - Every wait in
tests/is bounded.WAIT_INFINITEdoes not appear there.
mkdir -p build/normal
fpc -Mdelphi -Sa -Fusrc -Futests -FUbuild/normal -obuild/Tests tests/Tests.dpr
./build/Tests
96 assertions. --no-guards skips the one test that raises on purpose, so a
debugger session is not interrupted.
A concurrency test that always passes proves nothing. Each build below
removes exactly one safety property, and CI fails if the suite still passes.
Each gets its own -FU directory, because Free Pascal does not treat a changed
-d define as a reason to recompile — sharing one would silently reuse the
protected units and make the check meaningless.
| Build | Removes | Result |
|---|---|---|
-dPROVE_SWALLOW |
the worker's try/except |
8 assertions fail. Deterministic: no timing component, any core count, any OS. |
-dPROVE_NOWAKE |
the SetEvents in Close |
2 assertions fail — the latency ones. |
-dPROVE_RACE_QUEUE |
the lock around the ring indices | items lost or duplicated; the bitmap assertion fires. |
-dPROVE_RACE_ATOMIC |
the interlocked mutators | increments lost. Statistical — see below. |
PROVE_NOWAKE is worth a note, because writing it taught me something about my
own design. Removing the wakeups does not hang this queue: the parked thread's
WaitFor expires, the loop re-checks the predicate under the lock, sees the
closed flag and returns qwClosed. Correctness comes from the bounded re-check
loop; the events supply promptness. A test that only checked the result would
therefore have passed with the wakeups removed — five seconds later. So the
assertions are on latency, which is the property the events actually provide.
PROVE_RACE_ATOMIC is the only proof here that is not airtight: observing a lost
update needs real parallelism, and the CI runner has two cores. The window is
widened deliberately in that build, and CI retries it up to three times. It is
not the proof this README leads with, and the deterministic ones stand on their
own.
Every hazard in this library fails by blocking. A lost wakeup, a self-join, a missing release — none produce a wrong value, they produce a process that never returns, which tells you nothing and names nobody.
So the runner keeps a watchdog thread and halts with the test's name:
WATCHDOG: test "an empty Pop with a deadline waits, then times out" exceeded 0 s
and is presumed deadlocked.
CI verifies the watchdog rather than assuming it: Tests --watchdog=0 arms it
immediately and must exit 2 having named a test. An earlier version cleared the
test name after each assertion, which left the watchdog blind between them —
exactly where two of the negative builds hung, so it reported nothing.
CI builds the suite again with -gh -gl and greps heaptrc's output. Grepping is
mandatory: a deliberate leak produced 1 unfreed memory blocks : 8 while the
process still exited 0, so a naive step passes silently. The pattern is
anchored, because an unanchored 0 unfreed memory blocks also matches
10 unfreed memory blocks.
fpc -Mdelphi -Fusrc -FUbuild -obuild/AsyncSink demo/AsyncSink.dpr && ./build/AsyncSink
fpc -Mdelphi -Fusrc -FUbuild -obuild/Pipeline demo/Pipeline.dpr && ./build/Pipeline
AsyncSink puts a bounded queue in front of a deliberately slow sink and
runs the same 400 lines twice — once with back-pressure, once dropping — so the
trade-off is a number rather than a paragraph. A representative run:
| back-pressure | dropping | |
|---|---|---|
| offered | 400 | 400 |
| accepted | 400 | 17 |
| elapsed | 6141 ms | 266 ms |
Pipeline shows a task that throws not taking its worker down (70 jobs, 10
faults, 4 workers still alive) and cancellation reaching a task that is parked
rather than polling — released in ~300 ms instead of its 30-second timeout.
- Futures or per-task results.
IRunnablereturns nothing. A result type wants cancellation propagation, continuations and exception marshalling; that is a different library. - Work stealing, dynamic sizing, priorities. Fixed size, one shared queue. Anything else needs a benchmark to justify it, and there is none here.
- Anonymous-method callbacks. Free Pascal 3.2 has no
reference to procedure— it does not parse. Callbacks are interfaces orof object, andAsRunnablewraps a method so a one-line task costs one line. TMonitor,TInterlockedas the portable path. Delphi-only; measured absent on FPC 3.2.TInterlockedis used on Delphi behind the shim inConcurrentPool.Atomic.
MIT. See LICENSE.