协程CPU密集场景调度实现
抢占式 vs 非抢占式
如果服务场景是IO密集型,非抢占式可以表现的非常完美,但是如果服务中加入了CPU密集型操作,我们不得不考虑重新协程的调度模式。
在Swoole的协程系列文章中我们曾经介绍过IO密集场景下协程基于非抢占式调度的优势和卓越的性能。但是在CPU密集的场景下抢占式调度是非常重要的。试想有以下场景,程序中有A,B两个协程,协程A一直在执行CPU密集运算,非抢占式的调度模型中,A不会主动让出控制权,从而导致B得不到时间片,协程得不到均衡调度。导致的问题是假如当前服务A,B同时对外提供服务,B协程处理的请求就可能因为得不到时间片导致请求超时,在企业级的应用中,这种情况是不容忽视的。
PHP的实现方式
因为PHP是单线程运行,所以针对PHP的协程调度和golang完全不一样。我们选择使用declare(tick=N)语法功能实现协程调度。Tick(时钟周期)是一个在 declare 代码段中解释器每执行 N 条可计时的低级语句就会发生的事件。N 的值是在 declare 中的 directive 部分用 ticks=N 来指定的。不是所有语句都可计时。通常条件表达式和参数表达式都不可计时。以下类型都是不可被tick计数的。
static inline zend_bool zend_is_unticked_stmt(zend_ast *ast) /* {{{ */
{
return ast->kind == ZEND_AST_STMT_LIST || ast->kind == ZEND_AST_LABEL
|| ast->kind == ZEND_AST_PROP_DECL || ast->kind == ZEND_AST_CLASS_CONST_DECL
|| ast->kind == ZEND_AST_USE_TRAIT || ast->kind == ZEND_AST_METHOD;
}
协程调度的逻辑就是每次触发tick handler
,我们判断当前协程相对最近一次调度时间是否大于协程最大执行时间,这样就可以将协程超出执行时间后被抢占(其实也是主动让出),这种调度表现为抢占式调度,而且不是基于IO。首先来一段PHP最简单加法指令执行的压测
<?php
const N = 10000000;
$n = N;
$s = microtime(true);
$i = 0;
while ($n--) {
$i++;
}
$e = microtime(true);
echo "php add " . N . " times, takes " . round(($e - $s) * 1000, 2) . "ms\n";
echo "one add time = " . round(($e - $s) / N * (1000 * 1000 * 1000), 2) . "ns\n";
这里测试机器主频为 3.60GHz
输出结果为
php add 10000000 times, takes 310.37ms
one add time = 31.04ns
也就是1kw次$i++
操作,耗时310.37ms(我们忽略开始和结尾少数几个指令简单的操作),每执行一次while $n— $i++
操作耗时约30ns 。
Tick的基本原理是,在脚本的最开始声明tick=number
,表示每执行number
个指令会插入一个ZEND_TICKS指令,然后执行相应的handler。我们有了以上操作的压测,我们可以做一个粗略的估算:假设PHP的一次opcode执行操作为opcode_time = 50ns
(因机器和指令类型而异),如果我们想要实现一个协程最大执行时间为10ms,因为我们调度的时间误差粒度为 number * opcode_time
,比如:
tick=100
,handler执行的周期为100 opcode_time = 5000ns = 0.005ms
,10ms 误差为0.005mstick=1000
,handler执行的周期为1000
opcode_time = 50000ns = 0.05ms
,10ms 误差为0.05mstick=10000
,handler执行的周期为10000 * opcode_time = 500000ns = 0.5ms
,10ms 误差为0.5ms
可以看出这样的误差颗粒是完全可以接受的,误差的范围和tick的参数有关系,当然tick越大,对性能的影响为每tick=number
条指令执行一次tick hander 约50ns
,性能的影响非常小。
下面有一个例子
<?php
declare(ticks=1000);
$max_msec = 10;
Swoole\Coroutine::set([
'max_exec_msec' => $max_msec,
]);
$s = microtime(1);
echo "start\n";
$flag = 1;
go(function () use (&$flag, $max_msec){
echo "coro 1 start to loop for $max_msec msec\n";
$i = 0;
while($flag) {
$i ++;
}
echo "coro 1 can exit\n";
});
$t = microtime(1);
$u = $t-$s;
echo "shedule use time ".round($u * 1000, 5)." ms\n";
go(function () use (&$flag){
echo "coro 2 set flag = false\n";
$flag = false;
});
echo "end\n";
输出结果为
start
coro 1 start to loop for 10 msec
shedule use time 10.2849 ms
coro 2 set flag = false
end
coro 1 can exit
其中coro1的opcodes为
{closure}: ; (lines=18, args=0, vars=3, tmps=5)
; (before optimizer)
; /path-to-tick/tick.php:12-19
L0 (12): BIND_STATIC (ref) CV0($flag) string("flag")
L1 (12): BIND_STATIC CV1($max_msec) string("max_msec")
L2 (13): T4 = ROPE_INIT 3 string("coro 1 start to loop for ")
L3 (13): T4 = ROPE_ADD 1 T4 CV1($max_msec)
L4 (13): T3 = ROPE_END 2 T4 string(" msec
")
L5 (13): ECHO T3
L6 (13): TICKS 1000
L7 (14): ASSIGN CV2($i) int(0)
L8 (14): TICKS 1000
L9 (15): JMP L13
L10 (16): T7 = POST_INC CV2($i)
L11 (16): FREE T7
L12 (16): TICKS 1000
L13 (15): JMPNZ CV0($flag) L10
L14 (15): TICKS 1000
L15 (18): ECHO string("coro 1 can exit
")
L16 (18): TICKS 1000
L17 (19): RETURN null
LIVE RANGES:
4: L2 - L4 (rope)
现在基于tick的调度实现已经完成在单独的调度分支,测试用例可以在这里可以找到。
写在最后
使用tick
的方式实现的有一个最大的缺点是需要用户在PHP层的脚本开始处声明declare(tick=N)
,这样使得这个功能对于扩展层来说不够完备。但是他能够处理所有的PHP指令。同时我们在处理tick handler
的时候,HOOK了PHP默认的方式,因为使用默认的方式,PHP用户层可以注册tick函数会造成干扰。大家可以发现,我们的历史提交记录中有一种方式是基于HOOK循环指令的方式实现的,我们假设使得CPU密集的类型是大量的循环操作,我们检测循环的次数和当前协程执行的时间,即每次遇到循环指令的handler,我们去检查当前循环的次数和协程执行的时间,进而可以发现执行时间较长的协程。但是,这种方式无法处理没有使用循环,假如只有单纯的大量PHP指令密集运算是无法检测到的。我们权衡利弊,最终选择PHPtick
这种方式实现。
END
欢迎大家随时反馈。