以swoole為例,學習如何實現協程

信海龍發表於2017-03-06

聊聊Swoole2.0協程

Swoole 2.0正式版釋出了。2.0版本最大的更新是增加了對協程(Coroutine)的支援。正式版已同時支援PHP5和PHP7。基於Swoole2.0協程PHP開發者可以已同步的方式編寫程式碼,底層自動進行協程排程,轉變為非同步IO。解決了傳統非同步程式設計巢狀回撥的問題。

目前Swoole底層內建的協程客戶端元件包括:udpclient、tcpclient、httpclient、redisclient、mysqlclient,基本涵蓋了開發者常用的幾種通訊協議。協程元件只能在伺服器的onConnect、onRequest、onReceive、onMessage 回撥函式中使用。

注意,Swoole 2.0.5以前的版本還是灰度測試版本,可能會存在問題。 beta是因為協程是全新的版本。


協程的使用示例

/**
    只有在Server中才能使用協程。包括 http server,websocket server 和 server。
*/
$server = new SwooleHttpServer(`127.0.0.1`, 9501);

/**
    觸發on request事件時,SWOOLE會開闢一個協程棧,對協程棧進行初始化
 */
$server->on(`Request`, function ($request, $response) {
    $tcp_cli = new SwooleCoroutineClient(SWOOLE_SOCK_TCP);
    /**
        client在呼叫connect函式後,SWOOLE會將PHP上下文資訊儲存到當前棧內
        然後將協程掛起,待確認連線成功後,觸發epoll事件,然後協程切換
        恢復PHP上下文資訊,返回結果,繼續執行PHP程式碼
     */
    if ($tcp_cli->connect(`127.0.0.1`, 9906) === false) {
        $response->end("connect server failed.");
        return;
    }
    $tcp_cli->send(`test for the coro`);
    /**
        client在呼叫recv函式後,SWOOLE會將PHP上下文資訊儲存到當前棧內
        然後將協程掛起待後端svr回包,觸發epoll事件,然後協程切換
        恢復PHP上下文資訊,返回結果,繼續執行PHP程式碼
        如果後端在設定的超時時間內,未能回包,返回false
        client的errCode定為110
     */
    $ret = $tcp_cli->recv(100);
    $tcp_cli->close();
    if ($ret) {
        $response->end(" swoole response is ok");
    } else {
        $response->end(" recv failed error : {$tcp_cli->errCode}");
    }
});

$server->start();

協程的執行流程

主程式->回撥函式: 嘿,有資料到了,麻煩你處理下
Note right of 回撥函式: 遇到非同步IO
回撥函式-->主程式: 已儲存狀態,你執行其他任務吧
Note left of 主程式: 去執行其他任務
主程式->回撥函式: 傳送的資料,有訊息返回啦,你處理下

我們們就以上面的示例程式碼為例,說一說協程的執行流程。
Http Server監聽9051埠。當有相關事件發生時,如有資料到達,就會執行繫結到Request上的回撥函式。在執行回撥函式之前,會建立一個協程。這時,會儲存CPU暫存器的狀態和ZendVM Stack資訊。
在回撥函式執行過程中,如果遇到IO操作,如$tcp_cli->connect(,就會儲存當前的狀態,並讓出CPU使用權。當前請求執行被掛起。
讓CPU出使用權後,CPU就可以用於處理其他事件。如處理其他客戶端的Request請求。
當被掛起的請求,又有新的事件發生,如上面$tcp_cli->connect()的資料已經返回。這時,會使用掛起前儲存的狀態資訊恢復,然後繼續執行回撥函式。
如果在執行過程中,再次遇到IO操作,會繼續執行儲存狀態和讓出CPU使用權。

協程的意義

這些IO操作都是非阻塞的,即傳送請求和獲取資料分為兩步。當請求傳送完畢後,就會進行狀態儲存和讓出CPU使用權。在等待請求資料返的這段時間,CPU可以執行一些其他程式。這樣就可以充分利用CPU。

協程的實現

Swoole的協程是基於 setjmp 、 longjmp 實現的。Swoole為每個協程都分配了空間,用於儲存協程切換時的狀態資訊。進行協程切換時會自動儲存Zend VM的記憶體狀態(主要是EG全域性記憶體和vm stack)。當回撥函式執行完畢後,會自動銷燬分配的空間。

建立協程

什麼時候會建立協程?在Server的onConnect、onRequest、onReceive、onMessage 回撥函式被執行前會建立一個協程。
協程建立的方法是coro_create。相關原始碼可以檢視swoole_coroutine.c檔案。
coro_create方法中主要進行了如下操作:

int sw_coro_create(zend_fcall_info_cache *fci_cache, zval **argv, int argc, zval **retval, void *post_callback, void* params)
{
            // 為回撥函式的執行做一些準備工作
            .......
        COROG.require = 1;
    // 使用setjmp開啟一個協程
    if (!setjmp(*swReactorCheckPoint))
    {
            // setjmp第一次呼叫會進入此程式碼分支,執行回撥函式
        zend_execute_ex(execute_data TSRMLS_CC);
        ......
        // 執行完畢後,關閉協程
        coro_close(TSRMLS_C);
        ......
        coro_status = CORO_END;
    }
    else
    {
            /**
             如果執行longjump,會調到上面的setjmp(*swReactorCheckPoint)行。
             但是,setjmp的返回值為非0。因此,longjump後,會進入此程式碼分支。
             讓出CPU執行權。
             */
        coro_status = CORO_YIELD;
    }
    COROG.require = 0;

    return coro_status;
}

協程讓出CPU執行權yield

什麼時候會讓出CPU執行權?當回撥函式中遇到非同步IO的時候,會讓出CPU執行權。如,程式碼中的connect操作。下面,我們就以connect操作為例,看看讓出CPU執行權時都做了那些操作。
connect的相關程式碼在swoole_coroutine.c檔案中。程式碼如下:

static PHP_METHOD(swoole_client_coro, connect)
{
    long port = 0, sock_flag = 0;
    ......
    //nonblock async
    // 傳送連線資料,無需等待對方返回資料,就執行下面程式碼
    if (cli->connect(cli, host, port, timeout, sock_flag) < 0)
    {
       ......
    }
    ......
    // 獲取一個記憶體空間,用於儲存當前執行的上下文資訊。
    php_context *sw_current_context = swoole_get_property(getThis(), 0);
    ......
    // 儲存協程資訊
    coro_save(sw_current_context);
    // 讓出CPU使用權
    coro_yield();
}

儲存協程資訊

所謂的協程資訊主要就是當前的上下文執行資訊。coro_save方法在swoole_coroutine.c檔案中。程式碼如下:

sw_inline php_context *sw_coro_save(zval *return_value, php_context *sw_current_context)
{
    // 下面的程式碼主要是把當前的執行狀態儲存到之前獲取的記憶體空間中
    zend_execute_data *current = EG(current_execute_data);
    if (ZEND_CALL_INFO(current) & ZEND_CALL_RELEASE_THIS)
    {
        zval_ptr_dtor(&(current->This));
    }
    zend_vm_stack_free_args(EG(current_execute_data));
    zend_vm_stack_free_call_frame(EG(current_execute_data));

    strncpy(SWCC(uid), COROG.uid, 20);
    SWCC(current_coro_return_value_ptr) = return_value;
    SWCC(current_execute_data) = EG(current_execute_data)->prev_execute_data;
    SWCC(current_vm_stack) = EG(vm_stack);
    SWCC(current_vm_stack_top) = EG(vm_stack_top);
    SWCC(current_vm_stack_end) = EG(vm_stack_end);
    SWCC(current_task) = COROG.current_coro;
    SWCC(allocated_return_value_ptr) = COROG.allocated_return_value_ptr;

    return sw_current_context;

}

讓出CPU執行權

coro_yield方法的作用是讓出CPU執行權。程式碼在swoole_coroutine.c檔案中。

sw_inline void coro_yield()
{
    SWOOLE_GET_TSRMLS;
// 還原棧資訊
#if PHP_MAJOR_VERSION >= 7
    EG(vm_stack) = COROG.origin_vm_stack;
    EG(vm_stack_top) = COROG.origin_vm_stack_top;
    EG(vm_stack_end) = COROG.origin_vm_stack_end;
#else
    EG(argument_stack) = COROG.origin_vm_stack;
    EG(current_execute_data) = COROG.origin_ex;
#endif
      // 跳轉到coro_create方法中setjmp程式碼行。
    longjmp(*swReactorCheckPoint, 1);
}

在這個方法中主要進行了還原棧資訊和longjump操作。
COROG.origin_vm_stack 這些棧資訊的初始化在coro_init方法中。記錄了協程執行前的狀態。

恢復協程

當非同步IO有資料返回後,會進行協程恢復。協程恢復的方法是coro_resume。在swoole_coroutine.c檔案中。程式碼如下:

int sw_coro_resume(php_context *sw_current_context, zval *retval, zval *coro_retval)
{
    // 使用之前儲存的協程資訊恢復執行上下文環境。
    EG(vm_stack) = SWCC(current_vm_stack);
    ....
    int coro_status;
    // 設定跳轉點,方便在執行過程中再遇到非同步IO操作,進行跳轉。
    if (!setjmp(*swReactorCheckPoint))
    {
        //coro exit
        // 繼續執行回撥函式
        zend_execute_ex(sw_current_context->current_execute_data TSRMLS_CC);
        coro_close(TSRMLS_C);
        coro_status = CORO_END;
    }
    else
    {
        //coro yield
        coro_status = CORO_YIELD;
    }

    if (unlikely(coro_status == CORO_END && EG(exception)))
    {
        sw_zval_ptr_dtor(&retval);
        zend_exception_error(EG(exception), E_ERROR TSRMLS_CC);
    }
    return coro_status;
}

可見,建立協程和恢復協程的整體程式碼結構差不多。

結束協程

當回到函式執行完畢後,會結束協程。
coro_close方法用於結束協程。原始碼在swoole_coroutine.c檔案中。

sw_inline void coro_close(TSRMLS_D)
{
      // 釋放為協程而申請的相關資源
    efree(EG(vm_stack));
    efree(COROG.allocated_return_value_ptr);
    // 恢復執行棧
    EG(vm_stack) = COROG.origin_vm_stack;
    EG(vm_stack_top) = COROG.origin_vm_stack_top;
    EG(vm_stack_end) = COROG.origin_vm_stack_end;
    --COROG.coro_num;
    swTrace("closing coro and %d remained. usage size: %zu. malloc size: %zu", COROG.coro_num, zend_memory_usage(0), zend_memory_usage(1));
}


相關文章