在 PHP 中使用 Promise + co/yield 協程

摘要: 咱們知道 JavaScript 自從有了 Generator 以後,就有了各類基於 Generator 封裝的協程。其中 hprose 中封裝的 Promise 和協程庫實現了跟 ES2016 的 async/await 同樣的功能,而且更加靈活。咱們還知道 PHP 自從 5.5 以後,也引入了 Generator,一樣也有了各類基於它封裝的 PHP 協程庫,hprose 一樣也爲 PHP 提供的跟 JavaScript 版本相似的 Promise 和協程庫。下面咱們就來看一下它跟 swoole 結合的效果。php

爲何須要異步方式

一個函數執行以後,在它後面順序編寫的代碼中,若是可以直接使用它的返回結果或者它修改以後的引用參數,那麼咱們一般認爲該函數是同步的。git

而若是一個函數的執行結果或者其修改的引用參數,須要經過設置回調函數或者回調事件的方式來獲取,而在其後順序編寫的代碼中沒法直接獲取的話,那麼咱們一般認爲這樣的函數是異步的。github

PHP 提供的大部分函數都是同步的。一般咱們會有一個誤解,那就是容易把同步和阻塞當成同一個概念,但實際上同步代碼不必定都是阻塞的,只是同步代碼對阻塞自然友好,當同步代碼和阻塞結合時,代碼一般是簡單易懂的。編程

阻塞帶來的問題是當前線程(或進程)會陷入等待,一直等到阻塞結束,這樣就會形成線程(或進程)資源的浪費。因此,一般認爲阻塞是不夠高效的。segmentfault

可是若是要編寫非阻塞代碼,使用同步方式會變得有些複雜,且不夠靈活。同步方式的非阻塞代碼一般會使用 select 模式,例如 curl_multi_select, stream_select, socket_select 等就是 PHP 中提供的一些典型的 select 模式的函數。swoole

咱們說它複雜且不夠靈活是有理由的,例如使用上面的 select 模式編寫同步的非阻塞代碼時,咱們須要先構造一個併發任務的列表,以後手動構造循環來執行這些併發的任務,在循環開始以後,雖然這幾個任務能夠併發,可是這個循環相對於其後的代碼整體上仍然是阻塞的,咱們要想拿到這些併發任務的結果時,仍然須要等待。select 雖然能夠同時等待多個任務中某一個或幾個就位後,再執行後續操做,但仍然有一部分時間是被等待消耗掉的。並且若是是純同步非阻塞的狀況下,咱們也很難在循環開始後,動態添加更多的任務到這個循環中去。併發

因此,若是咱們但願程序可以更加高效,更加靈活,就須要引入異步方式。composer

傳統的異步方式有什麼問題

一提到異步模式,你們腦子中的第一印象可能就是回調、回調、回調。是的,這是最簡單最直接也是以前最多見的異步模式。只要在調用異步函數時設置一個或多個回調函數,函數就會在完成時自動調用回調函數。或者爲一個對象設置一堆事件,以後調用該對象上的某個異步方法,雖然這個異步方法自己可能再也不須要設置回調函數,可是設置的這堆事件實際上跟回調函數所起到的做用是同樣的。curl

若是你的程序邏輯夠簡單,簡單的一兩層回調也許並不會讓你以爲異步方式的編程有什麼麻煩。但若是你的程序邏輯一旦有些複雜,你可能就會被層層回調搞得疲憊不堪了。固然,實際上你的程序須要層層回調的緣由,也許並非你的程序邏輯真的複雜,而是你沒有辦法將回調函數中的參數結果傳出來,因此,你就不得不將另外一個回調函數傳進去。異步

咱們來舉一個簡單的例子,假設咱們有 1 個同步函數:

function sum($a, $b) {
    return $a + $b;
}

而後咱們按照下面的方式去調用它:

$a = sum(1, 2);
$b = sum($a, 3);
$c = sum($b, 4);
var_dump(array($a, $b, $c));

雖然上面的代碼很不精簡,但咱們要表達的意圖很明確,並且代碼看起來很清楚。

那接下來咱們把這個函數換成一個形式上的異步函數,例如:

function async_sum($a, $b, $callback) {
    $callback($a + $b);
}

固然,它的執行並非異步的,這裏咱們先不關心它的實現是否是真異步的。

如今若是要作上面一樣的操做,代碼就要這樣寫了:

async_sum(1, 2, function($a) {
    async_sum($a, 3, function($b) use ($a) {
        async_sum($b, 4, function($c) use ($a, $b) {
            var_dump(array($a, $b, $c));
        });
    });
});

代碼的執行結果是同樣的。但異步的代碼看起來顯然更難讀一些,雖然這已是很簡單的例子了。

好了,看到這裏,有些讀者可能會覺的我上面的這個例子很糟糕。由於明明有同步的函數可使用,而且代碼清晰可讀,爲啥非要寫個形似異步的函數,把原本同步能夠作的很好的事情用異步方式複雜化呢?並且那個異步調用的方式,最後不仍是想要實現同步化的結果嗎?

若是你這麼想的話,一點都沒錯。但咱們這裏想要解決的問題是,若是咱們拿到的只有一個異步函數,這個函數沒有同步實現,咱們也不知道這個異步函數的內部定義是怎樣的,咱們也沒辦法將這個異步函數改成同步函數實現。那咱們有沒有辦法將上面的程序改的更可讀一些呢?

固然是能夠的,因此,如今 Promise 要登場了。

爲何要引入 Promise

一般咱們對 Promise 的一個誤解就是,它要解決的是層層回調的問題,好比上面的問題看上去就是一個典型的層層回調的問題。

然而實際上,Promise 要解決的並非回調不回調的問題,若是你使用過 Promise 的話,你會發現使用 Promise 你仍然少不了要使用回調。Promise 要解決的問題是,如何將回調方法的參數從回調方法中傳遞出來,讓它能夠像同步函數的返回結果同樣,在回調函數之外的控制範圍內,能夠傳遞和複用。

下面這幾篇文章可能會對你們理解 Promise 有所幫助:

我以爲這幾篇文章講的比較透徹,因此我就不重複文章中的內容了。

下面咱們來看上面的例子用 Promise 如何解。

咱們如今用最簡單粗暴的方式來引入 Hprose 的庫,直接複製源碼而不是使用 composer。而後咱們在代碼中直接使用:

<?php
require_once("Hprose.php");
use Hprose\Promise;

這種方式來引入 Hprose 的 Promise 庫,固然你也能夠寫成:

<?php
require_once("Hprose.php");
use Hprose\Future;

Future 庫跟 Promise 庫基本上是同樣的,你能夠認爲 FuturePromise 的具體實現,Promise 只是 Future 實現的一個包裝。這個區別你能夠從源碼中直接看出來,這裏就很少作解釋了。

接下來,咱們要把前面的 async_sum 函數 Promise 化,Hprose 提供了這樣一個函數:Promisepromisify(或者 Futurepromisify),它的做用就是將一個使用回調方式的異步函數變成一個返回 Promise 對象的異步函數。這樣說,也許有些很差理解,下面直接上代碼:

<?php
require_once("Hprose.php");

use Hprose\Promise;

function async_sum($a, $b, $callback) {
    $callback($a + $b);
}

$sum = Promise\promisify('async_sum');

$a = $sum(1, 2);
$b = $a->then(function($a) use ($sum) {
    return $sum($a, 3);
});
$c = $b->then(function($b) use ($sum) {
   return $sum($b, 4);
});

Promise\all(array($a, $b, $c))->then(function($result) {
    var_dump($result);
});

好了,看到這裏,若是你對 Promise 的理解還不夠深刻的話,你的第一反應多是:這不是把程序變得更復雜了嗎?原來的程序是 3 個回調,如今仍然是 3 個回調,還多了包裝,都玩出花來了,有意思嗎?

確實,從上面的代碼來看,代碼並無被簡化,可是你會發現,如今回調函數中的參數已經經過 Promise 返回值的方式傳遞出來了,並且能夠在本來的回調函數控制範圍之外被傳遞和複用了。

可是你可能會說然並卵,程序不是仍然很複雜嗎?那咱們就來進一步簡化一下:

<?php
require_once("Hprose.php");

use Hprose\Promise;

function async_sum($a, $b, $callback) {
    $callback($a + $b);
}

$sum = Promise\wrap(Promise\promisify('async_sum'));
$var_dump = Promise\wrap('var_dump');

$a = $sum(1, 2);
$b = $sum($a, 3);
$c = $sum($b, 4);

$var_dump(Promise\all(array($a, $b, $c)));

如今,代碼中再也看不到回調了。由於咱們把函數包裝成了能夠接收 Promise 變量的函數。固然,其實現細節略微有些複雜,若是你感興趣,能夠去看一下源碼,這裏就不作源碼剖析了。若是感興趣的讀者多得話,之後有時間再寫源碼剖析。

固然,若是你只是想把異步調用同步化,除了 Promisewrap 外,你還能夠經過 co/yield 協程來實現。

Hprose 中的 co/yield 協程

仍是上面的例子,若是你使用的是 PHP 5.5 或者更高版本,那麼你能夠這樣來寫代碼了。

<?php
require_once("Hprose.php");

use Hprose\Promise;

function async_sum($a, $b, $callback) {
    $callback($a + $b);
}

Promise\co(function() {
    $sum = Promise\promisify('async_sum');

    $a = (yield $sum(1, 2));
    $b = (yield $sum($a, 3));
    $c = (yield $sum($b, 4));

    var_dump(array($a, $b, $c));
});

這代碼比使用 Promisewrap 的又要簡單了。這裏,代碼中的變量 $a, $b, $c 再也不是 Promise 變量,而是實實在在的整數變量。也就是說,yield 把一個 Promise 變量變成了一個普通變量。

如今 Promiseco 中的代碼已經被實實在在的同步化了。

如今你可能有新的疑問了,異步不是爲了高效嗎?如今把本來的異步代碼同步化了,那還會高效嗎?

固然,對這個例子上來講,效率確定是沒有提升,反而是嚴重下降的。甚至在這個例子中,最原始的那個形似異步的實現也不比同步實現更高效。由於在這個例子中,並無涉及到併發和 IO 阻塞的狀況。

下面咱們就放到真實場景下來看看 Promise 和 co/yield 協程是怎麼用的。

在 swoole 下使用 Promise 和 co/yield 協程

咱們知道在 PHP 中,若是要讓程序延時可使用 sleep 函數(或者 usleep, time_nanosleep 函數)來讓程序阻塞一下子,可是這個阻塞會讓整個進程都阻塞,因此在阻塞期間,什麼都不能幹。

下面咱們來看看使用 swoole_timer_after 實現的延時執行:

<?php
require_once("Hprose.php");

use Hprose\Future;

date_default_timezone_set('UTC');

function wait($time) {
    $wait = Future\promisify('swoole_timer_after');
    for ($i = 0; $i < 5; $i++) {
        yield $wait($time);
        var_dump("wait ". ($time / 1000) . "s, now is " . date("H:i:s"));
    }
}

Future\co(wait(2000));
Future\co(wait(1000));

該程序執行結果以下:

string(24) "wait 1s, now is 13:48:25"
string(24) "wait 2s, now is 13:48:26"
string(24) "wait 1s, now is 13:48:26"
string(24) "wait 1s, now is 13:48:27"
string(24) "wait 2s, now is 13:48:28"
string(24) "wait 1s, now is 13:48:28"
string(24) "wait 1s, now is 13:48:29"
string(24) "wait 2s, now is 13:48:30"
string(24) "wait 2s, now is 13:48:32"
string(24) "wait 2s, now is 13:48:34"

從結果中咱們能夠看出,wait(2000)wait(1000) 各自都是順序阻塞執行的,可是它們之間倒是併發執行的。

也就是說,協程之間並不會相互阻塞,雖然這幾個併發的協程是在同一個進程內跑的。

最後咱們再來看一個用 co/yield 協程實現的併發抓圖程序:

<?php
require_once("Hprose.php");

use Hprose\Promise;

function fetch($url) {
    $dns_lookup = Promise\promisify('swoole_async_dns_lookup');
    $writefile = Promise\promisify('swoole_async_writefile');
    $url = parse_url($url);
    list($host, $ip) = (yield $dns_lookup($url['host']));
    $cli = new swoole_http_client($ip, isset($url['port']) ? $url['port'] : 80);
    $cli->setHeaders([
        'Host' => $host,
        "User-Agent" => 'Chrome/49.0.2587.3',
    ]);
    $get = Promise\promisify([$cli, 'get']);
    yield $get($url['path']);
    list($filename) = (yield $writefile(basename($url['path']), $cli->body));
    echo "write $filename ok.\r\n";
    $cli->close();
}

$urls = array(
    'http://b.hiphotos.baidu.com/baike/c0%3Dbaike116%2C5%2C5%2C116%2C38/sign=5f4519ba037b020818c437b303b099b6/472309f790529822434d08dcdeca7bcb0a46d4b6.jpg',
    'http://f.hiphotos.baidu.com/baike/c0%3Dbaike116%2C5%2C5%2C116%2C38/sign=1c37718b3cc79f3d9becec62dbc8a674/38dbb6fd5266d016dc2eaa5c902bd40735fa358a.jpg',
    'http://h.hiphotos.baidu.com/baike/c0%3Dbaike116%2C5%2C5%2C116%2C38/sign=edd05c9c502c11dfcadcb771024e09b5/d6ca7bcb0a46f21f3100c52cf1246b600c33ae9d.jpg',
    'http://a.hiphotos.baidu.com/baike/c0%3Dbaike92%2C5%2C5%2C92%2C30/sign=4693756e8094a4c21e2eef796f9d70b0/54fbb2fb43166d22df5181f5412309f79052d2a9.jpg',
    'http://a.hiphotos.baidu.com/baike/c0%3Dbaike92%2C5%2C5%2C92%2C30/sign=9388507144a98226accc2375ebebd264/faf2b2119313b07eb2cc820c0bd7912397dd8c45.jpg',
);

foreach ($urls as $url) {
    Promise\co(fetch($url));
}

在這個程序中,fetch 函數內的代碼是同步執行的,可是多個 fetch 之間倒是併發執行的,從結果輸出就能夠看出來,輸出順序是不必定的。但最後,你總能獲得全部的美圖。

總結:經過 swoole 跟 hprose 中的 Promise 和 co/yield 協程相結合,你能夠方便的使用同步的方式來調用 swoole 中的異步函數和方法,並能夠實現協程間的併發。

由於篇幅所限,這裏沒法把 hprose 中 Promise 和 co/yield 協程的所有內容都介紹完,若是你想了解更多,能夠參考下面兩篇內容:

相關文章
相關標籤/搜索