PHPは長きにわたり同期的、すなわち、あらゆる処理を上から順に実行していくというスタイルを取ってきました。
しかしたとえば、複数のURLからデータを取ってきて結果をまとめたいといった場合、時間のかかるHTTPリクエストは同時に投げたいですよね。
この用途にはGuzzleというライブラリが存在し、これを使えば同時にリクエストを投げられます。
しかし、ではHTTPアクセスとDBアクセスを同時にやりたい場合は?
時間のかかる計算を裏でやりたい場合は?
などと考え始めると、こういった個別のライブラリでは対処しきれません。
ということで汎用的な非同期処理をPHPで書けるようにするRFCが提出されました。
PHP RFC: Fibers
Introduction
人類史上ほぼ全ての期間において、人々はPHPを同期的なコードとしてのみ書いてきました。
同期的に実行されるコードのみが存在し、そしてそれを同期的に呼び出します。
同期関数は、関数が結果を返すまで処理が止まります。
最近では、非同期のPHPコードを書くことを可能にするプロジェクトが複数存在します。
非同期関数は、コールバックを受け取ったり、Promiseのように後で値が確定するプレースホルダを返すなどして、後で結果が得られたときにコードを実行します。
これにより、最終結果を待たずに処理を続行することができるようになりました。
プロジェクトの例としてはamphp、ReactPHP、Guzzleなどが挙げられます。
このRFCが対処しようとしている課題は説明するのが難しいのですが、言うならばてめえの血はなに色だ?問題とでも言いましょうか。
リンク先の記事で説明されている問題を簡単にまとめると、
・非同期関数は、関数を呼ぶ仕組み自体を変化させてしまう。
・非同期関数は非同期関数を呼び出すことができない。同期関数を呼び出すことはできる。
・非同期関数を呼び出すためには、コールスタック全体が非同期でなければならない。
非同期処理にPromiseやawait、yieldを使い慣れている人にとっては、こう考えることができます。
『コールスタックのどこか一カ所でもPromiseを返そうとすると、そのPromiseがいつ解決されるかわからないから、コールスタック全体がPromiseを返さなければならなくなる』
このRFCでは、コールスタック全体を汚染することなく割り込み可能な非同期処理を導入することで、同期間数と非同期関数を区別なく取り扱うことを目指します。
これは次のように実現されます。
・PHPでFiberをサポートします。
・Fiberクラス、および対応するリフレクションクラスReflectionFiberを追加します。
・エラーのためのクラスFiberErrorとFiberExitを追加します。
Fiberは、PSR-7やDoctine ORMといった既存のインターフェイス上に、透過的にノンブロッキングIOを実装することを可能にします。
これはPromiseのようなプレースホルダオブジェクトを使用しないからです。
Fibers
Fiberを使用すると、割り込み可能なフルスタックの関数を作成することができます。
これはコルーチンやグリーンスレッドなどとも呼ばれています。
Fiberは実行スタック全体を停止させるため、関数を呼び出す側は、呼び出し方を変更する必要はありません。
Fiber::suspend()
を使うとコールスタックを任意に中断することができます。Fiber::suspend()
の呼び出しは、関数の深い入れ子の奥にあるかもしれないし、あるいはどこにも無いかもしれません。
スタックを持たないジェネレータと異なり、Fiberはそれ自身のコールスタックを保持しているため、深い入れ子の関数呼び出し中であろうと一時停止することができます。
Generatorインスタンスを返さなければならないジェネレータと異なり、Fiberを使う関数は戻り値の型を変更する必要はありません。
Fiberはarray_mapやIteratorのforeach、PHPのVM内からのコールなど、任意の関数呼び出しで一時停止することができます。
一時停止したFiberを再開するには、Fiber->resume()
で続けるか、もしくはFiber->throw()
で例外をスローします。、
値はFiber::suspend()
でreturnされるか、もしくは例外がスローされます。
Proposal
Fiber
FiberはPHPコアで定義され、シグネチャは以下のようになります。
finalclassFiber{/**
* @param callable $callback コールバック関数
*/publicfunction__construct(callable$callback){}/**
* 実行を開始する
*
* @param mixed ...$args コールバック関数に渡す引数
*
* @return mixed 一時停止した場合はsuspension point。終了したらnull
*
* @throw FiberError 既に実行されている場合。
* @throw Throwable その他
*/publicfunctionstart(mixed...$args):mixed{}/**
* 中断しているFiberを再開する。
*
* @param mixed $value
*
* @return mixed 一時停止した場合は次のsuspension point。終了したらnull
*
* @throw FiberError まだ実行されていない、現在一時停止されていない、既に終了している。
* @throw Throwable その他
*/publicfunctionresume(mixed$value=null):mixed{}/**
* 例外を投げる
*
* @param Throwable $exception
*
* @return mixed 一時停止した場合は次のsuspension point。終了したらnull
*
* @throw FiberError まだ実行されていない、現在一時停止されていない、既に終了している。
* @throw Throwable その他
*/publicfunctionthrow(Throwable$exception):mixed{}/**
* @return bool 既に実行開始されていればtrue
*/publicfunctionisStarted():bool{}/**
* @return bool 一時停止中であればtrue
*/publicfunctionisSuspended():bool{}/**
* @return bool 実行中であればtrue
*/publicfunctionisRunning():bool{}/**
* @return bool 実行が既に終わっていればtrue
*/publicfunctionisTerminated():bool{}/**
* @return mixed コールバック関数の返り値を返す。
*
* @throws FiberError まだ終了していない
*/publicfunctiongetReturn():mixed{}/**
* @return self|null 現在実行中のFiberインスタンスを返す。
*/publicstaticfunctionthis():?self{}/**
* 実行を一時停止する。メインスレッドからは呼べない。
*
* @param mixed resume()かthrow()で返した値。
*
* @return mixed resume()に渡す値。
*
* @throws FiberError メインスレッドから呼んだなぢ。
* @throws Throwable その他
*/publicstaticfunctionsuspend(mixed$value=null):mixed{}}
任意のコールバック関数をnew Fiber(callable $callback)
と渡すことでFiberオブジェクトを作成します。
コールバック関数はFiber::suspend()
を必ずしも呼び出す必要はありません。
入れ子のスタックの奥の方にあるかもしれませんし、あるいは一度も呼ばれないかもしれません。
作成したFiberオブジェクトは、Fiber->start(mixed ...$args)
と引数を渡して起動します。
Fiber::suspend()
は、現在のFiberの実行を一時停止し、処理をFiber->start()
・Fiber->resume()
・Fiber->throw()
いずれかの呼び出し元に戻します。
Generatorにおけるyield
に似たようなものと考えることができます。
一時停止されたFiberは、以下2つの方法で再開できます。
- Fiber->resume()
に値を渡して再開。
- Fiber->throw()
に値を渡して例外。
処理が完了したFiberからは、Fiber->getReturn()
でコールバック関数の返り値を取得することができます。
実行が完了していなかったり、例外が起きたときにはFiberErrorがスローされます。
Fiber::this()
は現在実行中のFiberインスタンスを返します。
これによってFiberの参照を、イベントループのコールバックや一時停止中のFiberインスタンスの配列など、別の場所に持っていくことができます。
ReflectionFiber
ReflectionFiberは実行中のFiberを検査するリフレクションです。
実行前でも終了済でも、任意のFiberオブジェクトからReflectionFiberを作成可能です。
ReflectionFiberは、ReflectionGeneratorとよく似ています。
finalclassReflectionFiber{/**
* @param Fiber Fiberオブジェクト
*/publicfunction__construct(Fiber$fiber){}/**
* @return Fiber Fiberオブジェクト
*/publicfunctiongetFiber():Fiber{}/**
* @return string 実行中のFiberのファイル名
*/publicfunctiongetExecutingFile():string{}/**
* @return int 実行中のFiberの行数
*/publicfunctiongetExecutingLine():int{}/**
* @param int $options debug_backtrace()の引数と同じ
*
* @return array Fiberのバックトレース。debug_backtrace()やReflectionGenerator::getTrace()と似てる。
*/publicfunctiongetTrace(int$options=DEBUG_BACKTRACE_PROVIDE_OBJECT):array{}/**
* @return bool 開始済であればtrue
*/publicfunctionisStarted():bool{}/**
* @return bool 一時停止中であればtrue
*/publicfunctionisSuspended():bool{}/**
* @return bool 実行中であればtrue
*/publicfunctionisRunning():bool{}/**
* @return bool 終了していればであればtrue
*/publicfunctionisTerminated():bool{}}
Unfinished Fibers
Fiberは、通常のオブジェクト同様、オブジェクトへの参照がなくなると破棄されます。
その際、実行が完了していないFiberは、実行完了していないGenerator同様破棄されます。
破棄されたFiberはFiber::suspend()
で再度呼び出すことはできません。
Fiber Stacks
各Fiberには、ヒープ上に個別のC stackとVM stackが割り当てられます。
C stackは、利用可能な場合はmmapを使用して割り当てます。
即ち、ほとんどのプラットフォームでは物理メモリはオンデマンドで消費されるということです。
各Fiberスタックは、デフォルトでは最大8Mまで割り当てられ、ini設定fiber.stack_size
で変更可能です。
このメモリはC stackに割り当てられるものであり、PHPの使うメモリとは無関係であることに注意してください。
VM stackはGeneratorと同じ方法でメモリとCPUが割り当てられます。
VM stackは動的に変更できるので、初期状態では4Kだけ使用します。
Backward Incompatible Changes
グローバル名前空間にFiber
・FiberError
・FiberExit
・ReflectionFiber
クラスが追加されます。
他に後方互換性のない変更はありません。
Future Scope
現在の実装は、PHP拡張モジュール用の内部Fiber APIを提供していません。
このRFCは、ユーザスペースのFiber APIに注力しています。
内部Fiber APIについては、他の拡張モジュール開発者と協力して追加していく予定です。
SwooleなどPHP拡張モジュール開発者からのフィードバックを受け、拡張モジュールがFiberを制御できるようにする予定です。
拡張モジュールは独自にFiberのような実装を持つこともできますが、内部APIが提供されれば、PHPコアのFiber実装を使うことができるようになります。
Proposed PHP Version(s)
PHP8.1。
Examples
単純な例
以下は、fiber
という文字列で一時停止するFiberの単純な例です。
この文字列は$fiber->start()
から返されます。
そしてresume
に渡した値がFiber::suspend()
に送られます。
$fiber=newFiber(function():void{$value=Fiber::suspend('fiber');echo"レジュームした。$value: ",$value,"\n";});$value=$fiber->start();echo"一時停止した。$value: ",$value,"\n";$fiber->resume('test');// 実行結果一時停止した。$value:fiberレジュームした。$value:test
イベントループ
次は非常に単純なイベントループの例です。
データを受信するためにソケットをポーリングし、利用可能になったときにコールバックを呼び出します。
このイベントループを使うことで、データがソケット上で利用可能になったときにだけFiberを再開できるようになり、読み込みのブロッキングを回避できます。
classEventLoop{privatestring$nextId='a';privatearray$deferCallbacks=[];privatearray$read=[];privatearray$streamCallbacks=[];publicfunctionrun():void{while(!empty($this->deferCallbacks)||!empty($this->read)){$defers=$this->deferCallbacks;$this->deferCallbacks=[];foreach($defersas$id=>$defer){$defer();}$this->select($this->read);}}privatefunctionselect(array$read):void{$timeout=empty($this->deferCallbacks)?null:0;if(!stream_select($read,$write,$except,$timeout,$timeout)){return;}foreach($readas$id=>$resource){$callback=$this->streamCallbacks[$id];unset($this->read[$id],$this->streamCallbacks[$id]);$callback($resource);}}publicfunctiondefer(callable$callback):void{$id=$this->nextId++;$this->deferCallbacks[$id]=$callback;}publicfunctionread($resource,callable$callback):void{$id=$this->nextId++;$this->read[$id]=$resource;$this->streamCallbacks[$id]=$callback;}}[$read,$write]=stream_socket_pair(stripos(PHP_OS,'win')===0?STREAM_PF_INET:STREAM_PF_UNIX,STREAM_SOCK_STREAM,STREAM_IPPROTO_IP);// ストリームをノンブロッキングモードにするstream_set_blocking($read,false);stream_set_blocking($write,false);$loop=newEventLoop;// ストリームが読み込み可能になったら、さらに別のFiberでデータ読み込みを起動$fiber=newFiber(function()use($loop,$read):void{echo"Waiting for data...\n";$fiber=Fiber::this();$loop->read($read,fn()=>$fiber->resume());Fiber::suspend();$data=fread($read,8192);echo"Received data: ",$data,"\n";});// Fiberを実行。$fiber->start();// コールバックでデータ書き込み$loop->defer(fn()=>fwrite($write,"Hello, world!"));// イベントループ実行$loop->run();
このスクリプトの実行結果は以下のようになります。
Waitingfordata...Receiveddata:Hello,world!
以下の図は、メインスレッドとFiber間の実行フローを表しています。
実行フローは、Fiber::suspend()
とFiber->resume()
を呼び出すたびに行ったり来たりします。
amphp
以下は非同期フレームワークamphp v3を利用して、非同期コードを同期コードのように記述する例です。
amphp v3はイベントループを用いて、、Fiber APIの上に非同期処理のための様々な関数やPromise、コードを実行するためのコルーチンを構築します。
amphp v3のユーザはFiber APIを直接使用する必要はありません。
必要に応じてフレームワークがFiberへの登録や一時停止などの処理を行います。
従って、他の類似のフレームワークでは作成・使用方法が多少異なる場合があります。
defer(callable $callback, mixed ...$args)
関数は、現在のFiberが終了したもしくは一時停止したときに実行される次のFiberを登録します。delay(int $milliseconds)
は、現在のFiberを指定したミリ秒中断します。
usefunctionAmp\defer;usefunctionAmp\delay;// deferは新しいFiberを作り、実行中のFiberが終了したら自動的に次を実行するdefer(function():void{delay(1500);var_dump(1);});defer(function():void{delay(1000);var_dump(2);});defer(function():void{delay(2000);var_dump(3);});// メインスレッドを一時停止delay(500);var_dump(4);
amphp その2
メインスレッドが一時停止している間、イベントループがどのように実行されるかを示すため、ふたたびamphp v3を用いた例を出します。await(Promise $promise)
は、引数のPromseが解決されるまで実行が停止します。
そしてasync(callable $callback, mixed ...$args)
は、Promiseオブジェクトを返します。
usefunctionAmp\async;usefunctionAmp\await;usefunctionAmp\defer;usefunctionAmp\delay;// 返り値はintであり、PromiseやGeneratorと異なりコルーチンとして実行されることに注意functionasyncTask(int$id):int{// ここでは何もしてない。非同期IOとかのかわり。delay(1000);// 1秒停止するだけreturn$id;}$running=true;defer(function()use(&$running):void{// ほかのFiberでブロックされないことを示したいだけwhile($running){delay(100);echo".\n";}});// asyncTask()は1秒後にintを返す$result=asyncTask(1);var_dump($result);// 2つのFiberを同時に実行する。await()は全部終わるまでFiberを中断する。$result=await([// 1秒でおわるasync(fn()=>asyncTask(2)),// async()はFiberを作成してPromiseを返すasync(fn()=>asyncTask(3)),]);var_dump($result);// 2秒後に実行される$result=asyncTask(4);// 1秒かかるvar_dump($result);// array_map()は2秒かかる。これは呼び出しが同時に行われないということを表す$result=array_map(fn(int$value)=>asyncTask($value),[5,6]);var_dump($result);$running=false;// 上のdeferを止める
ここawaitのvar_dump($result);
にExecuted after 2 seconds
って書いてあるんだけど、これ1 second
じゃないの?
読み間違い?
Generator
FiberはPHP VMの呼び出し中にも一時停止することができるので、非同期イテレータやGeneratorを作成することもできます。
以下の例ではamphp v3を使い、Generator内でFiberを一時停止させています。
Generatorを反復処理する際、Generatorの返り値を待っている間foreachループは一時停止します。
useAmp\Delayed;usefunctionAmp\await;functiongenerator():Generator{yieldawait(newDelayed(500,1));yieldawait(newDelayed(1500,2));yieldawait(newDelayed(1000,3));yieldawait(newDelayed(2000,4));yield5;yield6;yield7;yieldawait(newDelayed(2000,8));yield9;yieldawait(newDelayed(1000,10));}// 通常どおりGeneratorを反復しますが、必要に応じてループを一時停止しますforeach(generator()as$value){printf("Generator yielded %d\n",$value);}// 引数アンパックも同じvar_dump(...generator());
ReactPHP
最後はReactPHPを使って、await関数を定義する例です。
useReact\EventLoop\LoopInterface;useReact\Promise\PromiseInterface;functionawait(PromiseInterface$promise,LoopInterface$loop):mixed{$fiber=Fiber::this();if($fiber===null){thrownewError('Promises can only be awaited within a fiber');}$promise->done(fn(mixed$value)=>$loop->futureTick(fn()=>$fiber->resume($value)),fn(Throwable$reason)=>$loop->futureTick(fn()=>$fiber->throw($reason)));returnFiber::suspend();}
ReactPHPとFiberを統合するデモがtrowski/react-fiberで実装されています。
FAQ
Who is the target audience for this feature?
この機能のターゲットは誰?
Fiberは、ほとんどのユーザは直接使用することのない高度な機能です。
主にイベントループや非同期APIを提供するライブラリ・フレームワーク作者をターゲットとしています。
Fiberは、アプリケーションのコールスタックを変更したり、大きなコード変更を加えることなく、任意の時点で非同期コードを既存の同期コードにシームレスに導入することを可能にします。
Fiberは、アプリケーションレベルで直接使用することを想定していません。
Fiberはローレベルのフロー制御APIであり、アプリケーションコードで使用する高度な抽象化を提供します。
同じような事例のひとつとして、FFIは最近PHPに追加された機能の一部ですが、ほとんどのユーザは直接使用することはありません。
しかし、ユーザは使用しているライブラリを通して大きな恩恵を受けることができます。
What about performance?
パフォーマンスはどう?
Fiber間の切り替えは軽量で、プラットフォームにもよりますが、凡そ20箇所のポインタを変更するだけです。
PHP VM上での実行コンテキストの切り替えはGeneratorと似ていて、こちらも数個のポインタをスワップするだけです。
Fiberはひとつのスレッド内に存在するため、Fiberの切り替えはプロセスやスレッドの切り替えよりも高パフォーマンスです。
What platforms are supported?
サポートしているプラットフォームは?
x86、x86_64、ARM、PPC、MIPS、Windows(Fiber APIを提供しているためアーキテクチャを問わない)など大抵の最新CPU、そしてucontext対応の古いPosixをサポートしています。
How are execution stacks swapped?
実行スタックのスワップ方法は?
各FiberはC stackとVM stackのポインタを保持しています。
Fiberに入ると現在のC stackがスワップされます。
VM stackはメモリにバックアップされていて、Fiberが終了すると元に戻ります。debug_backtrace()
や例外のバックトレースは、現在のFiberのみトレースします。
Fiberの中に入ったFiberのトレースは行いません。
How does blocking code affect fibers
ブロッキングコードがFiberに与える影響。
ブロックするコード(file_get_contents()
など)は、他のFiberがあっても引き続きプロセス全体をブロックします。
パフォーマンスと同時実行性の両方を実現するには、非同期IO、イベントループ、そしてFiberを使用するようにコードを記述する必要があります。
非同期IOのライブラリは既にいくつか存在していますが、それらはFiberを利用することで同期コードと統合することができます。
Fiberは非同期IOを透過的に使用できるので、ブロッキング実装を非ブロッキング実装に置き換えることがでます。
コールスタック全体に影響を与えることはありません。
将来的に内部イベントループが実装された際は、sleep()
などの内部関数をデフォルトでノンブロッキングにすることも可能です。
How do various fibers access the same memory?
複数のFiberが同じメモリにアクセスするのは何故?
全てのFiberはひとつのスレッド内に存在します。
一度に実行できるFiberはひとつだけなので、メモリを同時に変更できるスレッドとは異なり、複数のFiberが同時にメモリアクセスしたり変更することはできません。
Fiberが中断・再開されると、同じメモリにアクセスする複数のFiberがインターリーブされます。
そのため、実行中のFiberが、中断されている別のFiberが私用しているメモリを変更する可能性があります。
この問題に対応する方法としては、mutexes・semaphores・memory parcels・channelsといった手段が存在します。
これらは、FiberAPIを使ってユーザベースで実装できるため、このRFCにおいては提供しません。
Why add this to PHP core?
なぜPHPコアに導入する?
この機能をPHPコアに直接追加することで、PHPを提供している全てのホストがこの機能を使うことができるようになります。
多くの場合、ユーザはホストがどのエクステンションを提供しているのかを知らなかったり、ユーザ側で任意にエクステンションを追加できなかったり、追加したくなかったりします。
FiberがPHPコアに含まれていれば、あらゆるライブラリ作者が移植性を気にすることなくFiberを利用することができます。
Why not add an event loop and async/await API to core?
イベントループとasync/awaitも追加しない?
このRFCは、フルスタックのコルーチンやグリーンスレッドをユーザコードで提供することを可能にする最低限の機能のみを提案しています。
独自のイベントループやPromise、その他非同期APIを提供しているフレームワークが幾つか存在しますが、それらのAPI設計は多様で、仕様が異なります。
それら特定のニーズのために設計されている非同期APIは、PHPコアに実装されたイベントループなどではカバーできないかもしれません。
最低限の機能をコアで提供し、ユーザ側で任意にコンポーネントを実装できるようにするのが最も良い、というのがこのRFCの主張です。
多くのフレームワークがひとつのイベントループAPIに集約されたり、PHPコアにイベントループを搭載したいという要求が出てきた場合には、次のRFCでそれらを導入することになるかもしれません。
このRFCは、今後PHPコアにasync/awaitやイベントループを追加することを妨げるものではありません。
How does this proposal differ from prior Fiber proposals?
かつてのFiber RFCとのちがいは?
かつて出されたFiberのRFCは、内部関数(array_map, preg_replace_callbackなど)やopcacheハンドラ(foreach, yieldなど)内でのコンテキスト切り替えをサポートしていませんでした。
そのため、Cコードから呼び出されるユーザコードや、Xdebugのようにzend_execute_ex
をオーバーライドするエクステンションを使うとクラッシュする可能性がありました。
Vote
投票期間は2021/03/08から2021/03/22です。
投票の2/3以上の賛成で受理されます。
2021/03/15時点では賛成38反対10の賛成多数であり、おそらく受理されます。
References
感想
残念ながら、ユーザが直接ばりばり並列処理を書けるようにする目的で導入されるものではなく、並列処理を書けるようにするライブラリを作れるようにするための機能です。
とはいえamphpが既にFiberに対応したバージョンを作ってしまうなど、導入の敷居も高くないようで、今後他のライブラリの対応も期待できるかもしれません。
また目的ではないとはいえ、べつに個人で導入することが禁止されているわけでもないので、何らかの並列処理する機能を作ってみるのも面白いかもしれませんね。
メーリングリストバトル
投票開始後、MLでtwoseeから長文のツッコミが入りました。
ソースコード上の無駄な呼び出しがある、他のC拡張からの割り込みを考慮していない、Windowsでboost-context
より性能の落ちるwin-fiber
を使っている、状態取得メソッドがたくさんあるのにFiber::getStatus()
が何故かない、Swooleと互換していない、などなど様々な指摘がなされています。
いきなりPHPコアに実用経験のない実験的機能をぶち込むのはどうなんだ、まずはSwooleのようにPECLでやるべきではないのか、SwooleはPDO、mysqli、phpredis、libcurlなど様々な機能をコルーチン化してきた実績がある、そしていつかPHP本体にマージされるのを待っている。
まあ概ね頷ける内容ではあるのですが、ただこれ実はちょっとだけ裏がありまして、この反論の主twoseeはSwooleの中の人で、そしてRFCの提出者Aaron Piotrowskiはamphpの中の人です。
SwooleもamphpもPHPの非同期ライブラリであり、その片側が出したRFCが通過したとなれば、Swoole側としては当然面白くないわけです。
次いでSwooleのファウンダーと名乗る人が登場。
Fiberはamphpにしか使えず、他のPHPプロジェクトには価値がないぞ。PECL行きが妥当。
PHP9あたりでGo言語ばりの非同期IDとCSPを導入してくれ。
この投稿に反応して今度はさらにReactPHPの中の人が登場。
おいィちょっと待てよSwooleにとってFiberは役立たずかもしれんがReactPHPにはめっちゃ有用なんだが?
なにしろこれでPSR-15がフルサポートできるってもんよ。
もちろんReactPHPもPHP用非同期ライブラリです。
と楽しい楽しいライブラリ間代理戦争が勃発しましたが、投票経過を見るにFiber側が勝利しそうではあります。