Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

14 Commits

Repository files navigation

TaskRunner.php

A process pool implementation with minimized forking overhead for high memory usage PHP5 apps, by running in a separate process.

Table of Contents

  1. The Problem
  2. Solution
  3. Basic Usage
  4. Benchmarks
  5. Conclusion

The Problem

In a PHP5 workflow processing system handling around 170,000 tasks, we encountered severe performance bottlenecks despite using a traditional process pool implementation. On an 8-core VM, the entire workflow required approximately 40 minutes to complete, resulting in a throughput of only 70 tasks per second.

After investigation, we identified that the bottleneck was in PHP5's proc_open() function, particularly when used in memory-intensive applications:

  • When using proc_open(), Linux uses fork+exec to create new processes
  • Despite Copy-On-Write (COW) optimization, the system must mark all memory pages as write-protected during fork
  • The larger the parent process memory footprint, the higher the overhead for each process creation
  • Our workflow application consumed nearly 1GB of memory, making each proc_open() call extremely expensive

This overhead effectively negated much of the performance benefit we expected from parallel processing. The traditional process pool approach became increasingly inefficient as memory usage grew.

Note:PHP 8.3 added usage of posix_spawn for proc_open which addresses this problem completely. If you're using PHP 8.3+, you should just use the native process pool implementation. Detailed benchmark results are provided in the Benchmarks section below.

Solution

TaskRunner.php takes the following approach:

  1. Separate Process: TaskRunner runs the process pool in a completely separate PHP process with minimal memory footprint, dramatically reducing fork overhead.

  2. Task Passing Using JSONL: Tasks are serialized to a temporary file in JSONL format, allowing workers to read incrementally and reducing memory usage versus loading all tasks at once.

  3. Streamlined Results Handling: Each completed task outputs results as JSON to stdout, where the parent process captures and decodes them, providing a clean data exchange mechanism.

This architecture minimizes the fork overhead, no matter what the memory level of the parent process. By adopting this solution, the processing time of the aforementioned case was reduced from 40 minutes to approximately 3 minutes, showcasing a dramatic improvement in efficiency.

Basic Usage

// just copy the `src/TaskRunner.php` to your project and require itrequire"TaskRunner.php";
useTaskRunner\TaskRunner;
// initialize TaskRunner with desired concurrency level$taskRunner = newTaskRunner(8); // max concurrency = 8// add tasks to runner, tasks are serialized and written to a temporary JSONL file (non-blocking)for ($i = 0; $i < 100; $i++) {
$taskRunner->add(
"task_{$i}", // task id (will be included in result)"echo {$i}"// shell command to execute
);
}
$sum = 0;
// start execution of all tasks and wait for completion (blocking)// process results with an optional callback function$taskRunner->runAndWait(function ($result, $completed, $total) use (&$sum) {
// each result contains:$id = $result["id"]; // task id you provided$status = $result["status"]; // exit code of the command$stdout = $result["stdout"]; // command output (stdout)$stderr = $result["stderr"]; // error output (stderr)// process the result$sum += (int) $stdout;
// optional: display progressif ($completed % 10 == 0 || $completed == $total) {
echo"Progress: {$completed}/{$total}\n";
}
});
echo"Sum: {$sum}\n";

Caveat: Pipe Buffer Limitations

For maximum performance, TaskRunner only reads process output pipes after process completion, which can cause deadlocks if child processes generate more output than the OS pipe buffer size (typically 64KB on Linux).

To circumvent this, redirect the output to a temporary file and read it in your callback:

$taskRunner = newTaskRunner(4);
// this will work (64KB output)$_1KB_str = str_repeat("a", 1024);
$_64KB_outputCmd = "for i in `seq 64`; do printf '{$_1KB_str}'; done";
$taskRunner->add("ok", $_64KB_outputCmd);
// this will deadlock (64KB + 1 byte output)$taskRunner->add("deadlock", "{$_64KB_outputCmd}; printf 'a'");
// solution: redirect large output to temporary file$tempFile = tempnam(sys_get_temp_dir(), "task_");
$taskRunner->add("large_output@{$tempFile}", "({$_64KB_outputCmd}; printf 'a') > {$tempFile}");
$taskRunner->runAndWait(function ($result) {
// extract file path from id if existlist($id, $file) = explode("@", $result["id"]);
if ($file) {
// read temp file and clean up$output = file_get_contents($file);
unlink($file);
} else {
$output = $result["stdout"];
}
echo"Task: {$id}\n\tOutput size: " . strlen($output) . " bytes\n";
// you will never see the output of "deadlock" task
});
echo"You will never see this message\n"; // because process deadlocked above

Benchmarks

To understand the performance improvements TaskRunner.php brings, a series of benchmarks were conducted using the included test/benchmark.php script. The benchmarks cover different PHP versions and simulated memory usage scenarios.

$ php ./test/benchmark.php -h
Usage: php benchmark.php [options]
-n NUM_TASKS Number of tasks to generate (default: 1000)
-m MEM_USAGE Simulate memory usage in MB (default: 500)
-p POOL_SIZE Max concurrency used by process pool (default: 8)
-s [0|1] Simulate extra task workload by sleep (default: 1)
-h Show this help message and exit

Benchmark Environment

  • CPU: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz (4C/8T)
  • OS: Windows 10 22H2 19045.4170
  • Docker Desktop: 4.40.0 (187762) WSL 2 backend
  • Docker images used:
    • php:5.6-alpine
    • php:7.0-alpine
    • php:8.2-alpine
    • php:8.3-alpine
  • Benchmark configurations: Default values were used unless specifically specified

Benchmark Results

For each PHP version, two types of tests are performed with varying memory sizes (-m${MB}):

  1. Raw Forking Speed: No sleep used as workload (-s0). Each task involves only 1 echo cmd, finishing quickly to focus on raw forking performance.
    forMBin 0 10 50 100 500;do
    php ./test/benchmark.php -s0 -m${MB}| awk '/TPS/{print $2}'| paste -sd ''done
  2. High Concurrency Test: With sleep included by default, each task is less cpu intensive, allowing higher concurrency to be tested (-p16).
    forMBin 0 10 50 100 500;do
    php ./test/benchmark.php -p16 -m${MB}| awk '/TPS/{print $2}'| paste -sd ''done

The output of each benchmark included three different measurements:

  • ForLoopExec: Tasks per second (TPS) for a simple loop using exec().
  • ProcessPool: TPS for a process pool running in the main PHP process.
  • TaskRunner: TPS time for a process pool running in a separate process, which is TaskRunner.

Raw Forking Speed (-s0 -m${MB})

PHP versionMemory (MB)ForloopExecProcessPoolTaskRunner
php:5.6-alpine01618.2723683.7023348.826
101603.0342857.0113329.294
501601.6071672.0873320.506
1001607.5461076.443342.102
5001618.671267.2263340.004
php:7.0-alpine01640.1773840.4813423.284
101641.5483675.1753377.75
501621.6293518.1543340.449
1001642.2173298.8693426.019
5001611.1932450.7343438.413
php:8.2-alpine01643.1123486.2153045.826
101667.023307.8423104.992
501641.7213177.1763094.48
1001659.6472938.8613137.422
5001642.8482284.673092.059
php:8.3-alpine01630.184777.5584132.384
101631.4284690.6554227.579
501646.664767.9024156.068
1001605.6984633.1924172.49
5001609.8634677.5094186.187

High Concurrency Test (-p16 -m${MB})

PHP versionMemory (MB)ForloopExecProcessPoolTaskRunner
php:5.6-alpine0141.8231581.5131495.676
10141.821516.8061505.006
50140.8161503.141507.477
100141.28996.2011511.355
500141.515240.0751497.19
php:7.0-alpine0141.2881574.631502.727
10141.4311567.1281507.591
50141.4021545.5141501.335
100141.3161539.3351503.375
500141.921475.4591506.494
php:8.2-alpine0141.121531.8631479.054
10140.3461523.3411472.798
50140.521511.721465.807
100141.8281501.151471.648
500140.9331459.0951508.225
php:8.3-alpine0142.3161496.7041503.024
10141.2741538.8361512.285
50141.0221537.7041518.287
100141.5371521.0021513.808
500141.5871529.0071509.596

Analysis

The benchmark results reveal these key insights:

  1. PHP 5.6: ProcessPool performance collapses by 93% (~3700 => ~270 TPS) as memory increases to 500MB, while TaskRunner maintains consistent ~3340 TPS regardless of memory usage.

  2. PHP 7.0-8.2: These versions show internal optimizations with less sensitivity to memory usage, though ProcessPool still degrades as memory usage increases.

  3. PHP 8.3: Significant improvements with posix_spawn() for proc_open(). ProcessPool no longer degrades with increased memory and outperforms TaskRunner in both tests.

  4. ForLoopExec Performance: Surprisingly, the sequential exec() approach is unaffected by memory usage but is the least efficient method due to its single-threaded execution.

Conclusion

TaskRunner is best for PHP 5.6 applications with high memory usage. For PHP 7.0-8.2, TaskRunner provides advantages primarily for memory-intensive applications, though the benefits are less pronounced than in PHP 5.6. For PHP 8.3+, the native process pool implementation is recommended for better performance without needing TaskRunner's separate architecture.

About

A process pool implementation with minimized forking overhead for high memory usage PHP5 apps, by running in a separate process.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages