refactor(build): replace pcntl parallel compilation with process pool

- Removed pcntl dependency and related functions from PlatformBase and Windows classes
- Implemented proc_open-based process pool in NativeBuilder for cross-platform parallel compilation
- Updated dispatchParallel to dispatchProcessParallel with new process management logic
- Modified Translator to use process pool instead of pcntl for parallel compilation
- Added fallback to sequential compilation when proc_open is unavailable
- Updated ParallelCompileTest to use process-based tests instead of pcntl mocking
- Bumped version from 0.8.1 to 0.8.2 in project configuration files
master
韩天峰 4 weeks ago
parent a1efda0f7f
commit 1b79fcd2b4
  1. 3
      composer.json
  2. 174
      phpunit/src/ParallelCompileTest.php
  3. 6
      project.yml
  4. 181
      src/Build/NativeBuilder.php
  5. 8
      src/Platform/PlatformBase.php
  6. 5
      src/Platform/Windows.php
  7. 228
      src/Translator.php

@ -41,8 +41,5 @@
"cs-fix": "php-cs-fixer fix $1",
"analyse": "phpstan analyse --memory-limit 1G -c phpstan.neon",
"rector": "rector process --clear-cache"
},
"suggest": {
"ext-pcntl": "Required for process control features."
}
}

@ -5,137 +5,109 @@ namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase;
use TypePhp\Backend\CompilerBackend;
use TypePhp\Build\NativeBuilder;
use TypePhp\CompilerTest;
class ParallelCompileTest extends TestCase
final class ParallelCompileTest extends TestCase
{
public function testWaitRetriesWhenInterrupted(): void
{
$compiler = new ScriptedWaitCompiler([
[-1, 0, PCNTL_EINTR],
[123, 0, 0],
]);
$this->assertSame([123, 0], $compiler->waitForTest());
$this->assertSame(2, $compiler->getWaitCallCount());
}
public function testWaitFailureOtherThanInterruptionThrows(): void
{
$compiler = new ScriptedWaitCompiler([
[-1, 0, PCNTL_ECHILD],
]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Failed to wait for compiler process');
$compiler->waitForTest();
}
private string $directory;
public function testSignaledChildIsNotSuccessful(): void
protected function setUp(): void
{
$compiler = new ScriptedWaitCompiler([]);
$this->assertTrue($compiler->statusSucceeded(0));
$this->assertFalse($compiler->statusSucceeded(1 << 8));
$this->assertFalse($compiler->statusSucceeded(SIGTERM));
$this->directory = sys_get_temp_dir() . '/typephp_process_pool_' . bin2hex(random_bytes(6));
mkdir($this->directory, 0777, true);
}
public function testForkFailureStillReapsRunningCompilerProcesses(): void
protected function tearDown(): void
{
$compiler = new ScriptedWaitCompiler(
[[101, 0, 0]],
[101, -1]
);
try {
$compiler->compileInParallelForTest(['first.cc', 'second.cc', 'third.cc'], 2);
$this->fail('The fork failure should fail the parallel compilation');
} catch (\Exception $e) {
$this->assertStringContainsString('second.cc', $e->getMessage());
$this->assertStringContainsString('third.cc', $e->getMessage());
foreach (glob($this->directory . '/*') ?: [] as $file) {
@unlink($file);
}
$this->assertSame(1, $compiler->getWaitCallCount());
@rmdir($this->directory);
}
public function testParallelDispatcherReportsEachCompletedTask(): void
public function testProcessPoolRunsCompilerTasksConcurrently(): void
{
$builder = new NativeBuilder($this->createMock(CompilerBackend::class));
$forkResults = [101, 102];
$waitResults = [[102, 0], [101, 1 << 8]];
$firstMarker = $this->directory . '/first.ready';
$secondMarker = $this->directory . '/second.ready';
$firstObject = $this->directory . '/first.o';
$secondObject = $this->directory . '/second.o';
$completed = [];
$result = $builder->dispatchParallel(
['first.cc', 'second.cc'],
$builder = new NativeBuilder($this->createMock(CompilerBackend::class));
$result = $builder->dispatchProcessParallel(
[
$this->task('first.cc', $firstObject, $this->barrierCommand($firstMarker, $secondMarker, $firstObject)),
$this->task('second.cc', $secondObject, $this->barrierCommand($secondMarker, $firstMarker, $secondObject)),
],
2,
static fn(string $source): string => $source . '.o',
static function (): void {},
static function () use (&$forkResults): int {
return array_shift($forkResults);
},
static function () use (&$waitResults): array {
return array_shift($waitResults);
},
static fn(int $status): bool => $status === 0,
static function (string $source, string $object, int $status, bool $success, int $count) use (&$completed): void {
$completed[] = [$source, $object, $status, $success, $count];
static function (
string $source,
string $object,
string $command,
array $output,
int $status,
bool $success,
int $count,
) use (&$completed): void {
$completed[$source] = [$object, $command, $output, $status, $success, $count];
},
);
$this->assertSame(['second.cc.o'], $result['objects']);
$this->assertSame(['first.cc'], $result['failures']);
$this->assertSame([
['second.cc', 'second.cc.o', 0, true, 1],
['first.cc', 'first.cc.o', 1 << 8, false, 2],
], $completed);
self::assertSame([], $result['failures'], var_export($completed, true));
self::assertEqualsCanonicalizing([$firstObject, $secondObject], $result['objects']);
self::assertCount(2, $completed);
self::assertTrue($completed['first.cc'][4]);
self::assertTrue($completed['second.cc'][4]);
}
}
class ScriptedWaitCompiler extends CompilerTest
{
private int $waitCallCount = 0;
private int $lastWaitError = 0;
public function __construct(private array $waitResults, private array $forkResults = [])
{
parent::__construct(TYPEPHP_ROOT_PATH);
$this->noProgress = true;
}
protected function pcntlFork(): int
public function testProcessPoolCapturesCompilerFailureOutputAndStatus(): void
{
return array_shift($this->forkResults);
}
$object = $this->directory . '/failed.o';
$completion = null;
$command = $this->phpCommand("fwrite(STDERR, 'compiler-error'); exit(7);");
protected function pcntlWait(?int &$status): int
{
$this->waitCallCount++;
[$pid, $status, $this->lastWaitError] = array_shift($this->waitResults);
return $pid;
}
protected function pcntlLastError(): int
{
return $this->lastWaitError;
}
$builder = new NativeBuilder($this->createMock(CompilerBackend::class));
$result = $builder->dispatchProcessParallel(
[$this->task('failed.cc', $object, $command)],
4,
static function (
string $source,
string $object,
string $command,
array $output,
int $status,
bool $success,
int $count,
) use (&$completion): void {
$completion = compact('source', 'object', 'command', 'output', 'status', 'success', 'count');
},
);
public function waitForTest(): array
{
return $this->waitForCompileChild();
self::assertSame([], $result['objects']);
self::assertSame(['failed.cc'], $result['failures']);
self::assertSame(7, $completion['status']);
self::assertFalse($completion['success']);
self::assertStringContainsString('compiler-error', implode("\n", $completion['output']));
}
public function statusSucceeded(int $status): bool
/** @return array{source: string, object: string, command: string} */
private function task(string $source, string $object, string $command): array
{
return $this->compileChildSucceeded($status);
return compact('source', 'object', 'command');
}
public function getWaitCallCount(): int
private function barrierCommand(string $ownMarker, string $otherMarker, string $object): string
{
return $this->waitCallCount;
$code = 'file_put_contents(' . var_export($ownMarker, true) . ", 'ready');"
. '$deadline = microtime(true) + 5;'
. 'while (file_exists(' . var_export($otherMarker, true) . ') === false && microtime(true) < $deadline) {'
. ' usleep(10000); }'
. 'if (file_exists(' . var_export($otherMarker, true) . ') === false) { exit(9); }'
. 'file_put_contents(' . var_export($object, true) . ", 'object');";
return $this->phpCommand($code);
}
public function compileInParallelForTest(array $sourceFiles, int $jobs): array
private function phpCommand(string $code): string
{
return $this->compileWithPcntl($sourceFiles, $jobs);
return escapeshellarg(PHP_BINARY) . ' -n -r ' . escapeshellarg($code);
}
}

@ -1,6 +1,6 @@
name: tpc
build-mode: bin
version: 0.8.1
version: 0.8.2
cxx-std: c++17
cxx-flags:
- -Wall
@ -12,8 +12,8 @@ resource:
icon: swoole-logo.ico
# 版本信息
version-info:
file-version: 0.8.1.1112
product-version: 0.8.1
file-version: 0.8.2.1112
product-version: 0.8.2
company-name: "上海识沃网络科技有限公司"
file-description: "TypePHP Compiler"
internal-name: "typephp"

@ -58,86 +58,141 @@ final readonly class NativeBuilder
}
/**
* @param Closure(string): string $objectFile
* @param Closure(string, string): void $worker
* @param Closure(): int $fork
* @param Closure(): array{int, int} $wait
* @param Closure(int): bool $succeeded
* @param null|Closure(string, string, int, bool, int): void $completed
* Run compiler commands concurrently through proc_open(). Keeping the
* process pool independent of pcntl makes parallel builds available in a
* stock PHP installation on Linux, macOS, and Windows.
*
* Output is redirected to one temporary file per process. This avoids the
* pipe-buffer deadlocks that can otherwise occur when a compiler emits a
* large diagnostic while the parent is waiting for another process.
*
* @param list<array{source: string, object: string, command: string}> $tasks
* @param null|Closure(string, string, string, list<string>, int, bool, int): void $completed
* @return array{objects: list<string>, failures: list<string>}
*/
public function dispatchParallel(
array $sources,
public function dispatchProcessParallel(
array $tasks,
int $jobs,
Closure $objectFile,
Closure $worker,
Closure $fork,
Closure $wait,
Closure $succeeded,
?Closure $completed = null,
): array {
// Keep most workers on the largest translation units to reduce the
// parallel tail, but reserve one fast lane for small files so progress
// remains visible while the expensive units are still compiling.
$queue = SourceCompileQueue::largestFirst($sources);
$jobs = max(1, $jobs);
$queue = $tasks;
$running = [];
$objects = [];
$failures = [];
$completedCount = 0;
$largeTaskCount = 0;
$largeLaneLimit = max(1, $jobs - 1);
while ($queue !== [] || $running !== []) {
while (count($running) < $jobs && $queue !== []) {
if ($largeTaskCount < $largeLaneLimit) {
$source = array_shift($queue);
$lane = 'large';
} else {
$source = array_pop($queue);
$lane = 'small';
$nullDevice = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null';
try {
while ($queue !== [] || $running !== []) {
while (count($running) < $jobs && $queue !== []) {
$task = array_shift($queue);
$logFile = tempnam(sys_get_temp_dir(), 'typephp-compile-');
if ($logFile === false) {
$failures[] = $task['source'];
$completedCount++;
$completed?->__invoke(
$task['source'],
$task['object'],
$task['command'],
['Unable to create compiler output file'],
1,
false,
$completedCount,
);
continue;
}
$process = @proc_open(
$task['command'],
[
0 => ['file', $nullDevice, 'r'],
1 => ['file', $logFile, 'a'],
2 => ['file', $logFile, 'a'],
],
$pipes,
);
if (!is_resource($process)) {
@unlink($logFile);
$failures[] = $task['source'];
$completedCount++;
$completed?->__invoke(
$task['source'],
$task['object'],
$task['command'],
['Unable to start compiler process'],
1,
false,
$completedCount,
);
continue;
}
$running[] = [
'task' => $task,
'process' => $process,
'log' => $logFile,
];
}
$object = $objectFile($source);
$pid = $fork();
if ($pid === -1) {
$failures[] = $source;
array_push($failures, ...$queue);
$queue = [];
break;
if ($running === []) {
continue;
}
if ($pid === 0) {
try {
$worker($source, $object);
exit(is_file($object) ? 0 : 1);
} catch (\Throwable) {
exit(1);
$finished = false;
foreach ($running as $index => $entry) {
$status = proc_get_status($entry['process']);
if ($status['running']) {
continue;
}
$finished = true;
$exitCode = (int) $status['exitcode'];
$closeCode = proc_close($entry['process']);
if ($exitCode < 0 && $closeCode >= 0) {
$exitCode = $closeCode;
}
$contents = file_get_contents($entry['log']);
@unlink($entry['log']);
$output = $contents === false || $contents === ''
? []
: (preg_split('/\R/', rtrim($contents)) ?: []);
$task = $entry['task'];
$success = $exitCode === 0 && is_file($task['object']);
if ($success) {
$objects[] = $task['object'];
} else {
$failures[] = $task['source'];
}
$completedCount++;
unset($running[$index]);
$completed?->__invoke(
$task['source'],
$task['object'],
$task['command'],
$output,
$exitCode,
$success,
$completedCount,
);
}
$running[$pid] = ['source' => $source, 'object' => $object, 'lane' => $lane];
if ($lane === 'large') {
$largeTaskCount++;
$running = array_values($running);
if (!$finished) {
usleep(10_000);
}
}
if ($running === []) {
break;
}
[$pid, $status] = $wait();
$task = $running[$pid] ?? null;
unset($running[$pid]);
if ($task === null) {
continue;
}
if ($task['lane'] === 'large') {
$largeTaskCount--;
}
$success = $succeeded($status);
if ($success) {
$objects[] = $task['object'];
} else {
$failures[] = $task['source'];
} finally {
foreach ($running as $entry) {
if (is_resource($entry['process'])) {
proc_terminate($entry['process']);
proc_close($entry['process']);
}
@unlink($entry['log']);
}
$completedCount++;
$completed?->__invoke($task['source'], $task['object'], $status, $success, $completedCount);
}
return ['objects' => $objects, 'failures' => $failures];
}

@ -140,14 +140,6 @@ abstract class PlatformBase
return $warnings;
}
/**
* Whether this platform is suitable for parallel compilation using pcntl_fork.
*/
public function supportsPcntlParallelCompile(): bool
{
return true;
}
public function getIntegerLiteralSuffix(): string
{
return 'LL';

@ -226,11 +226,6 @@ class Windows extends PlatformBase
return $warnings;
}
public function supportsPcntlParallelCompile(): bool
{
return false;
}
/**
* Get the debug options.
*/

@ -24,6 +24,7 @@ use TypePhp\Build\NativeDependencyAuditor;
use TypePhp\Build\NanoSourceComposer;
use TypePhp\Build\PrecompiledHeaderManager;
use TypePhp\Build\SourcePipelineTrait;
use TypePhp\Build\SourceCompileQueue;
use TypePhp\Build\WasmInterfaceGenerator;
use TypePhp\Config\ProjectYamlLoader;
use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic;
@ -82,7 +83,7 @@ class Translator extends Preprocessor
use ResourceCompilationTrait;
use ClassConstantValueTrait;
public const string VERSION = '0.8.1';
public const string VERSION = '0.8.2';
public const string APP_NAME = 'TypePHP Compiler (AOT)';
protected bool $hasExplicitOutput = false;
@ -1903,6 +1904,46 @@ CODE;
public function compileFile(string $cppFile, string $objectFile, bool $parallel = false): void
{
$task = $this->prepareCompileFileTask($cppFile, $objectFile, $parallel);
if ($task === null) {
return;
}
$result = $this->getNativeBuilder()->compile(
$cppFile,
$objectFile,
$task['options'],
$task['language'],
$parallel,
);
if (!$parallel) {
$this->climate->comment($result['command']);
}
if ($result['status'] !== 0) {
if ($parallel && !empty($result['output'])) {
foreach ($result['output'] as $line) {
$this->climate->red($line);
}
}
$this->error('compile failed: ' . $cppFile);
}
$this->finalizeCompileFileTask($cppFile, $objectFile, $task);
}
/**
* @return null|array{
* language: ?string,
* options: CompileOptions,
* cacheable_misc: bool,
* nano_runtime: bool
* }
*/
private function prepareCompileFileTask(
string $cppFile,
string $objectFile,
bool $parallel,
): ?array {
$isCacheableMiscFile = $this->isPhpxMiscFile($cppFile)
&& !$this->isProjectRuntimeEntryFile($cppFile);
$isNanoRuntimeSource = isset($this->nanoRuntimeSources[$cppFile]);
@ -1910,10 +1951,10 @@ CODE;
if (!$parallel) {
$this->climate->darkGray('[cache] skip: ' . $cppFile);
}
return;
return null;
}
if ($isNanoRuntimeSource && $this->hasNanoObjectFileCache($cppFile, $objectFile)) {
return;
return null;
}
if ($isCacheableMiscFile) {
@ -1921,24 +1962,21 @@ CODE;
}
$language = $this->getLanguageFromExtension($cppFile);
$options = $this->getSourceCompileCommandOptions($cppFile, $language);
$result = $this->getNativeBuilder()->compile($cppFile, $objectFile, $options, $language, $parallel);
if (!$parallel) {
$this->climate->comment($result['command']);
}
if ($result['status'] !== 0) {
if ($parallel && !empty($result['output'])) {
foreach ($result['output'] as $line) {
$this->climate->red($line);
}
}
$this->error('compile failed: ' . $cppFile);
}
return [
'language' => $language,
'options' => $this->getSourceCompileCommandOptions($cppFile, $language),
'cacheable_misc' => $isCacheableMiscFile,
'nano_runtime' => $isNanoRuntimeSource,
];
}
if ($isCacheableMiscFile) {
/** @param array{cacheable_misc: bool, nano_runtime: bool} $task */
private function finalizeCompileFileTask(string $cppFile, string $objectFile, array $task): void
{
if ($task['cacheable_misc']) {
$this->writeMiscObjectCacheMetadata($cppFile, $objectFile);
}
if ($isNanoRuntimeSource) {
if ($task['nano_runtime']) {
$this->writeMiscObjectCacheMetadata($cppFile, $objectFile);
}
}
@ -2015,12 +2053,18 @@ CODE;
// Windows: compile the resource file (icon, version info, etc.)
$this->compileResourceFile();
if (!$this->getPlatform()->supportsPcntlParallelCompile() or $job <= 1) {
if ($job <= 1) {
return $this->compileSourceFile($sourceFiles);
}
// Unix/Linux/macOS compile in parallel using pcntl
return $this->compileWithPcntl($sourceFiles, $job);
if (function_exists('proc_open') && function_exists('proc_get_status')) {
return $this->compileWithProcessPool($sourceFiles, $job);
}
$this->climate->warning(
'proc_open/proc_get_status unavailable, using sequential compilation',
);
return $this->compileSourceFile($sourceFiles);
}
/** @param list<string> $generatedSources @return list<string> */
@ -2151,63 +2195,8 @@ CODE;
return $objectFiles;
}
/**
* Parallel compilation on Unix/Linux/macOS (using pcntl).
*/
protected function pcntlWait(?int &$status): int
{
return pcntl_wait($status);
}
protected function pcntlFork(): int
{
return pcntl_fork();
}
protected function pcntlLastError(): int
{
return pcntl_get_last_error();
}
protected function waitForCompileChild(): array
protected function compileWithProcessPool(array $sourceFiles, int $job): array
{
do {
$status = null;
$pid = $this->pcntlWait($status);
$error = $pid === -1 ? $this->pcntlLastError() : 0;
} while ($pid === -1 && defined('PCNTL_EINTR') && $error === PCNTL_EINTR);
if ($pid === -1) {
$message = function_exists('pcntl_strerror') ? pcntl_strerror($error) : 'error ' . $error;
throw new \RuntimeException('Failed to wait for compiler process: ' . $message);
}
return [$pid, (int) $status];
}
protected function compileChildSucceeded(int $status): bool
{
return pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0;
}
protected function getCompileChildFailureReason(int $status): string
{
if (pcntl_wifsignaled($status)) {
return 'terminated by signal ' . pcntl_wtermsig($status);
}
if (pcntl_wifexited($status)) {
return 'exited with status ' . pcntl_wexitstatus($status);
}
return 'terminated abnormally';
}
protected function compileWithPcntl(array $sourceFiles, int $job): array
{
if (!function_exists('pcntl_fork')) {
$this->climate->warning('pcntl extension not available, using sequential compilation');
return $this->compileSourceFile($sourceFiles);
}
$totalFiles = count($sourceFiles);
$this->climate->lightBlue("Starting parallel compilation with {$job} jobs for {$totalFiles} files");
$progress = null;
@ -2218,29 +2207,78 @@ CODE;
->labelStyle([AnsiTerminal::FG_CYAN]);
$progress->renderInPlace(0, $totalFiles, 'Compiling');
}
$result = $this->getNativeBuilder()->dispatchParallel(
$sourceFiles,
$tasks = [];
$cachedObjects = [];
$taskMetadata = [];
$completedBeforeDispatch = 0;
foreach (SourceCompileQueue::largestFirst($sourceFiles) as $source) {
$object = $this->getObjectFile($source);
$task = $this->prepareCompileFileTask($source, $object, true);
if ($task === null) {
$cachedObjects[] = $object;
$completedBeforeDispatch++;
if ($this->noProgress) {
$percent = $completedBeforeDispatch >= $totalFiles
? 100
: min(99, (int) ceil($completedBeforeDispatch / $totalFiles * 100));
$shortSource = $this->removeCommonPrefix($this->buildDir, $source);
$this->climate->white(
"[{$completedBeforeDispatch}/{$totalFiles}] {$percent}% {$shortSource} [cache]",
);
}
continue;
}
$tasks[] = [
'source' => $source,
'object' => $object,
'command' => $this->getNativeBuilder()->compileCommand(
$source,
$object,
$task['options'],
$task['language'],
),
];
$taskMetadata[$object] = $task;
}
if ($completedBeforeDispatch > 0 && !$this->noProgress) {
$progress->renderInPlace($completedBeforeDispatch, $totalFiles, 'Compiling');
}
$result = $this->getNativeBuilder()->dispatchProcessParallel(
$tasks,
$job,
fn(string $source): string => $this->getObjectFile($source),
function (string $source, string $object): void {
$this->compileFile($source, $object, true);
},
fn(): int => $this->pcntlFork(),
fn(): array => $this->waitForCompileChild(),
fn(int $status): bool => $this->compileChildSucceeded($status),
function (string $source, string $object, int $status, bool $success, int $completed) use ($progress, $totalFiles): void {
if (!$success) {
echo PHP_EOL;
$this->climate->red("Compilation failed: {$source} ({$this->getCompileChildFailureReason($status)})");
function (
string $source,
string $object,
string $command,
array $output,
int $status,
bool $success,
int $completed,
) use ($progress, $totalFiles, $completedBeforeDispatch, $taskMetadata): void {
$absoluteCompleted = $completedBeforeDispatch + $completed;
if ($success) {
$this->finalizeCompileFileTask($source, $object, $taskMetadata[$object]);
} else {
if (!$this->noProgress) {
echo PHP_EOL;
}
foreach ($output as $line) {
$this->climate->red($line);
}
$this->climate->red("Compilation failed: {$source} (exit status {$status})");
$this->climate->comment($command);
}
if ($this->noProgress) {
$percent = $completed >= $totalFiles
$percent = $absoluteCompleted >= $totalFiles
? 100
: min(99, (int) ceil($completed / $totalFiles * 100));
: min(99, (int) ceil($absoluteCompleted / $totalFiles * 100));
$shortSource = $this->removeCommonPrefix($this->buildDir, $source);
$this->climate->white("[{$completed}/{$totalFiles}] {$percent}% {$shortSource}");
$this->climate->white("[{$absoluteCompleted}/{$totalFiles}] {$percent}% {$shortSource}");
} else {
$progress->renderInPlace($completed, $totalFiles, 'Compiling');
$progress->renderInPlace($absoluteCompleted, $totalFiles, 'Compiling');
}
},
);
@ -2253,7 +2291,7 @@ CODE;
throw new \Exception('Compilation failed for: ' . implode(', ', $result['failures']));
}
$this->climate->green("Successfully compiled {$totalFiles} files");
return $result['objects'];
return [...$cachedObjects, ...$result['objects']];
}
public function output(string $message, string $style = 'out'): void

Loading…
Cancel
Save