Skip to content

Tutorial: Parallel Queries from Python

This tutorial shows how to run Clausal queries in parallel from Python using threads. It covers the rules you need to follow, common patterns, and pitfalls.

Prerequisites

You need free-threaded Python (python3.14t+) for true parallelism. On a GIL-enabled build the same code works correctly but threads serialize — useful for testing but no speedup.


The golden rule

Each thread gets its own Trail and its own query variables.

A Trail records bindings so they can be undone on backtracking. It is not thread-safe — it's a mutable array with no internal locking. Clausal enforces this at runtime: using a Trail from a thread other than the one that created it raises RuntimeError.

import threading
from clausal.logic.variables import Var, Trail, unify, deref

def worker():
    trail = Trail()       # each thread creates its own
    x = Var()             # each thread creates its own query vars
    unify(x, 42, trail)
    print(deref(x))       # 42

threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

Pattern 1: Independent queries in parallel

The simplest pattern. Multiple threads query the same compiled database concurrently. Each thread has its own Trail and Vars — they share only the (read-only) clause database.

import threading
from clausal.logic.variables import Var, Trail, deref
from clausal.logic.solve import call

def query_worker(module, results, idx):
    """Run a query in its own thread."""
    x = Var()
    solutions = []
    for trail in call("path", "a", x, module=module):
        solutions.append(deref(x))
    results[idx] = solutions

# Assume `mod` is a compiled Module with edge/2 and path/2
results = [None] * 4
threads = [
    threading.Thread(target=query_worker, args=(mod, results, i))
    for i in range(4)
]
for t in threads:
    t.start()
for t in threads:
    t.join()

# All threads got the same answers (order may vary)
for r in results:
    print(sorted(r))

Why this works: call() creates a fresh Trail internally if you don't pass one. Each thread's StepGenerator chain is independent. The clause database's dispatch tables are immutable snapshots — no locking needed for reads. See Free-Threaded Python Support for details on the C extension locking design.


Pattern 2: Sharing ground terms

Ground terms (fully instantiated, no unbound Vars) are immutable and safe to share freely:

shared_data = [1, 2, [3, 4], "five"]   # ground — safe to share

def worker(idx):
    trail = Trail()
    x = Var()
    unify(x, shared_data, trail)
    assert deref(x) == shared_data      # reads the shared list

This is efficient because no copying happens — every thread reads the same Python objects.


Pattern 3: Collecting results from parallel searches

Use a lock or a per-index list to collect results without races:

import threading
from clausal.logic.variables import Var, Trail, deref, walk
from clausal.logic.solve import call

def search_worker(module, functor, arg, results, idx):
    x = Var()
    solutions = []
    for trail in call(functor, arg, x, module=module):
        solutions.append(walk(x))   # walk() deep-dereferences
    results[idx] = solutions

# Run different queries in parallel
queries = [("path", "a"), ("path", "b"), ("path", "c"), ("path", "d")]
results = [None] * len(queries)
threads = [
    threading.Thread(
        target=search_worker,
        args=(mod, f, a, results, i)
    )
    for i, (f, a) in enumerate(queries)
]
for t in threads:
    t.start()
for t in threads:
    t.join()

deref vs walk

deref(x) follows the top-level binding chain but doesn't recurse into lists or compounds. walk(x) deeply substitutes all bound variables throughout a term. Use walk when you want a fully ground snapshot to store across threads.


What NOT to do

Don't share a Trail between threads

trail = Trail()   # created in main thread

def bad_worker():
    x = Var()
    unify(x, 42, trail)   # RuntimeError!

t = threading.Thread(target=bad_worker)
t.start()
t.join()

This raises RuntimeError: Trail accessed from a different thread than it was created in.

Don't race to bind the same unbound variable

Two threads calling unify(X, a, trail1) and unify(X, b, trail2) on the same unbound X won't crash — the critical section serializes them — but the semantics are unpredictable: one thread's binding wins, the other retries and may fail if a != b.

x = Var()   # shared, unbound

def worker_a():
    trail = Trail()
    unify(x, "hello", trail)   # may succeed

def worker_b():
    trail = Trail()
    unify(x, "world", trail)   # may fail if worker_a bound x first

The fix: give each thread its own copy of the query variables.

Don't assert/retract concurrently (yet)

Phase 2 will add copy-on-write semantics for the clause database. Until then, concurrent assertz/retract from multiple threads is not safe. Concurrent reads are always safe.


Checking the build

You can check at runtime whether you're on a free-threaded build:

import sys

def is_free_threaded():
    return hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled()

if is_free_threaded():
    print("Free-threaded: true parallelism available")
else:
    print("GIL-enabled: threads will serialize")

To verify the C extensions are loaded (not the pure-Python fallbacks):

from clausal.logic.trampoline import StepGenerator
assert StepGenerator.__module__ == '_trampoline', "C extension not loaded"

Performance expectations

On free-threaded Python 3.14:

  • Single-threaded overhead: ~9% slower than the GIL-enabled build (biased reference counting, per-object locking).
  • Independent queries: Near-linear speedup. 4 threads querying the same database get ~3.5x throughput.
  • Shared variable contention: If many threads unify with the same variable, the per-variable critical section becomes a bottleneck. Keep variables per-thread where possible.

The trampoline loop, unification, and constraint propagation all run without the GIL. The C extensions (_variables, _trampoline) are compiled with the appropriate atomic/locking primitives and declare Py_MOD_GIL_NOT_USED.