PHP5.5一个比较好的新功能是实现对生成器和协同程序的支持。对于生成器,PHP的文档和各种其他的博客文章(就像这一个或这一个)已经有了非常详细的讲解。协同程序相对受到的关注就少了,所以协同程序虽然有很强大的功能但也很难被知晓,解释起来也比较困难。
这篇文章指导你通过使用协同程序来实施任务调度,通过实例实现对技术的理解。我将在前三节做一个简单的背景介绍。如果你已经有了比较好的基础,可以直接跳到“协同多任务处理”一节。
生成器
生成器最基本的思想也是一个函数,这个函数的返回值是依次输出,而不是只返回一个单独的值。或者,换句话说,生成器使你更方便的实现了迭代器接口。下面通过实现一个xrange函数来简单说明:
<?php
03 | function xrange( $start , $end , $step = 1) { |
04 | for ( $i = $start ; $i <= $end ; $i += $step ) { |
09 | foreach (xrange(1, 1000000) as $num ) { |
上面这个xrange()函数提供了和PHP的内建函数range()一样的功能。但是不同的是range()函数返回的是一个包含属组值从1到100万的数组(注:请查看手册)。而xrange()函数返回的是依次输出这些值的一个迭代器,而且并不会真正以数组形式计算。
这种方法的优点是显而易见的。它可以让你在处理大数据集合的时候不用一次性的加载到内存中。甚至你可以处理无限大的数据流。
当然,也可以不同通过生成器来实现这个功能,而是可以通过继承Iterator接口实现。通过使用生成器实现起来会更方便,而不用再去实现iterator接口中的5个方法了。
生成器为可中断的函数
要从生成器认识协同程序,理解它们内部是如何工作的非常重要:生成器是可中断的函数,在它里面,yield构成了中断点。
紧接着上面的例子,如果你调用xrange(1,1000000)的话,xrange()函数里代码没有真正地运行。相反,PHP只是返回了一个实现了迭代器接口的 生成器类实例:
3 | $range = xrange(1, 1000000); |
5 | var_dump( $range instanceof Iterator); |
你对某个对象调用迭代器方法一次,其中的代码运行一次。例如,如果你调用$range->rewind(),那么xrange()里的代码运行到控制流 第一次出现yield的地方。在这种情况下,这就意味着当$i=$start时yield $i才运行。传递给yield语句的值是使用$range->current()获取的。
为了继续执行生成器中的代码,你必须调用$range->next()方法。这将再次启动生成器,直到yield语句出现。因此,连续调用next()和current()方法 你将能从生成器里获得所有的值,直到某个点没有再出现yield语句。对xrange()来说,这种情形出现在$i超过$end时。在这中情况下, 控制流将到达函数的终点,因此将不执行任何代码。一旦这种情况发生,vaild()方法将返回假,这时迭代结束。
生成器为可中断的函数
要从生成器认识协同程序,理解它们内部是如何工作的非常重要:生成器是可中断的函数,在它里面,yield构成了中断点。
紧接着上面的例子,如果你调用xrange(1,1000000)的话,xrange()函数里代码没有真正地运行。相反,PHP只是返回了一个实现了迭代器接口的 生成器类实例:
3 | $range = xrange(1, 1000000); |
5 | var_dump( $range instanceof Iterator); |
你对某个对象调用迭代器方法一次,其中的代码运行一次。例如,如果你调用$range->rewind(),那么xrange()里的代码运行到控制流 第一次出现yield的地方。在这种情况下,这就意味着当$i=$start时yield $i才运行。传递给yield语句的值是使用$range->current()获取的。
为了继续执行生成器中的代码,你必须调用$range->next()方法。这将再次启动生成器,直到yield语句出现。因此,连续调用next()和current()方法 你将能从生成器里获得所有的值,直到某个点没有再出现yield语句。对xrange()来说,这种情形出现在$i超过$end时。在这中情况下, 控制流将到达函数的终点,因此将不执行任何代码。一旦这种情况发生,vaild()方法将返回假,这时迭代结束。
多任务协作
如果阅读了上面的logger()例子,那么你认为“为了双向通信我为什么要使用协程呢? 为什么我不能只用常见的类呢?”,你这么问完全正确。上面的例子演示了基本用法,然而上下文中没有真正的展示出使用协程的优点。这就是列举许多协程例子的理由。正如上面介绍里提到的,协程是非常强大的概念,不过这样的应用很稀少而且常常十分复杂。给出一些简单而真实的例子很难。
在这篇文章里,我决定去做的是使用协程实现多任务协作。我们尽力解决的问题是你想并发地运行多任务(或者“程序”)。不过处理器在一个时刻只能运行一个任务(这篇文章的目标是不考虑多核的)。因此处理器需要在不同的任务之间进行切换,而且总是让每个任务运行 “一小会儿”。
多任务协作这个术语中的“协作”说明了如何进行这种切换的:它要求当前正在运行的任务自动把控制传回给调度器,这样它就可以运行其他任务了。这与“抢占”多任务相反,抢占多任务是这样的:调度器可以中断运行了一段时间的任务,不管它喜欢还是不喜欢。协作多任务在Windows的早期版本(windows95)和Mac OS中有使用,不过它们后来都切换到使用抢先多任务了。理由相当明确:如果你依靠程序自动传回 控制的话,那么坏行为的软件将很容易为自身占用整个CPU,不与其他任务共享。
这个时候你应当明白协程和任务调度之间的联系:yield指令提供了任务中断自身的一种方法,然后把控制传递给调度器。因此协程可以运行多个其他任务。更进一步来说,yield可以用来在任务和调度器之间进行通信。
调度器现在不得不比多任务循环要做稍微多点了,然后才运行多任务:
04 | protected $maxTaskId = 0; |
05 | protected $taskMap = []; |
08 | public function __construct() { |
09 | $this ->taskQueue = new SplQueue(); |
12 | public function newTask(Generator $coroutine ) { |
13 | $tid = ++ $this ->maxTaskId; |
14 | $task = new Task( $tid , $coroutine ); |
15 | $this ->taskMap[ $tid ] = $task ; |
16 | $this ->schedule( $task ); |
20 | public function schedule(Task $task ) { |
21 | $this ->taskQueue->enqueue( $task ); |
24 | public function run() { |
25 | while (! $this ->taskQueue->isEmpty()) { |
26 | $task = $this ->taskQueue->dequeue(); |
29 | if ( $task ->isFinished()) { |
30 | unset( $this ->taskMap[ $task ->getTaskId()]); |
32 | $this ->schedule( $task ); |
newTask()方法(使用下一个空闲的任务id)创建一个新任务,然后把这个任务放入任务映射数组里。接着它通过把任务放入任务队列里来实现对任务的调度。接着run()方法扫描任务队列,运行任务。如果一个任务结束了,那么它将从队列里删除,否则它将在队列的末尾再次被调度。
让我们看看下面具有两个简单(并且没有什么意义)任务的调度器:
04 | for ( $i = 1; $i <= 10; ++ $i ) { |
05 | echo "This is task 1 iteration $i.\n" ; |
11 | for ( $i = 1; $i <= 5; ++ $i ) { |
12 | echo "This is task 2 iteration $i.\n" ; |
17 | $scheduler = new Scheduler; |
19 | $scheduler ->newTask(task1()); |
20 | $scheduler ->newTask(task2()); |
两个任务都仅仅回显一条信息,然后使用yield把控制回传给调度器。输出结果如下:
01 | This is task 1 iteration 1. |
02 | This is task 2 iteration 1. |
03 | This is task 1 iteration 2. |
04 | This is task 2 iteration 2. |
05 | This is task 1 iteration 3. |
06 | This is task 2 iteration 3. |
07 | This is task 1 iteration 4. |
08 | This is task 2 iteration 4. |
09 | This is task 1 iteration 5. |
10 | This is task 2 iteration 5. |
11 | This is task 1 iteration 6. |
12 | This is task 1 iteration 7. |
13 | This is task 1 iteration 8. |
14 | This is task 1 iteration 9. |
15 | This is task 1 iteration 10. |
输出确实如我们所期望的:对前五个迭代来说,两个任务是交替运行的,接着第二个任务结束后,只有第一个任务继续运行。
协程堆栈
如果你试图用我们的调度系统建立更大的系统的话,你将很快遇到问题:我们习惯了把代码分解为更小的函数,然后调用它们。然而, 如果使用了协程的话,就不能这么做了。例如,看下面代码:
03 | function echoTimes( $msg , $max ) { |
04 | for ( $i = 1; $i <= $max ; ++ $i ) { |
05 | echo "$msg iteration $i\n" ; |
17 | $scheduler = new Scheduler; |
18 | $scheduler ->newTask(task()); |
这段代码试图把重复循环“输出n次“的代码嵌入到一个独立的协程里,然后从主任务里调用它。然而它无法运行。正如在这篇文章的开始 所提到的,调用生成器(或者协程)将没有真正地做任何事情,它仅仅返回一个对象。这也出现在上面的例子里。echoTimes调用除了放回一个(无用的)协程对象外不做任何事情。为了仍然允许这么做,我们需要在这个裸协程上写一个小小的封装。我们将调用它:“协程堆栈”。因为它将管理嵌套的协程调用堆栈。 这将是通过生成协程来调用子协程成为可能:
1 | $retval = (yield someCoroutine( $foo , $bar )); |
使用yield,子协程也能再次返回值:
1 | yield retval( "I'm a return value!" ); |
retval函数除了返回一个值的封装外没有做任何其他事情。这个封装将表示它是一个返回值。
03 | class CoroutineReturnValue { |
06 | public function __construct( $value ) { |
07 | $this ->value = $value ; |
10 | public function getValue() { |
15 | function retval( $value ) { |
16 | return new CoroutineReturnValue( $value ); |
为了把协程转变为协程堆栈(它支持子调用),我们将不得不编写另外一个函数(很明显,它是另一个协程):
03 | function stackedCoroutine(Generator $gen ) { |
04 | $stack = new SplStack; |
07 | $value = $gen ->current(); |
09 | if ( $value instanceof Generator) { |
15 | $isReturnValue = $value instanceof CoroutineReturnValue; |
16 | if (! $gen ->valid() || $isReturnValue ) { |
17 | if ( $stack ->isEmpty()) { |
22 | $gen ->send( $isReturnValue ? $value ->getValue() : NULL); |
26 | $gen ->send(yield $gen ->key() => $value ); |
这个函数在调用者和当前正在运行的子协程之间扮演着简单代理的角色。在$gen->send(yield $gen->key()=>$value);这行完成了代理功能。另外它检查返回值是否是生成器,万一是生成器的话,它将开始运行这个生成器,并把前一个协程压入堆栈里。一旦它获得了CoroutineReturnValue的话,它将再次请求堆栈弹出,然后继续执行前一个协程。
为了使协程堆栈在任务里可用,任务构造器里的$this-coroutine =$coroutine;这行需要替代为$this->coroutine = StackedCoroutine($coroutine);。
现在我们可以稍微改进上面web服务器例子:把wait+read(和wait+write和warit+accept)这样的动作分组为函数。为了分组相关的 功能,我将使用下面类:
06 | public function __construct( $socket ) { |
07 | $this ->socket = $socket ; |
10 | public function accept() { |
11 | yield waitForRead( $this ->socket); |
12 | yield retval( new CoSocket(stream_socket_accept( $this ->socket, 0))); |
15 | public function read( $size ) { |
16 | yield waitForRead( $this ->socket); |
17 | yield retval( fread ( $this ->socket, $size )); |
20 | public function write( $string ) { |
21 | yield waitForWrite( $this ->socket); |
22 | fwrite( $this ->socket, $string ); |
25 | public function close() { |
26 | @fclose( $this ->socket); |
现在服务器可以编写的稍微简洁点了:
03 | function server( $port ) { |
04 | echo "Starting server at port $port...\n" ; |
06 | $socket = @stream_socket_server( "tcp://localhost:$port" , $errNo , $errStr ); |
07 | if (! $socket ) throw new Exception( $errStr , $errNo ); |
09 | stream_set_blocking( $socket , 0); |
11 | $socket = new CoSocket( $socket ); |
14 | handleClient(yield $socket ->accept()) |
19 | function handleClient( $socket ) { |
20 | $data = (yield $socket ->read(8192)); |
22 | $msg = "Received following request:\n\n$data" ; |
23 | $msgLength = strlen ( $msg ); |
27 | Content-Type: text/plain\r |
28 | Content-Length: $msgLength \r |
34 | yield $socket ->write( $response ); |
35 | yield $socket ->close(); |
错误处理
作为一个优秀的程序员,相信你已经察觉到上面的例子缺少错误处理。几乎所有的 socket 都是易出错的。我这样做的原因一方面固然是因为错误处理的乏味(特别是 socket!),另一方面也在于它很容易使代码体积膨胀。
不过,我仍然了一讲一下常见的协程错误处理:协程允许使用 throw() 方法在其内部抛出一个错误。尽管此方法还未在 PHP 中实现,但我很快就会提交它,就在今天。
throw() 方法接受一个 Exception,并将其抛出到协程的当前悬挂点,看看下面代码:
07 | } catch (Exception $e ) { |
08 | echo "Exception: {$e->getMessage()}\n" ; |
15 | $gen -> throw ( new Exception( 'Test' )); |
这非常棒,因为我们可以使用系统调用以及子协程调用异常抛出。对与系统调用,Scheduler::run() 方法需要一些小调整:
03 | if ( $retval instanceof SystemCall) { |
05 | $retval ( $task , $this ); |
06 | } catch (Exception $e ) { |
07 | $task ->setException( $e ); |
08 | $this ->schedule( $task ); |
Task 类也许要添加 throw 调用处理:
05 | protected $exception = null; |
07 | public function setException( $exception ) { |
08 | $this ->exception = $exception ; |
11 | public function run() { |
12 | if ( $this ->beforeFirstYield) { |
13 | $this ->beforeFirstYield = false; |
14 | return $this ->coroutine->current(); |
15 | } elseif ( $this ->exception) { |
16 | $retval = $this ->coroutine-> throw ( $this ->exception); |
17 | $this ->exception = null; |
20 | $retval = $this ->coroutine->send( $this ->sendValue); |
21 | $this ->sendValue = null; |
现在,我们已经可以在系统调用中使用异常抛出了!例如,要调用 killTask,让我们在传递 ID 不可用时抛出一个异常:
03 | function killTask( $tid ) { |
04 | return new SystemCall( |
05 | function (Task $task , Scheduler $scheduler ) use ( $tid ) { |
06 | if ( $scheduler ->killTask( $tid )) { |
07 | $scheduler ->schedule( $task ); |
09 | throw new InvalidArgumentException( 'Invalid task ID!' ); |
试试看:
6 | } catch (Exception $e ) { |
7 | echo 'Tried to kill task 500 but failed: ' , $e ->getMessage(), "\n" ; |
这些代码现在尚不能正常运作,因为 stackedCoroutine 函数无法正确处理异常。要修复需要做些调整:
03 | function stackedCoroutine(Generator $gen ) { |
04 | $stack = new SplStack; |
10 | $gen -> throw ( $exception ); |
15 | $value = $gen ->current(); |
17 | if ( $value instanceof Generator) { |
23 | $isReturnValue = $value instanceof CoroutineReturnValue; |
24 | if (! $gen ->valid() || $isReturnValue ) { |
25 | if ( $stack ->isEmpty()) { |
30 | $gen ->send( $isReturnValue ? $value ->getValue() : NULL); |
35 | $sendValue = (yield $gen ->key() => $value ); |
36 | } catch (Exception $e ) { |
41 | $gen ->send( $sendValue ); |
42 | } catch (Exception $e ) { |
43 | if ( $stack ->isEmpty()) { |