├── .gitignore ├── LICENSE ├── README.md ├── http-example.php ├── mysql-example.php └── src ├── Async.php ├── AsyncMySQL ├── AsyncMySQL.php ├── MySQLPool.php └── exceptions │ ├── OperationsOutOfSync.php │ └── QueryTimedOut.php ├── AsyncSocket ├── AsyncSocket.php └── exceptions │ ├── OpenSSLNotLoaded.php │ ├── SocketConnectionTimedOut.php │ ├── SocketEnableCryptoFailed.php │ ├── SocketEnableCryptoTimeout.php │ └── SocketNotConnected.php └── Http └── Http.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Fibers - Async Examples Without External Dependencies 2 | True asynchronous PHP I/O and HTTP without frameworks, extensions, or annoying code behemoths of libraries to install. 3 | 4 | More examples to come - currently only HTTP GET and asynchronous MySQLi queries are shown. 5 | ## Requirement 6 | 7 | Must be running PHP 8.1 - that is when PHP Fibers were introduced. 8 | 9 | ## Why do Fibers Help Make Async Code Possible? 10 | Asynchronous code can be simplified in layman's terms to basically read as "code that runs when there is time to run" - it doesn't mean it runs in parallel to other code. Imagine you send two HTTP requests in your code. Why should you wait for one to finish before starting the second one? This is asynchronous code - because we're waiting on the first request to finish, we can use that time to make another. 11 | 12 | This "waiting" is where Fibers can help us manage multiple code blocks. If you've ever used a language like JavaScript which has native asynchronous code support (async/await on Promises) then you've never had to worry about how it works behind the scenes. 13 | 14 | Fibers allow you to **code your own asynchronous architecture**. Asynchronous code relies on an underlying "Event Loop" (JavaScript handles this for you, for example). 15 | 16 | Consider that your main application has a loop similar to the pseudo code below: 17 | 18 | ``` 19 | fibers = []; 20 | 21 | fibers[] = new Fiber(function(){ 22 | // Imagine that run_my_code() has new Fibers added to the fibers array 23 | run_my_code(); 24 | }); 25 | 26 | do{ 27 | foreach($fibers as $fiber){ 28 | if ($fiber->isSuspended() && $fiber->isTerminated() === false_{ 29 | $fiber->resume(); 30 | }else{ 31 | // Remove it from the fibers array 32 | } 33 | } 34 | }while(!empty(fibers)); 35 | ``` 36 | 37 | If the function `run_my_code()` creates new fibers, the loop will keep looping. It's slightly more technical than this - you have to check if the fibers are terminated/finished or are still pending. But the gist is that Fibers allow you to make your own asynchronous framework to write asynchronous code - they don't magically make code asynchronous on their own. Suspending a fiber is similar to "waiting" and letting the next fiber run while we wait on that suspended fiber. 38 | 39 | The main loop (the "Event Loop") will make sure the PHP process doesn't end until all Fibers have terminated and thus all the code in your application is done running. 40 | 41 | ## Rundown of How It Works 42 | 43 | Because the internal PHP functions (such as `file_get_contents`) are blocking by default, to implement asynchronous HTTP with PHP 8.1 Fibers, you must rebuild an HTTP request wrapper with native `socket_` functions. Sockets in PHP can be set to be non-blocking. With this, a simple event loop can be created that can run all the Fibers in the event loop stack. If a socket isn't ready to be read (such as, the HTTP request is still pending) the Fiber will suspend and the next one can run. 44 | 45 | This is all done in native PHP (version 8.1+) with no libraries. A language, such as JavaScript, has an internal event loop (which PHP does not) so that is why we have to create our own event loop (in the `src/Async/Async.php` file) to manage our Fibers and run them. 46 | 47 | ## Async HTTP GET Example 48 | 49 | For the full example, checkout the http-example.php file. A small snippet is shown below. 50 | 51 | The classes `Async` and `Http` are provided in the `classes` folder of this repository. 52 | 53 | ```php 54 | $request1 = new Http("get", "http://example.com"); 55 | $request2 = new Http("get", "http://example.com"); 56 | 57 | foreach ([$request1, $request2] as $request){ 58 | $child = new Fiber(function() use ($request){ 59 | // ::await only blocks the _current_ thread. All other Fibers can still run 60 | Async::await($request->connect()); 61 | Async::await($request->fetch()); 62 | 63 | // Code here runs as soon as this URL is done fetching 64 | // and doesn't wait for others to finish :) 65 | }); 66 | $child->start(); 67 | } 68 | 69 | // Currently, ::run() is blocking the program and nothing below this 70 | // call will run until the fibers above are finished. 71 | // In the future, a top-level fiber can be introduced to make the entire 72 | // application fully asynchronous. One is used in the example.php file 73 | Async::run(); 74 | ``` 75 | 76 | ## Async MySQL Query Example 77 | 78 | For the full example, checkout the mysql-example.php file. A small snippet is shown below. 79 | 80 | The classes `Async` and `Http` are provided in the `classes` folder of this repository. 81 | 82 | ```php 83 | 84 | // Initial connections are blocking. All queries are asynchronous. 85 | // Spawn 10 connections in the pool. 86 | $connectionsPool = new MySQLPool(10, "localhost", "root", "", "test"); 87 | $queryFiber = new Fiber(function() use ($connectionsPool){ 88 | // ::await only blocks the _current_ thread. All other Fibers can still run 89 | $result = Async::await($connectionsPool->execute( 90 | "SELECT * FROM `test_table` WHERE `name` = :name", 91 | [':name'=>"nox7"], 92 | )); 93 | print_r($result->fetch_assoc()); 94 | }); 95 | $child->start(); 96 | Async::run(); 97 | ``` 98 | 99 | ## Result of the http-example.php run 100 | 101 | Shown below from a run of http-example.php within an Apache server request, URLs connected and fetched as soon as they were ready, and not in linear order - but asynchronously. 102 | 103 | ![Asynchronous PHP Requests](https://user-images.githubusercontent.com/17110935/113648260-f01ecd80-9651-11eb-9532-73c9f606d318.png) 104 | 105 | ## The Future of This Repository 106 | I will continue to implement standalone asynchronous methods for common waitable tasks. Currently HTTP is implemented with basic fetching of content from a URL. Moving forward, I will support different HTTP requests and abstractions, asynchronous MySQLi queries, and asynchronous local file I/O. 107 | 108 | The **goal** is to have a location where you can get the smallest amount of open source code to perform asynchronous tasks in PHP. A framework like AmpPHP will always be superior if you would like to center your codebase around it, but my goal is to give you the option to leave a smaller footprint or simply made an addition to your existing codebase. 109 | 110 | Want to spin up a quick Apache or nginx server on your Linux machine or via XAMPP for windows and grab a couple files for asynchronous MySQLi? That's what I want to give you the choice to do. 111 | -------------------------------------------------------------------------------- /http-example.php: -------------------------------------------------------------------------------- 1 | connect()); 27 | print(sprintf("Connected %s\n", $request->host)); 28 | $response = Async::await($request->fetch()); 29 | print(sprintf("Finished %s\n", $request->host)); 30 | }); 31 | $childFiber->start(); 32 | } 33 | 34 | // Start the event loop of all available fibers. This is blocking 35 | // TODO Make this yield as well! 36 | Async::run(); 37 | 38 | // Microtime is seconds on Windows as a float 39 | printf("All requests finished asynchronously in %fs\n", microtime(true) - $startTime); 40 | }); 41 | 42 | // Start the top-level Fiber 43 | $main->start(); 44 | -------------------------------------------------------------------------------- /mysql-example.php: -------------------------------------------------------------------------------- 1 | execute( 17 | "SELECT * FROM test_table WHERE `id` = :id", 18 | [":id"=>1] 19 | )); 20 | 21 | // Will return as soon as this query is done and does not wait on others 22 | print_r($result); 23 | }); 24 | $queryFiber->start(); 25 | 26 | // Hold here until all async operations are done 27 | Async::run(); 28 | }); 29 | 30 | // Start the top-level Fiber 31 | $main->start(); 32 | -------------------------------------------------------------------------------- /src/Async.php: -------------------------------------------------------------------------------- 1 | start(); 13 | while ($childFiber->isTerminated() === false){ 14 | $childFiber->resume(); 15 | 16 | // Don't suspend here if the childFiber is now terminated - it's 17 | // a wasted suspension. 18 | if (!$childFiber->isTerminated()){ 19 | Fiber::suspend(); 20 | }else{ 21 | break; 22 | } 23 | } 24 | 25 | return $childFiber->getReturn(); 26 | } 27 | 28 | /** 29 | * Starts the blocking event loop that runs all registered fibers. 30 | * TODO This could also yield by using Fiber::this() to detect if 31 | * this event loop is part of another parent fiber. 32 | */ 33 | public static function run(): void{ 34 | 35 | while (count(self::$activeAwaits) > 0){ 36 | $toRemove = []; 37 | foreach(self::$activeAwaits as $index=>$pair){ 38 | $parentFiber = $pair[0]; 39 | $childFiber = $pair[1]; 40 | 41 | if ($parentFiber->isSuspended() && $parentFiber->isTerminated() === false){ 42 | // Resume the parent fiber 43 | $parentFiber->resume(); 44 | }elseif ($parentFiber->isTerminated()){ 45 | // Register this fiber index to be removed from the activeAwaits 46 | $toRemove[] = $index; 47 | } 48 | } 49 | 50 | foreach($toRemove as $indexToRemove){ 51 | unset(self::$activeAwaits[$indexToRemove]); 52 | } 53 | 54 | // Re-index the array 55 | self::$activeAwaits = array_values(self::$activeAwaits); 56 | } 57 | 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/AsyncMySQL/AsyncMySQL.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 38 | 39 | // Run this synchronously 40 | $connection->query(sprintf("SET NAMES %s COLLATE %s", self::$defaultNamesEncoding, self::$defaultCollation)); 41 | } 42 | 43 | /** 44 | * Asynchronously executes a query with named arguments. 45 | * Will return the result of the query. 46 | * @throws OperationsOutOfSync 47 | */ 48 | public function execute(string $query, array $namedArgs = []): \Fiber{ 49 | 50 | if (!$this->isAvailable){ 51 | throw new OperationsOutOfSync("Cannot run execute on this AsyncMySQL object at this time. It is currently handling another query. Consider using the MySQLPool class."); 52 | } 53 | 54 | $this->isAvailable = false; 55 | 56 | return new \Fiber(function() use ($query, $namedArgs){ 57 | if (count($namedArgs) > 0){ 58 | $query = $this->buildQueryFromNamedArgs($query, $namedArgs); 59 | } 60 | 61 | $this->connection->query($query, MYSQLI_STORE_RESULT | MYSQLI_ASYNC); 62 | $toPoll = [$this->connection]; 63 | $errors = []; 64 | $rejections = []; 65 | $beginTime = time(); 66 | \Fiber::suspend(); 67 | 68 | $numReadyQueries; 69 | do{ 70 | $numReadyQueries = (int) \mysqli::poll($toPoll, $errors, $rejections, self::$defaultQueryTimeout); 71 | if ($numReadyQueries > 0){ 72 | break; 73 | } 74 | 75 | \Fiber::suspend(); 76 | } while ((time() - $beginTime <= self::$defaultQueryTimeout)); 77 | 78 | $this->isAvailable = true; 79 | 80 | if ($numReadyQueries > 0){ 81 | $result = $this->connection->reap_async_query(); 82 | if ($result === true){ 83 | // This was an UPDATE, INSERT, or DELETE 84 | return $this->connection; 85 | }else{ 86 | return $result; 87 | } 88 | }else{ 89 | throw new QueryTimedOut(); 90 | } 91 | }); 92 | } 93 | 94 | /** 95 | * Builds an escaped query from the provided named arguments 96 | */ 97 | private function buildQueryFromNamedArgs(string $query, array $namedArgs): string{ 98 | foreach($namedArgs as $parameterName=>$parameterValue){ 99 | $parameterValue = $this->connection->real_escape_string($parameterValue); 100 | if (is_string($parameterValue)){ 101 | $query = str_replace($parameterName, sprintf('"%s"', $parameterValue), $query); 102 | }elseif (is_bool($parameterValue)){ 103 | $query = str_replace($parameterName, sprintf('%s', $parameterValue ? "true" : "false"), $query); 104 | }elseif (is_double($parameterValue)){ 105 | $query = str_replace($parameterName, sprintf('%f', $parameterValue), $query); 106 | }elseif (is_int($parameterValue)){ 107 | $query = str_replace($parameterName, sprintf('%d', $parameterValue), $query); 108 | } 109 | } 110 | 111 | return $query; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/AsyncMySQL/MySQLPool.php: -------------------------------------------------------------------------------- 1 | asyncMySQLs[] = new AsyncMySQL($host, $user, $password, $database, $port); 20 | } 21 | } 22 | 23 | /** 24 | * Runs an asynchronous ::execute() on an available AsyncMySQL object. 25 | * If none are available, will wait until one is. 26 | */ 27 | public function execute(string $query, array $namedArgs = []): \Fiber{ 28 | $asyncMySQL = null; 29 | 30 | return new \Fiber(function() use ($query, $namedArgs){ 31 | do{ 32 | foreach ($this->asyncMySQLs as $obj){ 33 | if ($obj->isAvailable){ 34 | $asyncMySQL = $obj; 35 | break; 36 | } 37 | } 38 | 39 | if ($asyncMySQL === null){ 40 | \Fiber::suspend(); 41 | } 42 | } while ($asyncMySQL === null); 43 | 44 | return \Async::await($asyncMySQL->execute($query, $namedArgs)); 45 | }); 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/AsyncMySQL/exceptions/OperationsOutOfSync.php: -------------------------------------------------------------------------------- 1 | socket = stream_socket_client( 39 | sprintf("%s://%s:%s", "tcp", $this->host, $this->port), 40 | $errorNumber, 41 | $errorString, 42 | null, 43 | STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT, 44 | stream_context_create([ 45 | "socket"=>[ 46 | "tcp_nodelay"=>false, 47 | ], 48 | ]) 49 | ); 50 | 51 | return new \Fiber(function(){ 52 | // Turn blocking of the socket off 53 | stream_set_blocking($this->socket, false); 54 | 55 | $read = []; 56 | $write = [$this->socket]; 57 | $excepts = []; 58 | 59 | // Wait for a connection 60 | $beginTime = time(); 61 | do{ 62 | $socketsAvailable = stream_select($read, $write, $excepts, null); 63 | if ($socketsAvailable === 1){ 64 | $this->connected = true; 65 | return; 66 | }else{ 67 | \Fiber::suspend(); 68 | } 69 | } while (time() - $beginTime <= self::$defaultConnectionTimeoutSeconds); 70 | 71 | // If the code got here, then the socket didn't connect before the timeout 72 | throw new SocketConnectionTimedOut("The socket could not make a connection and timed out."); 73 | }); 74 | } 75 | 76 | /** 77 | * Asynchronously enable crypto mode/SSL/TLS on the socket 78 | * @throws OpenSSLNotLoaded 79 | */ 80 | public function enableCrypto(): \Fiber{ 81 | if (!extension_loaded('openssl')) { 82 | throw new OpenSSLNotLoaded("Missing OpenSSL support in your PHP installation."); 83 | } 84 | 85 | return new \Fiber(function(){ 86 | $beginTime = time(); 87 | /** @var ?int|?bool $result */ 88 | $result = null; 89 | do{ 90 | $result = stream_socket_enable_crypto( 91 | $this->socket, 92 | true, 93 | STREAM_CRYPTO_METHOD_TLS_CLIENT 94 | ); 95 | if ($result === true){ 96 | break; 97 | }elseif ($result === false){ 98 | break; 99 | }else{ 100 | // Wait, it's still working... 101 | \Fiber::suspend(); 102 | } 103 | }while ($result !== true && (time() - $beginTime <= self::$defaultSSLNegotiationsTimeoutSeconds)); 104 | 105 | if ($result === 0){ 106 | throw new SocketEnableCryptoTimeout(); 107 | }elseif ($result === false){ 108 | throw new SocketEnableCryptoFailed(sprintf("SSL failed for host %s", $this->host)); 109 | } 110 | 111 | // Success 112 | }); 113 | } 114 | 115 | /** 116 | * Asynchronous socket write 117 | */ 118 | public function write(string $data): \Fiber{ 119 | if (!$this->connected){ 120 | throw new SocketNotConnected("Cannot write to a socket that is not connected."); 121 | } 122 | 123 | return new \Fiber(function() use ($data){ 124 | $bytesWritten = 0; 125 | $reads = []; 126 | $writes = [$this->socket]; 127 | $excepts = []; 128 | 129 | $beginTime = time(); 130 | do{ 131 | $socketsAvailable = stream_select($reads, $writes, $excepts, null); 132 | if ($socketsAvailable === 1){ 133 | $bytes = fwrite($this->socket, $data, self::$maxWriteBytesPerIteration); 134 | if ($bytes !== false){ 135 | $data = substr($data, self::$maxWriteBytesPerIteration); 136 | }else{ 137 | // break; 138 | } 139 | } 140 | 141 | // Always suspend 142 | \Fiber::suspend(); 143 | } while ($data !== "" && (time() - $beginTime) <= self::$defaultWriteTimeoutSeconds); 144 | }); 145 | } 146 | 147 | /** 148 | * Asynchronous socket full-read until no more data is being sent to the socket 149 | */ 150 | public function readAllData(): \Fiber{ 151 | if (!$this->connected){ 152 | throw new SocketNotConnected("Cannot read from a socket that is not connected."); 153 | } 154 | 155 | return new \Fiber(function(){ 156 | $buffer = ""; 157 | $reads = [$this->socket]; 158 | $writes = []; 159 | $excepts = []; 160 | 161 | $beginTime = time(); 162 | do{ 163 | $socketsAvailable = stream_select($reads, $writes, $excepts, null); 164 | if ($socketsAvailable === 1){ 165 | $data = fread($this->socket, self::$maxReadBytesPerIteration); 166 | if ($data !== false){ 167 | if ($data === ""){ 168 | if ($buffer !== ""){ 169 | // All data has been read 170 | break; 171 | }else{ 172 | // The socket is still waiting on data to be sent 173 | // or the server is not sending data. 174 | // We can't actually know which here, so we just have 175 | // to wait for the timeout. 176 | } 177 | }else{ 178 | // Data is not blank, add it to the buffer 179 | $buffer .= $data; 180 | } 181 | } 182 | } 183 | // Always suspend 184 | \Fiber::suspend(); 185 | } while (time() - $beginTime <= self::$defaultReadTimeoutSeconds); 186 | 187 | // TODO Should an exception be thrown if data WAS read 188 | // and a default read timeout happened?/ 189 | // For now, just return the buffer 190 | 191 | return $buffer; 192 | }); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/AsyncSocket/exceptions/OpenSSLNotLoaded.php: -------------------------------------------------------------------------------- 1 | method = $method; 25 | $this->url = $url; 26 | 27 | // Break the URL into components 28 | $components = parse_url($url); 29 | $port = $components['port'] ?? "80"; // Port is a string here 30 | $this->port = (int) $port; // Force it as an integer 31 | 32 | $this->scheme = $components['scheme']; 33 | $this->host = $components['host']; 34 | $this->path = $components['path'] ?? "/"; 35 | $this->query = $components['query'] ?? ""; 36 | 37 | // For now, force SSL on 443, but in reality we know 38 | // you can serve an SSL certificate from any port. 39 | if ($this->scheme === "https"){ 40 | if ($this->port === 80){ 41 | $this->port = 443; 42 | } 43 | } 44 | 45 | $this->socket = new AsyncSocket($this->host, $this->port); 46 | } 47 | 48 | /** 49 | * Makes an asynchronous connection to the socket 50 | */ 51 | public function connect(): \Fiber{ 52 | // Because TLS/SSL requires both parties to send 53 | // and receive data upon connection, ssl:// cannot be used 54 | // in the initial request. TCP must be used and then 55 | // crypto must be enabled below 56 | return new \Fiber(function(){ 57 | \Async::await($this->socket->connect()); 58 | 59 | if ($this->scheme === "https"){ 60 | \Async::await($this->socket->enableCrypto()); 61 | } 62 | }); 63 | } 64 | 65 | /** 66 | * Begins fetching of the data 67 | */ 68 | public function fetch(): \Fiber{ 69 | return new \Fiber(function(){ 70 | if ($this->method === "get"){ 71 | $getBody = sprintf("GET %s\r\n", $this->path); 72 | $getBody .= sprintf("Host: %s\r\n", $this->host); 73 | $getBody .= sprintf("Accept: */*\r\n"); 74 | $getBody .= "\r\n"; 75 | $beginTime = time(); 76 | 77 | \Async::await($this->socket->write($getBody)); 78 | $data = \Async::await($this->socket->readAllData()); 79 | 80 | return $data; 81 | } 82 | }); 83 | } 84 | } 85 | --------------------------------------------------------------------------------