Threads & parallelism

The coroutine scheduler gives cooperative concurrency on a single OS thread. This page covers the other axis: real OS threads and data-parallel loops that use every core. Both are libc-free: the runtime talks to the kernel's thread primitives directly. When a program also links libc (for example through a Pascal external 'libc.so.6' declaration), threads are started through libc's pthread_create instead, so that each thread has its own libc state and C code that allocates can run on several threads at once.

These are advanced surfaces. Multi-threaded code compiled without --threadsafe shares unmanaged refcounts and will corrupt managed strings and dynamic arrays under contention. Build threaded programs with --threadsafe.

Targets

Threads work on x86-64, i386, aarch64 and arm32 Linux. On riscv32 and wasm32, --threadsafe is refused at compile time, for example --threadsafe is available for x86-64, i386, aarch64 and arm32 only; it is refused for riscv32.

With pin v446 (compiler sha256 ae3466a018d8) and with the compiler at e072d579b0 (ccd62c91f30e), on 2026-09-28, every program on this page and the threadvar example below compiled for each of the four targets and printed the same output on each. Programs for the targets other than x86-64 were run under QEMU user mode. The same happened for a C program using __thread, and for two threads calling libc's malloc and free 400,000 times each (test/thread_glibc_malloc_two_threads.pas, three runs per target).

Before v446, three things differed by target:

TThread — the palthreadobj unit

TThread is an FPC-style thread base class. Subclass it, override Execute, and the body runs on its own OS thread.

program worker;
{ compile with: ./pxx --threadsafe worker.pas worker }
uses palthread, palthreadobj;

type
  TAdder = class(TThread)
  public
    Sum: Int64;
  protected
    procedure Execute; override;
  end;

procedure TAdder.Execute;
var i: Integer;
begin
  Sum := 0;
  for i := 1 to 1000000 do
    Sum := Sum + i;            { runs on this thread's own OS thread }
end;

var t: TAdder;
begin
  t := TAdder.Create(False);   { False = start immediately }
  t.WaitFor;                   { block until Execute returns }
  writeln('sum = ', t.Sum);
  t.Free;                      { also Terminate+WaitFor if still running }
end.

To hand results back to the main thread safely, Synchronize(m) and Queue(m) marshal a TThreadMethod onto it. Bind the method to a variable first (m := @Self.SomeMethod;) — an inline Synchronize(@Self.SomeMethod) argument is not parsed yet — and have the main thread call CheckSynchronize periodically to run the queued work.

Surface

Member Effect
constructor Create(CreateSuspended: Boolean) Create the thread; False starts it at once, True waits for Start.
procedure Start Begin a suspended thread.
procedure Execute; virtual; abstract The thread body — override this.
procedure WaitFor Block the caller until the thread finishes.
procedure Terminate Set the cooperative Terminated flag; the body must observe it.
procedure Synchronize(m) / procedure Queue(m) Run a method on the main thread — blocking / fire-and-forget.
function ThreadID: Int64 The OS thread id.
property Terminated / Finished / Suspended Thread-state flags.
property FreeOnTerminate Self-Free when Execute returns.
property OnTerminate Method fired (on the main thread) when the thread ends.

Synchronize and Queue deliver work to the main thread; the main thread runs that work when it calls CheckSynchronize (or blocks in a runtime that pumps it). MainThreadID and CurrentThread identify the running thread.

threadvar: one copy per thread

A variable declared under threadvar has a separate copy in every thread. A new thread's copy starts at zero, whatever the other threads hold.

program tv;
{ compile with: ./pxx --threadsafe tv.pas tv }
uses palthread, palthreadobj;

threadvar
  Counter: Integer;           { one copy per thread }

type
  TWorker = class(TThread)
  public
    Id, Seen: Integer;
  protected
    procedure Execute; override;
  end;

procedure TWorker.Execute;
var i: Integer;
begin
  Counter := Id * 10;         { this thread's copy only }
  for i := 1 to 100000 do
    if Counter <> Id * 10 then Break;
  Seen := Counter;
end;

var a, b: TWorker;
begin
  Counter := 99;              { the main thread's copy }
  a := TWorker.Create(True); a.Id := 1;
  b := TWorker.Create(True); b.Id := 2;
  a.Start; b.Start;
  a.WaitFor; b.WaitFor;
  writeln('thread 1 saw ', a.Seen, ', thread 2 saw ', b.Seen, ', main has ', Counter);
  a.Free; b.Free;
end.

It prints thread 1 saw 10, thread 2 saw 20, main has 99. C's __thread is the same: each thread gets its own copy, which starts at the variable's initialiser.

parallel for — the palparallel unit

parallel for is a language-level statement that fans a counted loop across a libc-free worker pool. The compiler desugars it at parse time into a synthesised worker procedure plus a pool dispatch — there is no closure allocation.

program parsum;
{ compile with: ./pxx --threadsafe parsum.pas parsum }
uses palparallel;

const N = 100000;
var arr: array[0..N-1] of Integer;

procedure Run;
var i: Integer;
begin
  parallel for i := 0 to N-1 do
    arr[i] := i * 3;          { each iteration writes its own disjoint slot }
end;

begin
  Run;                        { must sit inside a routine, not the main body }
  writeln(arr[42]);
end.

Constraints in this version:

An optional policy clause tunes distribution (worker count, chunking); omit it for the default that fans the range evenly across the pool.

reduction and private

Two clauses sit between the range and do, and both exist because capture is by reference:

  parallel for i := 0 to N-1 reduction(+: total) private(scratch) do
  begin
    scratch := 0;
    while scratch < 3 do begin total := total + 1; scratch := scratch + 1; end;
  end;

Two things to know about private:

A variable may not appear in both clauses — reduction already gives it a private, plus the combine.

Scalars and AnsiString may be private. Arrays, records and classes may not yet; index a shared array by the loop variable instead, which needs no clause.

Next