Catalog · How the catalog works
delphi-concurrent-log
A thread-safe logging library for Delphi, with a portable core tested under Free Pascal in CI.
- ★ 2 on GitHub
Anyone may press Interesting, signed in or not. It helps others find this project.
README
ConcurrentLog
A small thread-safe logging library for Delphi.
Many threads can log at once without tearing each other's output. One lock, in one place; sinks and callers never lock. The core is plain Object Pascal with no platform or UI dependency, so it compiles under both Delphi and Free Pascal — which is what lets the test suite, including the concurrency test, run in CI.
Quick start
git clone https://github.com/ramonruanxc/delphi-concurrent-log.git(or download the ZIP from GitHub and extract it).
-
Delphi XE7 or later: open
demo/Demo.dprand press F9. Nothing to configure: no search path, no defines, no project options. The console window stays open until you press Enter. -
Free Pascal 3.2.2: from the repository root, run
fpc demo/Demo.dprthen
demo\Demo.exeon Windows or./demo/Demoon Linux.
Three threads log at once; the run ends with the path of the log file it wrote (next to the executable) and a single verdict line:
2026-09-23 13:18:45.178 [INFO ] (46884) starting three workers
2026-09-23 13:18:45.179 [INFO ] (61636) importer: step 1 of 5
...
2026-09-23 13:18:45.260 [INFO ] (46884) all workers finished
Log file: <clone>\demo\demo.log
SUCCESS: 20 lines from 3 threads, none lost or torn, Debug line filtered out.Compiler support, honestly stated: Free Pascal 3.2.2 is verified in CI (Linux) and locally (Windows, i386). Delphi XE7 and later is the intended target but has not been compiled here: Delphi cannot run on a hosted CI runner, and the Community Edition refuses command line builds. The Delphi code paths avoid anything newer than XE7.
Usage
uses
ConcurrentLog.Types, ConcurrentLog.Sinks, ConcurrentLog.Logger;
var
Logger: TLogger;
begin
Logger := TLogger.Create(llInfo);
try
Logger.AddSink(TConsoleSink.Create);
Logger.AddSink(TFileSink.Create('app.log'));
Logger.Info('service started');
Logger.Log(llError, 'request %d failed: %s', [id, reason]);
finally
Logger.Free;
end;
end;Execution flow

TLogger.Log acquires one lock, filters by level, builds an entry and writes to configured sinks serially before releasing the lock. Logging is synchronous; slow sinks hold up callers.
Install
With Boss:
boss install github.com/ramonruanxc/delphi-concurrent-logOr add src to your project's search path. Targets Delphi XE7 or later
(intended, not compiler-verified; see Quick start); the core also builds on
Free Pascal 3.2.2, which CI verifies.
Concepts
Logger. Holds the lock, the sink list, and the minimum level. Create one, add sinks, log to it from any thread.
Level. llTrace < llDebug < llInfo < llWarn < llError. Entries below the
logger's MinLevel are dropped. MinLevel is readable and writable at runtime
from any thread.
Sink. A destination — TConsoleSink, TFileSink, TMemorySink. Implement
ILogSink for your own. A sink never has to lock: the logger calls it from one
thread at a time.
type
ILogSink = interface
procedure Write(const AEntry: TLogEntry);
end;TMemorySink keeps entries in memory and hands back a copy through Snapshot
— useful in tests and for showing recent activity in a UI.
The design decision
The lock lives in the logger and nowhere else. Everything a log entry touches
between "someone called Log" and "every sink has seen it" — the level check,
building the entry, dispatching to each sink — happens inside one critical
section.
That is deliberate. It is the smallest region that can be correct, and the only one that has to be. A sink author cannot introduce a data race, because they never touch shared state without the logger's lock held. The alternative — each sink locking itself — is more code, more locks, and more ways to get it wrong, for no benefit.
The cost is that sinks run serially while the lock is held, so a slow sink slows every logging thread. If you need to log across a slow sink without blocking callers, put a queue in front of it — a sink that hands entries to a background writer thread. That is a natural extension and intentionally left out of the core.
A note on lifetimes
Sinks are reference-counted interfaces (ILogSink). The logger holds them, so
you do not free them yourself — construct and hand them straight to AddSink:
Logger.AddSink(TFileSink.Create('app.log'));The logger itself is a plain object; free it when you are done, and it releases its sinks.
The global logger
For code that does not want to pass a logger around, GlobalLog returns a
process-wide instance:
uses ConcurrentLog.Logger;
GlobalLog.AddSink(TConsoleSink.Create); // once, at startup
GlobalLog.Info('ready');It is created before any user code runs and destroyed at shutdown, so there is
no lazy-initialisation race. It starts with no sinks and the default llInfo
minimum.
Building from a clone
Every .dpr lists every unit it uses from this repository, directly or
indirectly, with an explicit in '...' path relative to the .dpr, so
opening one in the Delphi IDE and pressing F9 works with nothing to
configure — no search path, no library path, no defines. Under the debugger
the program waits for Enter before closing; run anywhere else, it never
pauses.
Free Pascal resolves in paths from the current directory rather than from
the .dpr, so each .dpr also carries an FPC-only {$UNITPATH ../src}.
That makes a plain fpc demo/Demo.dpr or fpc tests/Tests.dpr work from the
repository root with no options. Compiled units (.ppu, .o) land next to
their sources and are git-ignored.
Demo
demo/Demo.dpr is a short console program: three threads logging at once
through a console sink and a file sink, with a level filter dropping the
quietest line. Run it and the interleaved output stays intact. It then re-reads
its log file (demo.log, next to the executable), checks that every expected
line is there and whole, and prints SUCCESS: ... (exit code 0) or
FAILURE: ... (exit code 1). It takes no arguments.
fpc demo/Demo.dpr
./demo/Demo # demo\Demo.exe on WindowsRunning the tests
In Delphi, open tests/Tests.dpr and press F9. With Free Pascal, from the
repository root:
fpc tests/Tests.dpr
./tests/Tests # tests\Tests.exe on WindowsCI builds the same program with explicit -Fu/-FU options into build/;
see .github/workflows/ci.yml.
The runner exits non-zero if any assertion fails. It uses a small assertion runner rather than DUnitX, because the suite has to run under both compilers and DUnitX does not run under Free Pascal. The assertions hold no framework state, so they can be wrapped in DUnitX for IDE use without changes.
Proving the lock earns its keep
A concurrency test that always passes proves nothing — it has to fail when the thing it tests is removed. This one does. Build it with the lock compiled out:
mkdir -p build/race
fpc -B -dPROVE_RACE -FUbuild/race -obuild/TestsRace tests/Tests.dpr
./build/TestsRace-B and the separate build/race directory matter: FPC does not treat a
changed define as a reason to recompile, so without them the race build could
silently reuse units compiled with the lock.
Without the lock, eight threads calling TList.Add at once corrupt the backing
array, and the run dies in a storm of access violations instead of reporting
40000 entries. That failure is the evidence that the passing run means
something. PROVE_RACE is never defined by a normal build.
Licence
MIT. See LICENSE.

