├── .github ├── FUNDING.yml └── workflows │ ├── php-cs-fixer.yml │ └── run-tests.yml ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADING.md ├── composer.json ├── docs └── benchmarks.png └── src ├── FileTask.php ├── Output ├── ParallelError.php ├── ParallelException.php └── SerializableException.php ├── Pool.php ├── PoolStatus.php ├── Process ├── ParallelProcess.php ├── ProcessCallbacks.php ├── Runnable.php └── SynchronousProcess.php ├── Runtime ├── ChildRuntime.php └── ParentRuntime.php ├── Task.php └── helpers.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: spatie 2 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | 18 | - name: Commit changes 19 | uses: stefanzweifel/git-auto-commit-action@v4 20 | with: 21 | commit_message: Fix styling 22 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.3, 8.4] 13 | stability: [prefer-lowest, prefer-stable] 14 | 15 | name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 26 | coverage: none 27 | 28 | - name: Setup problem matchers 29 | run: | 30 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 31 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 32 | - name: Install dependencies 33 | run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction 34 | 35 | - name: Execute tests 36 | run: vendor/bin/phpunit 37 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->notName('ClassWithSyntaxError.php') 11 | ->ignoreDotFiles(true) 12 | ->ignoreVCS(true); 13 | 14 | return (new PhpCsFixer\Config()) 15 | ->setRiskyAllowed(true) 16 | ->setRules([ 17 | '@PSR12' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 20 | 'no_unused_imports' => true, 21 | 'not_operator_with_successor_space' => true, 22 | 'trailing_comma_in_multiline' => true, 23 | 'phpdoc_scalar' => true, 24 | 'unary_operator_spaces' => true, 25 | 'binary_operator_spaces' => true, 26 | 'blank_line_before_statement' => [ 27 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 28 | ], 29 | 'phpdoc_single_line_var_spacing' => true, 30 | 'phpdoc_var_without_name' => true, 31 | 'class_attributes_separation' => [ 32 | 'elements' => [ 33 | 'method' => 'one', 34 | ], 35 | ], 36 | 'method_argument_space' => [ 37 | 'on_multiline' => 'ensure_fully_multiline', 38 | 'keep_multiple_spaces_after_comma' => true, 39 | ], 40 | 'single_trait_insert_per_statement' => true, 41 | ]) 42 | ->setFinder($finder); 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `async` will be documented in this file 4 | 5 | ## 1.5.2 - 2020-11-20 6 | 7 | - Configure task in synchronous process 8 | - Add Pool::forceSynchronous function 9 | 10 | ## 1.5.1 - 2020-11-20 11 | 12 | - Support for PHP 8 13 | 14 | ## 1.5.0 - 2020-09-18 15 | 16 | - Add fallback to SerializableException to handle "complex" exceptions (#119) 17 | 18 | ## 1.4.1 - 2020-08-19 19 | 20 | - Properly stop process on timeout (#105) 21 | 22 | ## 1.4.0 - 2020-04-15 23 | 24 | - Make binary configurable (#111 and #112) 25 | 26 | ## 1.3.0 - 2020-03-17 27 | 28 | - Support microsecond timeouts (#109) 29 | 30 | ## 1.2.0 - 2020-02-14 31 | 32 | - Add ability to stop the pool early (#56) 33 | 34 | ## 1.1.1 - 2019-12-24 35 | 36 | - allow Symfony 5 components 37 | 38 | ## 1.1.0 - 2019-09-30 39 | 40 | - Make output length configurable (#86) 41 | 42 | ## 1.0.4 - 2019-08-02 43 | 44 | - Fix for `SynchronousProcess::resolveErrorOutput` (#73) 45 | 46 | ## 1.0.3 - 2019-07-22 47 | 48 | - Fix for Symfony Process argument deprecation 49 | 50 | ## 1.0.1 - 2019-05-17 51 | 52 | - Synchronous execution time bugfix 53 | 54 | ## 1.0.1 - 2019-05-07 55 | 56 | - Check on PCNTL support before registering listeners 57 | 58 | ## 1.0.0 - 2019-03-22 59 | 60 | - First stable release 61 | - Add the ability to catch exceptions by type 62 | - Thrown errors can only have one handler. 63 | See [UPGRADING](./UPGRADING.md#100) for more information. 64 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for async 6 | 7 | 8 | 9 |

Asynchronous and parallel PHP

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/async.svg?style=flat-square)](https://packagist.org/packages/spatie/async) 12 | ![Tests Status](https://img.shields.io/github/actions/workflow/status/spatie/async/run-tests.yml) 13 | [![Quality Score](https://img.shields.io/scrutinizer/g/spatie/async.svg?style=flat-square)](https://scrutinizer-ci.com/g/spatie/async) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/async.svg?style=flat-square)](https://packagist.org/packages/spatie/async) 15 | 16 |
17 | 18 | This library provides a small and easy wrapper around PHP's PCNTL extension. 19 | It allows running of different processes in parallel, with an easy-to-use API. 20 | 21 | ## Support us 22 | 23 | [](https://spatie.be/github-ad-click/async) 24 | 25 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 26 | 27 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 28 | 29 | ## Installation 30 | 31 | You can install the package via composer: 32 | 33 | ```bash 34 | composer require spatie/async 35 | ``` 36 | 37 | ## Usage 38 | 39 | ```php 40 | use Spatie\Async\Pool; 41 | 42 | $pool = Pool::create(); 43 | 44 | foreach ($things as $thing) { 45 | $pool->add(function () use ($thing) { 46 | // Do a thing 47 | })->then(function ($output) { 48 | // Handle success 49 | })->catch(function (Throwable $exception) { 50 | // Handle exception 51 | }); 52 | } 53 | 54 | $pool->wait(); 55 | ``` 56 | 57 | ### Event listeners 58 | 59 | When creating asynchronous processes, you'll get an instance of `ParallelProcess` returned. 60 | You can add the following event hooks on a process. 61 | 62 | ```php 63 | $pool 64 | ->add(function () { 65 | // ... 66 | }) 67 | ->then(function ($output) { 68 | // On success, `$output` is returned by the process or callable you passed to the queue. 69 | }) 70 | ->catch(function ($exception) { 71 | // When an exception is thrown from within a process, it's caught and passed here. 72 | }) 73 | ->timeout(function () { 74 | // A process took too long to finish. 75 | }) 76 | ; 77 | ``` 78 | 79 | ### Functional API 80 | 81 | Instead of using methods on the `$pool` object, you may also use the `async` and `await` helper functions. 82 | 83 | ```php 84 | use Spatie\Async\Pool; 85 | 86 | $pool = Pool::create(); 87 | 88 | foreach (range(1, 5) as $i) { 89 | $pool[] = async(function () { 90 | usleep(random_int(10, 1000)); 91 | 92 | return 2; 93 | })->then(function (int $output) { 94 | $this->counter += $output; 95 | }); 96 | } 97 | 98 | await($pool); 99 | ``` 100 | 101 | ### Error handling 102 | 103 | If an `Exception` or `Error` is thrown from within a child process, it can be caught per process by specifying a callback in the `->catch()` method. 104 | 105 | ```php 106 | $pool 107 | ->add(function () { 108 | // ... 109 | }) 110 | ->catch(function ($exception) { 111 | // Handle the thrown exception for this child process. 112 | }) 113 | ; 114 | ``` 115 | 116 | If there's no error handler added, the error will be thrown in the parent process when calling `await()` or `$pool->wait()`. 117 | 118 | If the child process would unexpectedly stop without throwing an `Throwable`, 119 | the output written to `stderr` will be wrapped and thrown as `Spatie\Async\ParallelError` in the parent process. 120 | 121 | ### Catching exceptions by type 122 | 123 | By type hinting the `catch` functions, you can provide multiple error handlers, 124 | each for individual types of errors. 125 | 126 | ```php 127 | $pool 128 | ->add(function () { 129 | throw new MyException('test'); 130 | }) 131 | ->catch(function (MyException $e) { 132 | // Handle `MyException` 133 | }) 134 | ->catch(function (OtherException $e) { 135 | // Handle `OtherException` 136 | }); 137 | ``` 138 | 139 | Note that as soon as an exception is handled, it won't trigger any other handlers 140 | 141 | ```php 142 | $pool 143 | ->add(function () { 144 | throw new MyException('test'); 145 | }) 146 | ->catch(function (MyException $e) { 147 | // This one is triggerd when `MyException` is thrown 148 | }) 149 | ->catch(function (Exception $e) { 150 | // This one is not triggerd, even though `MyException` extends `Exception` 151 | }); 152 | ``` 153 | 154 | ### Stopping a pool 155 | 156 | If you need to stop a pool early, because the task it was performing has been completed by one 157 | of the child processes, you can use the `$pool->stop()` method. This will prevent the 158 | pool from starting any additional processes. 159 | 160 | ```php 161 | use Spatie\Async\Pool; 162 | 163 | $pool = Pool::create(); 164 | 165 | // Generate 10k processes generating random numbers 166 | for($i = 0; $i < 10000; $i++) { 167 | $pool->add(function() use ($i) { 168 | return rand(0, 100); 169 | })->then(function($output) use ($pool) { 170 | // If one of them randomly picks 100, end the pool early. 171 | if ($output === 100) { 172 | $pool->stop(); 173 | } 174 | }); 175 | } 176 | 177 | $pool->wait(); 178 | ``` 179 | 180 | Note that a pool will be rendered useless after being stopped, and a new pool should be 181 | created if needed. 182 | 183 | ### Using another PHP binary 184 | 185 | By default the pool will use `php` to execute its child processes. You can configure another binary like so: 186 | 187 | ```php 188 | Pool::create() 189 | ->withBinary('/path/to/php'); 190 | ``` 191 | 192 | ### Working with tasks 193 | 194 | Besides using closures, you can also work with a `Task`. 195 | A `Task` is useful in situations where you need more setup work in the child process. 196 | Because a child process is always bootstrapped from nothing, chances are you'll want to initialise eg. the dependency container before executing the task. 197 | The `Task` class makes this easier to do. 198 | 199 | ```php 200 | use Spatie\Async\Task; 201 | 202 | class MyTask extends Task 203 | { 204 | public function configure() 205 | { 206 | // Setup eg. dependency container, load config,... 207 | } 208 | 209 | public function run() 210 | { 211 | // Do the real work here. 212 | } 213 | } 214 | 215 | // Add the task to the pool 216 | $pool->add(new MyTask()); 217 | ``` 218 | 219 | #### Simple tasks 220 | 221 | If you want to encapsulate the logic of your task, but don't want to create a full blown `Task` object, 222 | you may also pass an invokable object to the `Pool`. 223 | 224 | ```php 225 | class InvokableClass 226 | { 227 | // ... 228 | 229 | public function __invoke() 230 | { 231 | // ... 232 | } 233 | } 234 | 235 | $pool->add(new InvokableClass(/* ... */)); 236 | ``` 237 | 238 | ### Pool configuration 239 | 240 | You're free to create as many pools as you want, each pool has its own queue of processes it will handle. 241 | 242 | A pool is configurable by the developer: 243 | 244 | ```php 245 | use Spatie\Async\Pool; 246 | 247 | $pool = Pool::create() 248 | 249 | // The maximum amount of processes which can run simultaneously. 250 | ->concurrency(20) 251 | 252 | // The maximum amount of time a process may take to finish in seconds 253 | // (decimal places are supported for more granular timeouts). 254 | ->timeout(15) 255 | 256 | // Configure which autoloader sub processes should use. 257 | ->autoload(__DIR__ . '/../../vendor/autoload.php') 258 | 259 | // Configure how long the loop should sleep before re-checking the process statuses in microseconds. 260 | ->sleepTime(50000) 261 | ; 262 | ``` 263 | 264 | ### Synchronous fallback 265 | 266 | If the required extensions (`pcntl` and `posix`) are not installed in your current PHP runtime, the `Pool` will automatically fallback to synchronous execution of tasks. 267 | 268 | The `Pool` class has a static method `isSupported` you can call to check whether your platform is able to run asynchronous processes. 269 | 270 | If you're using a `Task` to run processes, only the `run` method of those tasks will be called when running in synchronous mode. 271 | 272 | ## Behind the curtains 273 | 274 | When using this package, you're probably wondering what's happening underneath the surface. 275 | 276 | We're using the `symfony/process` component to create and manage child processes in PHP. 277 | By creating child processes on the fly, we're able to execute PHP scripts in parallel. 278 | This parallelism can improve performance significantly when dealing with multiple synchronous tasks, 279 | which don't really need to wait for each other. 280 | By giving these tasks a separate process to run on, the underlying operating system can take care of running them in parallel. 281 | 282 | There's a caveat when dynamically spawning processes: you need to make sure that there won't be too many processes at once, 283 | or the application might crash. 284 | The `Pool` class provided by this package takes care of handling as many processes as you want 285 | by scheduling and running them when it's possible. 286 | 287 | That's the part that `async()` or `$pool->add()` does. Now let's look at what `await()` or `$pool->wait()` does. 288 | 289 | When multiple processes are spawned, each can have a separate time to completion. 290 | One process might eg. have to wait for a HTTP call, while the other has to process large amounts of data. 291 | Sometimes you also have points in your code which have to wait until the result of a process is returned. 292 | 293 | This is why we have to wait at a certain point in time: for all processes on a pool to finish, 294 | so we can be sure it's safe to continue without accidentally killing the child processes which aren't done yet. 295 | 296 | Waiting for all processes is done by using a `while` loop, which will wait until all processes are finished. 297 | Determining when a process is finished is done by using a listener on the `SIGCHLD` signal. 298 | This signal is emitted when a child process is finished by the OS kernel. 299 | As of PHP 7.1, there's much better support for listening and handling signals, 300 | making this approach more performant than eg. using process forks or sockets for communication. 301 | You can read more about it [here](https://wiki.php.net/rfc/async_signals). 302 | 303 | When a process is finished, its success event is triggered, which you can hook into with the `->then()` function. 304 | Likewise, when a process fails or times out, the loop will update that process' status and move on. 305 | When all processes are finished, the while loop will see that there's nothing more to wait for, and stop. 306 | This is the moment your parent process can continue to execute. 307 | 308 | ### Comparison to other libraries 309 | 310 | We've written a blog post containing more information about use cases for this package, as well as making comparisons to other asynchronous PHP libraries like ReactPHP and Amp: [http://stitcher.io/blog/asynchronous-php](http://stitcher.io/blog/asynchronous-php). 311 | 312 | ## Testing 313 | 314 | ``` bash 315 | composer test 316 | ``` 317 | 318 | ## Changelog 319 | 320 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 321 | 322 | ## Contributing 323 | 324 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 325 | 326 | ### Security 327 | 328 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 329 | 330 | ## Postcardware 331 | 332 | You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 333 | 334 | Our address is: Spatie, Kruikstraat 22, 2018 Antwerp, Belgium. 335 | 336 | We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards). 337 | 338 | ## Credits 339 | 340 | - [Brent Roose](https://github.com/brendt) 341 | - [All Contributors](../../contributors) 342 | 343 | ## License 344 | 345 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 346 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | - Thrown errors can only have one handler. 4 | If you have several handlers catching the same exception, only the first will be triggered. 5 | Please see the [README](./README.md#error-handling) for more information. 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/async", 3 | "description": "Asynchronous and parallel PHP with the PCNTL extension", 4 | "keywords": [ 5 | "spatie", 6 | "async" 7 | ], 8 | "homepage": "https://github.com/spatie/async", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Brent Roose", 13 | "email": "brent@spatie.be", 14 | "homepage": "https://spatie.be", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.3", 20 | "laravel/serializable-closure": "^1.3.7", 21 | "symfony/process": "^7.2" 22 | }, 23 | "require-dev": { 24 | "larapack/dd": "^1.1", 25 | "phpunit/phpunit": "^11.0", 26 | "symfony/stopwatch": "^7.2" 27 | }, 28 | "suggest": { 29 | "ext-pcntl": "Required to use async processes", 30 | "ext-posix": "Required to use async processes" 31 | }, 32 | "autoload": { 33 | "files": [ 34 | "src/helpers.php" 35 | ], 36 | "psr-4": { 37 | "Spatie\\Async\\": "src" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Spatie\\Async\\Tests\\": "tests" 43 | } 44 | }, 45 | "scripts": { 46 | "test": "vendor/bin/phpunit" 47 | }, 48 | "config": { 49 | "sort-packages": true 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/benchmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/async/2f7d13f141246288cdaf5cf48c08e4d26e1cac24/docs/benchmarks.png -------------------------------------------------------------------------------- /src/FileTask.php: -------------------------------------------------------------------------------- 1 | file = $file; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Output/ParallelError.php: -------------------------------------------------------------------------------- 1 | originalClass = $originalClass; 17 | $this->originalTrace = $originalTrace; 18 | } 19 | 20 | /** @return string */ 21 | public function getOriginalClass(): string 22 | { 23 | return $this->originalClass; 24 | } 25 | 26 | /** @return string */ 27 | public function getOriginalTrace(): string 28 | { 29 | return $this->originalTrace; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Output/SerializableException.php: -------------------------------------------------------------------------------- 1 | class = get_class($exception); 21 | $this->message = $exception->getMessage(); 22 | $this->trace = $exception->getTraceAsString(); 23 | } 24 | 25 | public function asThrowable(): Throwable 26 | { 27 | try { 28 | /** @var Throwable $throwable */ 29 | $throwable = new $this->class($this->message."\n\n".$this->trace); 30 | } catch (Throwable $exception) { 31 | $throwable = new ParallelException($this->message, $this->class, $this->trace); 32 | } 33 | 34 | return $throwable; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Pool.php: -------------------------------------------------------------------------------- 1 | registerListener(); 50 | } 51 | 52 | $this->status = new PoolStatus($this); 53 | } 54 | 55 | /** 56 | * @return static 57 | */ 58 | public static function create() 59 | { 60 | return new static(); 61 | } 62 | 63 | public static function isSupported(): bool 64 | { 65 | return 66 | function_exists('pcntl_async_signals') 67 | && function_exists('posix_kill') 68 | && function_exists('proc_open') 69 | && ! self::$forceSynchronous; 70 | } 71 | 72 | public function forceSynchronous(): self 73 | { 74 | self::$forceSynchronous = true; 75 | 76 | return $this; 77 | } 78 | 79 | public function concurrency(int $concurrency): self 80 | { 81 | $this->concurrency = $concurrency; 82 | 83 | return $this; 84 | } 85 | 86 | public function timeout(float $timeout): self 87 | { 88 | $this->timeout = $timeout; 89 | 90 | return $this; 91 | } 92 | 93 | public function autoload(string $autoloader): self 94 | { 95 | ParentRuntime::init($autoloader); 96 | 97 | return $this; 98 | } 99 | 100 | public function sleepTime(int $sleepTime): self 101 | { 102 | $this->sleepTime = $sleepTime; 103 | 104 | return $this; 105 | } 106 | 107 | public function withBinary(string $binary): self 108 | { 109 | $this->binary = $binary; 110 | 111 | return $this; 112 | } 113 | 114 | public function maxTaskPayload(int $maxSizeInBytes): self 115 | { 116 | $this->maxTaskPayloadInBytes = $maxSizeInBytes; 117 | 118 | return $this; 119 | } 120 | 121 | public function notify() 122 | { 123 | if (count($this->inProgress) >= $this->concurrency) { 124 | return; 125 | } 126 | 127 | $process = array_shift($this->queue); 128 | 129 | if (! $process) { 130 | return; 131 | } 132 | 133 | $this->putInProgress($process); 134 | } 135 | 136 | /** 137 | * @param \Spatie\Async\Process\Runnable|callable $process 138 | * @param int|null $outputLength 139 | * 140 | * @return \Spatie\Async\Process\Runnable 141 | */ 142 | public function add($process, ?int $outputLength = null): Runnable 143 | { 144 | if (! is_callable($process) && ! $process instanceof Runnable) { 145 | throw new InvalidArgumentException('The process passed to Pool::add should be callable.'); 146 | } 147 | 148 | if (! $process instanceof Runnable) { 149 | $process = ParentRuntime::createProcess( 150 | $process, 151 | $outputLength, 152 | $this->binary, 153 | $this->maxTaskPayloadInBytes 154 | ); 155 | } 156 | 157 | $this->putInQueue($process); 158 | 159 | return $process; 160 | } 161 | 162 | /** 163 | * @param callable|null $intermediateCallback Will be called every loop we wait for processes to finish. Return `false` to stop execution of the queue. 164 | * @return array 165 | */ 166 | public function wait(?callable $intermediateCallback = null): array 167 | { 168 | while ($this->inProgress) { 169 | foreach ($this->inProgress as $process) { 170 | if ($process->getCurrentExecutionTime() > $this->timeout) { 171 | $this->markAsTimedOut($process); 172 | } 173 | 174 | if ($process instanceof SynchronousProcess) { 175 | $this->markAsFinished($process); 176 | } 177 | } 178 | 179 | if (! $this->inProgress) { 180 | break; 181 | } 182 | 183 | if ($intermediateCallback && call_user_func_array($intermediateCallback, [$this])) { 184 | break; 185 | } 186 | 187 | usleep($this->sleepTime); 188 | } 189 | 190 | return $this->results; 191 | } 192 | 193 | public function putInQueue(Runnable $process) 194 | { 195 | $this->queue[$process->getId()] = $process; 196 | 197 | $this->notify(); 198 | } 199 | 200 | public function putInProgress(Runnable $process) 201 | { 202 | if ($this->stopped) { 203 | return; 204 | } 205 | 206 | if ($process instanceof ParallelProcess) { 207 | $process->getProcess()->setTimeout($this->timeout); 208 | } 209 | 210 | $process->start(); 211 | 212 | unset($this->queue[$process->getId()]); 213 | 214 | $this->inProgress[$process->getPid()] = $process; 215 | } 216 | 217 | public function markAsFinished(Runnable $process) 218 | { 219 | unset($this->inProgress[$process->getPid()]); 220 | 221 | $this->notify(); 222 | 223 | $this->results[] = $process->triggerSuccess(); 224 | 225 | $this->finished[$process->getPid()] = $process; 226 | } 227 | 228 | public function markAsTimedOut(Runnable $process) 229 | { 230 | unset($this->inProgress[$process->getPid()]); 231 | 232 | $process->stop(); 233 | 234 | $process->triggerTimeout(); 235 | $this->timeouts[$process->getPid()] = $process; 236 | 237 | $this->notify(); 238 | } 239 | 240 | public function markAsFailed(Runnable $process) 241 | { 242 | unset($this->inProgress[$process->getPid()]); 243 | 244 | $this->notify(); 245 | 246 | $process->triggerError(); 247 | 248 | $this->failed[$process->getPid()] = $process; 249 | } 250 | 251 | public function offsetExists($offset): bool 252 | { 253 | // TODO 254 | 255 | return false; 256 | } 257 | 258 | public function offsetGet($offset): Runnable 259 | { 260 | // TODO 261 | } 262 | 263 | public function offsetSet($offset, $value): void 264 | { 265 | $this->add($value); 266 | } 267 | 268 | public function offsetUnset($offset): void 269 | { 270 | // TODO 271 | } 272 | 273 | /** 274 | * @return \Spatie\Async\Process\Runnable[] 275 | */ 276 | public function getQueue(): array 277 | { 278 | return $this->queue; 279 | } 280 | 281 | /** 282 | * @return \Spatie\Async\Process\Runnable[] 283 | */ 284 | public function getInProgress(): array 285 | { 286 | return $this->inProgress; 287 | } 288 | 289 | /** 290 | * @return \Spatie\Async\Process\Runnable[] 291 | */ 292 | public function getFinished(): array 293 | { 294 | return $this->finished; 295 | } 296 | 297 | /** 298 | * @return \Spatie\Async\Process\Runnable[] 299 | */ 300 | public function getFailed(): array 301 | { 302 | return $this->failed; 303 | } 304 | 305 | /** 306 | * @return \Spatie\Async\Process\Runnable[] 307 | */ 308 | public function getTimeouts(): array 309 | { 310 | return $this->timeouts; 311 | } 312 | 313 | public function status(): PoolStatus 314 | { 315 | return $this->status; 316 | } 317 | 318 | protected function registerListener() 319 | { 320 | pcntl_async_signals(true); 321 | 322 | pcntl_signal(SIGCHLD, function ($signo, $status) { 323 | /** 324 | * PHP 8.1.22 and 8.2.9 changed SIGCHLD handling: 325 | * https://github.com/php/php-src/pull/11509 326 | * This changes pcntl_waitpid() at the same time, so it requires special handling. 327 | * 328 | * It was reverted already and probably won't work in any other PHP version. 329 | * https://github.com/php/php-src/pull/11863 330 | */ 331 | if (phpversion() === '8.1.22' || phpversion() === '8.2.9') { 332 | $this->handleFinishedProcess($status['pid'], $status['status']); 333 | 334 | return; 335 | } 336 | 337 | while (true) { 338 | $pid = pcntl_waitpid(-1, $processState, WNOHANG | WUNTRACED); 339 | 340 | if ($pid <= 0) { 341 | break; 342 | } 343 | 344 | $this->handleFinishedProcess($pid, $status['status']); 345 | } 346 | }); 347 | } 348 | 349 | protected function handleFinishedProcess(int $pid, int $status) 350 | { 351 | $process = $this->inProgress[$pid] ?? null; 352 | 353 | if (! $process) { 354 | return; 355 | } 356 | 357 | if ($status === 0) { 358 | $this->markAsFinished($process); 359 | 360 | return; 361 | } 362 | 363 | $this->markAsFailed($process); 364 | } 365 | 366 | public function stop() 367 | { 368 | $this->stopped = true; 369 | } 370 | 371 | public function clearFinished() 372 | { 373 | $this->finished = []; 374 | } 375 | 376 | public function clearResults() 377 | { 378 | $this->results = []; 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /src/PoolStatus.php: -------------------------------------------------------------------------------- 1 | pool = $pool; 15 | } 16 | 17 | public function __toString(): string 18 | { 19 | return $this->lines( 20 | $this->summaryToString(), 21 | $this->failedToString() 22 | ); 23 | } 24 | 25 | protected function lines(string ...$lines): string 26 | { 27 | return implode(PHP_EOL, $lines); 28 | } 29 | 30 | protected function summaryToString(): string 31 | { 32 | $queue = $this->pool->getQueue(); 33 | $finished = $this->pool->getFinished(); 34 | $failed = $this->pool->getFailed(); 35 | $timeouts = $this->pool->getTimeouts(); 36 | 37 | return 38 | 'queue: '.count($queue) 39 | .' - finished: '.count($finished) 40 | .' - failed: '.count($failed) 41 | .' - timeout: '.count($timeouts); 42 | } 43 | 44 | protected function failedToString(): string 45 | { 46 | return (string) array_reduce($this->pool->getFailed(), function ($currentStatus, ParallelProcess $process) { 47 | $output = $process->getErrorOutput(); 48 | 49 | if ($output instanceof SerializableException) { 50 | $output = get_class($output->asThrowable()).': '.$output->asThrowable()->getMessage(); 51 | } 52 | 53 | return $this->lines((string) $currentStatus, "{$process->getPid()} failed with {$output}"); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Process/ParallelProcess.php: -------------------------------------------------------------------------------- 1 | process = $process; 25 | $this->id = $id; 26 | } 27 | 28 | public static function create(Process $process, int $id): self 29 | { 30 | return new self($process, $id); 31 | } 32 | 33 | public function start(): self 34 | { 35 | $this->startTime = microtime(true); 36 | 37 | $this->process->start(); 38 | 39 | $this->pid = $this->process->getPid(); 40 | 41 | return $this; 42 | } 43 | 44 | public function stop($timeout = 0): self 45 | { 46 | $this->process->stop($timeout, SIGKILL); 47 | 48 | return $this; 49 | } 50 | 51 | public function isRunning(): bool 52 | { 53 | return $this->process->isRunning(); 54 | } 55 | 56 | public function isSuccessful(): bool 57 | { 58 | return $this->process->isSuccessful(); 59 | } 60 | 61 | public function isTerminated(): bool 62 | { 63 | return $this->process->isTerminated(); 64 | } 65 | 66 | public function getOutput() 67 | { 68 | if (! $this->output) { 69 | $processOutput = $this->process->getOutput(); 70 | 71 | $childResult = @unserialize(base64_decode($processOutput)); 72 | 73 | if ($childResult === false || ! array_key_exists('output', $childResult)) { 74 | $this->errorOutput = $processOutput; 75 | 76 | return null; 77 | } 78 | 79 | $this->output = $childResult['output']; 80 | } 81 | 82 | return $this->output; 83 | } 84 | 85 | public function getErrorOutput() 86 | { 87 | if (! $this->errorOutput) { 88 | $processOutput = $this->process->getErrorOutput(); 89 | 90 | $childResult = @unserialize(base64_decode($processOutput)); 91 | 92 | if ($childResult === false || ! array_key_exists('output', $childResult)) { 93 | $this->errorOutput = $processOutput; 94 | } else { 95 | $this->errorOutput = $childResult['output']; 96 | } 97 | } 98 | 99 | return $this->errorOutput; 100 | } 101 | 102 | public function getProcess(): Process 103 | { 104 | return $this->process; 105 | } 106 | 107 | public function getId(): int 108 | { 109 | return $this->id; 110 | } 111 | 112 | public function getPid(): ?int 113 | { 114 | return $this->pid; 115 | } 116 | 117 | public function getCurrentExecutionTime(): float 118 | { 119 | return microtime(true) - $this->startTime; 120 | } 121 | 122 | protected function resolveErrorOutput(): Throwable 123 | { 124 | $exception = $this->getErrorOutput(); 125 | 126 | if ($exception instanceof SerializableException) { 127 | $exception = $exception->asThrowable(); 128 | } 129 | 130 | if (! $exception instanceof Throwable) { 131 | $exception = ParallelError::fromException($exception); 132 | } 133 | 134 | return $exception; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Process/ProcessCallbacks.php: -------------------------------------------------------------------------------- 1 | successCallbacks[] = $callback; 17 | 18 | return $this; 19 | } 20 | 21 | public function catch(callable $callback): self 22 | { 23 | $this->errorCallbacks[] = $callback; 24 | 25 | return $this; 26 | } 27 | 28 | public function timeout(callable $callback): self 29 | { 30 | $this->timeoutCallbacks[] = $callback; 31 | 32 | return $this; 33 | } 34 | 35 | public function triggerSuccess() 36 | { 37 | $output = $this->getOutput(); 38 | 39 | if ($this->getErrorOutput()) { 40 | $this->triggerError(); 41 | 42 | return; 43 | } 44 | 45 | foreach ($this->successCallbacks as $callback) { 46 | call_user_func_array($callback, [$output]); 47 | } 48 | 49 | return $output; 50 | } 51 | 52 | public function triggerError() 53 | { 54 | $exception = $this->resolveErrorOutput(); 55 | 56 | if (! $this->errorCallbacks) { 57 | throw $exception; 58 | } 59 | 60 | foreach ($this->errorCallbacks as $callback) { 61 | if (! $this->isAllowedThrowableType($exception, $callback)) { 62 | continue; 63 | } 64 | 65 | call_user_func_array($callback, [$exception]); 66 | 67 | break; 68 | } 69 | } 70 | 71 | abstract protected function resolveErrorOutput(): Throwable; 72 | 73 | public function triggerTimeout() 74 | { 75 | foreach ($this->timeoutCallbacks as $callback) { 76 | call_user_func_array($callback, []); 77 | } 78 | } 79 | 80 | protected function isAllowedThrowableType(Throwable $throwable, callable $callable): bool 81 | { 82 | $reflection = new ReflectionFunction($callable); 83 | 84 | $parameters = $reflection->getParameters(); 85 | 86 | if (! isset($parameters[0])) { 87 | return true; 88 | } 89 | 90 | $firstParameter = $parameters[0]; 91 | 92 | if (! $firstParameter) { 93 | return true; 94 | } 95 | 96 | $type = $firstParameter->getType(); 97 | 98 | if (! $type) { 99 | return true; 100 | } 101 | 102 | if (is_a($throwable, $type->getName())) { 103 | return true; 104 | } 105 | 106 | return false; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Process/Runnable.php: -------------------------------------------------------------------------------- 1 | id = $id; 22 | $this->task = $task; 23 | } 24 | 25 | public static function create(callable $task, int $id): self 26 | { 27 | return new self($task, $id); 28 | } 29 | 30 | public function getId(): int 31 | { 32 | return $this->id; 33 | } 34 | 35 | public function getPid(): ?int 36 | { 37 | return $this->getId(); 38 | } 39 | 40 | public function start() 41 | { 42 | $startTime = microtime(true); 43 | 44 | if ($this->task instanceof Task) { 45 | $this->task->configure(); 46 | } 47 | 48 | try { 49 | $this->output = $this->task instanceof Task 50 | ? $this->task->run() 51 | : call_user_func($this->task); 52 | } catch (Throwable $throwable) { 53 | $this->errorOutput = $throwable; 54 | } finally { 55 | $this->executionTime = microtime(true) - $startTime; 56 | } 57 | } 58 | 59 | public function stop($timeout = 0): void 60 | { 61 | } 62 | 63 | public function getOutput() 64 | { 65 | return $this->output; 66 | } 67 | 68 | public function getErrorOutput() 69 | { 70 | return $this->errorOutput; 71 | } 72 | 73 | public function getCurrentExecutionTime(): float 74 | { 75 | return $this->executionTime; 76 | } 77 | 78 | protected function resolveErrorOutput(): Throwable 79 | { 80 | return $this->getErrorOutput(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Runtime/ChildRuntime.php: -------------------------------------------------------------------------------- 1 | $output])); 41 | 42 | if (strlen($serializedOutput) > $outputLength) { 43 | throw \Spatie\Async\Output\ParallelError::outputTooLarge($outputLength); 44 | } 45 | 46 | fwrite(STDOUT, $serializedOutput); 47 | 48 | exit(0); 49 | } catch (Throwable $exception) { 50 | require_once __DIR__.'/../Output/SerializableException.php'; 51 | 52 | $output = new \Spatie\Async\Output\SerializableException($exception); 53 | 54 | fwrite(STDERR, base64_encode(serialize(['output' => $output]))); 55 | 56 | exit(1); 57 | } 58 | -------------------------------------------------------------------------------- /src/Runtime/ParentRuntime.php: -------------------------------------------------------------------------------- 1 | $maxTaskPayloadInBytes) { 91 | // Write the serialized task to a temporary file and package it as a `FileTask`: 92 | $filename = tempnam(sys_get_temp_dir(), 'spatie_async_task_'); 93 | file_put_contents($filename, $serializedTask); 94 | $file_task = new FileTask($filename); 95 | $serializedTask = base64_encode(serialize($file_task)); 96 | } 97 | 98 | return $serializedTask; 99 | } 100 | 101 | public static function decodeTask(string $task) 102 | { 103 | $decodedTask = unserialize(base64_decode($task)); 104 | 105 | if (get_class($decodedTask) === 'Spatie\Async\FileTask') { 106 | $filename = $decodedTask->file; 107 | $decodedTask = unserialize(base64_decode(file_get_contents($filename))); 108 | unlink($filename); 109 | } 110 | 111 | return $decodedTask; 112 | } 113 | 114 | protected static function getId(): string 115 | { 116 | if (self::$myPid === null) { 117 | self::$myPid = getmypid(); 118 | } 119 | 120 | self::$currentId += 1; 121 | 122 | return (string)self::$currentId . (string)self::$myPid; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Task.php: -------------------------------------------------------------------------------- 1 | configure(); 14 | 15 | return $this->run(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | wait(); 23 | } 24 | } 25 | --------------------------------------------------------------------------------