PHP Fibers

Milko Kosturkov

  • Developer for over 16 years
  • Contractor
  • Founder of Ty's Software - consultancy and contracting
  • Head of Software Development @ mysuply

Fibers

  • can execute our code
  • can suspend their own execution
  • can be resumed later from something else
  • can spit out a value on suspension
  • can be injected with a value or error upon resuming them

The Fiber Class

final class Fiber
{
    public function __construct(callable $callback) {}

    public function start(mixed ...$args): mixed {}

    public function resume(mixed $value = null): mixed {}

    public function throw(Throwable $exception): mixed {}

    public function isStarted(): bool {}

    public function isSuspended(): bool {}

    public function isRunning(): bool {}

    public function isTerminated(): bool {}

    public function getReturn(): mixed {}

    public static function getCurrent(): ?self {}

    public static function suspend(mixed $value = null): mixed {}
}

ReflectionFiber

final class ReflectionFiber
{
    public function __construct(Fiber $fiber) {}

    public function getFiber(): Fiber {}

    public function getExecutingFile(): string {}

    public function getExecutingLine(): int {}

    public function getTrace(int $options = DEBUG_BACKTRACE_PROVIDE_OBJECT): array {}

    public function isStarted(): bool {}

    public function isSuspended(): bool {}

    public function isRunning(): bool {}

    public function isTerminated(): bool {}
}

Fibers Demo

<?php

$echo = function (string $name) {
    echo "This is fiber $name awaiting to be resumed\n";
    $input = Fiber::suspend($name);
    echo "$name is from the $input guys\n";
    return $name;
};

$f1 = new Fiber($echo);
$f2 = new Fiber($echo);

$r11 = $f1->start("Optimus");
$r21 = $f2->start("Megatron");

echo "F1 was suspended with $r11\n";
echo "F2 was suspended with $r21\n";


$f2->resume("bad");
$f1->resume("good");

echo "F1 returned {$f1->getReturn()}\n";
echo "F2 returned {$f2->getReturn()}\n";
This is fiber Optimus awaiting to be resumed
This is fiber Megatron awaiting to be resumed
F1 was suspended with Optimus
F2 was suspended with Megatron
Megatron is from the bad guys
Optimus is from the good guys
F1 returned Optimus
F2 returned Megatron

Nested Fibers Demo

<?php

$top = new Fiber(function () {
    echo "Starting top fiber\n";
    
    $nested = new Fiber(function () {
        echo "Starting nested\n";
        Fiber::suspend();
        echo "Ending nested\n";
    });
    
    $nested->start();
    echo "Ending top\n";
    return $nested;
});

$top->start();
$nested = $top->getReturn();
$nested->resume();
Starting top fiber
Starting nested
Ending top
Ending nested

How it works

  • each fiber has its own stack
  • when the fiber gets suspended its stack is copied in memory and replaced with the stack of the fiber which started/suspended it
  • when the fiber is resumed the same happens in reverse order
  • main can not be suspended

The Task

Download two files ASAP

Sync PHP

<?php

$file1 = file_get_contents('http://example.com/file1.txt');
$file2 = file_get_contents('http://example.com/file2.txt');

Fetching File 1

Fetching File 2

The Async Way

  • Request file 1
  • Request file 2
  • Get a bit of file 1
  • Get a bit of file 2
  • Get a bit of file 1
  • Get a bit of file 2
  • ........
  • File 2 is done
  • Get a bit of file 1
  • Get a bit of file 1
  • ....
  • File 1 is done

Fetching Both

Our own function

<?php

function fetchUrl(string $url) {
    $fp = @stream_socket_client("tcp://$url:80", $errno, $errstr, 30);
    if (!$fp) {
        throw new Exception($errstr);
    }
    stream_set_blocking($fp, false);
    fwrite($fp, "GET / HTTP/1.0\r\nHost: $url\r\nAccept: */*\r\n\r\n");

    $content = '';
    while (!feof($fp)) {
        $bytes = fgets($fp, 100);
        $content .= $bytes;
    }
    return $content;
}

URLFetcher class v.1

class URLFetcher
{
    public string $content = '';
    private $fp;
    public function __construct(private string $url) {}

    public function start(): void {
        $this->fp = @stream_socket_client("tcp://$this->url:80", $errno, $errstr, 30);
        if (!$this->fp) {
            throw new Exception($errstr);
        }
        stream_set_blocking($this->fp, false);
        fwrite($this->fp, "GET / HTTP/1.0\r\nHost: $this->url\r\nAccept: */*\r\n\r\n");
    }

    public function readSomeBytes(): void {
        $this->content .= fgets($this->fp, 100);
    }

    public function isDone(): bool {
        return feof($this->fp);
    }
}

Our own function

<?php

function fetchUrl(string $url) {
    $fetcher = new URLFetcher($url);
    $fetcher->start();
    while (!$fetcher->isDone()) {
        $fetcher->readSomeBytes();
    }
    return $fetcher->content;
}

External (Event) Loop

<?php 

class Loop 
{
    private static array $callbacks = [];

    public static function add(callable $callback) 
    {
        self::$callbacks[] = $callback;
    }

    public static function run() 
    {
        while (count(self::$callbacks)) {
            $cb = array_shift(self::$callbacks);
            $cb();
        }
    }
}

URLFetcher v.2

class URLFetcher {
    public string $content = '';
    private $fp;
    public function __construct(
    	private string $url, 
    	private Closure $done, 
        private Closure $onerror
    ) {}

    ...
 }

URLFetcher v.2

class URLFetcher {
    ...
    public function start(): void
    {
        $this->fp = @stream_socket_client("tcp://$this->url:80", $errno, $errstr, 30);
        if (!$this->fp) {
            ($this->onerror)($errstr);
        }
        stream_set_blocking($this->fp, false);
        fwrite(
            $this->fp, 
            "GET / HTTP/1.0\r\nHost: $this->url\r\nAccept: */*\r\n\r\n"
        );
        Loop::add(fn () => $this->tick());
    }
    ...
 }

URLFetcher v.2

class URLFetcher {
    ...
    public function tick(): void
    {
        if ($this->isDone()) {
            fclose($this->fp);
            ($this->done)($this->content);
        } else {
            $this->readSomeBytes();
            Loop::add(fn () => $this->tick());
        }
    }
    ...
 }

URLFetcher v.2

class URLFetcher {
    ...
    public function readSomeBytes(): void
    {
        $this->content .= fgets($this->fp, 100);
    }

    public function isDone(): bool
    {
        return feof($this->fp);
    }
    ...
 }

Our own function

<?php 

function fetchUrl(string $url, callable $done, callable $onerror) {
    $fetcher = new URLFetcher(
    	$url, 
        Closure::fromCallable($done), 
        Closure::fromCallable($onerror)
    );
    Loop::add(fn () => $fetcher->start());
}

Our main

Loop::add(function () {
    echo "Fetching dir.bg\n";
    fetchUrl(
        'www.dir.bg',
        function (string $dirbg) {
            echo "Got dir.bg\n";
            echo $dirbg;
        },
        function (string $err) {
            echo "Got error from dir.bg\n";
            echo "err\n";
        }
    );

    echo "Fetching google.com\n";
    fetchUrl(
        'www.google.com',
        function (string $google) {
            echo "Got Google\n";
            echo "Size is: " . strlen($google) . "\n";
        },
        function (string $err) {
            echo "Got error from google\n";
            echo "err\n";
        }
    );
});

Loop::run();
Fetching dir.bg
Fetching google.com
Got dir.bg
HTTP/1.1 301 Moved Permanently
Content-length: 0
Location: https://dir.bg/
Connection: close

Got Google
Size is: 51803

So far, so bad...

Our fetch func, but with fibers

function ffetchUrl(string $url): string {
    $fiber = Fiber::getCurrent();
    fetchUrl(
        $url, 
        fn ($value) => $fiber->resume($value), 
        fn ($err) => $fiber->throw(new Exception($err))
    );
    return Fiber::suspend();
}

A helper defer function

function defer(callable $coroutine) {
    $fiber = new Fiber($coroutine);
    Loop::add(fn () => $fiber->start());
}

Refactored main

defer(function () {
    echo "Fetching dir.bg\n";
    try {
        $dirbg = ffetchUrl('www.dir.bg');
        echo "Got dir.bg\n";
        echo $dirbg;
    } catch (Exception $e) {
        echo "Got error from dir.bg\n";
        echo $e->getMessage() . "\n";
    }
});

defer(function () {
    echo "Fetching google.com\n";
    try {
        $google = ffetchUrl('www.google.com');
        echo "Got Google\n";
        echo "Size is: " . strlen($google) . "\n";
    } catch (Exception $e) {
        echo "Got error from google\n";
        echo $e->getMessage() . "\n";
    }
});

Loop::run();
Fetching dir.bg
Fetching google.com
Got dir.bg
HTTP/1.1 301 Moved Permanently
Content-length: 0
Location: https://dir.bg/
Connection: close

Got Google
Size is: 51749

A quick recap

  • Not intended for end-user usage
  • Intended for async library/frameworks authors
  • Benefits for authors
    • No boilerplate needed
      • no need for promises
      • no need for generator wrappers
  • Benefits for authors and users
    • No sync and async (red and blue) functions 
    • Old sync code can be easily refactored to async code

 

Similarities

They both can:

  • suspend themselves
  • be resumed
  • spit something out
  • receive something in

 

Differences

Fibers:

  • can be suspended from anywhere on the stack
  • can be accessed from anywhere on the stack
  • don’t need ‘yield’ 
  • can not be iterated on

 

Compared to Generators

Fibers and generators can be combined to produce async generators

Fibers

  • do the switching themselves*
  • are cheap to switch
  • execute one at a time
  • good for I/O intensive stuff

 

Threads

  • are switched by the OS/VM
  • are not as cheap to switch
  • execute in parallel**
  • good for computationally intensive stuff

 

Compared to Threads

Thank you

Thank you!

Milko Kosturkov

@mkosturkov

linkedin.com/in/milko-kosturkov

mailto: mkosturkov@gmail.com

 

These slides:

https://slides.com/milkokosturkov/php-fibers-v-2-ipc

 

PHP Fibers v.2 - IPC Edition

By Milko Kosturkov

PHP Fibers v.2 - IPC Edition

An overview of PHP Fibers

  • 419