How we are adding Async PHP to our Stack
Hey folks, I hope you’ll have good days during Christmas. As a present, we come to tell you one of our tech tales for late 2021 and early 2022 at Drinks&Co Discovery Team.
As we already written in previous posts, at the Drinks & Co. engineering team, we are fans of playing with concurrency and parallelism to get the best performance and resource optimization in our systems.
This time, we will try to explain how we start adding Async PHP to our toolchain.
To add a bit of context, we meet a unicorn! A greenfield project to improve a critical service for our business model: It’s nothing less than the search, filtering, and product catalog system.
The prior requirements are to be extremely fast delivering search result responses, maintain a standard API to share between the Mobile App and the Website, and change Sphinx legacy implementation by the Algolia search engine.
When we started discussing the better approach with our resources to achieve our goal, a high-performance concurrent HTTP API, we had talked about the pros and cons of adding new technology to our stack.
Why PHP?
Why not use Go or any other language with better non-blocking and concurrency options like NodeJS? We have discussed this topic at length, and we decide to continue with PHP for a couple of reasons:
First, we don’t want to change our stack in one of our critical services. Our team has some experienced PHP developers who have a solid knowledge of the language. We don’t want to lose our expertise if we can reach a similar or too proximate result using our native language, as with another programming language, like the ones mentioned before Go or NodeJS.
Maintaining our current stack gives us the agility against change that we earned during more than ten years of experience with the language.
Async PHP?
Since at least nine years of the release of ReactPHP, we can use PHP in an async non-blocking way using event-driven architecture and promises.
With the latest release of the 8.1.0 version, PHP adds to its core a new player named Fibers, also known as Green threads. This feature provides the ability to use Sync APIs while in the background async IO processes are running. It solves the historical issue with the colors of functions.
Status of async PHP
While writing this Post, PHP already has a few stable tools, libraries, and frameworks that allow running non-blocking tasks. For example, ReactPHP has more than nine years old and has more than 100 million downloads on some of its components, Swoole goes from version 4.0.0, and it has more than 17K stars on Github, Amp is releasing its 2.0, and there are initiatives like RevoltPHP to standardize the lowest level components to allow async programming.
On the other side, Road Runner is a hybrid between Go and PHP, which uses go coroutines to orchestrate PHP processes.
Also, some Frameworks like Rachet, Laravel Octane, DriftPHP, Framework X, or Antidot Framework, and components like Symfony Runtime are trying to normalize async programming in the PHP world.
There is already a long road to ride. The PHP community has an interest in making Async programming available for everyone. Plus, the recent inclusion of Fibers to PHP core guarantees active support and improvements around the language async capabilities in the following years.
We can conclude that PHP is mature enough for async programming, taking care of some gold rules:
Gold Rules
There are a few things to take care of when we are programming Async PHP:
- Application Server: The deployment is most similar to the SSR javascript app than a classic PHP application. The application itself is the server.
- Stateless Classes: We can meet times when more than one client consumes the same instance of some class. It forces us to avoid any data state in our classes.
- Filesystem operations: At the moment we are writing this post, there isn’t a non-blocking way to write to the filesystem from PHP.
- Connection Pools: When we need to connect to a database or an external service, we’ll have to deal with shared connection pools.
In our case won’t need to make Input/Output operations in the filesystem. Also, we haven’t the requirement of connecting to a database, which means that we don’t need to be aware too much of those known trade-offs.
The only external connection is via an HTTP request to a third-party API, which is not a problem since ReactPHP, Amp, or Swoole have each non-blocking way to make requests.
Our options
To be consistent with our current style guide, we want to code on a Sync API with the benefits from Async performance. Also, if we can rely on the Symfony components, it will be way better.
- Road-Runner + Symfony: Road Runner offers us the simplest solution to implement since it does not require any extension or modification in the coding style. It allows us to use out of the box any framework on the market, and it is feasible to integrate custom applications or Vanilla PHP. We must be taken into account to create persistent connections and avoid creating services that depend on the state.
- React PHP + Fibers: ReactPHP gives us a native version of asynchrony with PHP. It uses an Event Loop like Node JS to manage concurrency in a single thread. To achieve this, React PHP uses Promises and|or Generators in PHP 8.0 and Fibers from PHP 8.1. As with Road Runner, we need to create persistent connections and stateless services. On the other hand, we use Fibers to solve the classic problem of the colors of the functions. It allows us to continue programming without extending the Promises throughout our code.
- Amp + Fibers: Amp PHP is another native PHP Event Loop option. As in React PHP, Amp uses Promises and Generators since PHP 7.2 and Fibers since PHP 8.1.0 to manage the asynchrony within the Loop. It shares the same current problem as react PHP since both libraries similarly address the same issue.
- Swoole + Symfony: Swoole is an extension that overwrites PHP’s own input/output methods. It is a too fast option, on the order of twice as fast as Road-Runner or React PHP. It makes use of Go-like Co-Routines to manage asynchrony. Using Swoole is also recommended to use persistent connections and stateless services. Although given its architecture, it would not be a requirement.
Hands-on: Benchmarks
The test application consists of two parts.
- TestController: Controller with a single public method. It makes a request to an external API and prints the received response.
- External Application: A Fake HTTP API that returns a JSON string with the weather in Barcelona on November 17, 2021.
We generate the benchmark results on a Linux machine with 32GB-RAM and 16 cores. All the applications are running in a single worker.
Road Runner + Symfony Full Pack
Non-concurrent speed test using Apache AB tool
# ab -n 10000 -c 1 -k http://127.0.0.1:8081/foo [68d7077]
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requestsServer Software:
Server Hostname: 127.0.0.1
Server Port: 8081Document Path: /foo
Document Length: 94 bytesConcurrency Level: 1
Time taken for tests: 19.289 seconds
Complete requests: 10000
Failed requests: 0
Keep-Alive requests: 10000
Total transferred: 3440000 bytes
HTML transferred: 940000 bytes
Requests per second: 518.43 [#/sec] (mean)
Time per request: 1.929 [ms] (mean)
Time per request: 1.929 [ms] (mean, across all concurrent requests)
Transfer rate: 174.16 [Kbytes/sec] receivedConnection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 1 2 0.2 2 7
Waiting: 1 2 0.2 2 7
Total: 2 2 0.2 2 7Percentage of the requests served within a certain time (ms)
50% 2
66% 2
75% 2
80% 2
90% 2
95% 2
98% 2
99% 2
100% 7 (longest request)
Speed test with concurrency using WRK tool
→ wrk -t8 -c512 -d15s http://127.0.0.1:8081/foo [68d7077]
Running 15s test @ http://127.0.0.1:8081/foo
8 threads and 512 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 604.78ms 68.88ms 775.22ms 96.12%
Req/Sec 104.34 40.35 260.00 68.61%
12411 requests in 15.09s, 3.34MB read
Requests/sec: 822.21
Transfer/sec: 226.43KB
Pros:
- We can continue using our style guide without changing anything.
- We already were using Symfony in other projects. Therefore, the learning curve is flatter than any other option.
- Symfony has a powerful team and has an extensive community.
- Road Runner + Symfony Full Pack is fast enough for our use case.
Cons:
- Road Runner is craft on Go, and we have no experience with that language.
- Road Runner + Symfony Full Pack is not as fast as ReactPHP or Swoole.
- Using complete Symfony Framework for small projects enables assumptions that maybe we do not need, like installing the Flex package manager.
Road Runner + Symfony MicroKernel
https://github.com/drinksandco/poc-symfony-micro-road-runner
Non-concurrent speed test using Apache AB tool
→ ab -n 10000 -c 1 -k http://127.0.0.1:8081/ [c81641e]
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requestsServer Software:
Server Hostname: 127.0.0.1
Server Port: 8081Document Path: /
Document Length: 94 bytesConcurrency Level: 1
Time taken for tests: 19.078 seconds
Complete requests: 10000
Failed requests: 0
Keep-Alive requests: 10000
Total transferred: 3440000 bytes
HTML transferred: 940000 bytes
Requests per second: 524.16 [#/sec] (mean)
Time per request: 1.908 [ms] (mean)
Time per request: 1.908 [ms] (mean, across all concurrent requests)
Transfer rate: 176.08 [Kbytes/sec] receivedConnection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 1 2 0.2 2 6
Waiting: 1 2 0.2 2 6
Total: 1 2 0.2 2 6Percentage of the requests served within a certain time (ms)
50% 2
66% 2
75% 2
80% 2
90% 2
95% 2
98% 2
99% 2
100% 6 (longest request)
Speed test with concurrency using WRK tool
→ wrk -t8 -c512 -d15s http://127.0.0.1:8081/ [c81641e]
Running 15s test @ http://127.0.0.1:8081/
8 threads and 512 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 590.17ms 65.93ms 634.25ms 96.46%
Req/Sec 139.07 101.10 323.00 49.65%
12708 requests in 15.09s, 3.42MB read
Requests/sec: 842.08
Transfer/sec: 231.90KB
Pros:
- As in full pack, we can use our style guide as-is.
- The very flat learning curve. Using the MicroKernel trait merely changes from the Symfony Full Pack, but it does not add much additional complexity.
- Community.
- Also, Road Runner + Symfony MicroKernel is a bit faster than using the complete framework.
- Road Runner + Symfony MicroKernel depends on a few packages.
Cons:
- The server is in Go.
- Slower than other Async PHP choices, as mentioned before.
ReactPHP + Fibers
Non-concurrent speed test using Apache AB tool
→ ab -n 10000 -c 1 -k http://127.0.0.1:5555/ [89fdcc0]
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requestsServer Software: ReactPHP/1
Server Hostname: 127.0.0.1
Server Port: 5555Document Path: /
Document Length: 94 bytesConcurrency Level: 1
Time taken for tests: 5.641 seconds
Complete requests: 10000
Failed requests: 0
Keep-Alive requests: 10000
Total transferred: 2460000 bytes
HTML transferred: 940000 bytes
Requests per second: 1772.84 [#/sec] (mean)
Time per request: 0.564 [ms] (mean)
Time per request: 0.564 [ms] (mean, across all concurrent requests)
Transfer rate: 425.90 [Kbytes/sec] receivedConnection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 0 1 0.1 1 4
Waiting: 0 1 0.1 1 4
Total: 0 1 0.1 1 5Percentage of the requests served within a certain time (ms)
50% 1
66% 1
75% 1
80% 1
90% 1
95% 1
98% 1
99% 1
100% 5 (longest request)
Speed test with concurrency using WRK tool
→ wrk -t8 -c512 -d15s http://127.0.0.1:5555/ [89fdcc0]
Running 15s test @ http://127.0.0.1:5555/
8 threads and 512 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 196.07ms 81.81ms 1.97s 94.91%
Req/Sec 292.96 100.41 560.00 65.38%
34842 requests in 15.09s, 7.38MB read
Socket errors: connect 0, read 0, write 0, timeout 185
Requests/sec: 2308.78
Transfer/sec: 500.54KB
Pros:
- ReactPHP + Fibers is the faster option we try, both with and without concurrency.
Cons:
- ReactPHP at the moment uses Fibers as an edge technology. There is no stable package to allow Sync API. It will force us to work with Promises everywhere.
- Requires a smother learning curve because we have to create our own Vanilla PHP micro-framework. Have to integrate at least a Router and a DI container.
Conclusion
Looking at the benchmarks, we can see ReactPHP winning in performance in both cases, with and without concurrency. Even with this, we believe that the best choice for our team is to choose Road Runner, as it allows us to keep our style guide intact and offers a good enough performance for our use case.
With the above, we do not want to put one technology above another both ReactPHP, Amp, Swoole, and Road Runner are great libraries|frameworks to create high-performance production applications. We choose Road Runner + Symfony MicroKernel because we think it is the best option for our team culture, but we are not closing the doors to change to a native PHP option once the Fibers become stable.
If you reach here, it only remains for me to thank you for your time and interest. We will continue telling you our next steps, and of course, we will show the final result. I hope you enjoyed the reading. Also, thanks to all my workmates for their patient, knowledge, and for being awesome people ;-D
Happy Coding!!