Don't Wait; Generate!
function translate(string $texan) : string
switch ($texan) { case "y'all": return "some of you"; case "all y'all": return "all of you"; default: return $texan; }
We'll Answer
- What's a Generator?
- What's a Coroutine?
- Why async/non-blocking I/O && event loops?
- Why not?
- What a Generator + Coroutine based app looks like
This is a generator
function gRange($min, $max, $step): \Generator { for ($i = $min; $i < $max; $i += $step) { yield $i; } } foreach (gRange(12, 82, 5) as $n) { echo "$n\n"; // 12, 17, 22, etc. }
What's going on here?
- Resumable function
- Uses yield rather than (or in addition to) return
- Incremental, iterable results
- Behaves a bit like an Iterator
- Values and Exceptions can be sent/thrown in
(more on this in a moment)
<spooky>This is a generator</spooky>
function spooky(): \Generator { while (true) { yield random_int(0, 1) ? 'O' : 'o'; } } foreach (spooky() as $chr) { echo $chr; usleep(100000); }
When can I use it?
- PHP 5.5+, but 7.0+ gets you...
- return
- yield from
- Throwables
- Was on HHVM and predecessors before PHP
- ES2015 (aka ES6) via function*
- C# (.NET 4.5+)
- Python 2.2+
Before we look at our next example...
If you call a function that yields, you will not execute that function. You'll get back a Generator object. To execute the function, you need to call methods on the Generator.
Also, "return" means something different for a Generator than for a normal function.
Generator
Parent
$g = gen(1);
$a = $g->current();
2
$b = yield $arg1 + 1;
$c = $g->send($a + 1);
$d = yield $b + 2;
5
3
function gen($arg1)
$e = $g->send($c + 1);
6
return $d + 2;
null
echo $g->getReturn();
8
We just did a coroutine.
- Yield: stop execution until caller restarts it via send()
- Yield from: pass through execution until yielded-from object has nothing else to yield
- Return: use just like normal
- Cooperative multitasking!
Standard PHP-FPM Request Model
Client (or Load Balancer)
Web Server (nginx or Apache)
FastCGI Daemon (php-fpm) + Workers
HTTP
FastCGI
Pros
- Common
- Multicore
- Shared-nothing (safe)
- Fast for static resources
- Library support
- Slim\Http Req\Res
- Aura.Sql
- ...basically anything else
- Don't worry (much) about blocking the thread
Cons
- No in-request parallelism*
- Blocking I/O
- Not memory-efficient
- Startup penalty on every req
- Not 12-factor
- Process manager (runit)
- nginx
- php-fpm + workers
* Ignoring curl_multi and wrappers (e.g. Guzzle)
DEMO TIME: ngx + FPM + SLIM 3 APP
Event Loops!
T_PARADIGM_SHIFT
- Async I/O
- For compute-heavy operations...
- Don't do in-process if you can avoid it
- Don't do all at once if it must be in-process
Callbacks/Promises
- Very common (used in e.g. ReactPHP), but...
- Hard to follow execution flow
- Error callback convention (vs. Exceptions)
- Messages only at function borders
- Callback Pyramid of Doom
Why a Generator?
- Easier to follow
- Cleaner error handling
- Pass control inside functions
- Can still do async!
Generators in an Event Loop
- Run until blocking I/O
- Yield promise representing blocking I/O
- Event loop skips coroutine until promise is resolved (can use yield from inside a coroutine when you want to call another coroutine and wait for it to complete)
- Event loop send()s promise result to coroutine
- Repeat from 1 until coroutine is complete (return)
Use Node (or Hack)? This should look familar.
- async functions -> Generators
- await -> yield
Event Loop Extensions
Amphp http-server Request Model
Client (or Load Balancer)
Application Server (AMPHP)
HTTP
var_dump(is_12_factor()); // bool(true)
Pros
- No per-request bootstrap time
- Fewer moving parts (12F app)
- Async execution
- Generator based (!pyramid)
- Async database access
- Lower memory use per request
- Fast!
Cons
- A bit fragile*
- Requires port match
- Single-threaded**
- Plenty to refactor
* Throwables to the rescue!
** amphp/cluster to the rescue!
Benchmarks!
- System under test
- Vultr 4GB server (2 vCPUs), Amsterdam
- Ubuntu 18.10, Docker 18.06ce via snap, HTTP-only
- Current docker-compose setups from GitHub
- Load generation system
- Vultr 512MB server, same data center
- Siege 4.0.4 on Ubuntu 18.10
- Benchmarked a few times to allow for warm-up
- Hitting raffle entrants URL w\cookie, one "blank" raffle
-
siege -c 15 -t 30S -b <url> <cookie header>
Benchmarks! Nginx + PHP-FPM (7.2)
Transactions: 17029 hits Availability: 100.00 % Elapsed time: 29.25 secs Data transferred: 0.68 MB Response time: 0.03 secs Transaction rate: 582.19 trans/sec Throughput: 0.02 MB/sec Concurrency: 14.95 Successful transactions: 17029 Failed transactions: 0 Longest transaction: 0.07 Shortest transaction: 0.00
~45MB RAM, ~120% CPU
Benchmarks! AMPHP (7.3)
Transactions: 20724 hits Availability: 99.57 % Elapsed time: 29.79 secs Data transferred: 0.96 MB Response time: 0.02 secs Transaction rate: 695.67 trans/sec Throughput: 0.03 MB/sec Concurrency: 14.84 Successful transactions: 20724 Failed transactions: 90 Longest transaction: 1.04 Shortest transaction: 0.00
~42MB RAM, ~120% CPU
Benchmark Caveats
- In favor of amphp
- Relatively low concurrency
- Didn't turn opcache revalidation completely off on FPM
- Failing requests didn't automatically disqualify
- In favor of nginx + fpm
- Very little I/O (a couple very quick DB calls)
- Very little I/O latency (DB was on-server)
Thanks! Questions?
- ian.im/gen19bnl - these slides
- joind.in/talk/16ff4 - rate this talk!
- github.com/iansltx/raphple - raffle code
- twitter.com/iansltx - me
- github.com/iansltx - my code
- "Give Way" photo from Flickr
- Yield sign photo is mine
Don't Wait; Generate! - PHPBenelux 2019
By Ian Littman
Don't Wait; Generate! - PHPBenelux 2019
Generators, which have been around since PHP 5.5 and got a lot better with PHP 7, take a lot of the angst out of asynchronous programming in PHP. In this talk I'll explain the basic concepts that you'll need to grok generators, then apply our new-found knowledge to turn an I/O-bottlenecked web app into a concurrent, performant one via the AMPHP family of libraries.
- 1,859