Real Multithreading in PHP with the parallel Extension
AI generated
<?php
8.4
PHP · parallel Extension · Threads · ZTS
Real Multithreading in PHP
with the parallel extension

The parallel extension brings real multithreading to PHP: Runtime, Future and Channel create isolated operating system threads instead of cooperative concurrency. Anyone who understands the strict data isolation between threads and knows the limits of closures and autoloading can noticeably speed up CPU intensive tasks without switching to external extensions like Swoole.

16 min read Runtime · Future · Channel · ZTS PHP 8.4 ZTS · ext-parallel

1. What the parallel extension does differently from pcntl

The parallel extension, developed by Joe Watkins, is the only widespread way to start real POSIX threads directly from PHP. Unlike pcntl_fork, which duplicates entire operating system processes with their own memory, the parallel extension creates threads inside the same process that share the same virtual memory space. This significantly lowers the startup overhead but in return demands much stricter separation of data between threads to prevent race conditions.

The central design decision of the parallel extension is: there is no implicit shared state between threads. Every thread gets its own isolated interpreter state, its own objects and its own variables, even if the code accesses the same physical memory. This decision differs fundamentally from classic multithreading in languages like Java or C, where threads share the same heap by default and developers must synchronize explicitly. PHP takes the opposite path: isolation is the default, shared data must be exchanged explicitly via Channel.

For many CPU intensive tasks, this isolation is not a drawback but a safety gain. Image processing, hashing, complex mathematical calculations, or parsing large files can be split into independent, concurrently running units without two threads ever being able to modify the same variable simultaneously. The parallel extension makes this kind of parallelization practical in PHP, where it was previously only possible via pcntl_fork with considerably higher startup overhead.

2. Prerequisite: compiling PHP in ZTS mode

The parallel extension only works with a PHP installation compiled in thread safe mode, called ZTS for short. Most standard PHP packages from distribution repositories are non-ZTS builds, optimized for classic PHP FPM operation without threads. Before using the parallel extension, either a ZTS build must be installed or PHP itself must be recompiled with the --enable-zts flag.

Thread safe mode inserts additional synchronization mechanisms in many internal places so internal Zend engine structures cannot be modified inconsistently by multiple threads at once. This costs minimal performance compared to non-ZTS builds under purely sequential execution, but is the mandatory prerequisite for using the parallel extension safely at all. Anyone already using Swoole or other thread based extensions typically already runs a ZTS build.

In Docker based setups, a ZTS build can be obtained via official PHP images tagged zts, for example php:8.4-zts-cli. For local development it is recommended to use the same ZTS variant as in production, to rule out differences in parallel extension behavior between development and production environments from the outset.


# Verify whether the installed PHP is ZTS-enabled
php -i | grep "Thread Safety"
# Thread Safety => enabled   <- required for ext-parallel

# Install ext-parallel via PECL against a ZTS build
pecl install parallel

# Enable the extension explicitly in php.ini
echo "extension=parallel.so" >> /usr/local/etc/php/conf.d/parallel.ini

# Docker: use an official ZTS image as base
# FROM php:8.4-zts-cli

3. Runtime and Future: starting a thread and collecting results

The core class of the parallel extension is parallel\Runtime. A Runtime object represents its own PHP interpreter running in a separate thread. The constructor optionally accepts a bootstrap path, a file that gets loaded in every new thread before the actual work, typically the Composer autoloader. The run method takes a closure and executes it in the associated thread, immediately returning a Future object without waiting for the result.

The Future object represents the still pending result of the thread execution. The value method blocks the calling thread until the target thread has finished, then returns the closure's return value. This pattern allows starting multiple Runtime instances in parallel without waiting for each one immediately, and collecting all Future objects only at the end, quite similar to the promise pattern from asynchronous JavaScript, only with real parallelism instead of cooperative multitasking.

Important for the parallel extension: every Runtime thread stays alive until explicitly ended with close or until the object goes out of scope. For short lived, one-off calculations, automatic cleanup is sufficient; for worker pools with recurring work, Runtime instances should be reused and called multiple times via run, so the overhead of thread startup is not paid again for every single task.


<?php

declare(strict_types=1);

use parallel\Runtime;
use parallel\Future;

/** @var Future[] $futures */
$futures = [];

for ($i = 1; $i <= 4; $i++) {
    // Each Runtime spawns a real OS thread with its own interpreter state
    $runtime = new Runtime();

    $futures[] = $runtime->run(function (int $chunkId): string {
        // CPU-bound work: hashing a large data chunk
        $data = str_repeat((string) $chunkId, 500_000);
        return hash('sha256', $data);
    }, [$i]);
}

foreach ($futures as $index => $future) {
    // value() blocks until this specific thread has finished
    $hash = $future->value();
    echo "Chunk {$index}: {$hash}\n";
}

4. Data isolation: why closures cannot simply capture variables

A detail that surprises PHP developers on their first contact with the parallel extension: closures passed to run must not capture variables from the surrounding scope via use if those contain objects, resources or closures themselves. The reason lies in the strict isolation between threads: an object created in the main thread cannot simply exist in the memory of another thread, because both threads own independent copies of the Zend engine's internal structures.

Instead, every argument a closure needs is passed explicitly as a second array argument to run. The parallel extension serializes these arguments, copies them into the target thread and passes them there as parameters to the closure. Only simple data types like strings, integers, floats, booleans and arrays of them can be reliably transferred this way; complex objects with internal resources such as database connections or file handles do not work.

This restriction is not a bug but a deliberate safety measure of the parallel extension. It prevents two threads from accidentally using the same database socket or the same file resource simultaneously and thereby producing unpredictable errors. Every thread must reopen its own resources such as database connections inside its own closure, quite similar to pcntl_fork, where connections should likewise only be opened after the fork.

5. Channel: exchanging messages safely between threads

For cases where threads need to exchange more than a single one-off result, the parallel extension offers the parallel\Channel class. A channel works like a thread safe queue: one thread sends values with send, another receives them with recv, both operations blocking until a communication partner is ready, unless the channel was buffered. Buffered channels, created with Channel::make($name, $capacity), allow a limited number of pending messages without the sender blocking immediately.

Channels in the parallel extension are excellent for producer consumer patterns: a main thread continuously produces tasks and sends them via a channel to several worker threads, which in turn send results back via a second channel. This pattern scales considerably better than the pure Future based model when the number of tasks to process is not known in advance or new tasks keep arriving continuously.

A channel must be explicitly closed with close once no further messages are being sent. Receiving threads waiting on recv then get a parallel\Channel\Error\Closed exception, which serves as a signal that processing has ended. Without this explicit closing, waiting threads block indefinitely, a common reason for seemingly hanging scripts during first contact with the parallel extension.


<?php

declare(strict_types=1);

use parallel\Runtime;
use parallel\Channel;

$tasks = Channel::make('tasks', 10);
$results = Channel::make('results', 10);

// Start three worker threads that consume tasks and produce results
$workers = [];
for ($i = 0; $i < 3; $i++) {
    $runtime = new Runtime();
    $workers[] = $runtime->run(function (Channel $tasks, Channel $results): void {
        while (true) {
            try {
                $task = $tasks->recv();
            } catch (\parallel\Channel\Error\Closed) {
                break; // Producer finished, no more tasks
            }

            $results->send(hash('sha256', (string) $task));
        }
    }, [$tasks, $results]);
}

// Producer: send work items, then close the channel
foreach (range(1, 9) as $item) {
    $tasks->send($item);
}
$tasks->close();

// Collect the expected number of results
for ($i = 0; $i < 9; $i++) {
    echo $results->recv() . "\n";
}
$results->close();

6. Autoloading and shared classes in every thread

Every Runtime thread in the parallel extension starts with an empty interpreter state where no application classes are known yet. Without a bootstrap path in the constructor, the thread knows neither Composer autoloading nor any custom class definitions, causing every call to a non-builtin function or class to end in an error. The constructor new Runtime(__DIR__ . '/vendor/autoload.php') loads the Composer autoloader in every new thread before the actual closure runs.

This necessity brings a measurable overhead per thread: the autoloader bootstrap costs time that can matter for very short lived tasks. For the parallel extension, the rule of thumb is therefore to reuse threads rather than creating a new Runtime instance for every single small task. A thread pool with a few long lived threads that receive many tasks sequentially via run or channels amortizes this bootstrap cost over the entire runtime.

Classes used by a closure inside the parallel extension must either be loadable via the autoloader or already included in the bootstrap process before the thread starts. Anonymous classes and dynamically defined closures with complex dependencies are a common stumbling block, because serializing closure bytecode between threads follows stricter rules than normal PHP closure behavior within the same process.

7. Typical use cases: when real threads pay off

The parallel extension shows its advantage with purely CPU bound work that can be split into independent subtasks: generating large numbers of image thumbnails, parsing and transforming large CSV or JSON files, cryptographic hashing for password migrations, or complex mathematical simulations whose partial results are merged at the end. In all these cases processing time scales nearly linearly with the number of available CPU cores.

For I/O bound tasks such as HTTP requests or database queries, however, the parallel extension offers no advantage over simpler alternatives such as Fibers or ReactPHP. A thread waiting for a network response only blocks itself and not the other threads, but the cost of thread creation and strict data isolation clearly outweighs the benefit compared to a cooperative event loop, which achieves the same effect with considerably less resource consumption.

A sensible combination in practice: ReactPHP for the I/O bound network layer of an application, combined with the parallel extension for occasional, clearly bounded CPU intensive subtasks that get offloaded from the event loop to a thread pool so they do not block the event loop itself. This hybrid architecture uses every tool for the task it was actually designed for.

8. Choosing error handling and thread pool size correctly

If a closure inside a Runtime thread of the parallel extension throws an exception, it gets rethrown when value is called on the Future object in the calling thread, complete with the full stack trace of the original error. This makes debugging considerably easier compared to pcntl_fork, where errors in child processes only become visible via the exit code unless explicitly logged.

Choosing the thread pool size for the parallel extension follows the same rule of thumb as with pcntl_fork: for CPU bound work, base it on the number of physical cores, determinable via nproc on Linux systems. More threads than cores bring no additional throughput, because the operating system can only actually run as many threads simultaneously as there are physical cores anyway; extra threads only create more context switching overhead.

A robust pattern: create a fixed thread pool at application startup, distribute tasks via a buffered channel, and replace crashed or faulty threads with new Runtime instances as needed. For applications with heavily fluctuating load, pool size can also be adjusted dynamically, though the parallel extension itself provides no built in autoscaling logic; this must be implemented at the application level.

9. parallel compared to pcntl and Fibers

The parallel extension is one of several tools for concurrency in PHP, with a clear focus on CPU bound parallelism at lower memory overhead than full processes.

Criterion pcntl_fork parallel extension Fibers
Isolation unit Full process Thread in the same process No separate thread
Startup cost High, entire address space Medium, autoloader bootstrap Very low
True CPU parallelism Yes Yes No
Requirement Unix operating system ZTS build required Everywhere since PHP 8.1
Data exchange Explicit IPC needed Channel built in Direct variables

The parallel extension sits between the two other options in this comparison: faster to start than pcntl_fork, but with stricter rules for data exchange than cooperative Fibers. For projects already running a ZTS build that frequently need to parallelize small, clearly bounded CPU tasks, it is often the most pragmatic choice among the three.

Mironsoft

CPU intensive PHP processing and ZTS deployments

Compute heavy PHP jobs that truly use every core?

We bring the parallel extension into your ZTS environment, design thread pools with Runtime, Future and Channel, and ensure clean data isolation without race conditions.

ZTS setup

Configuring Docker images and deployment for thread safe PHP

Thread pool architecture

Runtime reuse, Channel communication and error handling

Performance analysis

Measuring CPU utilization and setting thread pool size based on evidence

10. Summary

The parallel extension brings real multithreading to PHP, with Runtime and Future as the basic building blocks for running closures in separate threads and Channel for safe message exchange between them. The requirement of a ZTS build and the strict data isolation between threads are not limitations but deliberate design decisions that prevent race conditions from the outset instead of avoiding them after the fact through manual synchronization.

The biggest gain shows up with purely CPU bound work that can be split into independent subtasks, while I/O bound tasks are better solved with Fibers or ReactPHP. Anyone who applies the parallel extension deliberately for image processing, hashing or large file transformations, and reuses threads sensibly instead of restarting them for every tiny task, achieves noticeable speed gains that would be unreachable with purely sequential PHP code.

Real Multithreading with the parallel Extension — The Essentials at a Glance

Requirement

Only usable with a ZTS build of PHP. Check php -i | grep "Thread Safety" before use.

Runtime & Future

new Runtime(autoload) starts a thread, run() returns a Future, value() blocks until the result.

Data isolation

No shared objects via use. Pass arguments explicitly, reopen resources inside the thread.

Channel

Thread safe queue for producer consumer patterns. Always close explicitly via close().

11. FAQ: Real Multithreading in PHP with the parallel Extension

1What is the parallel extension?
An extension enabling real POSIX threads in a single process, via Runtime, Future and Channel, unlike the full processes of pcntl_fork.
2Why is a ZTS build needed?
Thread safe mode synchronizes internal Zend engine structures that multiple threads could otherwise leave inconsistent.
3Can closures capture variables?
Only simple types like strings, numbers and arrays, passed as an array argument to run(). Objects and resources do not work.
4What is the bootstrap path for?
Every thread starts empty. The bootstrap path, usually the Composer autoloader, makes application classes available there.
5What is a Channel?
A thread safe queue for producer consumer patterns using send() and recv() between threads.
6What happens when closing a Channel?
Waiting recv() calls throw a Closed exception as a signal that processing has ended. Without close(), threads block indefinitely.
7How many threads simultaneously?
Base it on the number of physical cores. More threads only increase context switching overhead without throughput gains.
8Suitable for HTTP requests?
Not ideal. Fibers or ReactPHP achieve the same concurrency for I/O tasks with less resource consumption.
9How do errors become visible?
An exception is rethrown at value() on the Future object, with the full stack trace of the original error.
10Difference from pcntl_fork?
pcntl_fork duplicates whole processes, the parallel extension creates threads in the same process with stricter isolation via Channel instead of free-form IPC.