疑难解答——在PHP中使用协程实现多任务调度

2018年7月31日 0 作者 筱枫

本文使用php的yield模拟系统中的进程调度,主要参考于此篇文章:在PHP中使用协程实现多任务调度
具体的代码与其一致,主要针对其中的一些难点做出解释
首先看完上面这篇文章后,会让人产生不少疑惑的地方
1.协程(进程)到底是做什么的,作者基于yield实现了php内部的一个进程调度方案,这也是linux、windows等系统的调度方式
对于单核cpu来说,一样能够实现多任务,但其实cpu同一时间只能够运行一个任务,之所以看起来所有程序都在运行,是因为cpu不停的切换每一个任务,在每个任务里面运行一点点时间,就好比抄作业,现在有三门科目,语文数学英语,一直抄一门是最快的,但是你可以选择一天里,前八个小时抄语文,中间八个小时抄数学,最后八个小时抄英语,这样看上去就像是你一天抄了三门作业。
而这篇文章里面作者也是实现了这样的调度。

2.可能你会问,为什么要这么切换?一直运行一个程序不是最快的吗?
当然,一直运行一个程序是最快的,切换进程时的损耗虽然不大,但也不能忽视。
其中最主要的原因还是外部有许多慢速设备在进行连接,对于cpu来说,最快的自然是寄存器、一级缓存、二级和三级缓存了,对于它来说,内存都已经慢得无法忍受,更别提更慢的硬盘,以及其他io设备,例如键盘输入,网络请求等等,所以为了不让其浪费在时间在等待io上,就产生了这样一种运行方式。

3.作者的这个多任务调度看不懂
其实很简单,作者写了那么多类,其实就是完成类似这样的任务:

function test()
{
    while(true)
    {
        echo "hello\n";
        yield;

        echo "world\n";
        yield;
    }
}

$test = test();
while (true)
{
    $test->send(null);
}

注意上面的两个yield,在真实系统中,起的就是中断的作用,当中断被处理后,会回到这里继续运行(如果觉得疑惑,可以了解下进程切换的原理,以及中断)
然后这样就会源源不断的打印出hello,world
接着又会产生上面的疑惑,这样写的意义何在?
跟直接写

function test()
{
    while(true)
    {
        echo "hello\n";
        echo "world\n";
    }
}

test();

有什么区别?
正如上面所说,区别不在这里,在于等待慢速io上
如果采用下面这种写法,一旦当中出现了等待io的情况下,整个进程就会卡死,而在上面那种情况则不一样(当然真实系统中是抢占式调度,而不是这种主动让出来的模式)
例如,可以对比下面的代码

function wait()
{
    sleep(2);
    echo "hello\n";
}

function test()
{
    while(true)
    {
        wait();
        yield;

        echo "world\n";
        yield;
    }
}

$test = test();
while (true)
{
    $test->send(null);
}

function wait()
{
    sleep(2);
    echo "hello\n";
}

function test()
{
    while(true)
    {
        wait();
        echo "world\n";
    }
}

test();

我这里使用了sleep函数模拟等待io的情况下,注意现在这种情况并非真正的进程调度!
在这里他们的结果是相同的,运行速度也没有任何变化,不同之处在于如果使用了抢占式调度,假设每次调用yield前最大的执行时间为一秒
那么上面那段使用yield打印出来的将是
world   0s
world   1s
hello    2s
world   2s
world   3s
hello    4s
因为最开始wait函数分到的一秒钟被sleep消耗掉,第二次分掉的一秒钟也被消耗掉,所以才会在第三次打印,正如同上面一样,在4秒钟的时间里面,一种执行了六次,接下来我们看看第二种方式的调用
看到了吗?同样是4秒,但只执行了4次,这还只是简单的调用,要是换成其他的,能够节省更多的时间
sleep…   0s
sleep…   1s
sleep…   2s
hello      2s
world     2s
sleep…   3s
sleep…   4s
hello      4s
world     4s

4.系统调用中的这段代码看不懂

setSendValue($scheduler->newTask($coroutine));
            $scheduler->schedule($task);
        }
    );
}
 
function killTask($tid) {
    return new SystemCall(
        function(Task $task, Scheduler $scheduler) use ($tid) {
            $task->setSendValue($scheduler->killTask($tid));
            $scheduler->schedule($task);
        }
    );
}

这段代码其实很简单,主要就是添加了个新任务,同时继续执行当前任务
让人有点看不懂的可能在于setSendValue这一段
追踪这个函数,我们来到最上面

    public function setSendValue($sendValue) {
        $this->sendValue = $sendValue;
    }
 
    public function run() {
        if ($this->beforeFirstYield) {
            $this->beforeFirstYield = false;
            return $this->coroutine->current();
        } else {
            $retval = $this->coroutine->send($this->sendValue);
            $this->sendValue = null;
            return $retval;
        }
    }

可以看到,sendValue设置的值是会传送给迭代器中
那么哪里接收的呢?

newTask(task(10));
    $scheduler->newTask(task(5));
 
    $scheduler->run();
?>

在这里,没想到吧!
看最上面的哪个yield,作者最开始的时候说过,yield这个关键字有两个意思,一个是用作接收send函数传进来的值,另外一个则是中断并返回当前值
所以当中的这段

$tid = (yield getTaskId()); // <-- here's the syscall!

并不能简单的认为

$tid = getTaskId();

如果你在getTaskId这个系统调用中写上return语句,$tid一样是无法接收的,因为系统调用最终的执行地点是在

if ($retval instanceof SystemCall) {
            $retval($task, $this);
            continue;
        }

这段代码中,而执行$retval这个回调函数的时候,是没有将返回值放到任何一个地方的!而且除了使用yield之外,是没有其他的方法能够将值传入迭代器中

所以

$tid = yield

这样写在一起看上有点迷惑,但实际问题不大,只要联系上下文,就能正确明白它的含义

这个问题解答暂时就到此为止了,在文章的最后作者也给出了个实际的案例:web服务器请求阻塞的解决方案,这样也算是把所学之物用上了吧,主要还是类似回调函数的解决方案,虽然表面是采用yield,实际上本质还是回调,只不过代码可以写成同步的结构