├── .gitignore ├── README.md ├── src ├── Exception │ └── PoolException.php ├── WaitGroup │ ├── WaitGroupInterface.php │ ├── Fiber.php │ ├── Swoole.php │ └── Swow.php ├── Barrier │ ├── Swow.php │ ├── Swoole.php │ ├── BarrierInterface.php │ └── Fiber.php ├── Context │ ├── Swoole.php │ ├── ContextInterface.php │ ├── Swow.php │ └── Fiber.php ├── Channel │ ├── Memory.php │ ├── ChannelInterface.php │ ├── Swoole.php │ ├── Swow.php │ └── Fiber.php ├── Barrier.php ├── PoolInterface.php ├── Utils │ └── DestructionWatcher.php ├── Locker.php ├── WaitGroup.php ├── Coroutine │ ├── Swow.php │ ├── CoroutineInterface.php │ ├── Swoole.php │ └── Fiber.php ├── Context.php ├── Parallel.php ├── Channel.php ├── Coroutine.php └── Pool.php ├── composer.json ├── stubs └── SwowStub.php ├── LICENSE └── tests ├── BarrierTest.php ├── WaitGroupTest.php ├── start.php ├── LockerTest.php ├── ContextTest.php ├── CoroutineTest.php ├── ChannelTest.php ├── ParallelTest.php ├── FiberChannelTest.php └── PoolTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor 3 | .idea 4 | tests/.phpunit.result.cache 5 | tests/workerman.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workerman coroutine library 2 | 3 | This is Workerman's coroutine library, which includes `Coroutine` `Channel` `Barrier` `Parallel` `Pool`. -------------------------------------------------------------------------------- /src/Exception/PoolException.php: -------------------------------------------------------------------------------- 1 | =8.1", 8 | "workerman/workerman": "^5.1" 9 | }, 10 | "autoload": { 11 | "psr-4": { 12 | "Workerman\\Coroutine\\": "src", 13 | "Workerman\\": "src" 14 | } 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^11.0", 18 | "psr/log": "*" 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Workerman\\Coroutine\\": "src", 23 | "Workerman\\": "src", 24 | "tests\\": "tests" 25 | } 26 | }, 27 | "scripts": { 28 | "test": "php tests/start.php start" 29 | }, 30 | "minimum-stability": "dev", 31 | "prefer-stable": true 32 | } 33 | -------------------------------------------------------------------------------- /stubs/SwowStub.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Barrier; 18 | 19 | use Swow\Sync\WaitReference; 20 | 21 | class Swow implements BarrierInterface 22 | { 23 | /** 24 | * @inheritDoc 25 | */ 26 | public static function wait(object &$barrier, int $timeout = -1): void 27 | { 28 | WaitReference::wait($barrier, $timeout); 29 | } 30 | 31 | /** 32 | * @inheritDoc 33 | */ 34 | public static function create(): object 35 | { 36 | return new WaitReference(); 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/Barrier/Swoole.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Barrier; 18 | 19 | use Swoole\Coroutine\Barrier as SwooleBarrier; 20 | class Swoole implements BarrierInterface 21 | { 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | public static function wait(object &$barrier, int $timeout = -1): void 27 | { 28 | SwooleBarrier::wait($barrier, $timeout); 29 | } 30 | 31 | /** 32 | * @inheritDoc 33 | */ 34 | public static function create(): object 35 | { 36 | return SwooleBarrier::make(); 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 workerman 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Barrier/BarrierInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Barrier; 18 | 19 | /** 20 | * Interface BarrierInterface 21 | */ 22 | interface BarrierInterface 23 | { 24 | /** 25 | * Wait for the barrier to be released. 26 | * 27 | * @param object $barrier 28 | * @param int $timeout 29 | * @return void 30 | */ 31 | public static function wait(object &$barrier, int $timeout = -1): void; 32 | 33 | /** 34 | * Create a new barrier instance. 35 | * 36 | * @return BarrierInterface 37 | */ 38 | public static function create(): object; 39 | } 40 | -------------------------------------------------------------------------------- /tests/BarrierTest.php: -------------------------------------------------------------------------------- 1 | assertNull($barrier, 'Barrier should be null after wait is called.'); 41 | $this->assertEquals([0, 1, 2, 3], $results, 'All coroutines should have been executed.'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/WaitGroup/Fiber.php: -------------------------------------------------------------------------------- 1 | count = 0; 26 | $this->channel = new Channel(1); 27 | } 28 | 29 | /** @inheritdoc */ 30 | public function add(int $delta = 1): bool 31 | { 32 | $this->count += max($delta, 1); 33 | 34 | return true; 35 | } 36 | 37 | /** @inheritdoc */ 38 | public function done(): bool 39 | { 40 | $this->count--; 41 | if ($this->count <= 0) { 42 | $this->channel->push(true); 43 | } 44 | 45 | return true; 46 | } 47 | 48 | /** @inheritdoc */ 49 | public function count(): int 50 | { 51 | return $this->count; 52 | } 53 | 54 | /** @inheritdoc */ 55 | public function wait(int|float $timeout = -1): bool 56 | { 57 | if ($this->count() > 0) { 58 | return $this->channel->pop($timeout); 59 | } 60 | return true; 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/WaitGroup/Swoole.php: -------------------------------------------------------------------------------- 1 | waitGroup = new WaitGroup(); 26 | } 27 | 28 | /** @inheritdoc */ 29 | public function add(int $delta = 1): bool 30 | { 31 | $this->waitGroup->add(max($delta, 1)); 32 | 33 | return true; 34 | } 35 | 36 | /** @inheritdoc */ 37 | public function done(): bool 38 | { 39 | if ($this->count() > 0) { 40 | $this->waitGroup->done(); 41 | } 42 | 43 | return true; 44 | } 45 | 46 | /** @inheritdoc */ 47 | public function count(): int 48 | { 49 | return $this->waitGroup->count(); 50 | } 51 | 52 | /** @inheritdoc */ 53 | public function wait(int|float $timeout = -1): bool 54 | { 55 | try { 56 | $this->waitGroup->wait(max($timeout, $timeout > 0 ? 0.001 : -1)); 57 | return true; 58 | } catch (Throwable) { 59 | return false; 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/WaitGroup/Swow.php: -------------------------------------------------------------------------------- 1 | waitGroup = new WaitGroup(); 25 | $this->count = 0; 26 | } 27 | 28 | /** @inheritdoc */ 29 | public function add(int $delta = 1): bool 30 | { 31 | $this->waitGroup->add($delta = max($delta, 1)); 32 | $this->count += $delta; 33 | 34 | return true; 35 | } 36 | 37 | /** @inheritdoc */ 38 | public function done(): bool 39 | { 40 | if ($this->count() > 0) { 41 | $this->count--; 42 | $this->waitGroup->done(); 43 | } 44 | 45 | return true; 46 | } 47 | 48 | /** @inheritdoc */ 49 | public function count(): int 50 | { 51 | return $this->count; 52 | } 53 | 54 | /** @inheritdoc */ 55 | public function wait(int|float $timeout = -1): bool 56 | { 57 | try { 58 | $this->waitGroup->wait($timeout > 0 ? (int) ($timeout * 1000) : $timeout); 59 | return true; 60 | } catch (Throwable) { 61 | return false; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Context/Swoole.php: -------------------------------------------------------------------------------- 1 | setFlags(ArrayObject::ARRAY_AS_PROPS); 21 | if ($name === null) { 22 | return $context; 23 | } 24 | return $context[$name] ?? $default; 25 | } 26 | 27 | /** 28 | * @inheritDoc 29 | */ 30 | public static function set(string $name, $value): void 31 | { 32 | Coroutine::getContext()[$name] = $value; 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | public static function has(string $name): bool 39 | { 40 | $context = Coroutine::getContext(); 41 | return $context->offsetExists($name); 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public static function reset(?ArrayObject $data = null): void 48 | { 49 | $context = Coroutine::getContext(); 50 | $context->setFlags(ArrayObject::ARRAY_AS_PROPS); 51 | $context->exchangeArray($data ?: []); 52 | } 53 | 54 | /** 55 | * @inheritDoc 56 | */ 57 | public static function destroy(): void 58 | { 59 | $context = Coroutine::getContext(); 60 | $context->exchangeArray([]); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Context/ContextInterface.php: -------------------------------------------------------------------------------- 1 | capacity = $capacity; 16 | } 17 | 18 | public function push(mixed $data, float $timeout = -1): bool 19 | { 20 | if ($this->closed) { 21 | return false; 22 | } 23 | if ($this->capacity > 0 && count($this->data) >= $this->capacity) { 24 | // Channel is full 25 | return false; 26 | } 27 | $this->data[] = $data; 28 | return true; 29 | } 30 | 31 | public function pop(float $timeout = -1): mixed 32 | { 33 | if (count($this->data) > 0) { 34 | return array_shift($this->data); 35 | } 36 | return false; 37 | } 38 | 39 | public function length(): int 40 | { 41 | return count($this->data); 42 | } 43 | 44 | public function getCapacity(): int 45 | { 46 | return $this->capacity; 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function hasConsumers(): bool 53 | { 54 | return false; 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function hasProducers(): bool 61 | { 62 | return false; 63 | } 64 | 65 | public function close(): void 66 | { 67 | $this->closed = true; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Barrier.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | use Workerman\Coroutine\Barrier\BarrierInterface; 20 | use Workerman\Events\Swoole; 21 | use Workerman\Events\Swow; 22 | use Workerman\Worker; 23 | 24 | /** 25 | * Class Barrier 26 | */ 27 | class Barrier implements BarrierInterface 28 | { 29 | 30 | /** 31 | * @var string 32 | */ 33 | protected static string $driver; 34 | 35 | /** 36 | * Get driver. 37 | * 38 | * @return string 39 | */ 40 | protected static function getDriver(): string 41 | { 42 | return static::$driver ??= match (Worker::$eventLoopClass) { 43 | Swoole::class => Barrier\Swoole::class, 44 | Swow::class => Barrier\Swow::class, 45 | default=> Barrier\Fiber::class, 46 | }; 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public static function wait(object &$barrier, int $timeout = -1): void 53 | { 54 | static::getDriver()::wait($barrier, $timeout); 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public static function create(): object 61 | { 62 | return static::getDriver()::create(); 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /src/PoolInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | /** 20 | * Interface PoolInterface 21 | */ 22 | interface PoolInterface 23 | { 24 | 25 | /** 26 | * Get a connection from the pool. 27 | * 28 | * @return mixed 29 | */ 30 | public function get(): mixed; 31 | 32 | /** 33 | * Put a connection back to the pool. 34 | * 35 | * @param object $connection 36 | * @return void 37 | */ 38 | public function put(object $connection): void; 39 | 40 | /** 41 | * Create a connection. 42 | * 43 | * @return object 44 | */ 45 | public function createConnection(): object; 46 | 47 | /** 48 | * Close the connection and remove the connection from the connection pool. 49 | * 50 | * @param object $connection 51 | * @return void 52 | */ 53 | public function closeConnection(object $connection): void; 54 | 55 | /** 56 | * Get the number of connections in the connection pool. 57 | * 58 | * @return int 59 | */ 60 | public function getConnectionCount(): int; 61 | 62 | /** 63 | * Close connections in the connection pool. 64 | * 65 | * @return void 66 | */ 67 | public function closeConnections(): void; 68 | 69 | } 70 | -------------------------------------------------------------------------------- /tests/WaitGroupTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(0, $waitGroup->count()); 24 | $results = [0]; 25 | $this->assertTrue($waitGroup->add()); 26 | Coroutine::create(function () use ($waitGroup, &$results) { 27 | try { 28 | Timer::sleep(0.1); 29 | $results[] = 1; 30 | } finally { 31 | $this->assertTrue($waitGroup->done()); 32 | } 33 | }); 34 | $this->assertTrue($waitGroup->add()); 35 | Coroutine::create(function () use ($waitGroup, &$results) { 36 | try { 37 | Timer::sleep(0.2); 38 | $results[] = 2; 39 | } finally { 40 | $this->assertTrue($waitGroup->done()); 41 | } 42 | }); 43 | $this->assertTrue($waitGroup->add()); 44 | Coroutine::create(function () use ($waitGroup, &$results) { 45 | try { 46 | Timer::sleep(0.3); 47 | $results[] = 3; 48 | } finally { 49 | $this->assertTrue($waitGroup->done()); 50 | } 51 | }); 52 | $this->assertTrue($waitGroup->wait()); 53 | $this->assertEquals(0, $waitGroup->count(), 'WaitGroup count should be 0 after wait is called.'); 54 | $this->assertEquals([0, 1, 2, 3], $results, 'All coroutines should have been executed.'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Utils/DestructionWatcher.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | namespace Workerman\Coroutine\Utils; 16 | 17 | use WeakMap; 18 | 19 | class DestructionWatcher 20 | { 21 | /** 22 | * @var WeakMap 23 | */ 24 | protected static WeakMap $objects; 25 | 26 | /** 27 | * @var callable[] 28 | */ 29 | protected array $callbacks = []; 30 | 31 | /** 32 | * DestructionWatcher constructor. 33 | * 34 | * @param callable|null $callback 35 | */ 36 | public function __construct(?callable $callback = null) 37 | { 38 | if ($callback) { 39 | $this->callbacks[] = $callback; 40 | } 41 | } 42 | 43 | /** 44 | * DestructionWatcher destructor. 45 | */ 46 | public function __destruct() 47 | { 48 | foreach (array_reverse($this->callbacks) as $callback) { 49 | $callback(); 50 | } 51 | } 52 | 53 | /** 54 | * Watch object destruction. 55 | * 56 | * @param object $object 57 | * @param callable $callback 58 | * @return void 59 | */ 60 | public static function watch(object $object, callable $callback): void 61 | { 62 | static::$objects ??= new WeakMap(); 63 | static::$objects[$object] ??= new static(); 64 | static::$objects[$object]->callbacks[] = $callback; 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /src/Locker.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | use RuntimeException; 20 | 21 | /** 22 | * Class Locker 23 | */ 24 | class Locker 25 | { 26 | /** 27 | * @var Channel[] 28 | */ 29 | protected static array $channels = []; 30 | 31 | /** 32 | * Lock. 33 | * 34 | * @param string $key 35 | * @return bool 36 | */ 37 | public static function lock(string $key): bool 38 | { 39 | if (!isset(static::$channels[$key])) { 40 | static::$channels[$key] = new Channel(1); 41 | } 42 | return static::$channels[$key]->push(true); 43 | } 44 | 45 | /** 46 | * Unlock. 47 | * 48 | * @param string $key 49 | * @return bool 50 | */ 51 | public static function unlock(string $key): bool 52 | { 53 | if ($channel = static::$channels[$key] ?? null) { 54 | // Must check hasProducers before pop, because pop in swow will wake up the producer, leading to inaccurate judgment. 55 | $hasProducers = $channel->hasProducers(); 56 | $result = $channel->pop(); 57 | if (!$hasProducers) { 58 | $channel->close(); 59 | unset(static::$channels[$key]); 60 | } 61 | return $result; 62 | } 63 | throw new RuntimeException("Unlock failed, because the key $key is not locked"); 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /src/Channel/ChannelInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Channel; 18 | 19 | /** 20 | * ChannelInterface 21 | */ 22 | interface ChannelInterface 23 | { 24 | /** 25 | * Push data to channel. 26 | * 27 | * @param mixed $data 28 | * @param float $timeout 29 | * @return bool 30 | */ 31 | public function push(mixed $data, float $timeout = -1): bool; 32 | 33 | /** 34 | * Pop data from channel. 35 | * 36 | * @param float $timeout 37 | * @return mixed 38 | */ 39 | public function pop(float $timeout = -1): mixed; 40 | 41 | /** 42 | * Get the length of channel. 43 | * 44 | * @return int 45 | */ 46 | public function length(): int; 47 | 48 | /** 49 | * Get the capacity of channel. 50 | * 51 | * @return int 52 | */ 53 | public function getCapacity(): int; 54 | 55 | /** 56 | * Check if there are consumers waiting to pop data from the channel. 57 | * 58 | * @return bool 59 | */ 60 | public function hasConsumers(): bool; 61 | 62 | /** 63 | * Check if there are producers waiting to push data to the channel. 64 | * 65 | * @return bool 66 | */ 67 | public function hasProducers(): bool; 68 | 69 | /** 70 | * Close the channel. 71 | * 72 | * @return void 73 | */ 74 | public function close(): void; 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/Context/Swow.php: -------------------------------------------------------------------------------- 1 | offsetExists($name); 46 | } 47 | 48 | /** 49 | * @inheritDoc 50 | */ 51 | public static function reset(?ArrayObject $data = null): void 52 | { 53 | $coroutine = Coroutine::getCurrent(); 54 | $data->setFlags(ArrayObject::ARRAY_AS_PROPS); 55 | static::$contexts[$coroutine] = $data; 56 | } 57 | 58 | /** 59 | * @inheritDoc 60 | */ 61 | public static function destroy(): void 62 | { 63 | unset(static::$contexts[Coroutine::getCurrent()]); 64 | } 65 | 66 | /** 67 | * Initialize the weakMap. 68 | * 69 | * @return void 70 | */ 71 | public static function initContext(): void 72 | { 73 | self::$contexts = new WeakMap(); 74 | } 75 | 76 | } 77 | 78 | Swow::initContext(); -------------------------------------------------------------------------------- /src/WaitGroup.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | protected static string $driverClass; 32 | 33 | /** 34 | * @var WaitGroupInterface 35 | */ 36 | protected WaitGroupInterface $driver; 37 | 38 | /** 39 | * 构造方法 40 | */ 41 | public function __construct() 42 | { 43 | $this->driver = new (self::driverClass()); 44 | } 45 | 46 | /** 47 | * Get driver class. 48 | * 49 | * @return class-string 50 | */ 51 | protected static function driverClass(): string 52 | { 53 | return static::$driverClass ??= match (Worker::$eventLoopClass ?? null) { 54 | Swoole::class => SwooleWaitGroup::class, 55 | Swow::class => SwowWaitGroup::class, 56 | default => FiberWaitGroup::class, 57 | }; 58 | } 59 | 60 | 61 | /** 62 | * 代理调用WaitGroupInterface方法 63 | * 64 | * @codeCoverageIgnore 系统魔术方法,忽略覆盖 65 | * @param string $name 66 | * @param array $arguments 67 | * @return mixed 68 | */ 69 | public function __call(string $name, array $arguments): mixed 70 | { 71 | if (!method_exists($this->driver, $name)) { 72 | throw new BadMethodCallException("Method $name not exists. "); 73 | } 74 | 75 | return $this->driver->$name(...$arguments); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Coroutine/Swow.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Coroutine; 18 | 19 | use Swow\Coroutine; 20 | 21 | /** 22 | * Class Swow 23 | */ 24 | class Swow extends Coroutine implements CoroutineInterface 25 | { 26 | 27 | /** 28 | * @var array 29 | */ 30 | private array $callbacks = []; 31 | 32 | /** 33 | * @inheritDoc 34 | */ 35 | public static function defer(callable $callable): void 36 | { 37 | $coroutine = static::getCurrent(); 38 | $coroutine->callbacks[] = $callable; 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public static function create(callable $callable, ...$args): CoroutineInterface 45 | { 46 | return static::run($callable, ...$args); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function start(mixed ...$args): mixed 53 | { 54 | return $this->resume(...$args); 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function id(): int 61 | { 62 | return $this->getId(); 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public static function suspend(mixed $value = null): mixed 69 | { 70 | return Coroutine::yield($value); 71 | } 72 | 73 | /** 74 | * @inheritDoc 75 | */ 76 | public static function isCoroutine(): bool 77 | { 78 | return true; 79 | } 80 | 81 | /** 82 | * Destructor. 83 | */ 84 | public function __destruct() 85 | { 86 | foreach (array_reverse($this->callbacks) as $callable) { 87 | $callable(); 88 | } 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | use ArrayObject; 20 | use Workerman\Coroutine\Context\ContextInterface; 21 | use Workerman\Events\Swoole; 22 | use Workerman\Events\Swow; 23 | use Workerman\Worker; 24 | 25 | /** 26 | * Class Context 27 | */ 28 | class Context implements ContextInterface 29 | { 30 | 31 | /** 32 | * @var class-string 33 | */ 34 | protected static string $driver; 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | public static function get(?string $name = null, mixed $default = null): mixed 40 | { 41 | return static::$driver::get($name, $default); 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public static function set(string $name, $value): void 48 | { 49 | static::$driver::set($name, $value); 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public static function has(string $name): bool 56 | { 57 | return static::$driver::has($name); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public static function reset(?ArrayObject $data = null): void 64 | { 65 | static::$driver::reset($data); 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public static function destroy(): void 72 | { 73 | static::$driver::destroy(); 74 | } 75 | 76 | /** 77 | * @return void 78 | */ 79 | public static function initDriver(): void 80 | { 81 | static::$driver ??= match (Worker::$eventLoopClass) { 82 | Swoole::class => Context\Swoole::class, 83 | Swow::class => Context\Swow::class, 84 | default=> Context\Fiber::class, 85 | }; 86 | } 87 | 88 | } 89 | 90 | Context::initDriver(); -------------------------------------------------------------------------------- /src/Channel/Swoole.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Channel; 18 | 19 | use Swoole\Coroutine\Channel; 20 | 21 | /** 22 | * Class Swoole 23 | */ 24 | class Swoole implements ChannelInterface 25 | { 26 | 27 | /** 28 | * @var Channel 29 | */ 30 | protected Channel $channel; 31 | 32 | /** 33 | * Constructor. 34 | * 35 | * @param int $capacity 36 | */ 37 | public function __construct(protected int $capacity = 1) 38 | { 39 | $this->channel = new Channel($capacity); 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function push(mixed $data, float $timeout = -1): bool 46 | { 47 | return $this->channel->push($data, $timeout); 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | public function pop(float $timeout = -1): mixed 54 | { 55 | return $this->channel->pop($timeout); 56 | } 57 | 58 | /** 59 | * @inheritDoc 60 | */ 61 | public function length(): int 62 | { 63 | return $this->channel->length(); 64 | } 65 | 66 | /** 67 | * @inheritDoc 68 | */ 69 | public function getCapacity(): int 70 | { 71 | return $this->channel->capacity; 72 | } 73 | 74 | /** 75 | * @inheritDoc 76 | */ 77 | public function hasConsumers(): bool 78 | { 79 | return $this->channel->stats()['consumer_num'] > 0; 80 | } 81 | 82 | /** 83 | * @inheritDoc 84 | */ 85 | public function hasProducers(): bool 86 | { 87 | return $this->channel->stats()['producer_num'] > 0; 88 | } 89 | 90 | /** 91 | * @inheritDoc 92 | */ 93 | public function close(): void 94 | { 95 | $this->channel->close(); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/Coroutine/CoroutineInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Coroutine; 18 | 19 | use Fiber; 20 | use Swow\Coroutine as SwowCoroutine; 21 | 22 | /** 23 | * Interface CoroutineInterface 24 | */ 25 | interface CoroutineInterface 26 | { 27 | 28 | /** 29 | * Create a coroutine. 30 | * 31 | * @param callable $callable 32 | * @param ...$data 33 | * @return CoroutineInterface 34 | */ 35 | public static function create(callable $callable, ...$data): CoroutineInterface; 36 | 37 | /** 38 | * Start a coroutine. 39 | * 40 | * @param mixed ...$args 41 | * @return mixed 42 | */ 43 | public function start(mixed ...$args): mixed; 44 | 45 | /** 46 | * Resume a coroutine. 47 | * 48 | * @param mixed ...$args 49 | * @return mixed 50 | */ 51 | public function resume(mixed ...$args): mixed; 52 | 53 | /** 54 | * Get the id of the coroutine. 55 | * 56 | * @return int 57 | */ 58 | public function id(): int; 59 | 60 | /** 61 | * Register a callable to be executed when the current fiber is destroyed 62 | * 63 | * @param callable $callable 64 | * @return void 65 | */ 66 | public static function defer(callable $callable): void; 67 | 68 | /** 69 | * Yield the coroutine. 70 | * 71 | * @param mixed|null $value 72 | * @return mixed 73 | */ 74 | public static function suspend(mixed $value = null): mixed; 75 | 76 | /** 77 | * Get the current coroutine. 78 | * 79 | * @return CoroutineInterface|Fiber|SwowCoroutine|static 80 | */ 81 | public static function getCurrent(): CoroutineInterface|Fiber|SwowCoroutine|static; 82 | 83 | /** 84 | * Check if the current coroutine is in a coroutine. 85 | * 86 | * @return bool 87 | */ 88 | public static function isCoroutine(): bool; 89 | 90 | } -------------------------------------------------------------------------------- /src/Channel/Swow.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Channel; 18 | 19 | use Swow\Channel; 20 | use Throwable; 21 | 22 | /** 23 | * Class Swow 24 | */ 25 | class Swow implements ChannelInterface 26 | { 27 | 28 | /** 29 | * @var Channel 30 | */ 31 | protected Channel $channel; 32 | 33 | /** 34 | * Constructor. 35 | * 36 | * @param int $capacity 37 | */ 38 | public function __construct(protected int $capacity = 1) 39 | { 40 | $this->channel = new Channel($capacity); 41 | } 42 | 43 | /** 44 | * @inheritDoc 45 | */ 46 | public function push(mixed $data, float $timeout = -1): bool 47 | { 48 | try { 49 | $this->channel->push($data, $timeout == -1 ? -1 : (int)($timeout * 1000)); 50 | } catch (Throwable) { 51 | return false; 52 | } 53 | return true; 54 | } 55 | 56 | /** 57 | * @inheritDoc 58 | */ 59 | public function pop(float $timeout = -1): mixed 60 | { 61 | try { 62 | return $this->channel->pop($timeout == -1 ? -1 : (int)($timeout * 1000)); 63 | } catch (Throwable) { 64 | return false; 65 | } 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function length(): int 72 | { 73 | return $this->channel->getLength(); 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function getCapacity(): int 80 | { 81 | return $this->channel->getCapacity(); 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public function hasConsumers(): bool 88 | { 89 | return $this->channel->hasConsumers(); 90 | } 91 | 92 | /** 93 | * @inheritDoc 94 | */ 95 | public function hasProducers(): bool 96 | { 97 | return $this->channel->hasProducers(); 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public function close(): void 104 | { 105 | $this->channel->close(); 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/Barrier/Fiber.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Barrier; 18 | 19 | use Revolt\EventLoop; 20 | use RuntimeException; 21 | use Workerman\Coroutine\Utils\DestructionWatcher; 22 | use Workerman\Timer; 23 | use Fiber as BaseFiber; 24 | use Workerman\Worker; 25 | 26 | /** 27 | * Class Fiber 28 | */ 29 | class Fiber implements BarrierInterface 30 | { 31 | 32 | /** 33 | * @inheritDoc 34 | */ 35 | public static function wait(object &$barrier, int $timeout = -1): void 36 | { 37 | $coroutine = BaseFiber::getCurrent(); 38 | $resumed = false; 39 | $timerId = null; 40 | 41 | if ($timeout > 0 && $coroutine) { 42 | $timerId = Timer::delay($timeout, function() use ($coroutine, &$resumed) { 43 | if (!$resumed) { 44 | $resumed = true; 45 | $coroutine->resume(); 46 | } 47 | }); 48 | } 49 | 50 | $coroutine && DestructionWatcher::watch($barrier, function() use ($coroutine, &$resumed, &$timerId) { 51 | if (!$resumed) { 52 | $resumed = true; 53 | if ($timerId !== null) { 54 | Timer::del($timerId); 55 | } 56 | // In PHP 8.4.0 and earlier, 57 | // switching fibers during the execution of an object's destructor method is not allowed, 58 | // so we implemented a delay. 59 | if ($coroutine instanceof BaseFiber) { 60 | Timer::delay(0.00001, function() use ($coroutine) { 61 | $coroutine->resume(); 62 | }); 63 | return; 64 | } 65 | EventLoop::defer(function () use ($coroutine) { 66 | $coroutine->resume(); 67 | }); 68 | } 69 | }); 70 | $barrier = null; 71 | $coroutine && BaseFiber::suspend(); 72 | } 73 | 74 | /** 75 | * @inheritDoc 76 | */ 77 | public static function create(): object 78 | { 79 | if (!Worker::isRunning()) { 80 | throw new RuntimeException('Fiber barrier only support in workerman runtime'); 81 | } 82 | return new self(); 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/Parallel.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | use Throwable; 20 | use Workerman\Coroutine; 21 | 22 | /** 23 | * Class Parallel 24 | */ 25 | class Parallel 26 | { 27 | /** 28 | * @var Channel|null 29 | */ 30 | protected ?Channel $channel = null; 31 | 32 | /** 33 | * @var array 34 | */ 35 | protected array $callbacks = []; 36 | 37 | /** 38 | * @var array 39 | */ 40 | protected array $results = []; 41 | 42 | /** 43 | * @var array 44 | */ 45 | protected array $exceptions = []; 46 | 47 | /** 48 | * Constructor. 49 | * 50 | * @param int $concurrent 51 | */ 52 | public function __construct(int $concurrent = -1) 53 | { 54 | if ($concurrent > 0) { 55 | $this->channel = new Channel($concurrent); 56 | } 57 | } 58 | 59 | /** 60 | * Add a coroutine. 61 | * 62 | * @param callable $callable 63 | * @param string|null $key 64 | * @return void 65 | */ 66 | public function add(callable $callable, ?string $key = null): void 67 | { 68 | if ($key === null) { 69 | $this->callbacks[] = $callable; 70 | } else { 71 | $this->callbacks[$key] = $callable; 72 | } 73 | } 74 | 75 | /** 76 | * Wait all coroutines complete and return results. 77 | * 78 | * @return array 79 | */ 80 | public function wait(): array 81 | { 82 | $barrier = Barrier::create(); 83 | foreach ($this->callbacks as $key => $callback) { 84 | $this->channel?->push(true); 85 | Coroutine::create(function () use ($callback, $key, $barrier) { 86 | try { 87 | $this->results[$key] = $callback(); 88 | } catch (Throwable $throwable) { 89 | $this->exceptions[$key] = $throwable; 90 | } finally { 91 | $this->channel?->pop(); 92 | } 93 | }); 94 | } 95 | Barrier::wait($barrier); 96 | return $this->results; 97 | } 98 | 99 | /** 100 | * Get failed results. 101 | * 102 | * @return array 103 | */ 104 | public function getExceptions(): array 105 | { 106 | return $this->exceptions; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Context/Fiber.php: -------------------------------------------------------------------------------- 1 | offsetExists($name); 61 | } 62 | return isset(static::$contexts[$fiber]) && static::$contexts[$fiber]->offsetExists($name); 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public static function reset(?ArrayObject $data = null): void 69 | { 70 | if ($data) { 71 | $data->setFlags(ArrayObject::ARRAY_AS_PROPS); 72 | } else { 73 | $data = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); 74 | } 75 | $fiber = BaseFiber::getCurrent(); 76 | if ($fiber === null) { 77 | static::$nonFiberContext = $data; 78 | return; 79 | } 80 | static::$contexts[$fiber] = $data; 81 | } 82 | 83 | /** 84 | * @inheritDoc 85 | */ 86 | public static function destroy(): void 87 | { 88 | $fiber = BaseFiber::getCurrent(); 89 | if ($fiber === null) { 90 | static::$nonFiberContext = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); 91 | return; 92 | } 93 | unset(static::$contexts[$fiber]); 94 | } 95 | 96 | /** 97 | * Initialize the weakMap. 98 | */ 99 | public static function initContext(): void 100 | { 101 | static::$contexts = new WeakMap(); 102 | static::$nonFiberContext = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); 103 | } 104 | 105 | } 106 | 107 | Fiber::initContext(); -------------------------------------------------------------------------------- /src/Channel.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | use InvalidArgumentException; 20 | use Workerman\Coroutine\Channel\ChannelInterface; 21 | use Workerman\Coroutine\Channel\Memory as ChannelMemory; 22 | use Workerman\Coroutine\Channel\Swoole as ChannelSwoole; 23 | use Workerman\Coroutine\Channel\Swow as ChannelSwow; 24 | use Workerman\Coroutine\Channel\Fiber as ChannelFiber; 25 | use Workerman\Events\Fiber; 26 | use Workerman\Events\Swoole; 27 | use Workerman\Events\Swow; 28 | use Workerman\Worker; 29 | 30 | /** 31 | * Class Channel 32 | */ 33 | class Channel implements ChannelInterface 34 | { 35 | 36 | /** 37 | * @var ChannelInterface 38 | */ 39 | protected ChannelInterface $driver; 40 | 41 | /** 42 | * Channel constructor. 43 | * 44 | * @param int $capacity 45 | */ 46 | public function __construct(int $capacity = 1) 47 | { 48 | if ($capacity < 1) { 49 | throw new InvalidArgumentException("The capacity must be greater than 0"); 50 | } 51 | $this->driver = match (Worker::$eventLoopClass) { 52 | Swoole::class => new ChannelSwoole($capacity), 53 | Swow::class => new ChannelSwow($capacity), 54 | Fiber::class => new ChannelFiber($capacity), 55 | default => new ChannelMemory($capacity), 56 | }; 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | public function push(mixed $data, float $timeout = -1): bool 63 | { 64 | return $this->driver->push($data, $timeout); 65 | } 66 | 67 | /** 68 | * @inheritDoc 69 | */ 70 | public function pop(float $timeout = -1): mixed 71 | { 72 | return $this->driver->pop($timeout); 73 | } 74 | 75 | /** 76 | * @inheritDoc 77 | */ 78 | public function length(): int 79 | { 80 | return $this->driver->length(); 81 | } 82 | 83 | /** 84 | * @inheritDoc 85 | */ 86 | public function getCapacity(): int 87 | { 88 | return $this->driver->getCapacity(); 89 | } 90 | 91 | /** 92 | * @inheritDoc 93 | */ 94 | public function hasConsumers(): bool 95 | { 96 | return $this->driver->hasConsumers(); 97 | } 98 | 99 | /** 100 | * @inheritDoc 101 | */ 102 | public function hasProducers(): bool 103 | { 104 | return $this->driver->hasProducers(); 105 | } 106 | 107 | /** 108 | * @inheritDoc 109 | */ 110 | public function close(): void 111 | { 112 | $this->driver->close(); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/start.php: -------------------------------------------------------------------------------- 1 | run([ 16 | __DIR__ . '/../vendor/bin/phpunit', 17 | '--colors=always', 18 | __DIR__ . '/ChannelTest.php', 19 | __DIR__ . '/PoolTest.php', 20 | __DIR__ . '/BarrierTest.php', 21 | __DIR__ . '/ContextTest.php', 22 | __DIR__ . '/WaitGroupTest.php', 23 | ]); 24 | }, Select::class); 25 | } 26 | 27 | if (extension_loaded('event')) { 28 | create_test_worker(function () { 29 | (new PHPUnit\TextUI\Application)->run([ 30 | __DIR__ . '/../vendor/bin/phpunit', 31 | '--colors=always', 32 | __DIR__ . '/ChannelTest.php', 33 | __DIR__ . '/PoolTest.php', 34 | __DIR__ . '/BarrierTest.php', 35 | __DIR__ . '/ContextTest.php', 36 | __DIR__ . '/WaitGroupTest.php', 37 | ]); 38 | }, Event::class); 39 | } 40 | 41 | if (class_exists(Revolt\EventLoop::class) && (DIRECTORY_SEPARATOR === '/' || !extension_loaded('swow'))) { 42 | create_test_worker(function () { 43 | (new PHPUnit\TextUI\Application)->run([ 44 | __DIR__ . '/../vendor/bin/phpunit', 45 | '--colors=always', 46 | ...glob(__DIR__ . '/*Test.php') 47 | ]); 48 | }, Fiber::class); 49 | } 50 | 51 | if (extension_loaded('Swoole')) { 52 | create_test_worker(function () { 53 | (new PHPUnit\TextUI\Application)->run([ 54 | __DIR__ . '/../vendor/bin/phpunit', 55 | '--colors=always', 56 | ...glob(__DIR__ . '/*Test.php') 57 | ]); 58 | }, Swoole::class); 59 | } 60 | 61 | if (extension_loaded('Swow')) { 62 | create_test_worker(function () { 63 | (new PHPUnit\TextUI\Application)->run([ 64 | __DIR__ . '/../vendor/bin/phpunit', 65 | '--colors=always', 66 | ...glob(__DIR__ . '/*Test.php') 67 | ]); 68 | }, Swow::class); 69 | } 70 | 71 | function create_test_worker(Closure $callable, $eventLoopClass): void 72 | { 73 | $worker = new Worker(); 74 | $worker->eventLoop = $eventLoopClass; 75 | $worker->onWorkerStart = function () use ($callable, $eventLoopClass) { 76 | $fp = fopen(__FILE__, 'r+'); 77 | flock($fp, LOCK_EX); 78 | echo PHP_EOL . PHP_EOL. PHP_EOL . '[TEST EVENT-LOOP: ' . basename(str_replace('\\', '/', $eventLoopClass)) . ']' . PHP_EOL; 79 | try { 80 | $callable(); 81 | } catch (Throwable $e) { 82 | echo $e; 83 | } finally { 84 | flock($fp, LOCK_UN); 85 | } 86 | Timer::repeat(1, function () use ($fp) { 87 | if (flock($fp, LOCK_EX | LOCK_NB)) { 88 | if(function_exists('posix_kill')) { 89 | posix_kill(posix_getppid(), SIGINT); 90 | } else { 91 | Worker::stopAll(); 92 | } 93 | } 94 | }); 95 | }; 96 | } 97 | 98 | Worker::runAll(); 99 | -------------------------------------------------------------------------------- /src/Coroutine.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman; 18 | 19 | use Workerman\Coroutine\Coroutine\CoroutineInterface; 20 | use Workerman\Coroutine\Coroutine\Fiber; 21 | use Workerman\Worker; 22 | use Workerman\Coroutine\Coroutine\Swoole as SwooleCoroutine; 23 | use Workerman\Coroutine\Coroutine\Swow as SwowCoroutine; 24 | use Workerman\Events\Swoole as SwooleEvent; 25 | use Workerman\Events\Swow as SwowEvent; 26 | 27 | /** 28 | * Class Coroutine 29 | */ 30 | class Coroutine implements CoroutineInterface 31 | { 32 | /** 33 | * @var class-string 34 | */ 35 | protected static string $driverClass; 36 | 37 | /** 38 | * @var CoroutineInterface 39 | */ 40 | public CoroutineInterface $driver; 41 | 42 | /** 43 | * Coroutine constructor. 44 | * 45 | * @param callable $callable 46 | */ 47 | public function __construct(callable $callable) 48 | { 49 | $this->driver = new static::$driverClass($callable); 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public static function create(callable $callable, ...$args): CoroutineInterface 56 | { 57 | return static::$driverClass::create($callable, ...$args); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function start(mixed ...$args): mixed 64 | { 65 | return $this->driver->start(...$args); 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function resume(mixed ...$args): mixed 72 | { 73 | return $this->driver->resume(...$args); 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function id(): int 80 | { 81 | return $this->driver->id(); 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public static function defer(callable $callable): void 88 | { 89 | static::$driverClass::defer($callable); 90 | } 91 | 92 | /** 93 | * @inheritDoc 94 | */ 95 | public static function suspend(mixed $value = null): mixed 96 | { 97 | return static::$driverClass::suspend($value); 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public static function getCurrent(): CoroutineInterface 104 | { 105 | return static::$driverClass::getCurrent(); 106 | } 107 | 108 | /** 109 | * @inheritDoc 110 | */ 111 | public static function isCoroutine(): bool 112 | { 113 | return static::$driverClass::isCoroutine(); 114 | } 115 | 116 | /** 117 | * @return void 118 | */ 119 | public static function init(): void 120 | { 121 | static::$driverClass = match (Worker::$eventLoopClass ?? null) { 122 | SwooleEvent::class => SwooleCoroutine::class, 123 | SwowEvent::class => SwowCoroutine::class, 124 | default => Fiber::class, 125 | }; 126 | } 127 | 128 | } 129 | Coroutine::init(); 130 | -------------------------------------------------------------------------------- /tests/LockerTest.php: -------------------------------------------------------------------------------- 1 | assertChannelExists($key); 25 | Locker::lock($key); 26 | $timeDiff = microtime(true) - $timeStart; 27 | $this->assertGreaterThan($timeDiff2, $timeDiff); 28 | Locker::unlock($key); 29 | }); 30 | usleep(100000); 31 | $timeDiff2 = microtime(true) - $timeStart; 32 | Locker::unlock($key); 33 | } 34 | 35 | public function testLockAndUnlock() 36 | { 37 | $key = 'testLockAndUnlock'; 38 | $this->assertTrue(Locker::lock($key)); 39 | $this->assertTrue(Locker::unlock($key)); 40 | $this->assertChannelRemoved($key); 41 | } 42 | 43 | public function testUnlockWithoutLockThrowsException() 44 | { 45 | $this->expectException(RuntimeException::class); 46 | Locker::unlock('non_existent_key'); 47 | } 48 | 49 | public function testRelockAfterUnlock() 50 | { 51 | $key = 'testRelockAfterUnlock'; 52 | Locker::lock($key); 53 | Locker::unlock($key); 54 | 55 | $this->assertTrue(Locker::lock($key)); 56 | Locker::unlock($key); 57 | $this->assertChannelRemoved($key); 58 | } 59 | 60 | public function testMultipleCoroutinesLocking() 61 | { 62 | $key = 'testMultipleCoroutinesLocking'; 63 | $results = []; 64 | Coroutine::create(function () use ($key, &$results) { 65 | Coroutine::create(function () use ($key, &$results) { 66 | Locker::lock($key); 67 | $results[] = 'A'; 68 | Timer::sleep(0.1); 69 | usleep(100000); 70 | Locker::unlock($key); 71 | }); 72 | 73 | Coroutine::create(function () use ($key, &$results) { 74 | Timer::sleep(0.05); 75 | Locker::lock($key); 76 | $results[] = 'B'; 77 | Locker::unlock($key); 78 | }); 79 | 80 | Coroutine::create(function () use ($key, &$results) { 81 | Timer::sleep(0.05); 82 | Locker::lock($key); 83 | $results[] = 'C'; 84 | Locker::unlock($key); 85 | }); 86 | 87 | }); 88 | 89 | Timer::sleep(0.3); 90 | $this->assertEquals(['A', 'B', 'C'], $results); 91 | $this->assertChannelRemoved($key); 92 | } 93 | 94 | public function testChannelRemainsWhenWaiting() 95 | { 96 | $key = 'testChannelRemainsWhenWaiting'; 97 | Locker::lock($key); 98 | 99 | Coroutine::create(function () use ($key) { 100 | Coroutine::create(function () use ($key) { 101 | Locker::lock($key); 102 | Locker::unlock($key); 103 | }); 104 | 105 | Locker::unlock($key); 106 | 107 | $this->assertChannelRemoved($key); 108 | }); 109 | } 110 | 111 | private function assertChannelExists(string $key): void 112 | { 113 | $channels = $this->getChannels(); 114 | $this->assertArrayHasKey($key, $channels, "Channel for key '$key' should exist"); 115 | } 116 | 117 | private function assertChannelRemoved(string $key): void 118 | { 119 | $channels = $this->getChannels(); 120 | $this->assertArrayNotHasKey($key, $channels, "Channel for key '$key' should be removed"); 121 | } 122 | 123 | private function getChannels(): array 124 | { 125 | $reflector = new ReflectionClass(Locker::class); 126 | $property = $reflector->getProperty('channels'); 127 | return $property->getValue(); 128 | } 129 | 130 | } -------------------------------------------------------------------------------- /src/Coroutine/Swoole.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Coroutine; 18 | 19 | use RuntimeException; 20 | use Swoole\Coroutine; 21 | use WeakReference; 22 | 23 | class Swoole implements CoroutineInterface 24 | { 25 | 26 | /** 27 | * @var array 28 | */ 29 | private static array $instances = []; 30 | 31 | /** 32 | * @var int 33 | */ 34 | private int $id = 0; 35 | 36 | /** 37 | * @var callable|null 38 | */ 39 | private $callable; 40 | 41 | /** 42 | * Coroutine constructor. 43 | * 44 | * @param callable|null $callable 45 | */ 46 | public function __construct(?callable $callable = null) 47 | { 48 | $this->callable = $callable; 49 | } 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | public static function create(callable $callable, ...$args): CoroutineInterface 55 | { 56 | $id = Coroutine::create($callable, ...$args); 57 | if (isset(self::$instances[$id]) && $coroutine = self::$instances[$id]->get()) { 58 | return $coroutine; 59 | } 60 | $coroutine = new self($callable); 61 | $coroutine->id = $id; 62 | self::$instances[$id] = WeakReference::create($coroutine); 63 | return $coroutine; 64 | } 65 | 66 | /** 67 | * @inheritDoc 68 | */ 69 | public function start(mixed ...$args): CoroutineInterface 70 | { 71 | if ($this->id) { 72 | throw new RuntimeException('Coroutine has already started'); 73 | } 74 | $this->id = Coroutine::create($this->callable, ...$args); 75 | $this->callable = null; 76 | if (isset(self::$instances[$this->id]) && $coroutine = self::$instances[$this->id]->get()) { 77 | return $coroutine; 78 | } 79 | self::$instances[$this->id] = WeakReference::create($this); 80 | return $this; 81 | } 82 | 83 | /** 84 | * @inheritDoc 85 | */ 86 | public function resume(mixed ...$args): mixed 87 | { 88 | return Coroutine::resume($this->id, ...$args); 89 | } 90 | 91 | /** 92 | * @inheritDoc 93 | */ 94 | public function id(): int 95 | { 96 | return $this->id; 97 | } 98 | 99 | /** 100 | * @inheritDoc 101 | */ 102 | public static function defer(callable $callable): void 103 | { 104 | Coroutine::defer($callable); 105 | } 106 | 107 | /** 108 | * @inheritDoc 109 | */ 110 | public static function suspend(mixed $value = null): mixed 111 | { 112 | return Coroutine::suspend($value); 113 | } 114 | 115 | /** 116 | * @inheritDoc 117 | */ 118 | public static function getCurrent(): CoroutineInterface 119 | { 120 | $id = Coroutine::getCid(); 121 | if ($id === -1) { 122 | throw new RuntimeException('Not in coroutine'); 123 | } 124 | if (!isset(self::$instances[$id])) { 125 | $coroutine = new self(); 126 | $coroutine->id = $id; 127 | self::$instances[$id] = WeakReference::create($coroutine); 128 | } 129 | return self::$instances[$id]->get(); 130 | } 131 | 132 | /** 133 | * @inheritDoc 134 | */ 135 | public static function isCoroutine(): bool 136 | { 137 | return Coroutine::getCid() > 0; 138 | } 139 | 140 | /** 141 | * Destructor. 142 | */ 143 | public function __destruct() 144 | { 145 | unset(self::$instances[$this->id]); 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/Coroutine/Fiber.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Coroutine; 18 | 19 | use Fiber as BaseFiber; 20 | use RuntimeException; 21 | use WeakMap; 22 | use Workerman\Coroutine\Utils\DestructionWatcher; 23 | 24 | /** 25 | * Class Fiber 26 | */ 27 | class Fiber implements CoroutineInterface 28 | { 29 | /** 30 | * @var BaseFiber|null 31 | */ 32 | private ?BaseFiber $fiber; 33 | 34 | /** 35 | * @var WeakMap 36 | */ 37 | private static WeakMap $instances; 38 | 39 | /** 40 | * @var int 41 | */ 42 | private int $id; 43 | 44 | /** 45 | * @param callable|null $callable 46 | */ 47 | public function __construct(?callable $callable = null) 48 | { 49 | static $id = 0; 50 | $this->id = ++$id; 51 | if ($callable) { 52 | $callable = function(...$args) use ($callable) { 53 | try { 54 | $callable(...$args); 55 | } finally { 56 | $this->fiber = null; 57 | } 58 | }; 59 | $this->fiber = new BaseFiber($callable); 60 | self::$instances[$this->fiber] = $this; 61 | } 62 | } 63 | 64 | /** 65 | * @inheritDoc 66 | */ 67 | public static function create(callable $callable, ...$args): CoroutineInterface 68 | { 69 | $fiber = new Fiber($callable); 70 | $fiber->start(...$args); 71 | return $fiber; 72 | } 73 | 74 | /** 75 | * @inheritDoc 76 | */ 77 | public function start(mixed ...$args): mixed 78 | { 79 | return $this->fiber->start(...$args); 80 | } 81 | 82 | /** 83 | * @inheritDoc 84 | */ 85 | public function resume(mixed ...$args): mixed 86 | { 87 | return $this->fiber->resume(...$args); 88 | } 89 | 90 | /** 91 | * @inheritDoc 92 | */ 93 | public static function suspend(mixed $value = null): mixed 94 | { 95 | return BaseFiber::suspend($value); 96 | } 97 | 98 | /** 99 | * @inheritDoc 100 | */ 101 | public function id(): int 102 | { 103 | return $this->id; 104 | } 105 | 106 | /** 107 | * @inheritDoc 108 | */ 109 | public static function defer(callable $callable): void 110 | { 111 | $baseFiber = BaseFiber::getCurrent(); 112 | if ($baseFiber === null) { 113 | throw new RuntimeException('Cannot defer outside of a fiber.'); 114 | } 115 | DestructionWatcher::watch($baseFiber, $callable); 116 | } 117 | 118 | /** 119 | * @inheritDoc 120 | */ 121 | public static function getCurrent(): CoroutineInterface 122 | { 123 | if (!$baseFiber = BaseFiber::getCurrent()) { 124 | throw new RuntimeException('Not in fiber context'); 125 | } 126 | if (!isset(self::$instances[$baseFiber])) { 127 | $fiber = new Fiber(); 128 | $fiber->fiber = $baseFiber; 129 | self::$instances[$baseFiber] = $fiber; 130 | } 131 | return self::$instances[$baseFiber]; 132 | } 133 | 134 | /** 135 | * @inheritDoc 136 | */ 137 | public static function isCoroutine(): bool 138 | { 139 | return BaseFiber::getCurrent() !== null; 140 | } 141 | 142 | /** 143 | * Initialize the fiber. 144 | * 145 | * @return void 146 | */ 147 | public static function init(): void 148 | { 149 | self::$instances = new WeakMap(); 150 | } 151 | 152 | } 153 | 154 | Fiber::init(); 155 | -------------------------------------------------------------------------------- /tests/ContextTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('value', Context::get($key)); 19 | }); 20 | } 21 | 22 | public function testContextGet() 23 | { 24 | Context::reset(new ArrayObject(['not_exist' => 'value'])); 25 | $key = 'testContextGet'; 26 | Context::reset(new ArrayObject([$key => 'value'])); 27 | $context = Context::get(); 28 | $this->assertArrayNotHasKey('not_exist', $context); 29 | $this->assertObjectNotHasProperty('not_exist', $context); 30 | $this->assertArrayHasKey($key, $context); 31 | $this->assertObjectHasProperty($key, $context); 32 | $this->assertEquals('value', $context[$key]); 33 | $this->assertEquals('value', $context->$key); 34 | $this->assertInstanceOf('ArrayObject', $context); 35 | unset($context[$key]); 36 | $this->assertNull(Context::get($key)); 37 | $context[$key] = 'value'; 38 | $this->assertEquals('value', Context::get($key)); 39 | unset($context->$key); 40 | $this->assertNull(Context::get($key)); 41 | $context->$key = 'value'; 42 | $this->assertEquals('value', Context::get($key)); 43 | } 44 | 45 | public function testContextIsolationBetweenCoroutines() 46 | { 47 | $values = []; 48 | 49 | Coroutine::create(function () use (&$values) { 50 | Context::set('key', 'value1'); 51 | $values[] = Context::get('key'); 52 | // Ensure the value is not available after coroutine ends 53 | Context::destroy(); 54 | }); 55 | 56 | Coroutine::create(function () use (&$values) { 57 | Context::set('key', 'value2'); 58 | $values[] = Context::get('key'); 59 | // Ensure the value is not available after coroutine ends 60 | Context::destroy(); 61 | }); 62 | 63 | $this->assertEquals(['value1', 'value2'], $values); 64 | } 65 | 66 | public function testContextDestroyedAfterCoroutineEnds() 67 | { 68 | Coroutine::create(function () { 69 | Context::set('key', 'value'); 70 | $this->assertTrue(Context::has('key')); 71 | // Simulate coroutine end and context destruction 72 | Context::destroy(); 73 | }); 74 | 75 | // After coroutine ends, the context should be destroyed 76 | // Need to simulate this by trying to access context outside coroutine 77 | $this->assertNull(Context::get('key')); 78 | $this->assertFalse(Context::has('key')); 79 | } 80 | 81 | public function testContextHasMethod() 82 | { 83 | Coroutine::create(function () { 84 | $this->assertFalse(Context::has('key')); 85 | Context::set('key', 'value'); 86 | $this->assertTrue(Context::has('key')); 87 | }); 88 | } 89 | 90 | public function testContextResetMethod() 91 | { 92 | Coroutine::create(function () { 93 | Context::reset(new ArrayObject(['key3' => 'value1'])); 94 | Context::reset(new ArrayObject(['key1' => 'value1', 'key2' => 'value2'])); 95 | $this->assertEquals('value1', Context::get('key1')); 96 | $this->assertEquals('value2', Context::get('key2')); 97 | // Test that other keys are not set 98 | $this->assertNull(Context::get('key3')); 99 | }); 100 | } 101 | 102 | public function testContextDataNotSharedBetweenCoroutines() 103 | { 104 | $result = []; 105 | 106 | Coroutine::create(function () use (&$result) { 107 | Context::set('counter', 1); 108 | $result[] = Context::get('counter'); 109 | Context::destroy(); 110 | }); 111 | 112 | Coroutine::create(function () use (&$result) { 113 | $this->assertNull(Context::get('counter')); 114 | Context::set('counter', 2); 115 | $result[] = Context::get('counter'); 116 | Context::destroy(); 117 | }); 118 | 119 | $this->assertEquals([1, 2], $result); 120 | } 121 | 122 | public function testContextDefaultValues() 123 | { 124 | Coroutine::create(function () { 125 | $this->assertEquals('default', Context::get('non_existing_key', 'default')); 126 | }); 127 | } 128 | 129 | public function testContextSetOverrideValue() 130 | { 131 | Coroutine::create(function () { 132 | Context::set('key', 'initial'); 133 | $this->assertEquals('initial', Context::get('key')); 134 | Context::set('key', 'overridden'); 135 | $this->assertEquals('overridden', Context::get('key')); 136 | }); 137 | } 138 | 139 | public function testContextMultipleKeys() 140 | { 141 | Coroutine::create(function () { 142 | Context::set('key1', 'value1'); 143 | Context::set('key2', 'value2'); 144 | $this->assertEquals('value1', Context::get('key1')); 145 | $this->assertEquals('value2', Context::get('key2')); 146 | }); 147 | } 148 | 149 | public function testContextPersistenceWithinCoroutine() 150 | { 151 | Coroutine::create(function () { 152 | Context::set('key', 'value'); 153 | 154 | // Simulate asynchronous operation within coroutine 155 | $this->someAsyncOperation(function () { 156 | $this->assertEquals('value', Context::get('key')); 157 | }); 158 | 159 | // Context should persist throughout the coroutine 160 | $this->assertEquals('value', Context::get('key')); 161 | }); 162 | } 163 | 164 | private function someAsyncOperation(callable $callback) 165 | { 166 | // Simulate async operation 167 | $callback(); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /tests/CoroutineTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(CoroutineInterface::class, $coroutine); 18 | } 19 | 20 | public function testStartExecutesCoroutine() 21 | { 22 | $value = null; 23 | Coroutine::create(function() use (&$value) { 24 | $value = 'started'; 25 | }); 26 | $this->assertEquals('started', $value); 27 | } 28 | 29 | public function testSuspendAndResumeCoroutine() 30 | { 31 | if (Worker::$eventLoopClass === Swoole::class) { 32 | // Swoole does not support suspend and resume 33 | $this->assertTrue(true); 34 | return; 35 | } 36 | $value = []; 37 | $coroutine = Coroutine::create(function() use (&$value) { 38 | $value[] = 'before suspend'; 39 | $resumedValue = Coroutine::suspend(); 40 | $value[] = 'after resume'; 41 | $value[] = $resumedValue; 42 | }); 43 | $this->assertEquals(['before suspend'], $value); 44 | $coroutine->resume('resumed data'); 45 | unset($coroutine); 46 | gc_collect_cycles(); 47 | $this->assertEquals(['before suspend', 'after resume', 'resumed data'], $value); 48 | } 49 | 50 | public function testGetCurrentReturnsCurrentCoroutine() 51 | { 52 | $currentCoroutine = null; 53 | $coroutine = Coroutine::create(function() use (&$currentCoroutine) { 54 | $currentCoroutine = Coroutine::getCurrent(); 55 | }); 56 | $this->assertSame($coroutine, $currentCoroutine); 57 | } 58 | 59 | public function testCoroutineIdIsInteger() 60 | { 61 | $coroutine = Coroutine::create(function() {}); 62 | $id = $coroutine->id(); 63 | $this->assertIsInt($id); 64 | } 65 | 66 | public function testDeferExecutesAfterCoroutineDestruction() 67 | { 68 | $value = []; 69 | $coroutine = Coroutine::create(function() use (&$value) { 70 | Coroutine::defer(function() use (&$value) { 71 | $value[] = 'defer1'; 72 | }); 73 | Coroutine::defer(function() use (&$value) { 74 | $value[] = 'defer2'; 75 | }); 76 | $value[] = 'before suspend'; 77 | Coroutine::suspend(); 78 | $value[] = 'after resume'; 79 | }); 80 | $this->assertEquals(['before suspend'], $value); 81 | $coroutine->resume(); 82 | unset($coroutine); 83 | gc_collect_cycles(); 84 | $this->assertEquals(['before suspend', 'after resume', 'defer2', 'defer1'], $value); 85 | } 86 | 87 | public function testMultipleCoroutines() 88 | { 89 | $sequence = []; 90 | $coroutine1 = Coroutine::create(function() use (&$sequence) { 91 | $sequence[] = 'coroutine1 start'; 92 | Coroutine::suspend(); 93 | $sequence[] = 'coroutine1 resumed'; 94 | }); 95 | $coroutine2 = Coroutine::create(function() use (&$sequence) { 96 | $sequence[] = 'coroutine2 start'; 97 | Coroutine::suspend(); 98 | $sequence[] = 'coroutine2 resumed'; 99 | }); 100 | $this->assertEquals(['coroutine1 start', 'coroutine2 start'], $sequence); 101 | $coroutine1->resume(); 102 | $coroutine2->resume(); 103 | $this->assertEquals( 104 | ['coroutine1 start', 'coroutine2 start', 'coroutine1 resumed', 'coroutine2 resumed'], 105 | $sequence 106 | ); 107 | } 108 | 109 | public function testCoroutineWithArguments() 110 | { 111 | $result = null; 112 | $coroutine = new Coroutine(function($a, $b) use (&$result) { 113 | $result = $a + $b; 114 | }); 115 | $coroutine->start(2, 3); 116 | $this->assertEquals(5, $result); 117 | } 118 | 119 | public function testSuspendReturnsValue() 120 | { 121 | if (Worker::$eventLoopClass === Swoole::class) { 122 | // Swoole does not support suspend and resume 123 | $this->assertTrue(true); 124 | return; 125 | } 126 | $coroutine = new Coroutine(function() { 127 | $valueFromResume = Coroutine::suspend('first suspend'); 128 | Coroutine::suspend($valueFromResume); 129 | }); 130 | $first_suspend = $coroutine->start(); 131 | $this->assertEquals('first suspend', $first_suspend); 132 | $result = $coroutine->resume('value from resume'); 133 | $this->assertEquals('value from resume', $result); 134 | } 135 | 136 | public function testNestedCoroutines() 137 | { 138 | $sequence = []; 139 | $coroutine = Coroutine::create(function() use (&$sequence) { 140 | $sequence[] = 'outer start'; 141 | $inner = Coroutine::create(function() use (&$sequence) { 142 | $sequence[] = 'inner start'; 143 | Coroutine::suspend(); 144 | $sequence[] = 'inner resumed'; 145 | }); 146 | Coroutine::suspend(); 147 | $sequence[] = 'outer resumed'; 148 | $inner->resume(); 149 | $sequence[] = 'outer end'; 150 | }); 151 | $this->assertEquals(['outer start', 'inner start'], $sequence); 152 | $coroutine->resume(); 153 | $this->assertEquals(['outer start', 'inner start', 'outer resumed', 'inner resumed', 'outer end'], $sequence); 154 | } 155 | 156 | /*public function testCoroutineExceptionHandling() 157 | { 158 | $this->expectException(\Exception::class); 159 | $this->expectExceptionMessage('Test exception'); 160 | Coroutine::create(function() { 161 | throw new \Exception('Test exception'); 162 | }); 163 | }*/ 164 | 165 | public function testDeferOrder() 166 | { 167 | $value = []; 168 | $coroutine = Coroutine::create(function() use (&$value) { 169 | Coroutine::defer(function() use (&$value) { 170 | $value[] = 'defer1'; 171 | }); 172 | Coroutine::defer(function() use (&$value) { 173 | $value[] = 'defer2'; 174 | }); 175 | $value[] = 'coroutine body'; 176 | }); 177 | unset($coroutine); 178 | // Force garbage collection 179 | gc_collect_cycles(); 180 | $this->assertEquals(['coroutine body', 'defer2', 'defer1'], $value); 181 | } 182 | 183 | } 184 | 185 | -------------------------------------------------------------------------------- /src/Channel/Fiber.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine\Channel; 18 | 19 | use Fiber as BaseFiber; 20 | use RuntimeException; 21 | use Workerman\Timer; 22 | use WeakMap; 23 | use Workerman\Worker; 24 | 25 | /** 26 | * Channel 27 | */ 28 | class Fiber implements ChannelInterface 29 | { 30 | /** 31 | * @var array 32 | */ 33 | private array $queue = []; 34 | 35 | /** 36 | * @var WeakMap 37 | */ 38 | private WeakMap $waitingPush; 39 | 40 | /** 41 | * @var WeakMap 42 | */ 43 | private WeakMap $waitingPop; 44 | 45 | /** 46 | * @var int 47 | */ 48 | private int $capacity; 49 | 50 | /** 51 | * @var bool 52 | */ 53 | private bool $closed = false; 54 | 55 | /** 56 | * Constructor 57 | * 58 | * @param int $capacity 59 | */ 60 | public function __construct(int $capacity = 1) 61 | { 62 | $this->capacity = $capacity; 63 | $this->waitingPush = new WeakMap(); 64 | $this->waitingPop = new WeakMap(); 65 | } 66 | 67 | /** 68 | * @inheritDoc 69 | */ 70 | public function push(mixed $data, float $timeout = -1): bool 71 | { 72 | if ($this->closed) { 73 | return false; 74 | } 75 | 76 | if (count($this->queue) >= $this->capacity) { 77 | 78 | if ($timeout == 0) { 79 | return false; 80 | } 81 | 82 | $fiber = BaseFiber::getCurrent(); 83 | if ($fiber === null) { 84 | throw new RuntimeException("Fiber::getCurrent() returned null. Ensure this method is called within a Fiber context."); 85 | } 86 | 87 | $this->waitingPush[$fiber] = true; 88 | 89 | $timedOut = false; 90 | $timerId = null; 91 | if ($timeout > 0 && Worker::isRunning()) { 92 | $timerId = Timer::delay($timeout, function () use ($fiber, &$timedOut) { 93 | $timedOut = true; 94 | if ($fiber->isSuspended()) { 95 | unset($this->waitingPush[$fiber]); 96 | $fiber->resume(false); 97 | } 98 | }); 99 | } 100 | 101 | BaseFiber::suspend(); 102 | unset($this->waitingPush[$fiber]); 103 | 104 | if (!$timedOut && $timerId) { 105 | Timer::del($timerId); 106 | } 107 | 108 | if ($timedOut) { 109 | return false; 110 | } 111 | 112 | // If the channel is closed while waiting, return false. 113 | if ($this->closed) { 114 | return false; 115 | } 116 | 117 | } 118 | 119 | foreach ($this->waitingPop as $popFiber => $_) { 120 | unset($this->waitingPop[$popFiber]); 121 | if ($popFiber->isSuspended()) { 122 | $popFiber->resume($data); 123 | return true; 124 | } 125 | } 126 | 127 | $this->queue[] = $data; 128 | return true; 129 | } 130 | 131 | /** 132 | * @inheritDoc 133 | */ 134 | public function pop(float $timeout = -1): mixed 135 | { 136 | if ($this->closed && empty($this->queue)) { 137 | return false; 138 | } 139 | 140 | if (empty($this->queue)) { 141 | if ($timeout == 0) { 142 | return false; 143 | } 144 | 145 | $fiber = BaseFiber::getCurrent(); 146 | if ($fiber === null) { 147 | throw new RuntimeException("Fiber::getCurrent() returned null. Ensure this method is called within a Fiber context."); 148 | } 149 | 150 | $this->waitingPop[$fiber] = true; 151 | 152 | $timedOut = false; 153 | $timerId = null; 154 | if ($timeout > 0) { 155 | Worker::isRunning() && $timerId = Timer::delay($timeout, function () use ($fiber, &$timedOut) { 156 | $timedOut = true; 157 | if ($fiber->isSuspended()) { 158 | unset($this->waitingPop[$fiber]); 159 | $fiber->resume(false); 160 | } 161 | }); 162 | } 163 | 164 | $data = BaseFiber::suspend(); 165 | 166 | unset($this->waitingPop[$fiber]); 167 | 168 | if (!$timedOut && $timerId !== null) { 169 | Timer::del($timerId); 170 | } 171 | 172 | if ($timedOut) { 173 | return false; 174 | } 175 | 176 | if ($data === false && $this->closed) { 177 | return false; 178 | } 179 | 180 | return $data; 181 | } 182 | 183 | $value = array_shift($this->queue); 184 | 185 | foreach ($this->waitingPush as $pushFiber => $_) { 186 | unset($this->waitingPush[$pushFiber]); 187 | if ($pushFiber->isSuspended()) { 188 | $pushFiber->resume(); 189 | break; 190 | } 191 | } 192 | 193 | return $value; 194 | } 195 | 196 | /** 197 | * @inheritDoc 198 | */ 199 | public function length(): int 200 | { 201 | return count($this->queue); 202 | } 203 | 204 | /** 205 | * @inheritDoc 206 | */ 207 | public function getCapacity(): int 208 | { 209 | return $this->capacity; 210 | } 211 | 212 | /** 213 | * @inheritDoc 214 | */ 215 | public function hasConsumers(): bool 216 | { 217 | return count($this->waitingPop) > 0; 218 | } 219 | 220 | /** 221 | * @inheritDoc 222 | */ 223 | public function hasProducers(): bool 224 | { 225 | return count($this->waitingPush) > 0; 226 | } 227 | 228 | /** 229 | * @inheritDoc 230 | */ 231 | public function close(): void 232 | { 233 | $this->closed = true; 234 | 235 | foreach ($this->waitingPush as $fiber => $_) { 236 | unset($this->waitingPush[$fiber]); 237 | if ($fiber->isSuspended()) { 238 | $fiber->resume(false); 239 | } 240 | } 241 | $this->waitingPush = new WeakMap(); 242 | 243 | foreach ($this->waitingPop as $fiber => $_) { 244 | unset($this->waitingPop[$fiber]); 245 | if ($fiber->isSuspended()) { 246 | $fiber->resume(false); 247 | } 248 | } 249 | $this->waitingPop = new WeakMap(); 250 | } 251 | 252 | } -------------------------------------------------------------------------------- /tests/ChannelTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Channel::class, $channel); 25 | $this->assertEquals(1, $channel->getCapacity()); 26 | } 27 | 28 | /** 29 | * Test initializing channel with invalid capacities. 30 | */ 31 | #[DataProvider('invalidCapacitiesProvider')] 32 | public function testInitializeWithInvalidCapacity($capacity) 33 | { 34 | $this->expectException(InvalidArgumentException::class); 35 | new Channel($capacity); 36 | } 37 | 38 | /** 39 | * Data provider for invalid capacities. 40 | */ 41 | public static function invalidCapacitiesProvider(): array 42 | { 43 | return [ 44 | [0], 45 | [-1], 46 | [-100] 47 | ]; 48 | } 49 | 50 | /** 51 | * Test pushing and popping data. 52 | */ 53 | public function testPushAndPop() 54 | { 55 | $channel = new Channel(2); 56 | $data1 = 'test data 1'; 57 | $data2 = 'test data 2'; 58 | 59 | // Push data into the channel 60 | $this->assertTrue($channel->push($data1)); 61 | $this->assertTrue($channel->push($data2)); 62 | 63 | // Verify the length of the channel 64 | $this->assertEquals(2, $channel->length()); 65 | 66 | // Pop data from the channel 67 | $this->assertEquals($data1, $channel->pop()); 68 | $this->assertEquals($data2, $channel->pop()); 69 | } 70 | 71 | /** 72 | * Test pushing data when the channel is full. 73 | * @throws ReflectionException 74 | */ 75 | public function testPushWhenFull() 76 | { 77 | // Memory driver does not support push with timeout 78 | if ($this->driverIsMemory()) { 79 | $this->assertTrue(true); 80 | return; 81 | } 82 | $channel = new Channel(1); 83 | $this->assertTrue($channel->push('data1')); 84 | 85 | $timeout = 0.5; 86 | // Attempt to push when the channel is full with a timeout 87 | $startTime = microtime(true); 88 | $this->assertFalse($channel->push('data2', $timeout)); 89 | $elapsedTime = microtime(true) - $startTime; 90 | 91 | // Verify that the push operation timed out 92 | $this->assertTrue(0.1 > abs($elapsedTime - $timeout)); 93 | } 94 | 95 | /** 96 | * Test popping data when the channel is empty. 97 | * @throws ReflectionException 98 | */ 99 | public function testPopWhenEmpty() 100 | { 101 | // Memory driver does not support push with timeout 102 | if ($this->driverIsMemory()) { 103 | $this->assertTrue(true); 104 | return; 105 | } 106 | $channel = new Channel(1); 107 | 108 | // Attempt to pop when the channel is empty with a timeout 109 | $startTime = microtime(true); 110 | $this->assertFalse($channel->pop(0.1)); 111 | $elapsedTime = microtime(true) - $startTime; 112 | 113 | // Verify that the pop operation timed out 114 | $this->assertGreaterThanOrEqual(0.09, $elapsedTime); 115 | } 116 | 117 | /** 118 | * Test closing the channel and its effects. 119 | */ 120 | public function testCloseChannel() 121 | { 122 | $channel = new Channel(1); 123 | $this->assertTrue($channel->push('data')); 124 | 125 | // Close the channel 126 | $channel->close(); 127 | 128 | // Attempt to push after closing 129 | $this->assertFalse($channel->push('new data')); 130 | 131 | // Pop the remaining data 132 | $this->assertEquals('data', $channel->pop()); 133 | 134 | // Attempt to pop after channel is empty and closed 135 | $this->assertFalse($channel->pop()); 136 | } 137 | 138 | /** 139 | * Test that push and pop return false when channel is closed. 140 | */ 141 | public function testPushAndPopReturnFalseWhenClosed() 142 | { 143 | $channel = new Channel(1); 144 | $channel->close(); 145 | 146 | $this->assertFalse($channel->push('data')); 147 | $this->assertFalse($channel->pop()); 148 | } 149 | 150 | /** 151 | * Test the length and capacity methods. 152 | */ 153 | public function testLengthAndCapacity() 154 | { 155 | $channel = new Channel(5); 156 | $this->assertEquals(0, $channel->length()); 157 | $this->assertEquals(5, $channel->getCapacity()); 158 | 159 | $channel->push('data1'); 160 | $channel->push('data2'); 161 | 162 | $this->assertEquals(2, $channel->length()); 163 | } 164 | 165 | /** 166 | * Test pushing and popping with different data types. 167 | */ 168 | #[DataProvider('dataTypesProvider')] 169 | public function testPushAndPopWithDifferentDataTypes($data) 170 | { 171 | $channel = new Channel(1); 172 | $this->assertTrue($channel->push($data)); 173 | $this->assertSame($data, $channel->pop()); 174 | } 175 | 176 | /** 177 | * Data provider for different data types. 178 | */ 179 | public static function dataTypesProvider(): array 180 | { 181 | return [ 182 | ['string'], 183 | [123], 184 | [123.456], 185 | [true], 186 | [false], 187 | [null], 188 | [[]], 189 | [['key' => 'value']], 190 | [new stdClass()], 191 | [fopen('php://memory', 'r')], 192 | ]; 193 | } 194 | 195 | /** 196 | * Test pushing to a closed channel immediately returns false. 197 | */ 198 | public function testPushToClosedChannel() 199 | { 200 | $channel = new Channel(1); 201 | $channel->close(); 202 | $this->assertFalse($channel->push('data', 0)); 203 | } 204 | 205 | /** 206 | * Test popping from a closed and empty channel immediately returns false. 207 | */ 208 | public function testPopFromClosedAndEmptyChannel() 209 | { 210 | $channel = new Channel(1); 211 | $channel->close(); 212 | $this->assertFalse($channel->pop(0)); 213 | } 214 | 215 | /** 216 | * @return bool 217 | * @throws ReflectionException 218 | */ 219 | protected function driverIsMemory(): bool 220 | { 221 | $reflectionClass = new ReflectionClass(Channel::class); 222 | $instance = $reflectionClass->newInstance(); 223 | $property = $reflectionClass->getProperty('driver'); 224 | $driverValue = $property->getValue($instance); 225 | return $driverValue instanceof Memory; 226 | } 227 | 228 | /** 229 | * 测试 hasConsumers 当没有消费者时返回 false 230 | */ 231 | public function testHasConsumersWhenNoConsumers() 232 | { 233 | if (!Coroutine::isCoroutine()) { 234 | $this->assertTrue(true); 235 | return; 236 | } 237 | $channel = new Channel(1); 238 | $this->assertFalse($channel->hasConsumers()); 239 | $channel->close(); 240 | } 241 | 242 | /** 243 | * 测试 hasConsumers 当有消费者等待时返回 true 244 | * @throws ReflectionException 245 | */ 246 | public function testHasConsumersWhenConsumersWaiting() 247 | { 248 | if ($this->driverIsMemory()) { 249 | $this->assertTrue(true); 250 | return; 251 | } 252 | $channel = new Channel(1); 253 | $sync = new Channel(1); 254 | 255 | Coroutine::create(function () use ($channel, $sync) { 256 | $sync->push(true); 257 | $channel->pop(); 258 | }); 259 | 260 | $sync->pop(); 261 | 262 | $this->assertTrue($channel->hasConsumers()); 263 | 264 | Coroutine::create(function () use ($channel) { 265 | $channel->push('data'); 266 | }); 267 | $channel->close(); 268 | } 269 | 270 | /** 271 | * 测试 hasProducers 当没有生产者时返回 false 272 | * @throws ReflectionException 273 | */ 274 | public function testHasProducersWhenNoProducers() 275 | { 276 | if ($this->driverIsMemory()) { 277 | $this->assertTrue(true); 278 | return; 279 | } 280 | $channel = new Channel(1); 281 | $this->assertFalse($channel->hasProducers()); 282 | $channel->close(); 283 | } 284 | 285 | /** 286 | * 测试 hasProducers 当有生产者等待时返回 true 287 | * @throws ReflectionException 288 | */ 289 | public function testHasProducersWhenProducersWaiting() 290 | { 291 | if ($this->driverIsMemory()) { 292 | $this->assertTrue(true); 293 | return; 294 | } 295 | $channel = new Channel(1); 296 | $channel->push('data1'); 297 | 298 | $sync = new Channel(1); 299 | 300 | Coroutine::create(function () use ($channel, $sync) { 301 | $sync->push(true); 302 | $channel->push('data2'); 303 | }); 304 | 305 | $sync->pop(); 306 | 307 | $this->assertTrue($channel->hasProducers()); 308 | 309 | $channel->pop(); 310 | $channel->close(); 311 | } 312 | 313 | } 314 | -------------------------------------------------------------------------------- /tests/ParallelTest.php: -------------------------------------------------------------------------------- 1 | add(function () { 23 | // Simulate some work. 24 | Timer::sleep(0.01); 25 | return 1; 26 | }, 'task1'); 27 | 28 | $parallel->add(function () { 29 | // Simulate some work. 30 | Timer::sleep(0.005); 31 | return 2; 32 | }, 'task2'); 33 | 34 | $results = $parallel->wait(); 35 | 36 | $this->assertEquals(['task1' => 1, 'task2' => 2], $results); 37 | } 38 | 39 | /** 40 | * Test that exceptions thrown in callables are caught and can be retrieved. 41 | */ 42 | public function testExceptions() 43 | { 44 | $parallel = new Parallel(); 45 | 46 | $parallel->add(function () { 47 | throw new \Exception('Test exception'); 48 | }, 'task_with_exception'); 49 | 50 | $parallel->add(function () { 51 | return 'normal result'; 52 | }, 'normal_task'); 53 | 54 | $results = $parallel->wait(); 55 | $exceptions = $parallel->getExceptions(); 56 | 57 | // Check that the normal task result is present. 58 | $this->assertEquals(['normal_task' => 'normal result'], $results); 59 | 60 | // Check that the exception is captured for the failing task. 61 | $this->assertArrayHasKey('task_with_exception', $exceptions); 62 | $this->assertInstanceOf(\Exception::class, $exceptions['task_with_exception']); 63 | $this->assertEquals('Test exception', $exceptions['task_with_exception']->getMessage()); 64 | } 65 | 66 | /** 67 | * Test concurrency control by limiting the number of concurrent tasks. 68 | */ 69 | public function testConcurrencyLimit() 70 | { 71 | $concurrentLimit = 2; 72 | $parallel = new Parallel($concurrentLimit); 73 | 74 | $startTimes = []; 75 | $endTimes = []; 76 | 77 | for ($i = 0; $i < 5; $i++) { 78 | $parallel->add(function () use (&$startTimes, &$endTimes, $i) { 79 | $startTimes[$i] = microtime(true); 80 | // Simulate some work. 81 | Timer::sleep(0.1); // 100 milliseconds 82 | $endTimes[$i] = microtime(true); 83 | return $i; 84 | }, "task{$i}"); 85 | } 86 | 87 | $parallel->wait(); 88 | 89 | // Since we limited concurrency to 2, tasks should finish in batches. 90 | // We'll check that at no point more than $concurrentLimit tasks were running simultaneously. 91 | 92 | // Collect start and end times into an array of intervals. 93 | $intervals = []; 94 | for ($i = 0; $i < 5; $i++) { 95 | $intervals[] = ['start' => $startTimes[$i], 'end' => $endTimes[$i]]; 96 | } 97 | 98 | // Check the maximum number of overlapping intervals does not exceed the concurrency limit. 99 | $maxConcurrent = $this->getMaxConcurrentIntervals($intervals); 100 | 101 | $this->assertLessThanOrEqual($concurrentLimit, $maxConcurrent); 102 | } 103 | 104 | /** 105 | * Helper function to determine the maximum number of overlapping intervals. 106 | * 107 | * @param array $intervals 108 | * @return int 109 | */ 110 | private function getMaxConcurrentIntervals(array $intervals) 111 | { 112 | $events = []; 113 | foreach ($intervals as $interval) { 114 | $events[] = ['time' => $interval['start'], 'type' => 'start']; 115 | $events[] = ['time' => $interval['end'], 'type' => 'end']; 116 | } 117 | 118 | // Sort events by time, 'start' before 'end' if times are equal. 119 | usort($events, function ($a, $b) { 120 | if ($a['time'] == $b['time']) { 121 | return $a['type'] === 'start' ? -1 : 1; 122 | } 123 | return $a['time'] < $b['time'] ? -1 : 1; 124 | }); 125 | 126 | $maxConcurrent = 0; 127 | $currentConcurrent = 0; 128 | 129 | foreach ($events as $event) { 130 | if ($event['type'] === 'start') { 131 | $currentConcurrent++; 132 | if ($currentConcurrent > $maxConcurrent) { 133 | $maxConcurrent = $currentConcurrent; 134 | } 135 | } else { 136 | $currentConcurrent--; 137 | } 138 | } 139 | 140 | return $maxConcurrent; 141 | } 142 | 143 | /** 144 | * Test that callables are executed in parallel when no concurrency limit is set. 145 | */ 146 | public function testParallelExecutionWithoutConcurrencyLimit() 147 | { 148 | $parallel = new Parallel(); 149 | 150 | $startTimes = []; 151 | $endTimes = []; 152 | 153 | $parallel->add(function () use (&$startTimes, &$endTimes) { 154 | $startTimes[] = microtime(true); 155 | Timer::sleep(0.1); // 100 milliseconds 156 | $endTimes[] = microtime(true); 157 | return 'task1'; 158 | }, 'task1'); 159 | 160 | $parallel->add(function () use (&$startTimes, &$endTimes) { 161 | $startTimes[] = microtime(true); 162 | Timer::sleep(0.1);// 100 milliseconds 163 | $endTimes[] = microtime(true); 164 | return 'task2'; 165 | }, 'task2'); 166 | 167 | $parallel->wait(); 168 | 169 | // Calculate total elapsed time. 170 | $totalTime = max($endTimes) - min($startTimes); 171 | 172 | // The total time should be approximately the duration of one task, not the sum of both. 173 | $this->assertLessThan(0.2, $totalTime); 174 | } 175 | 176 | /** 177 | * Test adding callables without specifying keys and ensure results are correctly indexed. 178 | */ 179 | public function testAddWithoutKeys() 180 | { 181 | $parallel = new Parallel(); 182 | 183 | $parallel->add(function () { 184 | return 'result1'; 185 | }); 186 | 187 | $parallel->add(function () { 188 | return 'result2'; 189 | }); 190 | 191 | $results = $parallel->wait(); 192 | 193 | // Since no keys were specified, indices should be 0 and 1. 194 | $this->assertEquals(['result1', 'result2'], $results); 195 | } 196 | 197 | /** 198 | * Test that the Parallel class can handle a large number of tasks. 199 | */ 200 | public function testLargeNumberOfTasks() 201 | { 202 | $parallel = new Parallel(); 203 | 204 | $taskCount = 100; 205 | for ($i = 0; $i < $taskCount; $i++) { 206 | $parallel->add(function () use ($i) { 207 | return $i * $i; 208 | }, "task{$i}"); 209 | } 210 | 211 | $results = $parallel->wait(); 212 | 213 | // Verify that all tasks have been completed and results are correct. 214 | for ($i = 0; $i < $taskCount; $i++) { 215 | $this->assertEquals($i * $i, $results["task{$i}"]); 216 | } 217 | } 218 | 219 | /** 220 | * Test that adding a non-callable throws a TypeError. 221 | */ 222 | public function testAddNonCallable() 223 | { 224 | $this->expectException(\TypeError::class); 225 | 226 | $parallel = new Parallel(); 227 | $parallel->add('not a callable'); 228 | } 229 | 230 | /** 231 | * Test that the wait method can be called multiple times safely. 232 | */ 233 | public function testMultipleWaitCalls() 234 | { 235 | $parallel = new Parallel(); 236 | 237 | $parallel->add(function () { 238 | return 'first call'; 239 | }, 'task1'); 240 | 241 | $resultsFirst = $parallel->wait(); 242 | 243 | $this->assertEquals(['task1' => 'first call'], $resultsFirst); 244 | 245 | // Add another task after first wait. 246 | $parallel->add(function () { 247 | return 'second call'; 248 | }, 'task2'); 249 | 250 | $resultsSecond = $parallel->wait(); 251 | 252 | // Since the callbacks array is not cleared after wait, results should include both tasks. 253 | $this->assertEquals(['task1' => 'first call', 'task2' => 'second call'], $resultsSecond); 254 | } 255 | 256 | /** 257 | * Test that the class properly handles empty tasks (no callables added). 258 | */ 259 | public function testNoTasks() 260 | { 261 | $parallel = new Parallel(); 262 | 263 | $results = $parallel->wait(); 264 | 265 | $this->assertEmpty($results); 266 | } 267 | 268 | /** 269 | * Test that the class handles tasks that return null. 270 | */ 271 | public function testTasksReturningNull() 272 | { 273 | $parallel = new Parallel(); 274 | 275 | $parallel->add(function () { 276 | // No return statement, implicitly returns null. 277 | }, 'nullTask'); 278 | 279 | $results = $parallel->wait(); 280 | 281 | $this->assertArrayHasKey('nullTask', $results); 282 | $this->assertNull($results['nullTask']); 283 | } 284 | 285 | /** 286 | * Test defer can be used in tasks. 287 | */ 288 | public function testWithDefer() 289 | { 290 | $parallel = new Parallel(); 291 | $results = []; 292 | $parallel->add(function () use (&$results) { 293 | Coroutine::defer(function () use (&$results) { 294 | $results[] = 'defer1'; 295 | }); 296 | }); 297 | $parallel->wait(); 298 | $this->assertEquals(['defer1'], $results); 299 | } 300 | 301 | } 302 | 303 | -------------------------------------------------------------------------------- /tests/FiberChannelTest.php: -------------------------------------------------------------------------------- 1 | push('test data'); 19 | }); 20 | 21 | $fiber->start(); 22 | 23 | $this->assertEquals('test data', $channel->pop()); 24 | } 25 | 26 | /** 27 | * Test that pop will block until data is available or timeout occurs. 28 | */ 29 | public function testPopWithTimeout() 30 | { 31 | $channel = new Channel(); 32 | 33 | $fiber = new BaseFiber(function() use ($channel) { 34 | $result = $channel->pop(0.5); 35 | $this->assertFalse($result); 36 | }); 37 | 38 | $startTime = microtime(true); 39 | 40 | $fiber->start(); 41 | 42 | // Allow time for the fiber to suspend and wait 43 | Timer::sleep(0.2); // 200 ms 44 | 45 | // Ensure that the fiber is still waiting (not timed out yet) 46 | $this->assertTrue($fiber->isSuspended()); 47 | 48 | // Wait until the timeout should have occurred 49 | Timer::sleep(0.4); // 400 ms 50 | 51 | $endTime = microtime(true); 52 | 53 | $this->assertTrue($fiber->isTerminated()); 54 | $this->assertGreaterThanOrEqual(0.5, $endTime - $startTime); 55 | } 56 | 57 | /** 58 | * Test that push will block when capacity is reached and timeout occurs. 59 | */ 60 | public function testPushWithTimeout() 61 | { 62 | $channel = new Channel(1); 63 | 64 | $this->assertTrue($channel->push('data1')); 65 | 66 | $fiber = new BaseFiber(function() use ($channel) { 67 | $result = $channel->push('data2', 0.5); 68 | $this->assertFalse($result); 69 | }); 70 | 71 | $startTime = microtime(true); 72 | 73 | $fiber->start(); 74 | 75 | // Allow time for the fiber to suspend and wait 76 | Timer::sleep(0.2); // 200 ms 77 | 78 | // Ensure that the fiber is still waiting (not timed out yet) 79 | $this->assertTrue($fiber->isSuspended()); 80 | 81 | // Wait until the timeout should have occurred 82 | Timer::sleep(0.4); // 400 ms 83 | 84 | $endTime = microtime(true); 85 | 86 | $this->assertTrue($fiber->isTerminated()); 87 | $this->assertGreaterThanOrEqual(0.5, $endTime - $startTime); 88 | } 89 | 90 | /** 91 | * Test that push returns false immediately if capacity is full and timeout is zero. 92 | */ 93 | public function testPushNonBlockingWhenFull() 94 | { 95 | $channel = new Channel(1); 96 | 97 | $this->assertTrue($channel->push('data1')); 98 | 99 | $result = $channel->push('data2', 0); 100 | $this->assertFalse($result); 101 | } 102 | 103 | /** 104 | * Test that pop returns false immediately if the channel is empty and timeout is zero. 105 | */ 106 | public function testPopNonBlockingWhenEmpty() 107 | { 108 | $channel = new Channel(); 109 | 110 | $result = $channel->pop(0); 111 | $this->assertFalse($result); 112 | } 113 | 114 | /** 115 | * Test closing the channel. 116 | */ 117 | public function testCloseChannel() 118 | { 119 | $channel = new Channel(); 120 | 121 | $channel->close(); 122 | 123 | $this->assertFalse($channel->push('data')); 124 | $this->assertFalse($channel->pop()); 125 | } 126 | 127 | /** 128 | * Test that waiting pushers and poppers are resumed when the channel is closed. 129 | */ 130 | public function testWaitersAreResumedOnClose() 131 | { 132 | $channelPush = new Channel(1); 133 | $channelPop = new Channel(1); 134 | 135 | $pushFiber = new BaseFiber(function() use ($channelPush) { 136 | $channelPush->push('data', 1); 137 | $result = $channelPush->push('data', 1); 138 | $this->assertFalse($result); 139 | }); 140 | 141 | $popFiber = new BaseFiber(function() use ($channelPop) { 142 | $result = $channelPop->pop(1); 143 | $this->assertFalse($result); 144 | }); 145 | 146 | $pushFiber->start(); 147 | $popFiber->start(); 148 | 149 | // Allow time for fibers to suspend 150 | Timer::sleep(0.1); // 100 ms 151 | 152 | // Close the channel to resume fibers 153 | $channelPush->close(); 154 | $channelPop->close(); 155 | 156 | // Allow time for fibers to process after resuming 157 | Timer::sleep(0.1); // 100 ms 158 | 159 | $this->assertTrue($pushFiber->isTerminated()); 160 | $this->assertTrue($popFiber->isTerminated()); 161 | } 162 | 163 | /** 164 | * Test that length and getCapacity methods return correct values. 165 | */ 166 | public function testLengthAndCapacity() 167 | { 168 | $capacity = 2; 169 | $channel = new Channel($capacity); 170 | 171 | $this->assertEquals(0, $channel->length()); 172 | $this->assertEquals($capacity, $channel->getCapacity()); 173 | 174 | $channel->push('data1'); 175 | $this->assertEquals(1, $channel->length()); 176 | 177 | $channel->push('data2'); 178 | $this->assertEquals(2, $channel->length()); 179 | 180 | $channel->pop(); 181 | $this->assertEquals(1, $channel->length()); 182 | 183 | $channel->pop(); 184 | $this->assertEquals(0, $channel->length()); 185 | } 186 | 187 | /** 188 | * Test pushing to a closed channel. 189 | */ 190 | public function testPushToClosedChannel() 191 | { 192 | $channel = new Channel(); 193 | 194 | $channel->close(); 195 | 196 | $result = $channel->push('data'); 197 | $this->assertFalse($result); 198 | } 199 | 200 | /** 201 | * Test popping from a closed channel. 202 | */ 203 | public function testPopFromClosedChannel() 204 | { 205 | $channel = new Channel(); 206 | 207 | $channel->push('data'); 208 | 209 | $channel->close(); 210 | 211 | $this->assertEquals('data', $channel->pop()); 212 | $this->assertFalse($channel->pop()); 213 | } 214 | 215 | /** 216 | * Test multiple push and pop operations with fibers. 217 | */ 218 | public function testMultiplePushPopWithFibers() 219 | { 220 | $channel = new Channel(2); 221 | 222 | $results = []; 223 | 224 | $producerFiber = new BaseFiber(function() use ($channel) { 225 | $channel->push('data1'); 226 | $channel->push('data2'); 227 | $channel->push('data3'); 228 | }); 229 | 230 | $consumerFiber = new BaseFiber(function() use ($channel, &$results) { 231 | $results[] = $channel->pop(); 232 | $results[] = $channel->pop(); 233 | $results[] = $channel->pop(); 234 | }); 235 | 236 | $producerFiber->start(); 237 | $consumerFiber->start(); 238 | 239 | // Allow time for fibers to execute 240 | usleep(500000); // 500 ms 241 | 242 | $this->assertEquals(['data1', 'data2', 'data3'], $results); 243 | } 244 | 245 | /** 246 | * Test that fibers are properly blocked and resumed in push and pop operations. 247 | */ 248 | public function testFiberBlockingAndResuming() 249 | { 250 | $channel = new Channel(1); 251 | 252 | $pushFiber = new BaseFiber(function() use ($channel) { 253 | $channel->push('data1'); 254 | $channel->push('data2'); 255 | $channel->push('data3'); 256 | }); 257 | 258 | $popFiber = new BaseFiber(function() use ($channel) { 259 | $this->assertEquals('data1', $channel->pop()); 260 | $this->assertEquals('data2', $channel->pop()); 261 | $this->assertEquals('data3', $channel->pop()); 262 | }); 263 | 264 | $pushFiber->start(); 265 | $popFiber->start(); 266 | 267 | // Allow time for fibers to execute 268 | Timer::sleep(0.5); // 500 ms 269 | 270 | $this->assertTrue($pushFiber->isTerminated()); 271 | $this->assertTrue($popFiber->isTerminated()); 272 | } 273 | 274 | /** 275 | * Test that pushing data after capacity is reached blocks until space is available. 276 | */ 277 | public function testPushBlocksWhenFull() 278 | { 279 | $channel = new Channel(1); 280 | 281 | $channel->push('data1'); 282 | 283 | $pushFiber = new BaseFiber(function() use ($channel) { 284 | $channel->push('data2'); 285 | }); 286 | 287 | $popFiber = new BaseFiber(function() use ($channel) { 288 | Timer::sleep(0.2); // Wait before popping 289 | $this->assertEquals('data1', $channel->pop()); 290 | }); 291 | 292 | $pushFiber->start(); 293 | $popFiber->start(); 294 | 295 | // Allow time for fibers to execute 296 | Timer::sleep(0.5); // 500 ms 297 | 298 | $this->assertTrue($pushFiber->isTerminated()); 299 | $this->assertTrue($popFiber->isTerminated()); 300 | } 301 | 302 | /** 303 | * Test that popping data from an empty channel blocks until data is available. 304 | */ 305 | public function testPopBlocksWhenEmpty() 306 | { 307 | $channel = new Channel(); 308 | 309 | $popFiber = new BaseFiber(function() use ($channel) { 310 | $this->assertEquals('data1', $channel->pop()); 311 | }); 312 | 313 | $pushFiber = new BaseFiber(function() use ($channel) { 314 | Timer::sleep(0.2); // Wait before pushing 315 | $channel->push('data1'); 316 | }); 317 | 318 | $popFiber->start(); 319 | $pushFiber->start(); 320 | 321 | // Allow time for fibers to execute 322 | Timer::sleep(0.5); // 500 ms 323 | 324 | $this->assertTrue($pushFiber->isTerminated()); 325 | $this->assertTrue($popFiber->isTerminated()); 326 | } 327 | 328 | /** 329 | * Test pushing and popping with zero timeout. 330 | */ 331 | public function testPushPopWithZeroTimeout() 332 | { 333 | $channel = new Channel(1); 334 | 335 | $this->assertTrue($channel->push('data1')); 336 | 337 | $result = $channel->push('data2', 0); 338 | $this->assertFalse($result); 339 | 340 | $result = $channel->pop(0); 341 | $this->assertEquals('data1', $result); 342 | 343 | $result = $channel->pop(0); 344 | $this->assertFalse($result); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/Pool.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright walkor 11 | * @link http://www.workerman.net/ 12 | * @license http://www.opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | 15 | declare(strict_types=1); 16 | 17 | namespace Workerman\Coroutine; 18 | 19 | use Closure; 20 | use Psr\Log\LoggerInterface; 21 | use stdClass; 22 | use Throwable; 23 | use WeakMap; 24 | use Workerman\Coroutine; 25 | use Workerman\Coroutine\Exception\PoolException; 26 | use Workerman\Coroutine\Utils\DestructionWatcher; 27 | use Workerman\Timer; 28 | use Workerman\Worker; 29 | 30 | /** 31 | * Class Pool 32 | */ 33 | class Pool implements PoolInterface 34 | { 35 | /** 36 | * @var Channel 37 | */ 38 | protected Channel $channel; 39 | 40 | /** 41 | * @var int 42 | */ 43 | protected int $minConnections = 1; 44 | 45 | /** 46 | * @var WeakMap 47 | */ 48 | protected WeakMap $connections; 49 | 50 | /** 51 | * @var ?object 52 | */ 53 | protected ?object $nonCoroutineConnection = null; 54 | 55 | /** 56 | * @var WeakMap 57 | */ 58 | protected WeakMap $lastUsedTimes; 59 | 60 | /** 61 | * @var WeakMap 62 | */ 63 | protected WeakMap $lastHeartbeatTimes; 64 | 65 | /** 66 | * @var Closure|null 67 | */ 68 | protected ?Closure $connectionCreateHandler = null; 69 | 70 | /** 71 | * @var Closure|null 72 | */ 73 | protected ?Closure $connectionDestroyHandler = null; 74 | 75 | /** 76 | * @var Closure|null 77 | */ 78 | protected ?Closure $connectionHeartbeatHandler = null; 79 | 80 | /** 81 | * @var float 82 | */ 83 | protected float $idleTimeout = 60; 84 | 85 | /** 86 | * @var float 87 | */ 88 | protected float $heartbeatInterval = 50; 89 | 90 | /** 91 | * @var float 92 | */ 93 | protected float $waitTimeout = 10; 94 | 95 | /** 96 | * @var LoggerInterface|Closure|null 97 | */ 98 | protected LoggerInterface|Closure|null $logger = null; 99 | 100 | /** 101 | * @var array|string[] 102 | */ 103 | private array $configurableProperties = [ 104 | 'minConnections', 105 | 'idleTimeout', 106 | 'heartbeatInterval', 107 | 'waitTimeout', 108 | ]; 109 | 110 | /** 111 | * Constructor. 112 | * 113 | * @param int $maxConnections 114 | * @param array $config 115 | */ 116 | public function __construct(protected int $maxConnections = 1, protected array $config = []) 117 | { 118 | foreach ($config as $key => $value) { 119 | $camelCaseKey = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key)))); 120 | if (in_array($camelCaseKey, $this->configurableProperties, true)) { 121 | $this->$camelCaseKey = $value; 122 | } 123 | } 124 | 125 | $this->channel = new Channel($maxConnections); 126 | $this->lastUsedTimes = new WeakMap(); 127 | $this->lastHeartbeatTimes = new WeakMap(); 128 | $this->connections = new WeakMap(); 129 | 130 | if (Worker::isRunning()) { 131 | Timer::repeat(1, function () { 132 | $this->checkConnections(); 133 | }); 134 | } 135 | } 136 | 137 | /** 138 | * Set the connection creator. 139 | * 140 | * @param callable $connectionCreateHandler 141 | * @return $this 142 | */ 143 | public function setConnectionCreator(callable $connectionCreateHandler): self 144 | { 145 | $this->connectionCreateHandler = $connectionCreateHandler; 146 | return $this; 147 | } 148 | 149 | /** 150 | * Set the connection closer. 151 | * 152 | * @param callable $connectionDestroyHandler 153 | * @return $this 154 | */ 155 | public function setConnectionCloser(callable $connectionDestroyHandler): self 156 | { 157 | $this->connectionDestroyHandler = $connectionDestroyHandler; 158 | return $this; 159 | } 160 | 161 | /** 162 | * Set the connection heartbeat checker. 163 | * 164 | * @param callable $connectionHeartbeatHandler 165 | * @return $this 166 | */ 167 | public function setHeartbeatChecker(callable $connectionHeartbeatHandler): self 168 | { 169 | $this->connectionHeartbeatHandler = $connectionHeartbeatHandler; 170 | return $this; 171 | } 172 | 173 | /** 174 | * Get connection. 175 | * 176 | * @return object 177 | * @throws Throwable 178 | */ 179 | public function get(): object 180 | { 181 | if (!Coroutine::isCoroutine()) { 182 | if (!$this->nonCoroutineConnection) { 183 | $this->nonCoroutineConnection = $this->createConnection(); 184 | } 185 | return $this->nonCoroutineConnection; 186 | } 187 | $num = $this->channel->length(); 188 | if ($num === 0 && $this->getConnectionCount() < $this->maxConnections) { 189 | return $this->createConnection(); 190 | } 191 | $connection = $this->channel->pop($this->waitTimeout); 192 | if (!$connection) { 193 | throw new PoolException("Failed to get a connection from the pool within the wait timeout ($this->waitTimeout seconds). The connection pool is exhausted."); 194 | } 195 | $this->lastUsedTimes[$connection] = time(); 196 | return $connection; 197 | } 198 | 199 | /** 200 | * Put connection to pool. 201 | * 202 | * @param object $connection 203 | * @return void 204 | * @throws Throwable 205 | */ 206 | public function put(object $connection): void 207 | { 208 | // This connection does not belong to the connection pool. 209 | // It may have been closed by $this->closeConnection($connection). 210 | if (!isset($this->connections[$connection])) { 211 | throw new PoolException('The connection does not belong to the connection pool.'); 212 | } 213 | if ($connection === $this->nonCoroutineConnection) { 214 | return; 215 | } 216 | try { 217 | $this->channel->push($connection); 218 | } catch (Throwable $throwable) { 219 | $this->closeConnection($connection); 220 | throw $throwable; 221 | } 222 | } 223 | 224 | /** 225 | * Check if the connection is valid. 226 | * 227 | * @param $connection 228 | * @return bool 229 | */ 230 | protected function isValidConnection($connection): bool 231 | { 232 | return is_object($connection); 233 | } 234 | 235 | /** 236 | * Create connection. 237 | * 238 | * @return object 239 | * @throws Throwable 240 | */ 241 | public function createConnection(): object 242 | { 243 | if ($this->getConnectionCount() >= $this->maxConnections) { 244 | throw new PoolException('CreateConnection failed, maximum connection limit reached.'); 245 | } 246 | // Create a placeholder to ensure the correct value of getConnectionCount(). 247 | $placeholder = new stdClass; 248 | $this->connections[$placeholder] = 0; 249 | try { 250 | // Coroutines will switch here, so we need $placeholder to ensure the correct value of getConnectionCount(). 251 | $connection = ($this->connectionCreateHandler)(); 252 | if (!$this->isValidConnection($connection)) { 253 | throw new PoolException('CreateConnection failed, expected a connection object, but got ' . gettype($connection) . '.'); 254 | } 255 | unset($this->connections[$placeholder]); 256 | $this->connections[$connection] = $this->lastUsedTimes[$connection] = $this->lastHeartbeatTimes[$connection] = time(); 257 | } catch (Throwable $throwable) { 258 | unset($this->connections[$placeholder]); 259 | throw $throwable; 260 | } 261 | return $connection; 262 | } 263 | 264 | /** 265 | * Close the connection and remove the connection from the connection pool. 266 | * 267 | * @param object $connection 268 | * @return void 269 | */ 270 | public function closeConnection(object $connection): void 271 | { 272 | if (!isset($this->connections[$connection])) { 273 | return; 274 | } 275 | // Mark this connection as no longer belonging to the connection pool. 276 | unset($this->lastUsedTimes[$connection], $this->lastHeartbeatTimes[$connection], $this->connections[$connection]); 277 | if ($this->nonCoroutineConnection === $connection) { 278 | $this->nonCoroutineConnection = null; 279 | } 280 | if (!$this->connectionDestroyHandler) { 281 | return; 282 | } 283 | try { 284 | ($this->connectionDestroyHandler)($connection); 285 | } catch (Throwable $throwable) { 286 | $this->log($throwable); 287 | } 288 | } 289 | 290 | /** 291 | * Cleanup idle connections. 292 | * 293 | * @return void 294 | */ 295 | protected function checkConnections(): void 296 | { 297 | $num = $this->channel->length(); 298 | $time = time(); 299 | for($i = $num; $i > 0; $i--) { 300 | $connection = $this->channel->pop(0.001); 301 | if (!$connection) { 302 | return; 303 | } 304 | $lastUsedTime = $this->lastUsedTimes[$connection]; 305 | if ($time - $lastUsedTime > $this->idleTimeout && $this->channel->length() >= $this->minConnections) { 306 | $this->closeConnection($connection); 307 | continue; 308 | } 309 | $this->trySendHeartbeat($connection) && $this->channel->push($connection); 310 | } 311 | if ($this->nonCoroutineConnection) { 312 | $this->trySendHeartbeat($this->nonCoroutineConnection); 313 | } 314 | } 315 | 316 | /** 317 | * Try to send heartbeat. 318 | * 319 | * @param $connection 320 | * @return bool 321 | */ 322 | private function trySendHeartbeat($connection): bool 323 | { 324 | $lastHeartbeatTime = $this->lastHeartbeatTimes[$connection] ?? 0; 325 | $time = time(); 326 | if ($this->connectionHeartbeatHandler && $time - $lastHeartbeatTime >= $this->heartbeatInterval) { 327 | try { 328 | ($this->connectionHeartbeatHandler)($connection); 329 | $this->lastHeartbeatTimes[$connection] = $time; 330 | } catch (Throwable $throwable) { 331 | $this->log($throwable); 332 | $this->closeConnection($connection); 333 | return false; 334 | } 335 | } 336 | return true; 337 | } 338 | 339 | /** 340 | * Get the number of connections in the connection pool. 341 | * 342 | * @return int 343 | */ 344 | public function getConnectionCount(): int 345 | { 346 | return count($this->connections); 347 | } 348 | 349 | /** 350 | * Close connections. 351 | * 352 | * @return void 353 | */ 354 | public function closeConnections(): void 355 | { 356 | $num = $this->channel->length(); 357 | for ($i = $num; $i > 0; $i--) { 358 | $connection = $this->channel->pop(0.001); 359 | if (!$connection) { 360 | return; 361 | } 362 | $this->closeConnection($connection); 363 | } 364 | $this->nonCoroutineConnection && $this->closeConnection($this->nonCoroutineConnection); 365 | } 366 | 367 | /** 368 | * Log. 369 | * 370 | * @param $message 371 | * @return void 372 | */ 373 | protected function log($message): void 374 | { 375 | if (!$this->logger) { 376 | echo $message . PHP_EOL; 377 | return; 378 | } 379 | if ($this->logger instanceof Closure) { 380 | ($this->logger)($message); 381 | return; 382 | } 383 | $this->logger->info((string)$message); 384 | } 385 | 386 | } 387 | -------------------------------------------------------------------------------- /tests/PoolTest.php: -------------------------------------------------------------------------------- 1 | 2, 27 | 'idle_timeout' => 30, 28 | 'heartbeat_interval' => 10, 29 | 'wait_timeout' => 5, 30 | ]; 31 | $pool = new Pool(10, $config); 32 | 33 | $this->assertEquals(10, $this->getPrivateProperty($pool, 'maxConnections')); 34 | $this->assertEquals(2, $this->getPrivateProperty($pool, 'minConnections')); 35 | $this->assertEquals(30, $this->getPrivateProperty($pool, 'idleTimeout')); 36 | $this->assertEquals(10, $this->getPrivateProperty($pool, 'heartbeatInterval')); 37 | $this->assertEquals(5, $this->getPrivateProperty($pool, 'waitTimeout')); 38 | } 39 | 40 | public function testSetConnectionCreator() 41 | { 42 | $pool = new Pool(5); 43 | $connectionCreator = function () { 44 | return new stdClass(); 45 | }; 46 | $pool->setConnectionCreator($connectionCreator); 47 | $this->assertSame($connectionCreator, $this->getPrivateProperty($pool, 'connectionCreateHandler')); 48 | } 49 | 50 | public function testSetConnectionCloser() 51 | { 52 | $pool = new Pool(5); 53 | $connectionCloser = function ($conn) { 54 | // Close connection. 55 | }; 56 | $pool->setConnectionCloser($connectionCloser); 57 | $this->assertSame($connectionCloser, $this->getPrivateProperty($pool, 'connectionDestroyHandler')); 58 | } 59 | 60 | public function testGetConnection() 61 | { 62 | $pool = new Pool(5); 63 | 64 | $connectionMock = $this->createMock(stdClass::class); 65 | 66 | // 设置连接创建器 67 | $pool->setConnectionCreator(function () use ($connectionMock) { 68 | return $connectionMock; 69 | }); 70 | 71 | $connection = $pool->get(); 72 | 73 | $this->assertSame($connectionMock, $connection); 74 | $this->assertEquals(1, $this->getCurrentConnections($pool)); 75 | 76 | // 检查 WeakMap 是否更新 77 | $connections = $this->getPrivateProperty($pool, 'connections'); 78 | $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); 79 | $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); 80 | 81 | $this->assertTrue($connections->offsetExists($connection)); 82 | $this->assertTrue($lastUsedTimes->offsetExists($connection)); 83 | $this->assertTrue($lastHeartbeatTimes->offsetExists($connection)); 84 | } 85 | 86 | public function testPutConnection() 87 | { 88 | $pool = new Pool(5); 89 | 90 | $connectionMock = $this->createMock(stdClass::class); 91 | 92 | $pool->setConnectionCreator(function () use ($connectionMock) { 93 | return $connectionMock; 94 | }); 95 | 96 | $connection = $pool->get(); 97 | 98 | $pool->put($connection); 99 | 100 | if (Coroutine::isCoroutine()) { 101 | $channel = $this->getPrivateProperty($pool, 'channel'); 102 | $this->assertEquals(1, $channel->length()); 103 | } 104 | 105 | $this->assertEquals(1, $pool->getConnectionCount()); 106 | } 107 | 108 | public function testPutConnectionDoesNotBelong() 109 | { 110 | $this->expectException(PoolException::class); 111 | $this->expectExceptionMessage('The connection does not belong to the connection pool.'); 112 | 113 | $pool = new Pool(5); 114 | $connection = new stdClass(); 115 | 116 | $pool->put($connection); 117 | } 118 | 119 | public function testCreateConnection() 120 | { 121 | $pool = new Pool(5); 122 | $connectionMock = $this->createMock(stdClass::class); 123 | 124 | $pool->setConnectionCreator(function () use ($connectionMock) { 125 | return $connectionMock; 126 | }); 127 | 128 | $connection = $pool->createConnection(); 129 | 130 | $this->assertSame($connectionMock, $connection); 131 | 132 | // 确保 currentConnections 增加 133 | $this->assertEquals(1, $this->getCurrentConnections($pool)); 134 | 135 | // 检查 WeakMap 是否更新 136 | $connections = $this->getPrivateProperty($pool, 'connections'); 137 | $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); 138 | $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); 139 | 140 | $this->assertTrue($connections->offsetExists($connection)); 141 | $this->assertTrue($lastUsedTimes->offsetExists($connection)); 142 | $this->assertTrue($lastHeartbeatTimes->offsetExists($connection)); 143 | } 144 | 145 | public function testCreateMaxConnections() 146 | { 147 | if (in_array(Worker::$eventLoopClass, [Select::class, Event::class])) { 148 | $this->assertTrue(true); 149 | return; 150 | } 151 | $maxConnections = 2; 152 | $pool = new Pool($maxConnections); 153 | 154 | $pool->setConnectionCreator(function () { 155 | Timer::sleep(0.01); 156 | return $this->createMock(stdClass::class); 157 | }); 158 | 159 | $connections = []; 160 | for ($i = 0; $i < 3; $i++) { 161 | Coroutine::create(function () use ($pool, &$connections) { 162 | $connections[] = $pool->get(); 163 | }); 164 | } 165 | 166 | Timer::sleep(0.1); 167 | $this->assertEquals($maxConnections, $this->getCurrentConnections($pool)); 168 | 169 | $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); 170 | $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); 171 | 172 | $this->assertCount($maxConnections, $lastUsedTimes); 173 | $this->assertCount($maxConnections, $lastHeartbeatTimes); 174 | 175 | foreach ($connections as $connection) { 176 | $pool->put($connection); 177 | } 178 | 179 | } 180 | 181 | public function testCreateConnectionThrowsException() 182 | { 183 | $pool = new Pool(5); 184 | 185 | $pool->setConnectionCreator(function () { 186 | throw new Exception('Failed to create connection'); 187 | }); 188 | 189 | $this->expectException(Exception::class); 190 | $this->expectExceptionMessage('Failed to create connection'); 191 | 192 | try { 193 | $pool->createConnection(); 194 | } finally { 195 | // 确保 currentConnections 减少 196 | $this->assertEquals(0, $this->getCurrentConnections($pool)); 197 | } 198 | } 199 | 200 | public function testCloseConnection() 201 | { 202 | $pool = new Pool(5); 203 | 204 | $connection = $this->createMock(ConnectionMock::class); 205 | 206 | // 模拟连接属于连接池 207 | $connections = $this->getPrivateProperty($pool, 'connections'); 208 | $connections[$connection] = time(); 209 | 210 | $connection->expects($this->once())->method('close'); 211 | $pool->setConnectionCloser(function ($conn) { 212 | $conn->close(); 213 | }); 214 | 215 | $pool->closeConnection($connection); 216 | 217 | // 确保 currentConnections 减少 218 | $this->assertEquals(0, $this->getCurrentConnections($pool)); 219 | 220 | // 确保连接从 WeakMap 中移除 221 | $this->assertFalse($connections->offsetExists($connection)); 222 | } 223 | 224 | public function testCloseConnections() 225 | { 226 | $maxConnections = 5; 227 | 228 | $pool = new Pool($maxConnections); 229 | 230 | $pool->setConnectionCreator(function () { 231 | $connection = $this->createMock(ConnectionMock::class); 232 | $connection->expects($this->once())->method('close'); 233 | return $connection; 234 | }); 235 | 236 | $pool->setConnectionCloser(function ($conn) { 237 | $conn->close(); 238 | }); 239 | 240 | $connections = []; 241 | for ($i = 0; $i < $maxConnections; $i++) { 242 | $connections[] = $pool->get(); 243 | } 244 | 245 | $this->assertEquals(Coroutine::isCoroutine() ? $maxConnections : 1, $this->getCurrentConnections($pool)); 246 | 247 | $pool->closeConnections(); 248 | $this->assertEquals(Coroutine::isCoroutine() ? $maxConnections : 0, $this->getCurrentConnections($pool)); 249 | if (!Coroutine::isCoroutine()) { 250 | return; 251 | } 252 | 253 | foreach ($connections as $connection) { 254 | $pool->put($connection); 255 | } 256 | $this->assertEquals($maxConnections, $this->getCurrentConnections($pool)); 257 | $pool->closeConnections(); 258 | $this->assertEquals(0, $this->getCurrentConnections($pool)); 259 | 260 | $connections = []; 261 | for ($i = 0; $i < $maxConnections; $i++) { 262 | $connections[] = $pool->get(); 263 | } 264 | $this->assertEquals($maxConnections, $this->getCurrentConnections($pool)); 265 | foreach ($connections as $connection) { 266 | $pool->put($connection); 267 | } 268 | $pool->closeConnections(); 269 | unset($connections); 270 | $this->assertEquals(0, $this->getCurrentConnections($pool)); 271 | } 272 | 273 | public function testCloseConnectionWithExceptionInDestroyHandler() 274 | { 275 | $pool = new Pool(5); 276 | 277 | $connection = $this->createMock(stdClass::class); 278 | 279 | // 模拟连接属于连接池 280 | $connections = $this->getPrivateProperty($pool, 'connections'); 281 | $connections[$connection] = time(); 282 | 283 | $exception = new Exception('Error closing connection'); 284 | 285 | $pool->setConnectionCloser(function ($conn) use ($exception) { 286 | throw $exception; 287 | }); 288 | 289 | // 设置日志记录器 290 | $loggerMock = $this->createMock(LoggerInterface::class); 291 | $loggerMock->expects($this->once()) 292 | ->method('info') 293 | ->with($this->stringContains('Error closing connection')); 294 | 295 | $this->setPrivateProperty($pool, 'logger', $loggerMock); 296 | 297 | $pool->closeConnection($connection); 298 | 299 | // 确保 currentConnections 减少 300 | $this->assertEquals(0, $this->getCurrentConnections($pool)); 301 | 302 | // 确保连接从 WeakMap 中移除 303 | $this->assertFalse($connections->offsetExists($connection)); 304 | } 305 | 306 | public function testHeartbeatChecker() 307 | { 308 | $pool = $this->getMockBuilder(Pool::class) 309 | ->setConstructorArgs([5]) 310 | ->onlyMethods(['closeConnection']) 311 | ->getMock(); 312 | 313 | $connection = $this->createMock(stdClass::class); 314 | 315 | // 设置连接心跳检测器 316 | $pool->setHeartbeatChecker(function ($conn) { 317 | // 模拟心跳检测 318 | }); 319 | 320 | // 模拟连接在通道中 321 | $channel = $this->getPrivateProperty($pool, 'channel'); 322 | $channel->push($connection); 323 | 324 | // 设置连接的上次使用时间和心跳时间 325 | $connections = $this->getPrivateProperty($pool, 'connections'); 326 | $connections[$connection] = time(); 327 | 328 | $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); 329 | $lastUsedTimes[$connection] = time(); 330 | 331 | $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); 332 | $lastHeartbeatTimes[$connection] = time() - 100; // 超过心跳间隔 333 | 334 | // 调用受保护的 checkConnections 方法 335 | $reflectedMethod = new ReflectionMethod($pool, 'checkConnections'); 336 | $reflectedMethod->invoke($pool); 337 | 338 | // 检查心跳时间是否更新 339 | $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); 340 | $this->assertGreaterThan(time() - 2, $lastHeartbeatTimes[$connection]); 341 | } 342 | 343 | public function testConnectionDestroyedWithoutReturn() 344 | { 345 | $pool = new Pool(5); 346 | 347 | // 设置连接创建器 348 | $pool->setConnectionCreator(function () { 349 | return new stdClass; 350 | }); 351 | 352 | // 获取初始的 currentConnections 353 | $initialConnections = $this->getCurrentConnections($pool); 354 | 355 | // 从连接池获取一个连接 356 | $connection = $pool->get(); 357 | 358 | // 检查 currentConnections 是否增加 359 | $this->assertEquals(Coroutine::isCoroutine() ? $initialConnections + 1 : 1, $this->getCurrentConnections($pool)); 360 | 361 | // 不归还连接,并销毁连接对象 362 | unset($connection); 363 | 364 | // 检查 currentConnections 是否减少 365 | $this->assertEquals(Coroutine::isCoroutine() ? $initialConnections : 1, $this->getCurrentConnections($pool)); 366 | } 367 | 368 | private function getPrivateProperty($object, string $property) 369 | { 370 | $prop = new ReflectionProperty($object, $property); 371 | return $prop->getValue($object); 372 | } 373 | 374 | private function setPrivateProperty($object, string $property, $value) 375 | { 376 | $prop = new ReflectionProperty($object, $property); 377 | $prop->setValue($object, $value); 378 | } 379 | 380 | private function getCurrentConnections($object): int 381 | { 382 | return $object->getConnectionCount(); 383 | } 384 | 385 | } 386 | 387 | // 定义 ConnectionMock 类用于测试 388 | class ConnectionMock 389 | { 390 | public function close() 391 | { 392 | // 模拟关闭连接 393 | } 394 | } 395 | --------------------------------------------------------------------------------