• Swoole协程之旅-中篇

    Swoole协程之旅-中篇

     本篇我们开始深入PHP来分析Swoole协程的PHP部分。

     先从一个协程最简单的例子入手:

    1. <?php
    2. go(function(){
    3. echo "coro 1 start\n";
    4. co::sleep(1);
    5. echo "coro 1 exit";
    6. });
    7. echo "main flag\n";
    8. go(function(){
    9. echo "coro 2 start\n";
    10. co::sleep(1);
    11. echo "coro 2 exit\n";
    12. });
    13. echo "main end\n";
    14. //输出内容为
    15. coro 1 start
    16. main flag
    17. coro 2 start
    18. main end
    19. coro 1 exit
    20. coro 2 exit

    可以发现,原生协程是在函数内部发生了跳转,控制流从第4行跳转到第7行,接着执行从第8行开始执行go函数,到第10行跳转到了第13行,紧接着执行第9行,然后执行第15行的代码。为什么Swoole的协程可以这样执行呢?我们下面将一步一步进行分析。

      我们知道PHP作为一门解释型的语言,需要经过编译为中间字节码才可以执行,首先会经过词法和语法分析,将脚本编译为opcode数组,成为zend_op_array,然后经过vm引擎来执行。我们这里只关注vm执行部分。执行的部分需要关注几个重要的数据结构

    • Opcodes
    1. struct _zend_op {
    2. const void *handler;//每个opcode对应的c处理函数
    3. znode_op op1;//操作数1
    4. znode_op op2;//操作数2
    5. znode_op result;//返回值
    6. uint32_t extended_value;
    7. uint32_t lineno;
    8. zend_uchar opcode;//opcode指令
    9. zend_uchar op1_type;//操作数1类型
    10. zend_uchar op2_type;//操作数2类型
    11. zend_uchar result_type;//返回值类型
    12. };

    从结构中很容易发现opcodes本质上是一个三地址码,这里opcode是指令的类型,有两个输入的操作数数和一个表示输出的操作数。每个指令可能全部或者部分使用这些操作数,比如加、减、乘、除等会用到全部三个; 操作只用到op1和result两个;函数调用会涉及到是否有返回值等。

    • Op arrays

    zend_op_array PHP的主脚本会生成一个zend_op_array,每个function,eval,甚至是assert断言一个表达式等都会生成一个新得op_array。

    1. struct _zend_op_array {
    2. /* Common zend_function header here */
    3. /* ... */
    4. uint32_t last;//数组中opcode的数量
    5. zend_op *opcodes;//opcode指令数组
    6. int last_var;// CVs的数量
    7. uint32_t T;//IS_TMP_VAR、IS_VAR的数量
    8. zend_string **vars;//变量名数组
    9. /* ... */
    10. int last_literal;//字面量数量
    11. zval *literals;//字面量数组 访问时通过_zend_op_array->literals + 偏移量读取
    12. /* ... */
    13. };

    我们已经熟知php的函数内部有自己的单独的作用域,这归功于每个zend_op_array包含有当前作用域下所有的堆栈信息,函数之间的调用关系也是基于zend_op_array的切换来实现。

    • PHP栈帧
      PHP执行需要的所有状态都保存在一个个通过链表结构关联的VM栈里,每个栈默认会初始化为256K,Swoole可以单独定制这个栈的大小(协程默认为8k),当栈容量不足的时候,会自动扩容,仍然以链表的关系关联每个栈。在每次函数调用的时候,都会在VM Stack空间上申请一块新的栈帧来容纳当前作用域执行所需。栈帧结构的内存布局如下所示:
    1. +----------------------------------------+
    2. | zend_execute_data |
    3. +----------------------------------------+
    4. | VAR[0] = ARG[1] | arguments
    5. | ... |
    6. | VAR[num_args-1] = ARG[N] |
    7. | VAR[num_args] = CV[num_args] | remaining CVs
    8. | ... |
    9. | VAR[last_var-1] = CV[last_var-1] |
    10. | VAR[last_var] = TMP[0] | TMP/VARs
    11. | ... |
    12. | VAR[last_var+T-1] = TMP[T] |
    13. | ARG[N+1] (extra_args) | extra arguments
    14. | ... |
    15. +----------------------------------------+

    zend_execute_data 最后要介绍的一个结构,也是最重要的一个。

    1. struct _zend_execute_data {
    2. const zend_op *opline;//当前执行的opcode,初始化会zend_op_array起始
    3. zend_execute_data *call;//
    4. zval *return_value;//返回值
    5. zend_function *func;//当前执行的函数(非函数调用时为空)
    6. zval This;/* this + call_info + num_args */
    7. zend_class_entry *called_scope;//当前call的类
    8. zend_execute_data *prev_execute_data;
    9. zend_array *symbol_table;//全局变量符号表
    10. void **run_time_cache; /* cache op_array->run_time_cache */
    11. zval *literals; /* cache op_array->literals */
    12. };

    prev_execute_data 表示前一个栈帧结构,当前栈执行结束以后,会把当前执行指针(类比PC)指向这个栈帧。PHP的执行流程正是将很多个zend_op_array依次装载在栈帧上执行。这个过程可以分解为以下几个步骤:

    • 1: 为当前需要执行的op_array从vm stack上申请当前栈帧,结构如上。初始化全局变量符号表,将全局指针EG(current_execute_data)指向新分配的zend_execute_data栈帧,EX(opline)指向op_array起始位置。
    • 2:EX(opline)开始调用各opcode的C处理handler(即_zend_op.handler),每执行完一条opcode将EX(opline)++继续执行下一条,直到执行完全部opcode,遇到函数或者类成员方法调用:
      • EG(function_table)中根据function_name取出此function对应的zend_op_array,然后重复步骤1,将EG(current_execute_data)赋值给新结构的prev_execute_data,再将EG(current_execute_data)指向新的zend_execute_data栈帧,然后开始执行新栈帧,从位置zend_execute_data.opline开始执行,函数执行完将EG(current_execute_data)重新指向EX(prev_execute_data),释放分配的运行栈帧,执行位置回到函数执行结束的下一条opline。
    • 3: 全部opcodes执行完成后将1分配的栈帧释放,执行阶段结束

    有了以上php执行的细节,我们回到最初的例子,可以发现协程需要做的是,改变原本php的运行方式,不是在函数运行结束切换栈帧,而是在函数执行当前op_array中间任意时候(swoole内部控制为遇到IO等待),可以灵活切换到其他栈帧。接下来我们将Zend VM和Swoole结合分析,如何创建协程栈,遇到IO切换,IO完成后栈恢复,以及协程退出时栈帧的销毁等细节。 先介绍协程PHP部分的主要结构

    • 协程 php_coro_task
    1. struct php_coro_task
    2. {
    3. /* 只列出关键结构*/
    4. /*...*/
    5. zval *vm_stack_top;//栈顶
    6. zval *vm_stack_end;//栈底
    7. zend_vm_stack vm_stack;//当前协程栈指针
    8. /*...*/
    9. zend_execute_data *execute_data;//当前协程栈帧
    10. /*...*/
    11. php_coro_task *origin_task;//上一个协程栈帧,类比prev_execute_data的作用
    12. };

    协程切换主要是针对当前栈执行发生中断时对上下文保存,和恢复。结合上面VM的执行流程我们可以知道上面几个字段的作用。

    • execute_data 栈帧指针需要保存和恢复是毋容置疑的
    • vm_stack* 系列是什么作用呢?原因是PHP是动态语言,我们上面分析到,每次有新函数进入执行和退出的时候,都需要在全局stack上创建和释放栈帧,所以需要正确保存和恢复对应的全局栈指针,才能保障每个协程栈帧得到释放,不会导致内存泄漏的问题。(当以debug模式编译PHP后,每次释放都会检查当全局栈是否合法)
    • origin_task 是当前协程执行结束后需要自动执行的前一个栈帧。
      主要涉及到的操作有:

    • 协程的创建 create,在全局stack上为协程申请栈帧。

      • 协程的创建是创建一个闭包函数,将函数(可以理解为需要执行的op_array)当作一个参数传入Swoole的内建函数go();
    • 协程让出,yield,遇到IO,保存当前栈帧的上下文信息
    • 协程的恢复,resume,IO完成,恢复需要执行的协程上下文信息到yield让出前的状态
    • 协程的退出,exit,协程op_array全部执行完毕,释放栈帧和swoole协程的相关数据。

    经过上面的介绍大家应该对Swoole协程在运行过程中可以在函数内部实现跳转有一个大概了解,回到最初我们例子结合上面php执行细节,我们能够知道,该例子会生成3个op_array,分别为 主脚本,协程1,协程2。我们可以利用一些工具打印出opcodes来直观的观察一下。通常我们会使用下面两个工具

    1. //Opcache, version >= PHP 7.1
    2. php -d opcache.opt_debug_level=0x10000 test.php
    3. //vld, 第三方扩展
    4. php -d vld.active=1 test.php

    我们用opcache来观察没有被优化前的opcodes,我们可以很清晰的看到这三组op_array的详细信息。

    1. php -dopcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 test.php
    2. $_main: ; (lines=11, args=0, vars=0, tmps=4)
    3. ; (before optimizer)
    4. ; /path-to/test.php:2-6
    5. L0 (2): INIT_FCALL 1 96 string("go")
    6. L1 (2): T0 = DECLARE_LAMBDA_FUNCTION string("")
    7. L2 (6): SEND_VAL T0 1
    8. L3 (6): DO_ICALL
    9. L4 (7): ECHO string("main flag
    10. ")
    11. L5 (8): INIT_FCALL 1 96 string("go")
    12. L6 (8): T2 = DECLARE_LAMBDA_FUNCTION string("")
    13. L7 (12): SEND_VAL T2 1
    14. L8 (12): DO_ICALL
    15. L9 (13): ECHO string("main end
    16. ")
    17. L10 (14): RETURN int(1)
    18. {closure}: ; (lines=6, args=0, vars=0, tmps=1)
    19. ; (before optimizer)
    20. ; /path-to/test.php:2-6
    21. L0 (9): ECHO string("coro 2 start
    22. ")
    23. L1 (10): INIT_STATIC_METHOD_CALL 1 string("co") string("sleep")
    24. L2 (10): SEND_VAL_EX int(1) 1
    25. L3 (10): DO_FCALL//yiled from 当前op_array [coro 1] ; resume
    26. L4 (11): ECHO string("coro 2 exit
    27. ")
    28. L5 (12): RETURN null
    29. {closure}: ; (lines=6, args=0, vars=0, tmps=1)
    30. ; (before optimizer)
    31. ; /path-to/test.php:2-6
    32. L0 (3): ECHO string("coro 1 start
    33. ")
    34. L1 (4): INIT_STATIC_METHOD_CALL 1 string("co") string("sleep")
    35. L2 (4): SEND_VAL_EX int(1) 1
    36. L3 (4): DO_FCALL//yiled from 当前op_array [coro 2];resume
    37. L4 (5): ECHO string("coro 1 exit
    38. ")
    39. L5 (6): RETURN null
    40. coro 1 start
    41. main flag
    42. coro 2 start
    43. main end
    44. coro 1 exit
    45. coro 2 exit

    Swoole在执行co::sleep()的时候让出当前控制权,跳转到下一个op_array,结合以上注释,也就是在DO_FCALL的时候分别让出和恢复协程执行栈,达到原生协程控制流跳转的目的。

    我们分析下 INIT_FCALL DO_FCALL指令在内核中如何执行。以便于更好理解函数调用栈切换的关系。

    VM内部指令会根据当前的操作数返回值等特殊化为一个c函数,我们这个例子中 有以下对应关系 INIT_FCALL => ZEND_INIT_FCALL_SPEC_CONST_HANDLER DO_FCALL => ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER
    1. ZEND_INIT_FCALL_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
    2. {
    3. USE_OPLINE
    4. zval *fname = EX_CONSTANT(opline->op2);
    5. zval *func;
    6. zend_function *fbc;
    7. zend_execute_data *call;
    8. fbc = CACHED_PTR(Z_CACHE_SLOT_P(fname));
    9. if (UNEXPECTED(fbc == NULL)) {
    10. func = zend_hash_find(EG(function_table), Z_STR_P(fname));
    11. if (UNEXPECTED(func == NULL)) {
    12. SAVE_OPLINE();
    13. zend_throw_error(NULL, "Call to undefined function %s()", Z_STRVAL_P(fname));
    14. HANDLE_EXCEPTION();
    15. }
    16. fbc = Z_FUNC_P(func);
    17. CACHE_PTR(Z_CACHE_SLOT_P(fname), fbc);
    18. if (EXPECTED(fbc->type == ZEND_USER_FUNCTION) && UNEXPECTED(!fbc->op_array.run_time_cache)) {
    19. init_func_run_time_cache(&fbc->op_array);
    20. }
    21. }
    22. call = zend_vm_stack_push_call_frame_ex(
    23. opline->op1.num, ZEND_CALL_NESTED_FUNCTION,
    24. fbc, opline->extended_value, NULL, NULL); //从全局stack上申请当前函数的执行栈
    25. call->prev_execute_data = EX(call); //将正在执行的栈赋值给将要执行函数栈的prev_execute_data,函数执行结束后恢复到此处
    26. EX(call) = call; //将函数栈赋值到全局执行栈,即将要执行的函数栈
    27. ZEND_VM_NEXT_OPCODE();
    28. }
    1. ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
    2. {
    3. USE_OPLINE
    4. zend_execute_data *call = EX(call);//获取到执行栈
    5. zend_function *fbc = call->func;//当前函数
    6. zend_object *object;
    7. zval *ret;
    8. SAVE_OPLINE();//有全局寄存器的时候 ((execute_data)->opline) = opline
    9. EX(call) = call->prev_execute_data;//当前执行栈execute_data->call = EX(call)->prev_execute_data 函数执行结束后恢复到被调函数
    10. /*...*/
    11. LOAD_OPLINE();
    12. if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) {
    13. ret = NULL;
    14. if (0) {
    15. ret = EX_VAR(opline->result.var);
    16. ZVAL_NULL(ret);
    17. }
    18. call->prev_execute_data = execute_data;
    19. i_init_func_execute_data(call, &fbc->op_array, ret);
    20. if (EXPECTED(zend_execute_ex == execute_ex)) {
    21. ZEND_VM_ENTER();
    22. } else {
    23. ZEND_ADD_CALL_FLAG(call, ZEND_CALL_TOP);
    24. zend_execute_ex(call);
    25. }
    26. } else if (EXPECTED(fbc->type < ZEND_USER_FUNCTION)) {
    27. zval retval;
    28. call->prev_execute_data = execute_data;
    29. EG(current_execute_data) = call;
    30. /*...*/
    31. ret = 0 ? EX_VAR(opline->result.var) : &retval;
    32. ZVAL_NULL(ret);
    33. if (!zend_execute_internal) {
    34. /* saves one function call if zend_execute_internal is not used */
    35. fbc->internal_function.handler(call, ret);
    36. } else {
    37. zend_execute_internal(call, ret);
    38. }
    39. EG(current_execute_data) = execute_data;
    40. zend_vm_stack_free_args(call);//释放局部变量
    41. if (!0) {
    42. zval_ptr_dtor(ret);
    43. }
    44. } else { /* ZEND_OVERLOADED_FUNCTION */
    45. /*...*/
    46. }
    47. fcall_end:
    48. /*...*/
    49. }
    50. zend_vm_stack_free_call_frame(call);//释放栈
    51. if (UNEXPECTED(EG(exception) != NULL)) {
    52. zend_rethrow_exception(execute_data);
    53. HANDLE_EXCEPTION();
    54. }
    55. ZEND_VM_SET_OPCODE(opline + 1);
    56. ZEND_VM_CONTINUE();
    57. }

    Swoole在PHP层可以按照以上方式来进行切换,至于执行过程中有IO等待发生,需要额外的技术来驱动,我们后续的文章将会介绍每个版本的驱动技术结合Swoole原有的事件模型,讲述Swoole协程如何进化到现在。