├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── lib ├── DispatchException.php ├── Dispatcher.php ├── SharedData.php ├── Task.php ├── TaskException.php ├── TaskNotifier.php ├── Thread.php ├── TimeoutException.php ├── TooBusyException.php └── Worker.php ├── phpunit.xml └── test ├── AutoloadableClassFixture.php ├── DispatcherTest.php └── fixtures.php /.gitignore: -------------------------------------------------------------------------------- 1 | test/coverage 2 | composer.lock 3 | vendor 4 | .idea 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v0.8.0 2 | ------ 3 | 4 | - Migrate repo to new amphp/thread repo 5 | 6 | > **BC Breaks:** 7 | 8 | - The library now resides at `amphp/thread` (was `rdlowrey/Amp`) 9 | - All existing references to `Alert` or `After` libs must be refactored to point to the same 10 | classes in the new `Amp` repo. 11 | 12 | v0.7.0 13 | ------ 14 | 15 | - Use completely refactored (new) After dependency 16 | - Allow for custom task progress updates via `After\Promise` API 17 | - Cleanup edge-case IPC failure in worker threads 18 | - Add new "After" submodule for moved concurrency primitives 19 | 20 | > **BC Breaks:** 21 | 22 | - This update uses the new 0.2.x version of the After dependency. As such, the public API dealing 23 | with promised results has completely changed. Please see https://github.com/rdlowrey/After for 24 | details. 25 | 26 | v0.6.0 27 | ------ 28 | 29 | - Removed `Dispatcher::OPT_ON_WORKER_TASK` option 30 | - Worker start tasks are now controlled with the following new methods: 31 | - `Dispatcher::addWorkerStartTask(Threaded $task)` 32 | - `Dispatcher::removeWorkerStartTask(Threaded $task)` 33 | 34 | v0.5.0 35 | ------ 36 | 37 | - Pool size is now elastic subject to min/max size configuration settings 38 | - Worker threads exceeding the idle timeout (since last processing activity) are 39 | now automatically unloaded to scale thread pool size back when not under load. 40 | - New option constants: 41 | - `Dispatcher::OPT_POOL_SIZE_MIN` 42 | - `Dispatcher::OPT_POOL_SIZE_MAX` 43 | - `Dispatcher::OPT_IDLE_WORKER_TIMEOUT` (seconds) 44 | - Updated rdlowrey\Alert dependency to latest 45 | - Default worker thread task execution limit before recycling is now 2048 (was 1024) 46 | - Performance improvements when tasks are rejected due to excessive load 47 | 48 | v0.4.0 49 | ------ 50 | 51 | - Major migration to pthreads-only functionality. Previous versions no longer supported. 52 | - Job server support removed. 53 | 54 | #### v0.3.1 55 | 56 | - Addressed fatal error when returning data frames from worker processes exceeding 65535 bytes in size. 57 | 58 | v0.3.0 59 | ------ 60 | 61 | - Job server script renamed (bye-bye .php extension), added hashbang for easier execution in 62 | _*nix_ environments 63 | - Job server binary now correctly interprets space separators in command line arguments 64 | - Composer support improved, now plays nice with submodules 65 | - Convenience autoloader script moved into vendor directory to play nice with composer 66 | 67 | 68 | #### v0.2.2 69 | 70 | - Minor bugfixes 71 | 72 | #### v0.2.1 73 | 74 | - Addressed execution time drift in repeating native reactor alarms 75 | - Addressed infinite recursion in repeating callbacks 76 | 77 | v0.2.0 78 | ------ 79 | 80 | > **NOTE:** This release introduces significant changes in the AMP API to affect performance and 81 | > functionality improvements; BC breaks are prevalent and blindly upgrading will break your 82 | > application. v0.1.0 is deprecated and no longer supported. 83 | 84 | - Extracted event reactor functionality into separate [Alert][alert-repo] repo 85 | - Added TCP job server (`bin/amp.php`) and asynchronous clients for interfacing with job servers 86 | - Messaging transport protocols extended and simplified to favor bytes over bits in places 87 | - Removed IO stream watcher timeouts 88 | - Removed schedule watcher iteration limits 89 | - Removed subscription/watcher object abstraction in favor of watcher IDs 90 | - Reactors now control all `enable/disable/cancel` actions for timer/stream watchers 91 | 92 | v0.1.0 93 | ------ 94 | 95 | - Initial tagged release 96 | 97 | [alert-repo]: https://github.com/rdlowrey/Alert "Alert" 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2014 Daniel Lowrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in 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 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # thread 2 | 3 | This library is unmaintained. Please use https://github.com/amphp/parallel instead. 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/thread", 3 | "homepage": "https://github.com/amphp/thread", 4 | "description": "Non-blocking thread pool task dispatch", 5 | "keywords": ["async", "concurrency", "parallelization", "non-blocking", "threads", "pthreads", "amp"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Daniel Lowrey", 10 | "email": "rdlowrey@gmail.com", 11 | "role": "Creator / Lead Developer" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=5.4.0", 16 | "amphp/amp": "~0.12" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "Amp\\Thread\\": "lib/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Amp\\Thread\\Test\\": "test/" 26 | }, 27 | "files": ["test/fixtures.php"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/DispatchException.php: -------------------------------------------------------------------------------- 1 | reactor = $reactor ?: \Amp\reactor(); 55 | $this->nextId = PHP_INT_MAX * -1; 56 | $this->workerStartTasks = new \SplObjectStorage; 57 | $this->taskReflection = new \ReflectionClass('Amp\Thread\Task'); 58 | $this->taskNotifier = new TaskNotifier; 59 | } 60 | 61 | /** 62 | * Dispatch a procedure call to the thread pool 63 | * 64 | * This method will auto-start the thread pool if workers have not been spawned. 65 | * 66 | * @param string $procedure The name of the function to invoke 67 | * @param mixed $varArgs A variable-length argument list to pass the procedure 68 | * @throws \InvalidArgumentException if the final parameter is not a valid callback 69 | * @return \Amp\Promise 70 | */ 71 | public function call($procedure, $varArgs = null /*..., $argN*/) { 72 | if (!is_string($procedure)) { 73 | throw new \InvalidArgumentException( 74 | sprintf('%s requires a string at Argument 1', __METHOD__) 75 | ); 76 | } 77 | 78 | if (!$this->isStarted) { 79 | $this->start(); 80 | } 81 | 82 | if ($this->maxTaskQueueSize < 0 || $this->maxTaskQueueSize > $this->outstandingTaskCount) { 83 | $task = $this->taskReflection->newInstanceArgs(func_get_args()); 84 | return $this->acceptNewTask($task); 85 | } else { 86 | return new Failure(new TooBusyException( 87 | sprintf("Cannot execute '%s' task; too busy", $procedure) 88 | )); 89 | } 90 | } 91 | 92 | /** 93 | * Dispatch a pthreads Stackable to the thread pool for processing 94 | * 95 | * This method will auto-start the thread pool if workers have not been spawned. 96 | * 97 | * @param \Stackable $task A custom pthreads stackable 98 | * @return \Amp\Promise 99 | */ 100 | public function execute(\Stackable $task) { 101 | if (!$this->isStarted) { 102 | $this->start(); 103 | } 104 | 105 | if ($this->maxTaskQueueSize < 0 || $this->maxTaskQueueSize > $this->outstandingTaskCount) { 106 | return $this->acceptNewTask($task); 107 | } else { 108 | return new Failure(new TooBusyException( 109 | sprintf('Cannot execute task of type %s; too busy', get_class($task)) 110 | )); 111 | } 112 | } 113 | 114 | private function acceptNewTask(\Stackable $task) { 115 | $future = new Future($this->reactor); 116 | $promiseId = $this->nextId++; 117 | $this->queue[$promiseId] = [$future, $task]; 118 | $this->outstandingTaskCount++; 119 | 120 | if ($this->isPeriodWatcherEnabled === false) { 121 | $this->now = microtime(true); 122 | $this->reactor->enable($this->timeoutWatcher); 123 | $this->isPeriodWatcherEnabled = true; 124 | } 125 | 126 | if ($this->taskTimeout > -1) { 127 | $timeoutAt = $this->taskTimeout + $this->now; 128 | $this->promiseTimeoutMap[$promiseId] = $timeoutAt; 129 | } 130 | 131 | if ($this->availableWorkers) { 132 | $this->dequeueNextTask(); 133 | } elseif (($this->poolSize + $this->pendingWorkerCount) < $this->poolSizeMax) { 134 | $this->spawnWorker(); 135 | } 136 | 137 | return $future->promise(); 138 | } 139 | 140 | private function dequeueNextTask() { 141 | $promiseId = key($this->queue); 142 | list($future, $task) = $this->queue[$promiseId]; 143 | 144 | unset($this->queue[$promiseId]); 145 | 146 | $worker = array_shift($this->availableWorkers); 147 | 148 | $this->promiseWorkerMap[$promiseId] = $worker; 149 | 150 | $worker->promiseId = $promiseId; 151 | $worker->future = $future; 152 | $worker->task = $task; 153 | $worker->thread->stack($task); 154 | $worker->thread->stack($this->taskNotifier); 155 | $worker->lastStackedAt = $this->now; 156 | } 157 | 158 | /** 159 | * Spawn worker threads 160 | * 161 | * No tasks will be dispatched until Dispatcher::start is invoked. 162 | * 163 | * @return \Amp\Dispatcher Returns the current object instance 164 | */ 165 | public function start() { 166 | if (!$this->isStarted) { 167 | $this->generateIpcServer(); 168 | $this->isStarted = true; 169 | for ($i=0;$i<$this->poolSizeMin;$i++) { 170 | $this->spawnWorker(); 171 | } 172 | $this->registerTaskTimeoutWatcher(); 173 | } 174 | 175 | return $this; 176 | } 177 | 178 | private function generateIpcUri() { 179 | $availableTransports = array_flip(stream_get_transports()); 180 | 181 | if (isset($availableTransports['unix'])) { 182 | $dir = $this->unixIpcSocketDir ? $this->unixIpcSocketDir : sys_get_temp_dir(); 183 | $uri = sprintf('unix://%s/amp_ipc_%s', $dir, md5(microtime())); 184 | } elseif (isset($availableTransports['tcp'])) { 185 | $uri = 'tcp://127.0.0.1:0'; 186 | } else { 187 | throw new \RuntimeException( 188 | 'Cannot bind IPC server: no usable stream transports exist' 189 | ); 190 | } 191 | 192 | return $uri; 193 | } 194 | 195 | private function generateIpcServer() { 196 | $uri = $this->ipcUri ?: $this->generateIpcUri(); 197 | $flags = STREAM_SERVER_BIND | STREAM_SERVER_LISTEN; 198 | if (!$server = @stream_socket_server($uri, $errno, $errstr, $flags)) { 199 | throw new \RuntimeException( 200 | sprintf("Failed binding IPC server socket: (%d) %s", $errno, $errstr) 201 | ); 202 | } 203 | 204 | stream_set_blocking($server, false); 205 | 206 | $serverName = stream_socket_get_name($server, false); 207 | $protocol = ($serverName[0] === '/') ? 'unix' : 'tcp'; 208 | 209 | $this->ipcUri = sprintf('%s://%s', $protocol, $serverName); 210 | $this->ipcServer = $server; 211 | $this->ipcAcceptWatcher = $this->reactor->onReadable($server, function($reactor, $watcherId, $server) { 212 | $this->acceptIpcClient($server); 213 | }); 214 | } 215 | 216 | private function spawnWorker() { 217 | $results = new SharedData; 218 | $resultCodes = new SharedData; 219 | $thread = new Thread($results, $resultCodes, $this->ipcUri); 220 | 221 | if (!$thread->start()) { 222 | throw new \RuntimeException( 223 | 'Worker thread failed to start' 224 | ); 225 | } 226 | 227 | $worker = new Worker; 228 | $worker->id = $thread->getThreadId(); 229 | $worker->results = $results; 230 | $worker->resultCodes = $resultCodes; 231 | $worker->thread = $thread; 232 | 233 | $this->pendingWorkers[$worker->id] = $worker; 234 | $this->pendingWorkerCount++; 235 | 236 | return $worker; 237 | } 238 | 239 | private function acceptIpcClient($ipcServer) { 240 | if (!$ipcClient = @stream_socket_accept($ipcServer)) { 241 | throw new \RuntimeException( 242 | 'Failed accepting IPC client' 243 | ); 244 | } 245 | 246 | $ipcClientId = (int) $ipcClient; 247 | stream_set_blocking($ipcClient, false); 248 | $readWatcher = $this->reactor->onReadable($ipcClient, function($reactor, $watcherId, $ipcClient) { 249 | $this->onPendingReadableIpcClient($ipcClient); 250 | }); 251 | 252 | $this->pendingIpcClients[$ipcClientId] = [$ipcClient, $readWatcher]; 253 | } 254 | 255 | private function onPendingReadableIpcClient($ipcClient) { 256 | $openMsg = @fgets($ipcClient); 257 | if (isset($openMsg[0])) { 258 | $workerId = (int) rtrim($openMsg); 259 | $this->openIpcClient($workerId, $ipcClient); 260 | } elseif (!is_resource($ipcClient) || feof($ipcClient)) { 261 | $this->clearPendingIpcClient($ipcClient); 262 | } 263 | } 264 | 265 | private function clearPendingIpcClient($ipcClient) { 266 | $ipcClientId = (int) $ipcClient; 267 | $readWatcher = end($this->pendingIpcClients[$ipcClientId]); 268 | $this->reactor->cancel($readWatcher); 269 | unset($this->pendingIpcClients[$ipcClientId]); 270 | } 271 | 272 | private function openIpcClient($workerId, $ipcClient) { 273 | $this->clearPendingIpcClient($ipcClient); 274 | if (isset($this->pendingWorkers[$workerId])) { 275 | $this->importPendingIpcClient($workerId, $ipcClient); 276 | } 277 | } 278 | 279 | private function importPendingIpcClient($workerId, $ipcClient) { 280 | $worker = $this->pendingWorkers[$workerId]; 281 | unset($this->pendingWorkers[$workerId]); 282 | $this->pendingWorkerCount--; 283 | $worker->ipcClient = $ipcClient; 284 | $worker->ipcReadWatcher = $this->reactor->onReadable($ipcClient, function() use ($worker) { 285 | $this->onReadableIpcClient($worker); 286 | }); 287 | $this->workers[$workerId] = $worker; 288 | $this->availableWorkers[$workerId] = $worker; 289 | $this->poolSize++; 290 | 291 | foreach ($this->workerStartTasks as $task) { 292 | $worker->thread->stack($task); 293 | } 294 | 295 | if ($this->queue) { 296 | $this->dequeueNextTask(); 297 | } 298 | } 299 | 300 | private function onReadableIpcClient(Worker $worker) { 301 | $ipcClient = $worker->ipcClient; 302 | 303 | if (fgetc($ipcClient)) { 304 | $this->processWorkerTaskResult($worker); 305 | } elseif (!is_resource($ipcClient) || feof($ipcClient)) { 306 | $this->respawnWorker($worker); 307 | } 308 | } 309 | 310 | private function registerTaskTimeoutWatcher() { 311 | if ($this->taskTimeout > -1) { 312 | $this->now = microtime(true); 313 | $this->timeoutWatcher = $this->reactor->repeat(function() { 314 | $this->executePeriodTimeouts(); 315 | }, $this->periodTimeoutInterval); 316 | $this->isPeriodWatcherEnabled = true; 317 | } 318 | } 319 | 320 | private function executePeriodTimeouts() { 321 | $this->now = $now = microtime(true); 322 | 323 | if ($this->promiseTimeoutMap) { 324 | $this->timeoutOverdueTasks($now); 325 | } 326 | 327 | if ($this->availableWorkers && ($this->poolSize > $this->poolSizeMin)) { 328 | $this->decrementSuperfluousWorkers($now); 329 | } 330 | } 331 | 332 | private function decrementSuperfluousWorkers($now) { 333 | foreach ($this->availableWorkers as $worker) { 334 | $idleTime = $now - $worker->lastStackedAt; 335 | if ($idleTime > $this->idleWorkerTimeout) { 336 | $this->unloadWorker($worker); 337 | // we don't want to unload more than one worker per second, so break; afterwards 338 | break; 339 | } 340 | } 341 | 342 | if ($this->outstandingTaskCount === 0 && $this->poolSize === $this->poolSizeMin) { 343 | $this->isPeriodWatcherEnabled = false; 344 | $this->reactor->disable($this->timeoutWatcher); 345 | } 346 | } 347 | 348 | private function timeoutOverdueTasks($now) { 349 | foreach ($this->promiseTimeoutMap as $promiseId => $timeoutAt) { 350 | if ($now >= $timeoutAt) { 351 | unset($this->promiseTimeoutMap[$promiseId]); 352 | $this->killTask($promiseId, new TimeoutException( 353 | sprintf( 354 | 'Task timeout exceeded (%d second%s)', 355 | $this->taskTimeout, 356 | ($this->taskTimeout === 1 ? '' : 's') 357 | ) 358 | )); 359 | } else { 360 | break; 361 | } 362 | } 363 | } 364 | 365 | private function killTask($promiseId, DispatchException $error) { 366 | if (isset($this->promiseWorkerMap[$promiseId])) { 367 | $this->outstandingTaskCount--; 368 | $worker = $this->promiseWorkerMap[$promiseId]; 369 | $worker->future->fail($error); 370 | $this->unloadWorker($worker); 371 | $worker->thread->kill(); 372 | $this->spawnWorker(); 373 | } else { 374 | $this->outstandingTaskCount--; 375 | list($future) = $this->queue[$promiseId]; 376 | $future->fail($error); 377 | unset($this->queue[$promiseId]); 378 | } 379 | } 380 | 381 | private function unloadWorker(Worker $worker) { 382 | $this->reactor->cancel($worker->ipcReadWatcher); 383 | $this->poolSize--; 384 | 385 | unset( 386 | $this->workers[$worker->id], 387 | $this->availableWorkers[$worker->id], 388 | $this->promises[$worker->promiseId], 389 | $this->promiseTimeoutMap[$worker->promiseId] 390 | ); 391 | 392 | if (is_resource($worker->ipcClient)) { 393 | @fclose($worker->ipcClient); 394 | } 395 | } 396 | 397 | private function processWorkerTaskResult(Worker $worker) { 398 | $resultCode = $worker->resultCodes->shift(); 399 | $data = $worker->results->shift(); 400 | $error = $mustKill = null; 401 | 402 | switch ($resultCode) { 403 | case Thread::SUCCESS: 404 | // nothing to do 405 | break; 406 | case Thread::FAILURE: 407 | $error = new TaskException($data); 408 | break; 409 | case Thread::FATAL: 410 | $mustKill = true; 411 | $error = new TaskException($data); 412 | break; 413 | case Thread::UPDATE: 414 | $worker->future->update($data); 415 | return; // return here because the task is not yet complete 416 | default: 417 | $mustKill = true; 418 | $error = new TaskException( 419 | sprintf( 420 | 'Unexpected worker notification code: %s', 421 | ord($resultCode) ? $resultCode : 'null' 422 | ) 423 | ); 424 | } 425 | 426 | $this->outstandingTaskCount--; 427 | $worker->tasksExecuted++; 428 | 429 | if ($error) { 430 | $worker->future->fail($error); 431 | } else { 432 | $worker->future->succeed($data); 433 | } 434 | 435 | $this->afterTaskCompletion($worker, $mustKill); 436 | } 437 | 438 | private function afterTaskCompletion(Worker $worker, $mustKill) { 439 | if ($mustKill || $this->shouldRespawn($worker)) { 440 | $this->respawnWorker($worker); 441 | } else { 442 | $promiseId = $worker->promiseId; 443 | unset( 444 | $this->promiseWorkerMap[$promiseId], 445 | $this->promiseTimeoutMap[$promiseId] 446 | ); 447 | $worker->promiseId = $worker->promise = $worker->task = null; 448 | $this->availableWorkers[$worker->id] = $worker; 449 | } 450 | 451 | if ($this->queue && $this->availableWorkers) { 452 | $this->dequeueNextTask(); 453 | } 454 | } 455 | 456 | private function shouldRespawn(Worker $worker) { 457 | if ($this->executionLimit <= 0) { 458 | return false; 459 | } elseif ($worker->tasksExecuted >= $this->executionLimit) { 460 | return true; 461 | } else { 462 | return false; 463 | } 464 | } 465 | 466 | private function respawnWorker(Worker $worker) { 467 | $this->unloadWorker($worker); 468 | $this->spawnWorker(); 469 | } 470 | 471 | /** 472 | * Configure dispatcher options 473 | * 474 | * @param string $option A case-insensitive option key 475 | * @param mixed $value The value to assign 476 | * @throws \DomainException On unknown option key 477 | * @return \Amp\Dispatcher Returns the current object instance 478 | */ 479 | public function setOption($option, $value) { 480 | switch ($option) { 481 | case self::OPT_THREAD_FLAGS: 482 | $this->setThreadStartFlags($value); break; 483 | case self::OPT_POOL_SIZE_MIN: 484 | $this->setPoolSizeMin($value); break; 485 | case self::OPT_POOL_SIZE_MAX: 486 | $this->setPoolSizeMax($value); break; 487 | case self::OPT_TASK_TIMEOUT: 488 | $this->setTaskTimeout($value); break; 489 | case self::OPT_IDLE_WORKER_TIMEOUT: 490 | $this->setIdleWorkerTimeout($value); break; 491 | case self::OPT_EXEC_LIMIT: 492 | $this->setExecutionLimit($value); break; 493 | case self::OPT_IPC_URI: 494 | $this->setIpcUri($value); break; 495 | case self::OPT_UNIX_IPC_DIR: 496 | $this->setUnixIpcSocketDir($value); break; 497 | default: 498 | throw new \DomainException( 499 | sprintf('Unknown option: %s', $option) 500 | ); 501 | } 502 | 503 | return $this; 504 | } 505 | 506 | private function setIdleWorkerTimeout($seconds) { 507 | $this->idleWorkerTimeout = filter_var($int, FILTER_VALIDATE_INT, ['options' => [ 508 | 'min_range' => 1, 509 | 'default' => 1 510 | ]]); 511 | } 512 | 513 | private function setThreadStartFlags($flags) { 514 | $this->threadStartFlags = $flags; 515 | } 516 | 517 | private function setPoolSizeMin($int) { 518 | $this->poolSizeMin = filter_var($int, FILTER_VALIDATE_INT, ['options' => [ 519 | 'min_range' => 1, 520 | 'default' => 1 521 | ]]); 522 | } 523 | 524 | private function setPoolSizeMax($int) { 525 | $this->poolSizeMax = filter_var($int, FILTER_VALIDATE_INT, ['options' => [ 526 | 'min_range' => 1, 527 | 'default' => 8 528 | ]]); 529 | } 530 | 531 | private function setTaskTimeout($seconds) { 532 | $this->taskTimeout = filter_var($seconds, FILTER_VALIDATE_INT, ['options' => [ 533 | 'min_range' => 0, 534 | 'default' => 30 535 | ]]); 536 | } 537 | 538 | private function setExecutionLimit($int) { 539 | $this->executionLimit = filter_var($int, FILTER_VALIDATE_INT, ['options' => [ 540 | 'min_range' => -1, 541 | 'default' => 256 542 | ]]); 543 | } 544 | 545 | private function setIpcUri($uri) { 546 | if ($this->isStarted) { 547 | throw new \RuntimeException( 548 | 'Cannot assign IPC URI while the dispatcher is running!' 549 | ); 550 | } elseif (stripos($uri, 'unix://') === 0) { 551 | $transport = 'unix'; 552 | } elseif (stripos($uri, 'tcp://') === 0) { 553 | $transport = 'tcp'; 554 | } else { 555 | throw new \DomainException( 556 | 'Cannot set IPC server URI: tcp:// or unix:// URI scheme required' 557 | ); 558 | } 559 | 560 | $availableTransports = array_flip(stream_get_transports()); 561 | if (!isset($availableTransport[$transport])) { 562 | throw new \RuntimeException( 563 | sprintf('PHP is not compiled with support for %s:// streams', $transport) 564 | ); 565 | } 566 | 567 | $this->ipcUri = $uri; 568 | } 569 | 570 | private function setUnixIpcSocketDir($dir) { 571 | if (!is_dir($dir) || @mkdir($dir, $permissions = 0744, $recursive = true)) { 572 | throw new \RuntimeException( 573 | sprintf('Socket directory does not exist and could not be created: %s', $dir) 574 | ); 575 | } else { 576 | $this->unixIpcSocketDir = $dir; 577 | } 578 | } 579 | 580 | /** 581 | * Retrieve a count of all outstanding tasks (both queued and in-progress) 582 | * 583 | * @return int 584 | */ 585 | public function count() { 586 | return $this->outstandingTaskCount; 587 | } 588 | 589 | /** 590 | * Execute a Stackable task in the thread pool 591 | * 592 | * @param \Stackable $task 593 | * @return \Amp\Promise 594 | */ 595 | public function __invoke(\Stackable $task) { 596 | return $this->execute($task); 597 | } 598 | 599 | /** 600 | * Store a worker task to execute each time a worker spawns 601 | * 602 | * @param \Stackable $task 603 | * @return void 604 | */ 605 | public function addStartTask(\Stackable $task) { 606 | $this->workerStartTasks->attach($task); 607 | } 608 | 609 | /** 610 | * Clear a worker task currently stored for execution each time a worker spawns 611 | * 612 | * @param \Stackable $task 613 | * @return void 614 | */ 615 | public function removeStartTask(\Stackable $task) { 616 | if ($this->workerStartTasks->contains($task)) { 617 | $this->workerStartTasks->detach($task); 618 | } 619 | } 620 | 621 | /** 622 | * Assume unknown methods are dispatches for functions in the global namespace 623 | * 624 | * @param string $method 625 | * @param array $args 626 | * @return \Amp\Promise 627 | */ 628 | public function __call($method, $args) { 629 | array_unshift($args, $method); 630 | return call_user_func_array([$this, 'call'], $args); 631 | } 632 | 633 | public function __destruct() { 634 | $this->reactor->cancel($this->timeoutWatcher); 635 | 636 | foreach ($this->workers as $worker) { 637 | $this->unloadWorker($worker); 638 | } 639 | 640 | if (is_resource($this->ipcServer)) { 641 | @fclose($this->ipcServer); 642 | } 643 | 644 | if (stripos($this->ipcUri, 'unix://') === 0) { 645 | $this->unlinkUnixSocketPath($this->ipcUri); 646 | } 647 | } 648 | 649 | private function unlinkUnixSocketPath($absoluteUri) { 650 | $path = substr($absoluteUri, 7); 651 | if (file_exists($path)) { 652 | @unlink($unixPath); 653 | } 654 | } 655 | } 656 | -------------------------------------------------------------------------------- /lib/SharedData.php: -------------------------------------------------------------------------------- 1 | procedure = $procedure; 12 | $this->argCount = (func_num_args() - 1); 13 | 14 | if ($this->argCount > 0) { 15 | $args = func_get_args(); 16 | array_shift($args); 17 | for ($i=0; $i<$this->argCount; $i++) { 18 | $this->{"_$i"} = $args[$i]; 19 | } 20 | } 21 | } 22 | 23 | public function run() { 24 | try { 25 | if (!is_callable($this->procedure)) { 26 | throw new \BadFunctionCallException( 27 | sprintf("Function does not exist: %s", $this->procedure) 28 | ); 29 | } elseif ($this->argCount) { 30 | $args = []; 31 | for ($i=0; $i<$this->argCount; $i++) { 32 | $args[] = $this->{"_$i"}; 33 | } 34 | $result = call_user_func_array($this->procedure, $args); 35 | } else { 36 | $result = call_user_func($this->procedure); 37 | } 38 | 39 | $resultCode = Thread::SUCCESS; 40 | 41 | } catch (\Exception $e) { 42 | $resultCode = Thread::FAILURE; 43 | $result = $e->__toString(); 44 | } 45 | 46 | $this->worker->resolve($resultCode, $result); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /lib/TaskException.php: -------------------------------------------------------------------------------- 1 | worker->completedPreviousTask()) { 9 | $this->registerErrorResult(); 10 | } 11 | 12 | $this->worker->notifyDispatcher(); 13 | } 14 | 15 | private function registerErrorResult() { 16 | $fatals = [ 17 | E_ERROR, 18 | E_PARSE, 19 | E_USER_ERROR, 20 | E_CORE_ERROR, 21 | E_CORE_WARNING, 22 | E_COMPILE_ERROR, 23 | E_COMPILE_WARNING 24 | ]; 25 | 26 | $error = error_get_last(); 27 | 28 | if ($error && in_array($error['type'], $fatals)) { 29 | $resultCode = Thread::FATAL; 30 | $data = sprintf("%s in %s on line %d", $error['message'], $error['file'], $error['line']); 31 | } else { 32 | $resultCode = Thread::FAILURE; 33 | $data = "Stackable tasks MUST register results with the worker thread"; 34 | } 35 | 36 | $this->worker->resolve($resultCode, $data); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /lib/Thread.php: -------------------------------------------------------------------------------- 1 | results = $results; 19 | $this->resultCodes = $resultCodes; 20 | $this->ipcUri = $ipcUri; 21 | } 22 | 23 | public function run() { 24 | if (!$ipcSocket = @stream_socket_client($this->ipcUri, $errno, $errstr, 5)) { 25 | throw new \RuntimeException( 26 | sprintf("Failed connecting to IPC server: (%d) %s", $errno, $errstr) 27 | ); 28 | } 29 | 30 | stream_set_write_buffer($ipcSocket, 0); 31 | stream_socket_shutdown($ipcSocket, STREAM_SHUT_RD); 32 | 33 | $openMsg = $this->getThreadId() . "\n"; 34 | 35 | if (@fwrite($ipcSocket, $openMsg) !== strlen($openMsg)) { 36 | throw new \RuntimeException( 37 | "Failed writing open message to IPC server" 38 | ); 39 | } 40 | 41 | $this->ipcSocket = $ipcSocket; 42 | } 43 | 44 | private function resolve($resultCode, $data) { 45 | switch ($resultCode) { 46 | case self::SUCCESS: // fallthrough 47 | case self::FAILURE: // fallthrough 48 | case self::FATAL: // fallthrough 49 | break; 50 | default: 51 | $data = sprintf('Unknown task result code: %s', $resultCode); 52 | $resultCode = self::FATAL; 53 | } 54 | 55 | $this->results[] = $data; 56 | $this->resultCodes[] = $resultCode; 57 | } 58 | 59 | private function update($data) { 60 | $this->results[] = $data; 61 | $this->resultCodes[] = self::UPDATE; 62 | $this->notifyDispatcher(); 63 | } 64 | 65 | private function completedPreviousTask() { 66 | return ($resultCount = count($this->results)) && $resultCount === count($this->resultCodes); 67 | } 68 | 69 | private function notifyDispatcher() { 70 | if (!@fwrite($this->ipcSocket, '.')) { 71 | // Our IPC socket has died somehow ... all we can do now is exit. 72 | exit(1); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/TimeoutException.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./test 17 | 18 | 19 | 20 | 21 | 22 | ./src/ 23 | 24 | 25 | 26 | 27 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /test/AutoloadableClassFixture.php: -------------------------------------------------------------------------------- 1 | setOption($badOptionName, 42); 20 | } 21 | 22 | public function provideBadOptionKeys() { 23 | return [ 24 | [45678], 25 | ['unknownName'] 26 | ]; 27 | } 28 | 29 | public function testNativeFunctionDispatch() { 30 | $dispatcher = new Dispatcher(new NativeReactor); 31 | $value = $dispatcher->call('strlen', 'zanzibar!')->wait(); 32 | $this->assertEquals(9, $value); 33 | } 34 | 35 | public function testUserlandFunctionDispatch() { 36 | $dispatcher = new Dispatcher(new NativeReactor); 37 | $value = $dispatcher->call('Amp\Thread\Test\multiply', 6, 7)->wait(); 38 | $this->assertEquals($value, 42); 39 | } 40 | 41 | /** 42 | * @expectedException \Amp\Thread\DispatchException 43 | */ 44 | public function testErrorResultReturnedIfInvocationThrows() { 45 | $dispatcher = new Dispatcher(new NativeReactor); 46 | $dispatcher->call('exception')->wait(); // should throw 47 | } 48 | 49 | /** 50 | * @expectedException \Amp\Thread\DispatchException 51 | */ 52 | public function testErrorResultReturnedIfInvocationFatals() { 53 | $dispatcher = new Dispatcher(new NativeReactor); 54 | $dispatcher->call('fatal')->wait(); // should throw 55 | } 56 | 57 | public function testNextTaskDequeuedOnCompletion() { 58 | $reactor = new NativeReactor; 59 | $dispatcher = new Dispatcher($reactor); 60 | 61 | $count = 0; 62 | 63 | // Make sure the second call gets queued 64 | $dispatcher->setOption(Dispatcher::OPT_POOL_SIZE_MAX, 1); 65 | $dispatcher->call('usleep', 50000)->when(function() use (&$count) { 66 | $count++; 67 | }); 68 | 69 | $dispatcher->call('strlen', 'zanzibar')->when(function($error, $result) use ($reactor, &$count) { 70 | $count++; 71 | fwrite(STDERR, $error); 72 | $this->assertTrue(is_null($error)); 73 | $this->assertEquals(8, $result); 74 | $this->assertEquals(2, $count); 75 | $reactor->stop(); 76 | }); 77 | 78 | $reactor->run(); 79 | } 80 | 81 | public function testCount() { 82 | (new NativeReactor)->run(function($reactor) { 83 | $dispatcher = new Dispatcher($reactor); 84 | 85 | // Make sure repeated calls get queued behind the first call 86 | $dispatcher->setOption(Dispatcher::OPT_POOL_SIZE_MAX, 1); 87 | 88 | // Something semi-slow that will cause subsequent calls to be queued 89 | $dispatcher->call('usleep', 50000); 90 | $this->assertEquals(1, $dispatcher->count()); 91 | 92 | $dispatcher->call('strlen', 'zanzibar'); 93 | $this->assertEquals(2, $dispatcher->count()); 94 | 95 | $promise = $dispatcher->call('strlen', 'zanzibar'); 96 | $this->assertEquals(3, $dispatcher->count()); 97 | 98 | $promise->when(function($error, $result) use ($reactor, $dispatcher) { 99 | $reactor->stop(); 100 | $this->assertTrue(is_null($error)); 101 | $this->assertEquals(8, $result); 102 | $count = $dispatcher->count(); 103 | if ($count !== 0) { 104 | $this->fail( 105 | sprintf('Zero expected for dispatcher count; %d returned', $count) 106 | ); 107 | } 108 | }); 109 | }); 110 | } 111 | 112 | public function testNewWorkerIncludes() { 113 | (new NativeReactor)->run(function($reactor) { 114 | $dispatcher = new Dispatcher($reactor); 115 | $dispatcher->addStartTask(new TestAutoloaderStackable); 116 | $dispatcher->setOption(Dispatcher::OPT_POOL_SIZE_MAX, 1); 117 | $promise = $dispatcher->call('Amp\Thread\Test\AutoloadableClassFixture::test'); 118 | $promise->when(function($error, $result) use ($reactor) { 119 | $this->assertEquals(42, $result); 120 | $reactor->stop(); 121 | }); 122 | }); 123 | } 124 | 125 | public function testStreamingResult() { 126 | $this->expectOutputString("1\n2\n3\n4\n"); 127 | (new NativeReactor)->run('Amp\Thread\Test\testUpdate'); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /test/fixtures.php: -------------------------------------------------------------------------------- 1 | nonexistentMethod(); 20 | } 21 | 22 | class FatalStackable extends \Stackable { 23 | public function run() { 24 | $nonexistentObj->nonexistentMethod(); 25 | } 26 | } 27 | 28 | class ThrowingStackable extends \Stackable { 29 | public function run() { 30 | throw new \Exception('test'); 31 | } 32 | } 33 | 34 | class TestAutoloaderStackable extends \Stackable { 35 | public function run() { 36 | spl_autoload_register(function() { 37 | require_once __DIR__ . '/AutoloadableClassFixture.php'; 38 | }); 39 | } 40 | } 41 | 42 | class TestStreamStackable extends \Stackable { 43 | public function run() { 44 | $this->worker->update(1); 45 | $this->worker->update(2); 46 | $this->worker->update(3); 47 | $this->worker->update(4); 48 | $this->worker->registerResult(Thread::SUCCESS, null); 49 | } 50 | } 51 | 52 | function testUpdate($reactor) { 53 | $dispatcher = new Dispatcher($reactor); 54 | $promise = $dispatcher->execute(new TestStreamStackable); 55 | $promise->watch(function($update) { 56 | echo "$update\n"; 57 | }); 58 | $promise->when(function($error, $result) use ($reactor) { 59 | assert($result === null); 60 | $reactor->stop(); 61 | }); 62 | } 63 | --------------------------------------------------------------------------------