Google “戰敗”後,C++20 用微軟的提案進入協程時代!

【CSDN 編者按】兩年前,C++20 正式發佈。在這一版本,開發者終於迎來了協程特性,它可以讓代碼非常清爽,簡單易懂,同時保持了異步的高性能。但不少開發者直言,C++的協程標準是給庫的開發者使用的,非常複雜,對普通開發者一點都不友好。在這篇文章中,C++ 資深技術專家祁宇立足於 C++20 使用的無棧協程標準,以具體示例分享協程的具體應用實踐與經驗。


作者 | 祁宇,許傳奇,韓垚      責編 | 屠敏
出品 | CSDN(ID:CSDNnews)

經過多年的醞釀、爭論、準備後,協程終於進入 C++20 標準。

圖片

 

圖片

微軟提出並主導的無棧協程成爲C++20協程標準

 

協程並不是一個新的概念,它距今已經有幾十年的歷史了,也早已存在於許多其它編程語言(Python、C#、Go)。

協程分爲無棧協程和有棧協程兩種,無棧指可掛起/恢復的函數,有棧協程則相當於用戶態線程。有棧協程切換的成本是用戶態線程切換的成本,而無棧協程切換的成本則相當於函數調用的成本。

無棧協程和線程的區別:無棧協程只能被線程調用,本身並不搶佔內核調度,而線程則可搶佔內核調度。

C++20 協程中採納的是微軟提出並主導(源於 C#)的無棧協程。很多人反對這個特性,主要槽點包括:難於理解、過於靈活、動態分配導致的性能問題等等。Google 對該提案發起了一系列吐槽並嘗試給出了有棧協程的方案。有棧協程比系統級線程輕量很多,但比起無棧協程還是差了許多。

由於 C++ 的設計哲學是"Zero Overhead Abstractions",最終無棧協程成爲了 C++20 協程標準。

當今 C++ 世界演化的兩大主旋律是異步化與並行化。而 C++20 協程能夠以同步語法寫異步代碼的特性,使其成爲編寫異步代碼的好工具,異步庫的協程化將是大勢所趨,因此很有必要掌握 C++20 協程。

通過一個簡單的例子來展示一下協程的“妙處”。

async_resolve({host, port}, [](auto endpoint){
  async_connect(endpoint, [](auto error_code){
    async_handle_shake([](auto error_code){
        send_data_ = build_request();

        async_write(send_data_, [](auto error_code){
            async_read();
        });
    });
    });
});

void async_read() {
    async_read(response_, [](auto error_code){
        if(!finished()) {
            append_response(recieve_data_);
            async_read();
        }else {
            std::cout<<"finished ok\n";
        }
    });
}

基於回調的異步client的僞代碼

基於異步回調的 client 流程如下:

  • 異步域名解析

  • 異步連接

  • 異步 SSL 握手

  • 異步發送數據

  • 異步接收數據

這個代碼有很多回調函數,使用回調的時候還有一些陷阱,比如如何保證安全的回調、如何讓異步讀實現異步遞歸調用,如果再結合異步業務邏輯,回調的嵌套層次會更深,我們已經看到 callback hell 的影子了!可能也有讀者覺得這個程度的異步回調還可以接受,但是如果工程變大,業務邏輯變得更加複雜,回調層次越來越深,維護起來就很困難了。

再來看看用協程是怎麼寫這個代碼的:

auto endpoint = co_await async_query({host, port});
auto error_code = co_await async_connect(endpoint);
error_code = co_await async_handle_shake();
send_data = build_request();
error_code = co_await async_write(send_data);
while(true) {
    co_await async_read(response);
    if(finished()) {
        std::cout<<"finished ok\n";
        break;
    }

    append_response(recieve_data_);
}

基於C++20協程的異步client

同樣是異步 client,相比回調模式的異步 client,整個代碼非常清爽,簡單易懂,同時保持了異步的高性能,這就是 C++20 協程的威力!

相信你看了這個例子之後應該不會再想用異步回調去寫代碼了吧,是時候擁抱協程了!

 

圖片

C++20 爲什麼選擇無棧協程?

 

有棧(stackful)協程通常的實現手段是在堆上提前分配一塊較大的內存空間(比如 64K),也就是協程所謂的“棧”,參數、return address 等都可以存放在這個“棧”空間上。如果需要協程切換,那麼通過 swapcontext 一類的形式來讓系統認爲這個堆上空間就是普通的棧,這就實現了上下文的切換。

有棧協程最大的優勢就是侵入性小,使用起來非常簡便,已有的業務代碼幾乎不需要做什麼修改,但是 C++20 最終還是選擇了使用無棧協程,主要出於下面這幾個方面的考慮。

  • 棧空間的限制

有棧協程的“棧”空間普遍是比較小的,在使用中有棧溢出的風險;而如果讓“棧”空間變得很大,對內存空間又是很大的浪費。無棧協程則沒有這些限制,既沒有溢出的風險,也無需擔心內存利用率的問題。

  • 性能

有棧協程在切換時確實比系統線程要輕量,但是和無棧協程相比仍然是偏重的,這一點雖然在我們目前的實際使用中影響沒有那麼大(異步系統的使用通常伴隨了 IO,相比於切換開銷多了幾個數量級),但也決定了無棧協程可以用在一些更有意思的場景上。舉個例子,C++20 coroutines 提案的作者 Gor Nishanov 在 CppCon 2018 上演示了無棧協程能做到納秒級的切換,並基於這個特點實現了減少 Cache Miss 的特性。

無棧協程是普通函數的泛化

無棧協程是一個可以暫停和恢復的函數,是函數調用的泛化。

爲什麼?

我們知道一個函數的函數體(function body)是順序執行的,執行完之後將結果返回給調用者,我們沒辦法掛起它並稍後恢復它,只能等待它結束。而無棧協程則允許我們把函數掛起,然後在任意需要的時刻去恢復並執行函數體,相比普通函數,協程的函數體可以掛起並在任意時刻恢復執行。

圖片

所以,從這個角度來說,無棧協程是普通函數的泛化。

 

圖片

C++20 協程的“微言大義”

 

C++20 提供了三個新關鍵字(co_await、co_yield 和 co_return),如果一個函數中存在這三個關鍵字之一,那麼它就是一個協程。

編譯器會爲協程生成許多代碼以實現協程語義。會生成什麼樣的代碼?我們怎麼實現協程的語義?協程的創建是怎樣的?co_await機制是怎樣的?在探索這些問題之前,先來看看和 C++20 協程相關的一些基本概念。

協程相關的對象

協程幀(coroutine frame)

當 caller 調用一個協程的時候會先創建一個協程幀,協程幀會構建 promise 對象,再通過 promise 對象產生 return object。

協程幀中主要有這些內容:

  • 協程參數

  • 局部變量

  • promise 對象

這些內容在協程恢復運行的時候需要用到,caller 通過協程幀的句柄 std::coroutine_handle 來訪問協程幀。

promise_type

promise_type 是 promise 對象的類型。promise_type 用於定義一類協程的行爲,包括協程創建方式、協程初始化完成和結束時的行爲、發生異常時的行爲、如何生成 awaiter 的行爲以及 co_return 的行爲等等。promise 對象可以用於記錄/存儲一個協程實例的狀態。每個協程楨與每個 promise 對象以及每個協程實例是一一對應的。

coroutine return object

它是promise.get_return_object()方法創建的,一種常見的實現手法會將 coroutine_handle 存儲到 coroutine object 內,使得該 return object 獲得訪問協程的能力。

std::coroutine_handle

協程幀的句柄,主要用於訪問底層的協程幀、恢復協程和釋放協程幀。
程序員可通過調用 std::coroutine_handle::resume() 喚醒協程。

co_await、awaiter、awaitable

  • co_await:一元操作符;

  • awaitable:支持 co_await 操作符的類型;

  • awaiter:定義了 await_ready、await_suspend 和 await_resume 方法的類型。

co_await expr 通常用於表示等待一個任務(可能是 lazy 的,也可能不是)完成。co_await expr 時,expr 的類型需要是一個 awaitable,而該 co_await表達式的具體語義取決於根據該 awaitable 生成的 awaiter。

看起來和協程相關的對象還不少,這正是協程複雜又靈活的地方,可以藉助這些對象來實現對協程的完全控制,實現任何想法。但是,需要先要了解這些對象是如何協作的,把這個搞清楚了,協程的原理就掌握了,寫協程應用也會遊刃有餘了。

協程對象如何協作

以一個簡單的代碼展示這些協程對象如何協作:

Return_t foo () { 
    auto res = co_await awaiter; 
    co_return res ; 
}

Return_t:promise return object。

awaiter: 等待一個task完成。

圖片

協程運行流程圖

圖中淺藍色部分的方法就是 Return_t 關聯的 promise 對象的函數,淺紅色部分就是 co_await 等待的 awaiter。

這個流程的驅動是由編譯器根據協程函數生成的代碼驅動的,分成三部分:

  • 協程創建;

  • co_await awaiter 等待 task 完成;

  • 獲取協程返回值和釋放協程幀。

協程的創建

Return_t foo () { 
    auto res = co_await awaiter; 
    co_return res ; 
}

foo()協程會生成下面這樣的模板代碼(僞代碼),協程的創建都會產生類似的代碼:

{
  co_await promise.initial_suspend();
  try
  {
    coroutine body;
  }
  catch (...)
  {
    promise.unhandled_exception();
  }
FinalSuspend:
  co_await promise.final_suspend();
}

 

首先需要創建協程,創建協程之後是否掛起則由調用者設置 initial_suspend 的返回類型來確定。

創建協程的流程大概如下:

  • 創建一個協程幀(coroutine frame)

  • 在協程幀裏構建 promise 對象

  • 把協程的參數拷貝到協程幀裏

  • 調用 promise.get_return_object() 返回給 caller 一個對象,即代碼中的 Return_t 對象

在這個模板框架裏有一些可定製點:如 initial_suspend、final_suspend、unhandled_exception 和 return_value。

我們可以通過 promise 的 initial_suspend 和 final_suspend 返回類型來控制協程是否掛起,在 unhandled_exception 裏處理異常,在 return_value 裏保存協程返回值。

可以根據需要定製 initial_suspend 和 final_suspend 的返回對象來決定是否需要掛起協程。如果掛起協程,代碼的控制權就會返回到caller,否則繼續執行協程函數體(function body)。

圖片

另外值得注意的是,如果禁用異常,那麼生成的代碼裏就不會有 try-catch。此時協程的運行效率幾乎等同非協程版的普通函數。這在嵌入式場景很重要,也是協程的設計目的之一。

co_await 機制

co_await 操作符是 C++20 新增的一個關鍵字,co_await expr 一般表示等待一個惰性求值的任務,這個任務可能在某個線程執行,也可能在 OS 內核執行,什麼時候執行結束不知道,爲了性能,我們又不希望阻塞等待這個任務完成,所以就藉助 co_await 把協程掛起並返回到 caller,caller 可以繼續做事情,當任務完成之後協程恢復並拿到 co_await 返回的結果。

所以 co_await 一般有這幾個作用:

  • 掛起協程;

  • 返回到 caller;

  • 等待某個任務(可能是 lazy 的,也可能是非 lazy 的)完成之後返回任務的結果。

編譯器會根據 co_await expr 生成這樣的代碼:

{
  auto&& value = <expr>;
  auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
  auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
  if (!awaiter.await_ready()) //是否需要掛起協程
  {
    using handle_t = std::experimental::coroutine_handle<P>;

    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)); //異步(也可能同步)執行task
      <return-to-caller-or-resumer> //返回給caller
    }
    else
    {
      static_assert(
         std::is_same_v<await_suspend_result_t, bool>,
         "await_suspend() must return 'void' or 'bool'.");

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

    <resume-point> //task執行完成,恢復協程,這裏是協程恢復執行的地方
  }

  return awaiter.await_resume(); //返回task結果
}

這個代碼執行流程就是“協程運行流程圖”中粉紅色部分,從這個生成的代碼可以看到,通過定製 awaiter.await_ready() 的返回值就可以控制是否掛起協程還是繼續執行,返回 false 就會掛起協程,並執行 awaiter.await_suspend,通過 awaiter.await_suspend 的返回值來決定是返回 caller 還是繼續執行。

正是 co_await 的這種機制是變“異步回調”爲“同步”的關鍵。

C++20 協程中最重要的兩個對象就是 promise 對象(恢復協程和獲取某個任務的執行結果)和 awaiter(掛起協程,等待task執行完成),其它的都是“工具人”,要實現想要的的協程,關鍵是要設計如何讓這兩個對象協作好。

關於co_await的更多細節,讀者可以看這個文檔(https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await)。

微言大義

再回過頭來看這個簡單的協程:

Return_t foo () { 
    auto res = co_await awaiter; 
    co_return res ; 
}

foo 協程只有三行代碼,但它最終生成的是一百多行的代碼, 如論是協程的創建還是 co_await 機制都是由這些代碼實現的,這就是 C++20 協程的“微言大義”。

關於 C++20 協程的概念和實現原理已經講了很多了,接下來通過一個簡單的 C++20 協程示例來展示協程是如何運行的。

 

圖片

一個簡單的 C++20 協程例子

 

這個例子很簡單,通過 co_await 把協程調度到一個線程中打印一下線程 id。

#include <coroutine>
#include <iostream>
#include <thread>

namespace Coroutine {
  struct task {
    struct promise_type {
      promise_type() {
        std::cout << "1.create promie object\n";
      }
      task get_return_object() {
        std::cout << "2.create coroutine return object, and the coroutine is created now\n";
        return {std::coroutine_handle<task::promise_type>::from_promise(*this)};
      }
      std::suspend_never initial_suspend() {
        std::cout << "3.do you want to susupend the current coroutine?\n";
        std::cout << "4.don't suspend because return std::suspend_never, so continue to execute coroutine body\n";
        return {};
      }
      std::suspend_never final_suspend() noexcept {
        std::cout << "13.coroutine body finished, do you want to susupend the current coroutine?\n";
        std::cout << "14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye\n";
        return {};
      }
      void return_void() {
        std::cout << "12.coroutine don't return value, so return_void is called\n";
      }
      void unhandled_exception() {}
    };

    std::coroutine_handle<task::promise_type> handle_;
  };

  struct awaiter {
    bool await_ready() {
      std::cout << "6.do you want to suspend current coroutine?\n";
      std::cout << "7.yes, suspend becase awaiter.await_ready() return false\n";
      return false;
    }
    void await_suspend(
      std::coroutine_handle<task::promise_type> handle) {
      std::cout << "8.execute awaiter.await_suspend()\n";
      std::thread([handle]() mutable { handle(); }).detach();
      std::cout << "9.a new thread lauched, and will return back to caller\n";
    }
    void await_resume() {}
  };

  task test() {
    std::cout << "5.begin to execute coroutine body, the thread id=" << std::this_thread::get_id() << "\n";//#1
    co_await awaiter{};
    std::cout << "11.coroutine resumed, continue execcute coroutine body now, the thread id=" << std::this_thread::get_id() << "\n";//#3
  }
}// namespace Coroutine

int main() {
  Coroutine::test();
  std::cout << "10.come back to caller becuase of co_await awaiter\n";
  std::this_thread::sleep_for(std::chrono::seconds(1));

  return 0;
}

測試輸出:

1.create promie object
2.create coroutine return object, and the coroutine is created now
3.do you want to susupend the current coroutine?
4.don't suspend because return std::suspend_never, so continue to execute coroutine body
5.begin to execute coroutine body, the thread id=0x10e1c1dc0
6.do you want to suspend current coroutine?
7.yes, suspend becase awaiter.await_ready() return false
8.execute awaiter.await_suspend()
9.a new thread lauched, and will return back to caller
10.come back to caller becuase of co_await awaiter
11.coroutine resumed, continue execcute coroutine body now, the thread id=0x700001dc7000
12.coroutine don't return value, so return_void is called
13.coroutine body finished, do you want to susupend the current coroutine?
14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye

從這個輸出可以清晰的看到協程是如何創建的、co_await 等待線程結束、線程結束後協程返回值以及協程銷燬的整個過程。

協程創建

輸出內容中的 1、2、3 展示了協程創建過程,先創建 promise,再通過 promise.get_return_object() 返回 task,這時協程就創建完成了。

協程創建後的行爲

協程創建完成之後是要立即執行協程函數呢?還是先掛起來?這個行爲由 promise.initial_suspend() 來確定,由於它返回的是一個 std::suspend_never的awaiter,所以不會掛起協程,於是就立即執行協程函數了。

co_await awaiter

執行協程到函數的 co_await awaiter 時,是否需要等待某個任務?返回 false 表明希望等待,於是接着進入到 awaiter.wait_suspend(),並掛起協程,在 await_suspend 中創建了一個線程去執行任務(注意協程具柄傳入到線程中了,以便後面在線程中恢復協程),之後就返回到 caller了,caller 這時候可以不用阻塞等待線程結束,可以做其它事情。注意:這裏的 awaiter 同時也是一個 awaitable,因爲它支持 co_await。

更多時候我們在線程完成之後纔去恢復協程,這樣可以告訴掛起等待任務完成的協程:任務已經完成了,現在可以恢復了,協程恢復後拿到任務的結果繼續執行。

協程恢復

當線程開始運行的時候恢復掛起的協程,這時候代碼執行會回到協程函數繼續執行,這就是最終的目標:在一個新線程中去執行協程函數的打印語句。

協程銷燬

awaiter.final_suspend 決定是否要自動銷燬協程,返回 std::suspend_never 就自動銷燬協程,否則需要用戶手動去銷燬。

協程的“魔法”

再回過頭來看協程函數:

task test() {
    std::cout << std::this_thread::get_id() << "\n";
    co_await awaiter{};
    std::cout << std::this_thread::get_id() << "\n";
}
輸出結果顯示 co_await 上面和下面的線程是不同的,以 co_await 爲分界線,co_await 之上的代碼在一個線程中執行,co_await 之下的代碼在另外一個線程中執行,一個協程函數跨了兩個線程,這就是協程的“魔法”。本質是因爲在另外一個線程中恢復了協程,恢復後代碼的執行就在另外一個線程中了。

另外,這裏沒有展示如何等待一個協程完成,簡單的使用了線程休眠來實現等待的,如果要實現等待協程結束的邏輯,代碼還會增加一倍。

相信你通過這個簡單的例子對 C++20 協程的運行機制有了更深入的理解,同時也會感嘆,協程的使用真的只適合庫作者,普通的開發者想用 C++20 協程還是挺難的,這時就需要協程庫了,協程庫可以大幅降低使用協程的難度。

 

圖片

爲什麼需要一個協程庫

 

通過前面的介紹可以看到,C++20 協程還是比較複雜的,它的概念多、細節多,又是編譯器生成的模板框架,又是一些可定製點,需要了解如何和編譯器生成的模板框架協作,這些對於普通的使用者來說光理解就比較喫力,更逞論靈活運用了。

這時也可以理解爲什麼當初 Google 吐槽這樣的協程提案難於理解、過於靈活了,然而它的確可以讓我們僅需要通過定製化一些特定方法就可以隨心所欲的控制協程,還是很靈活的。

總之,這就是 C++20 協程,它目前只適合給庫作者使用,因爲它只提供了一些底層的協程原語和一些協程暫停和恢復的機制,普通用戶如果希望使用協程只能依賴協程庫,由協程庫來屏蔽這些底層細節,提供簡單易用的 API。因此,我們迫切需要一個基於 C++20 協程封裝好的簡單易用的協程庫。

正是在這種背景下,C++20 協程庫 async_simple(https://github.com/alibaba/async_simple)就應運而生了!

阿里巴巴開發的 C++20 協程庫,目前廣泛應用於圖計算引擎、時序數據庫、搜索引擎等在線系統。連續兩年經歷天貓雙十一磨礪,承擔了億級別流量洪峯,具備非常強勁的性能和可靠的穩定性。

async_simple 現在已經在 GitHub 上開源,有了它你在也不用爲 C++20 協程的複雜而苦惱了,正如它的名字一樣,讓異步變得簡單。

接下來我們將介紹如何使用 async_simple 來簡化異步編程。

 

圖片

async_simple 讓協程變得簡單

 

async_simple 提供了豐富的協程組件和簡單易用的 API,主要有:

  1. Lazy:lazy 求值的無棧協程

  2. Executor:協程執行器

  3. 批量操作協程的 API:collectAll 和 collectAny

  4. uthread:有棧協程

關於 async_simple 的更多介紹和示例,可以看 GitHub(https://github.com/alibaba/async_simple/tree/main/docs/docs.cn)上的文檔。

有了這些常用的豐富的協程組件,我們寫異步程序就變得很簡單了,通過之前打印線程 id 例子來展示如何使用 async_simple 來實現它,也可以對比下用協程庫的話,代碼會簡單多少。

#include "async_simple/coro/Lazy.h"
#include "async_simple/executors/SimpleExecutor.h"

Lazy<void> PrintThreadId(){
    std::cout<<"thread id="<<std::this_thread::get_id()<<"\n";
    co_return;
}

Lazy<void> TestPrintThreadId(async_simple::executors::SimpleExecutor &executor){
    std::cout<<"thread id="<<std::this_thread::get_id()<<"\n";
    PrintThreadId().via(&executor).detach();
    co_return;
}

int main() {
    async_simple::executors::SimpleExecutor executor(/*thread_num=*/1);
    async_simple::coro::syncAwait(TestPrintThreadId(executor));
    return 0;
}

藉助 async_simple 可以輕鬆地把協程調度到 executor 線程中執行,整個代碼變得非常清爽,簡單易懂,代碼量相比之前少得多,用戶也不用去關心 C++20 協程的諸多細節了。

藉助 async_simple 這個協程庫,可以輕鬆的讓 C++20 協程這隻“王謝堂前燕,飛入尋常百姓家”!

async_simple 提供了很多 example,比如使用 async_simple 開發 http client、http server、smtp client 等示例,更多 Demo 可以看 async_simple 的 demo example(https://github.com/alibaba/async_simple/blob/main/demo_example)。

 

圖片

性能

 

使用 async_simple 中的 Lazy 與 folly 中的 Task 以及 cppcoro 中的 task 進行比較,對無棧協程的創建速度與切換速度進行性能測試。需要說明的是,這只是一個高度裁剪的測試用於簡單展示 async_simple,並不做任何性能比較的目的。而且 Folly::Task 有着更多的功能,例如 Folly::Task 在切換時會在 AsyncStack 記錄上下文以增強程序的 Debug 便利性。

測試硬件

CPU: Intel® Xeon® Platinum 8163 CPU @ 2.50GHz

測試結果

單位: 納秒,數值越低越好。

圖片

圖片

測試結果表明 async_simple 的性能還是比較出色的,未來還會持續去優化改進。

 

圖片

總結

 

C++20 協程像一臺精巧的“機器”,雖然複雜,但非常靈活,允許我們去定製化它的一些“零件”,通過這些定製化的“零件”我們可以隨心所欲的控制這臺“機器”,讓它幫我們實現任何想法。

正是這種複雜性和靈活性讓 C++20 協程的使用變得困難,幸運的是我們可以使用工業級的成熟易用的協程庫 async_simple 來簡化協程的使用,讓異步變得簡單!

參考資料:

  • https://github.com/alibaba/async_simple

  • https://timsong-cpp.github.io/cppwp/n4868/

  • https://blog.panicsoftware.com/coroutines-introduction/

  • https://lewissbaker.github.io/

  • https://juejin.cn/post/6844903715099377672

  • https://wiki.tum.de/download/attachments/93291100/Kolb%20report%20-%20Coroutines%20in%20C%2B%2B20.pdf

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章