C++20協程學習

語言: CN / TW / HK

圖片

導語 | 本文推選自騰訊雲開發者社群-【技思廣益 · 騰訊技術人原創集】專欄。該專欄是騰訊雲開發者社群為騰訊技術人與廣泛開發者打造的分享交流視窗。欄目邀約騰訊技術人分享原創的技術積澱,與廣泛開發者互啟迪共成長。本文作者是騰訊後臺開發工程師楊良聰。

協程(coroutine)是在執行過程中可以被掛起,在後續可以被恢復執行的函式。在C++20中,當一個函式內部出現了co_await、co_yield、co_return中的任何一個時,這個函式就是一個協程。

圖片

C++20協程的一個簡單的示例程式碼:

coro_ret<int> number_generator(int begin, int count) {     std::cout << "number_generator invoked." << std::endl;     for (int i=begin; i<count; ++i) {         co_yield i;     }     co_return; } int main(int argc, char* argv[]) {     auto g = number_generator(1, 10);     std::cout << "begin to run!" << std::endl;     while(!g.resume()) {         std::cout << "got number:" << g.get() << std::endl;     }     std::cout << "coroutine done, return value:" << g.get() << std::endl;     return 0; }

number_generator內出現了co_yield和co_return所以這不是一個普通的函式,而是一個協程,每當程式執行到第4行co_yield i;時,協程就會掛起,程式的控制權會回到呼叫者那裡,直到呼叫者呼叫resume方法,此時會恢復到上次協程yield的地方,繼續開始執行。

圖片

Promise

number_generator的返回型別是coro_ret,而協程本身的程式碼中並沒有通過return返回這個型別的資料,這就是C++20裡實現協程的一個關鍵點: 協程的返回型別T中,必須有T::promise_type這個型別定義,這個型別要實現幾個介面。還是先看程式碼:

``` //!coro_ret 協程函式的返回值,內部定義promise_type,承諾物件 template struct coro_ret { struct promise_type; using handle_type = std::coroutine_handle; //! 協程控制代碼 handle_type coro_handle_;

//!promise_type就是承諾物件,承諾物件用於協程內外交流
struct promise_type
{    
    promise_type() {
        std::cout << "promise constructor invoded." << std::endl;
    }
    ~promise_type() = default;

    //!生成協程返回值
    auto get_return_object()
    {
        std::cout << "get_return_object invoked." << std::endl;
        return coro_ret<T>{handle_type::from_promise(*this)};
    }

    //! 注意這個函式,返回的就是awaiter
    //! 如果返回std::suspend_never{},就不掛起,
    //! 返回std::suspend_always{} 掛起
    //! 當然你也可以返回其他awaiter
    auto initial_suspend()
    {
        //return std::suspend_never{};
        std::cout << "initial_suspend invoked." << std::endl;
        return std::suspend_always{};
    }
    //!co_return 後這個函式會被呼叫
    /*
    void return_value(const T&amp; v)
    {
        return_data_ = v;
        return;
    }
    */

    void return_void()
    {
        std::cout << "return void invoked." << std::endl;
    }

    //!
    auto yield_value(const T&amp; v)
    {
        std::cout << "yield_value invoked." << std::endl;
        return_data_ = v;
        return std::suspend_always{};
        //return std::suspend_never{};
    }
    //! 在協程最後退出後呼叫的介面。
    auto final_suspend() noexcept
    {
        std::cout << "final_suspend invoked." << std::endl;
        return std::suspend_always{};
    }
    //
    void unhandled_exception()
    {
        std::cout << "unhandled_exception invoked." << std::endl;
        std::exit(1);
    }
    //返回值
    T return_data_;
};

coro_ret(handle_type h)
        : coro_handle_(h)
{
}
~coro_ret()
{
    //!自行銷燬
    if (coro_handle_)
    {
        coro_handle_.destroy();
    }
}

//!恢復協程,返回是否結束
bool resume()
{
    if (!coro_handle_.done()) {  //! 如果已經done了,再呼叫resume,會導致coredump
        coro_handle_.resume();
    }
    return coro_handle_.done();
}

bool done() const
{
    return coro_handle_.done();
}

//!通過promise獲取資料,返回值
T get()
{
    return coro_handle_.promise().return_data_;
}

};

```

coro_ret是個自定義的結構,為了能作為協程的返回值,需要定義一個promise_type。這個型別需要實現如下的介面:

  • coro_ret get_return_object() 這個介面要能用promise自己的例項構造出一個協程的返回值,會在協程正在執行前進行呼叫,這個介面的返回值會作為協程的返回值。

  • awaiter initial_suspend() 這個介面會在協程被建立(也就是第一次呼叫),真正執行前,被呼叫,如果這個介面返回的是std::suspend_never{},那麼協程一創建出來,就會立刻執行;如果返回的是std::suspend_always{},那麼協程被創建出來時,會處於掛起狀態,不會立刻執行,需要呼叫者主動resume才會觸發第一次執行。這兩個值其實都是awaiter型別,後面再解釋這個型別。

  • awaiter yield_value(T v) 這個介面會在 co_yield v 時被呼叫,把co_yield後面跟著的值v做為引數傳入,這裡一般就是把這個值儲存下來,提供給協程的呼叫者,返回值也是awaiter,這裡一般返回的是std::suspend_always{}。

  • void return_value(T v) 這個介面會在 co_return v 時被呼叫,把co_return後面跟著的值v作為引數傳入,這裡一般就是把這個值儲存下來,提供給協程呼叫者。

  • void return_void() 如果 co_return 後面沒有接任何值,那麼就會呼叫這個介面。return_void和return_value只能選擇一個實現,否則會報編譯錯誤。

  • awaiter final_suspend() 在協程最後退出後呼叫的介面,如果返回 std::suspend_always 則需要使用者自行呼叫coroutine_handle的destroy介面來釋放協程相關的資源;如果返回std::suspend_never則在協程結束後,協程對應的handle就已經為空,不能再呼叫destroy了(會coredump)

  • void unhandled_exception()如果協程內的程式碼丟擲了異常,那麼這個介面會被呼叫。

圖片

協程相關物件

可以看出promise類的工作主要是兩個:一是定義協程的執行流程,主要介面是initial_suspend,final_suspend,二是負責協程和呼叫者之間的資料傳遞,主要介面是yield_value和return_value。

std::coroutine_handle是協程的控制控制代碼類,最重要的介面是promise、resume,前者可以獲得協程的promise物件,後者可以恢復協程的執行。此外還有destroy介面,用來銷燬協程例項,done介面用於返回協程是否已經結束執行。通過std::coroutine_handle::from_promise()方法,可以從promise例項獲得對應的handle。

coro_ret中其他幾個介面resume,done和get_data不是必須的,只是為了方便使用而存在。

總結一下,一個協程與這幾個物件關聯在一起:

  • promise

  • coroutine handle

  • coroutine state

這是個在堆上分配的內部物件,沒有暴露給開發者,是用來儲存協程內相關資料和狀態的,具體來說就是:

  • promise物件

  • 傳給協程的引數

  • 當前掛起點的相關資料

  • 生命週期跨越掛起點的臨時變數和本地變數,也就是在resume後需要恢復出來的變數。

協程的建立

圖片

圖片

臨時總結

要在c++20裡實現一個協程,需要定義一個協程的返回型別T,這個T內需要定義一個promise_type的型別,這個型別要實現幾個指定的介面,這樣就足夠了。這樣,要開發一個包含非同步操作的協程,程式碼的結構大致會是這樣的:

``` coro_return logic() { // 發起非同步操作 some_async_oper(); co_yield xxx

 // 恢復執行了,要先檢查和獲得非同步操作的結果
 auto result = get_async_oper_result()
 do_some_thing(result)

 co_return

}

int main() { auto co_ret = logic(); // 迴圈檢查非同步操作是否結束 while(true) { auto result = get_async_result(); if (result) { // 非同步操作結束了,恢復協程的執行,要把結果傳過去 co_ret.resume() break; } } } ```

可以看到,在協程內部,發起非同步操作和獲取結果,被yield分割為了兩步,和同步程式碼還是有著明顯的區別。這時,co_await就可以發揮它的作用了,使用了co_await後的協程程式碼會是這樣的

coro_return<T> logic() { auto result = co_await some_async_oper(); do_some_thing(result); }

這樣就和同步程式碼就基本沒有區別了,除了這個co_await

  • co_await

co_await最常見的使用方式為auto ret=co_await expr,co_await後跟一個表示式,整個語句的執行過程有多種情況,是比較複雜的。這裡描述的是簡化版本,主要是簡化了promise.await_transform的作用,以及awaitable物件,可以點選下面連結看完整的描述。這裡假定協程的promise_type沒有實現await_transform方法。 

http://en.cppreference.com/w/cpp/language/coroutines

圖片

用程式碼表達,是這樣:

``` if (!awaiter.await_ready()) { using handle_t = std::experimental::coroutine_handle

;

using await_suspend_result_t =
  decltype(awaiter.await_suspend(handle_t::from_promise(p)));

<suspend-coroutine>

if constexpr (std::is_void_v<await_suspend_result_t>)

{ awaiter.await_suspend(handle_t::from_promise(p)); } else { static_assert( std::is_same_v, "await_suspend() must return 'void' or 'bool'.");

  if (awaiter.await_suspend(handle_t::from_promise(p)))
  {
    <return-to-caller-or-resumer>
  }
}

<resume-point>

}

return awaiter.await_resume(); ```

  • 首先是expr求值

  • expr表示式的返回值型別(awaiter)必須實現這幾個介面: await_ready、await_suspend和await_resume。

  • await_ready被呼叫,如果返回true,那麼協程完全不會被掛起,直接會去呼叫await_resume()介面,把這個介面作為await的返回值,繼續執行協程。

  • 如果await_ready返回false,那麼協程會被掛起,然後呼叫await_suspend介面,並將協程的控制代碼傳給這個介面。注意,此時協程已經被掛起,但控制權還沒有交給呼叫者。

  • 如果await_suspend介面的返回型別是void,或者返回型別是bool,返回值是true,那麼就將控制權交還給呼叫者。

  • 如果await_suspend介面返回的是false,那麼協程會被resume,並接著呼叫await_resume,把這個介面作為await的返回值,繼續執行協程。

  • 如果前面的步驟中,協程被掛起了,那麼當協程被呼叫者resume的時候,會先呼叫await_resume介面,把這個介面作為await的返回值,繼續執行協程。

  • co_await的例子

以封裝一個socket的connect操作為例,我們希望能像這樣在協程中去connect一個tcp地址:

``` coro_ret connect_addr_example(io_service& service, const char* ip, int16_t port) { coroutine_tcp_client client; // 非同步連線, service是對epoll的一個封裝 auto connect_ret = co_await client.connect(ip, port, 3, service); printf("client.connect return:%d\n", connect_ret); if (connect_ret) { printf("connect failed, coroutine return\n"); co_return -1; }

do_something_with_connect(client);

co_return 0;

} ```

那麼需要做的事情是

  • 第5行中的client.connect首先發起一個非同步連線的請求(設定socket為noneblock,然後connect, 並把socket和自己的指標加入epoll),返回的型別需要是一個awaiter,也就是要實現這三個介面:await_ready、await_suspend和await_resume

  • 在await_ready中,判斷連線是否已經建立了(某些情況下connect會立刻成功返回),或者出錯了(比如給connect傳了非法的引數),此時需要返回true,協程就完全不會掛起。其他情況需要返回false,讓協程掛起

  • 在await_suspend中,可以儲存下傳入的協程控制代碼,然後直接返回true。

  • 在await_resume中,判斷下連線的結果,成功返回0,其他情況返回錯誤碼。

  • 協程外的主迴圈裡,使用epoll進行輪詢,當對應的控制代碼有事件時(成功連線、超時、出錯),就取出對應的client指標,設定好連線的結果,並resume協程。

大致的程式碼如下:

``` struct connect_awaiter { coroutine_tcp_client& tcp_client_;

    // co_await開始會呼叫,根據返回值決定是否掛起協程
    bool await_ready()

{ auto status = tcp_client_.status(); switch(status) { case ERROR: printf("await_ready: status error invalid, should not suspend!\n"); return true; case CONNECTED: printf("await_ready: already connected, should not suspend!\n"); return true; default: printf("await_ready: status:%d, return false.\n", status); return false; }

    }

    // 在協程掛起後會呼叫這個,如果返回true,會返回呼叫者,如果返回false,會立刻resume協程
    bool await_suspend(std::coroutine_handle<> awaiting)

{ printf("await_suspend invoked.\n"); tcp_client_.handle_ = awaiting; return true; }

    // 在協程resume的時候會呼叫這個,這個的返回值會作為await的返回值
    int await_resume()

{ int ret = tcp_client_.status() == CONNECTED ? 0 : -1; printf("awati_resume invoked, ret:%d\n", ret); return ret; } }; ```

瞭解了co_await之後,可以回頭看一下之前的內容,前面多次出現的std::suspend_never和std::suspend_always就是兩個預定義好的awaiter,也有那三個介面的定義,有興趣的同學可以看看對應的原始碼。promise物件的initial_suspend、final_suspend、yield_value返回的都是awaiter,實際上系統執行的是 co_await promise.initial_suspend() ,co_yield實際上執行的是 co_await promise.yield_value() 。如果有需要,也可以返回自定義的awaiter。

總結

可以看出C++20給出了一個非常靈活、有很強大可定製性的協程機制,但缺少基本的庫支援,連寫一個最簡單的協程都需要開發者付出不少理解和學習的成本,目前的狀態只能說是打了一個的地基,在C++23中,為協程提供庫的支援是重要的目標之一,可以拭目以待。

參考資料:

1.協程 (C++20)

2.C++ 協程:瞭解運算子co_await

3.C++20即將到來的coroutine能否與Golang的goroutine媲美?

如果你是騰訊技術內容創作者,騰訊雲開發者社群誠邀您加入【騰訊雲原創分享計劃】,領取禮品,助力職級晉升。

閱讀原文