Skip to content

04 协程

小马哥 edited this page Apr 8, 2018 · 2 revisions

基本用法

PHP 5.5 引入了 GeneratorGenerator 通过封装之后,可以作为协程来进行使用。

Hprose 也提供了对 Generator 的一个封装,并且跟 Promise 相结合之后,可以实现异步代码同步化。

让我们来看一个例子:

use \Hprose\Future;
use \Hprose\Http\Client;

Future\co(function() {
    $test = new Client("http://hprose.com/example/");
    var_dump((yield $test->hello("hprose")));
    $a = $test->sum(1, 2, 3);
    $b = $test->sum(4, 5, 6);
    $c = $test->sum(7, 8, 9);
    var_dump((yield $test->sum($a, $b, $c)));
    var_dump((yield $test->hello("world")));
});

Future\co(也可以用 Promise\co)就是一个协程封装函数。它的功能以协程的方式来执行生成器函数。该方法允许带入参数执行。

在上面的例子中,$test 是一个 Hprose 的异步 Http 客户端。Hprose 2.0 的默认客户端为异步客户端,而不是同步客户端,这一点与 1.x 不同。

所以 $test->hello$test->sum 两个调用的返回值实际上是一个 promise 对象。而 yield 关键字在这里的作用就是,可以等待调用完成并返回 promise 所包含的值,如果 promise 的最后的状态为 REJECTED,那么 yield 将抛出一个异常,异常的值为 promise 对象中的 reason 属性值。

在上面的调用中,$a, $b, $c 三个变量都是 promise 对象,而 $test->sum 可以直接接受 promise 参数作为调用参数,当 $a, $b, $c 三个 promise 对象的状态都变为 FULFILLED 状态时,$test-sum($a, $b, $c) 才会真正的开始调用。而获取 $a,$b,$c 的三个调用是异步并发执行的。

上面程序的执行结果为:

string(12) "Hello hprose"
int(45)
string(11) "Hello world"

从结果我们可以看出,co 函数和 yield 的结合可以很方便的让异步程序编写同步化。这也是 Hprose 2.0 最有特色的改进之一。

虽然上面用 Hprose 远程调用来举例,但是 co 函数所实现的协程不是只对 Hprose 远程调用有效,而是对任何返回 promise 的对象都有效。所以,即使你不使用 Hprose 远程调用,也可以使用 co 函数和 Promise 来进行异步代码的同步化编写。

多协程并发

我们前面说过,如果在同一个协程内进行远程调用,如果不加 yield 关键字,多个远程调用就是并发执行的。加上 yield 关键字,就会变成顺序执行。

那么当开两个或多个协程时,结果是什么样子呢?我们来看一个例子:

use \Hprose\Future;
use \Hprose\Http\Client;

$test = new Client("http://hprose.com/example/");

Future\co(function() use ($test) {
    for ($i = 0; $i < 5; $i++) {
        var_dump((yield $test->hello("1-" . $i)));
    }
});

Future\co(function() use ($test) {
    for ($i = 0; $i < 5; $i++) {
        var_dump((yield $test->hello("2-" . $i)));
    }
});

我们运行该程序之后,可以看到如下结果:

string(9) "Hello 2-0"
string(9) "Hello 1-0"
string(9) "Hello 1-1"
string(9) "Hello 2-1"
string(9) "Hello 2-2"
string(9) "Hello 1-2"
string(9) "Hello 1-3"
string(9) "Hello 2-3"
string(9) "Hello 2-4"
string(9) "Hello 1-4"

这个运行结果并不唯一,我们有可能看到不同顺序的输出,但是有一点可以保证,就是 Hello-1-X 中的 X 是按照顺序输出的,而 Hello-2-Y 中的 Y 也是按照顺序输出的。

也就是说,每个协程内的语句是按照顺序执行的,而两个协程确是并行执行的。

不过有一点要注意,上面的例子跟第一个例子有一点不同,那就是我们把 $test 客户端的创建拿到了协程外面。

如果像第一个例子那样放在协程中,我们就看不到这样的并发执行结果了。原因是,这里的每个客户端都有一个自己独立的事件循环,只有当一个事件循环执行完之后,才会执行另一个事件循环。

但如果换成 Hprose 的 Swoole 的客户端,则不管是放在里面还是外面,看到的都是并发执行的结果,原因是 Swoole 客户端的事件循环是统一的。而且 Swoole 的事件循环一旦开始,如果不手动执行 swoole_event_exit,事件循环不会结束,你也就不会看到程序退出。在使用 Swoole 客户端时,需要注意这一点。

协程的参数和返回值

co 函数允许传参给协程。

而且 co 函数本身的返回值也是一个 promise 对象。它的值在 PHP 5 和 PHP 7 中略有差别。

PHP 5 的生成器函数本身不允许使用 return 返回值。例如:

function test() {
    $x = (yield 1);
    return $x;
}

这样的代码是非法的(但你不用担心因为写了这样的代码而被抓去坐牢:laughing:)。但是在 PHP 7 中,这种写法是被允许的,但这个返回值跟普通函数的返回值是不同的。PHP 7 中为 Generator 对象提供了一个 getReturn 方法来专门获取这个返回值。

co 函数的返回值在 PHP 5 中是最后一次执行的 yield 的返回值的 promise 包装。在 PHP 7 中,如果没有 return 语句,或者 return 语句没有返回值(或者返回值为 NULL),那么,co 函数的返回值跟 PHP 5 相同。如果在 PHP 7 中,使用了 return 语句并且有返回值,比如像上面那个 test 函数,那么返回值为 return 语句返回值的 promise 包装。

因此如果你的代码是按照 PHP 5 的方式编写的,那么执行的效果在 PHP 5 和 PHP 7 中是相同的,如果你是按照 PHP 7 的方式单独编写的,那么你也能够得到你希望得到的 PHP 7 的返回值。因此,co 函数既做到了对旧版本的兼容性,又做到了对新版本特殊功能的支持。

因为 co 函数的结果本身也是一个 promise 对象,因此,你也可以在另外一个协程中来 yield co 函数的执行结果。

下面这个例子演示了传参和 co 函数返回值的使用:

use \Hprose\Future;
use \Hprose\Http\Client;

$test = new Client("http://hprose.com/example/");

function hello($n, $test) {
    $result = array();
    for ($i = 0; $i < 5; $i++) {
        $result[] = $test->hello("$n-$i");
    }
    yield Future\all($result);
}

Future\co(function() use ($test) {
    $result = (yield Future\co(function($test) {
        $result = array();
        for ($i = 0; $i < 3; $i++) {
             $result[] = Future\co('hello', $i, $test);
        }
        yield Future\all($result);
    }, $test));

    var_dump($result);
});

该程序执行结果为:

array(3) {
  [0]=>
  array(5) {
    [0]=>
    string(9) "Hello 0-0"
    [1]=>
    string(9) "Hello 0-1"
    [2]=>
    string(9) "Hello 0-2"
    [3]=>
    string(9) "Hello 0-3"
    [4]=>
    string(9) "Hello 0-4"
  }
  [1]=>
  array(5) {
    [0]=>
    string(9) "Hello 1-0"
    [1]=>
    string(9) "Hello 1-1"
    [2]=>
    string(9) "Hello 1-2"
    [3]=>
    string(9) "Hello 1-3"
    [4]=>
    string(9) "Hello 1-4"
  }
  [2]=>
  array(5) {
    [0]=>
    string(9) "Hello 2-0"
    [1]=>
    string(9) "Hello 2-1"
    [2]=>
    string(9) "Hello 2-2"
    [3]=>
    string(9) "Hello 2-3"
    [4]=>
    string(9) "Hello 2-4"
  }
}

在这个程序里,所有的调用都是并发执行的,最后一次 yield 汇集最终所有结果。yield 语句在这里同时扮演了 return 的角色。

wrap 包装函数和 yield 的区别

我们在 Promise 异步编程 一章中,介绍了功能强大的 wrap 函数。通过它包装的函数可以直接将 promise 对象像普通参数一样带入函数执行。但是要注意,wrap 包装之后的函数虽然看上去像是同步的,但是实际上是异步执行的。当你有多个 wrap 包装的函数顺序执行的时候,实际上并不保证执行顺序按照书写顺序来。而 yield 则是同步的,它一定会保证 yield 语句的执行顺序。

我们来看一个例子:

use \Hprose\Future;
use \Hprose\Http\Client;

$test = new Client("http://hprose.com/example/");
Future\co(function() use ($test) {
    for ($i = 0; $i < 5; $i++) {
        var_dump((yield $test->hello("1-" . $i)));
    }
    $var_dump = Future\wrap('var_dump');
    for ($i = 0; $i < 5; $i++) {
        $var_dump($test->hello("2-" . $i));
    }
    for ($i = 0; $i < 5; $i++) {
        var_dump((yield $test->hello("3-" . $i)));
    }
});

运行该程序之后,执行结果为:

string(9) "Hello 1-0"
string(9) "Hello 1-1"
string(9) "Hello 1-2"
string(9) "Hello 1-3"
string(9) "Hello 1-4"
string(9) "Hello 2-0"
string(9) "Hello 2-2"
string(9) "Hello 3-0"
string(9) "Hello 2-1"
string(9) "Hello 2-3"
string(9) "Hello 2-4"
string(9) "Hello 3-1"
string(9) "Hello 3-2"
string(9) "Hello 3-3"
string(9) "Hello 3-4"

这个结果可能每次执行都不一样。

但是,Hello 1-X 始终都是按照顺序输出的,而且始终都是在 Hello 2-YHello 3-Z 之前输出的。

Hello 2-Y 的输出则不是按照顺序输出的(虽然偶尔结果也是按照顺序输出,但这一点并不能保证),而且它甚至还会穿插在 Hello 3-Z 的输出结果中。

Hello 3-Z 本身也是按照顺序输出的,但是 Hello 2-Y 却可能穿插在它的输出中间,原因是 Hello 2-Y 先执行,并且是异步执行的,因此它并不等结果执行完,就开始执行后面的语句了,所以当它执行完时,可能已经执行过几条 Hello 3-Zyield 语句了。

将协程包装成闭包函数

wrap 函数不仅仅可以将普通函数包装成支持 promise 参数的函数。

wrap 函数还支持将协程包装成闭包函数的功能,包装之后的函数,不仅可以将协程当做普通函数一样执行,而且还支持传递 promise 参数。例如:

use \Hprose\Future;
use \Hprose\Http\Client;

$test = new Client("http://hprose.com/example/");

$coroutine = Future\wrap(function($test) {
    var_dump(1);
    var_dump((yield $test->hello("hprose")));
    $a = $test->sum(1, 2, 3);
    $b = $test->sum(4, 5, 6);
    $c = $test->sum(7, 8, 9);
    var_dump((yield $test->sum($a, $b, $c)));
    var_dump((yield $test->hello("world")));
});

$coroutine($test);
$coroutine(Future\value($test));

该程序执行结果为:

int(1)
int(1)
string(12) "Hello hprose"
string(12) "Hello hprose"
int(45)
int(45)
string(11) "Hello world"
string(11) "Hello world"

我们会发现通过 wrap 函数包装的协程,不再需要使用 co 函数来执行了。

另外,wrap 函数包装的对象上的生成器方法也会自动变为普通方法。例如:

use \Hprose\Future;

class Test {
    function testco($x) {
        yield $x;
    }
}

$test = Future\wrap(new Test());

$test->testco(123)->then('var_dump');
$test->testco(Future\value('hello'))->then('var_dump');

该程序运行结果为:

int(123)
string(5) "hello"

协程与异常处理

在协程内,yield 不但可以将异步的 promise 结果转换成同步结果,而且可以将 REJECTED 状态的 promise 对象转换为抛出异常。例如:

use Hprose\Client;
use Hprose\Future;

Future\co(function() {
    $client = Client::create('http://hprose.com/example/');
    try {
        (yield $client->ooxx());
    }
    catch (Exception $e) {
        echo $e->getMessage();
    }
});

该程序运行结果为:

Can't find this function ooxx().

在协程内抛出的异常如果没有用 try catch 语句捕获,那么第一个抛出的异常将会中断协程的执行,并将整个协程的返回值设置为 REJECTED 状态的 promise 对象,异常本身作为 reason 的值。例如:

use Hprose\Client;
use Hprose\Future;

Future\co(function() {
    $client = Client::create('http://hprose.com/example/');
    (yield $client->oo());
    (yield $client->xx());
})->catchError(function($e) {
    echo $e->getMessage();
});

该程序运行结果为:

Can't find this function oo().